import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js'; // First-run mission launcher. // // The map deliberately does not auto-enable live feeds on every visit: doing so // would spend optional API quotas, surprise returning operators, and fight share // links. A new visitor instead gets one compact, explicit choice after startup. // // SHOW POLICY (owner ruling, 2026-08-23). The launcher is NOT one-shot. A new // operator needs the map explained more than once, so it returns every fresh // browser session until they say otherwise: // // - a share link never sees it — its author already chose the experience; // - `?welcome=0` suppresses, `?welcome=1` replays (it outranks BOTH the // session flag and the durable one, so support can always demo it); // - ticking "Don't show this again" writes the DURABLE suppression — that // tick is the only thing that stops it coming back; // - any other close (a mission, Explore manually, ESC) writes only the // SESSION flag, so it stays gone for this tab and returns next session. // // Choosing a mission is deliberately NOT durable suppression: picking a mission // is enthusiasm, not "never show me this again". import { t, msg } from './i18n/index.js'; import { EXCLUSIVE_SURFACE_CLASSES } from './exclusiveSurfaces.js'; /** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */ export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1'; /** Per-session dismissal. Written by every close path; scoped to sessionStorage. */ export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1'; /** * Owner-selectable name for the fires/quakes mission. Flip this ONE constant to * re-label the tile; the alternates are pre-written so the choice is a taste * call at review time, not an edit. * @type {'ENVIRONMENTAL'|'EARTH_WATCH'|'ACTIVE_EVENTS'} */ export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL'; const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({ ENVIRONMENTAL: msg('ENVIRONMENTAL'), EARTH_WATCH: msg('EARTH WATCH'), ACTIVE_EVENTS: msg('ACTIVE EVENTS'), }); /** * @param {string} [choice] * @returns {{title: string}} The label set the constant above selects. */ export function environmentalLabel(choice = ENVIRONMENTAL_LABEL_CHOICE) { const key = ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL; return { title: t(key) }; } /* * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST * ───────────────────────────────────────────────────────────────────────────── * Owner ruling: picking a mission carries the same weight as clicking the * toggles it represents — durable where those clicks are durable — but it must * never write a preference the visitor did not effectively choose by picking it. * Layer enablement IS durable in this app (`gev:layer-state:v2`, written by * LayerStateCoordinator._commitExplicit only for origin user/voice/tool), so: * * TOUCHED, DURABLE layer enables for the mission's OWN layers, at * `origin: 'user'` — identical to clicking those rows. * Choosing ENVIRONMENTAL *is* choosing those layers. * TOUCHED, DURABLE the Context panel reveal, but only for the two * Context missions, exactly as the visible Contacts / * Space Missions tabs do it. The globe missions open no * panel at all — nothing there needs explaining, and a * panel-collapse write is a pref nobody chose. * TOUCHED, SESSION the camera. Never persisted by anything. * NOT TOUCHED detection mode + density. The reasonable-defaults * landing owns the DENSE/75 start, and Contacts owns * detection through contactsDetectionPolicy while it is * active. A mission has no opinion. * NOT TOUCHED `_detectionUserOverridden`. Setting it would mean "the * operator hand-edited detection" and would silently * kill the CRT/NVG/FLIR auto-preset contract for the * whole session. Missions run through setContextMode and * DataManager.setEnabled, neither of which writes it. * NOT TOUCHED detection allocation (`gev:detection-allocation:v1`), * 3D aircraft models, scope feather. All are defaults or * separate durable prefs the visitor did not choose here. * In particular nothing calls `_setModels3dEnabled` / * `_setModels3dMode`, which default to origin 'user' and * would persist a 3D choice nobody made. * * The two Context missions deliberately reuse `styleManager.setContextMode`, the * same facade the visible tabs and voice use, so Contacts detection ownership, * layer isolation and rollback stay in exactly one place. */ /** @type {Readonly>} */ export const FIRST_RUN_MISSIONS = Object.freeze({ contacts: Object.freeze({ kind: 'context', contextMode: 'contacts', busyKey: msg('Starting live contacts…'), }), 'space-missions': Object.freeze({ kind: 'context', contextMode: 'space-missions', busyKey: msg('Opening space missions…'), }), environmental: Object.freeze({ kind: 'globe', // Live USGS earthquakes AND NASA FIRMS active fires. The launcher optimizes // for the FULLY CONFIGURED experience (owner ruling, 2026-08-23): the tile // promises both, so it turns on both, and the subcopy in index.html says so. // // Keyless, FIRMS is honest where it counts — its own layer row reads // "UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED", and the quakes half of // the tile still delivers in full. What is NOT honest is the GLOBAL status // chip, which has no key-required terminal state and folds that row into // "LOAD FAILED". That aggregation is the defect, not this preset: fixing it // means a KEY REQUIRED terminal state in src/loadingFeedback.js, a state // machine shared by every layer and not a thing to refactor the night // before a launch. LEDGERED post-launch. Until it lands, keyless visitors // are judged on the layer row, which tells them the truth. layerIds: Object.freeze(['earthquakes', 'local-firms']), busyKey: msg('Scanning active events…'), }), explore: Object.freeze({ kind: 'none' }), }); /* * STORAGE ACCESS IS LAZY AND GUARDED — NEVER A DEFAULT PARAMETER. * * `globalThis.localStorage` is a GETTER, and in Safari's private mode (and under * some enterprise policies) reading it THROWS SecurityError. A default parameter * like `storage = globalThis.localStorage` evaluates that getter before the * function body starts, so it throws outside every try/catch this module has — * the exception escapes, initFirstRunExperience never runs, and the launcher * silently never appears. That is the exact opposite of failing open. * * So the storage AREA is resolved inside a try, at the moment it is used, and an * injected stub (tests, callers) short-circuits the global entirely. */ /** * Resolve a Web Storage area without letting a hostile getter escape. * @param {'local'|'session'} kind * @param {object|null|undefined} injected Explicit store; `undefined` means "use the global". * @returns {{getItem?: Function, setItem?: Function, removeItem?: Function}|null} */ function resolveStore(kind, injected) { if (injected !== undefined) return injected; try { return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage; } catch { // Privacy-restricted storage should not make first launch silent. return null; } } /** Read one key, treating every failure as "nothing stored". */ function readStored(kind, injected, key) { try { return resolveStore(kind, injected)?.getItem?.(key) ?? null; } catch { return null; } } /** * Write one key, best-effort. Never throws; REPORTS whether the value landed so * a caller that showed the visitor a promise ("don't show this again") can take * it back rather than display a preference nothing stored. * @returns {boolean} true only if the value was actually written. */ function writeStored(kind, injected, key, value) { try { const store = resolveStore(kind, injected); if (typeof store?.setItem !== 'function') return false; store.setItem(key, value); return true; } catch { return false; } } /** * Remove one key, best-effort. * @returns {boolean} true only if the removal actually happened. */ function removeStored(kind, injected, key) { try { const store = resolveStore(kind, injected); if (typeof store?.removeItem !== 'function') return false; store.removeItem(key); return true; } catch { return false; } } /** * Decide whether the launcher belongs in this page load. * @param {object} input * @param {boolean} [input.hasShareState] * @param {{getItem: Function}|null} [input.storage] Durable (localStorage). * @param {{getItem: Function}|null} [input.sessionStorageRef] Per-session. * @param {{search?: string}|null} [input.location] * @returns {boolean} */ export function shouldShowFirstRun({ hasShareState = false, storage, sessionStorageRef, location = globalThis.location, } = {}) { if (hasShareState) return false; const params = new URLSearchParams(location?.search || ''); if (params.get('welcome') === '0') return false; // An embedded globe never shows the welcome launcher. if (params.get('embed') === '1' || globalThis.GEV_EMBED_INLINE === true) return false; // The demo/support escape hatch outranks both suppressions on purpose. if (params.get('welcome') === '1') return true; if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false; if ( readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed' ) return false; return true; } /** * Write (or clear) the durable "don't show this again" suppression. Storage is * best-effort — a blocked store still closes the launcher for this session — * but the outcome is RETURNED, because the checkbox that calls this is showing * the visitor a claim about the future and must not keep a tick nothing saved. * @param {boolean} suppressed * @param {{setItem: Function, removeItem?: Function}|null} [storage] * @returns {boolean} true if the durable state now matches what was asked. */ export function setFirstRunSuppressed(suppressed, storage) { return suppressed ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed') : removeStored('local', storage, FIRST_RUN_STORAGE_KEY); } /** * Record that this browser session has seen and closed the launcher. * @param {{setItem: Function}|null} [sessionStorageRef] * @returns {void} */ export function rememberFirstRunSessionDismissed(sessionStorageRef) { writeStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY, 'dismissed'); } /** * Run a launcher choice against the app's existing internal APIs. * * A failed mission is NOT closed: the visitor can retry or fall back to manual * exploration rather than being stranded on a map they did not ask for. * * @param {string} choice Key of FIRST_RUN_MISSIONS. * @param {object} deps * @param {(mode: string) => Promise} deps.setContextMode * @param {(layerId: string) => Promise} deps.setLayerEnabled * @param {() => Promise} deps.flyToGlobe * @returns {Promise<{ok: boolean, choice: string, result?: object, failedLayerIds?: string[]}>} */ export async function runFirstRunChoice( choice, { setContextMode, setLayerEnabled, flyToGlobe }, ) { const mission = FIRST_RUN_MISSIONS[choice]; if (!mission) return { ok: false, choice }; if (mission.kind === 'none') return { ok: true, choice }; if (mission.kind === 'context') { const result = await setContextMode(mission.contextMode); return { ok: Boolean(result?.ok), choice, result }; } // Globe missions: start the pull-out and the layer work together so the // camera is already moving while the feeds spin up. The flight is framing, // not the mission — a stalled or superseded flight never fails the tile. const flight = Promise.resolve() .then(() => flyToGlobe()) .catch(() => null); const outcomes = await Promise.all( mission.layerIds.map(async (layerId) => { try { return { layerId, ok: (await setLayerEnabled(layerId)) !== false }; } catch { return { layerId, ok: false }; } }), ); await flight; const failedLayerIds = outcomes .filter((entry) => !entry.ok) .map((entry) => entry.layerId); return { ok: failedLayerIds.length === 0, choice, failedLayerIds }; } /** * Body classes that mean "another surface owns the screen". Each of these also * hides the launcher in CSS, and the observer below turns that into a yield — * see the ESC ARBITRATION note on initFirstRunExperience. */ export { EXCLUSIVE_SURFACE_CLASSES }; /** * Is some other surface currently claiming the screen? * @param {Document} [documentRef] * @returns {boolean} */ export function exclusiveSurfaceActive(documentRef = globalThis.document) { const list = documentRef?.body?.classList; if (!list) return false; return EXCLUSIVE_SURFACE_CLASSES.some((name) => list.contains(name)); } /** * Wire and reveal the mission launcher. * @param {object} input * @param {object} input.styleManager Initialized StyleManager. * @param {object} [input.dataManager] DataManager, for the globe missions' layers. * @param {Document} [input.documentRef] * @param {Storage} [input.storage] * @param {Storage} [input.sessionStorageRef] * @param {Location} [input.location] * @returns {null|{dismiss: Function}} */ export function initFirstRunExperience({ styleManager, dataManager = styleManager?._dataManager, documentRef = globalThis.document, storage, sessionStorageRef, location = globalThis.location, } = {}) { const root = documentRef?.getElementById?.('first-run-launcher'); if (!root || root.dataset.initialized === 'true') return null; root.dataset.initialized = 'true'; if ( !shouldShowFirstRun({ hasShareState: styleManager?.hasShareState, storage, sessionStorageRef, location, }) ) { root.remove(); return null; } // The tile name is owner-switchable from one constant, so paint it from the // module rather than trusting the markup to match. The description and the // tile subcopy are data-i18n-runtime in welcome.html and translated here. const environmentalTitle = root.querySelector( '[data-first-run-environmental-title]', ); if (environmentalTitle) environmentalTitle.textContent = environmentalLabel().title; const environmentalSub = root.querySelector( '[data-first-run-environmental-title] + small', ); if (environmentalSub) environmentalSub.textContent = t( 'Live earthquakes and active fires, from USGS and NASA', ); const description = root.querySelector('#first-run-description'); if (description) description.textContent = t( 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.', ); const status = root.querySelector('[data-first-run-status]'); const suppressBox = root.querySelector('[data-first-run-suppress]'); const buttons = [...root.querySelectorAll('[data-first-run-choice]')]; const defaultStatus = status?.textContent || ''; let busy = false; let closing = false; /** * Is something painted OVER the card? A measurable box is not a visible card. * The attribution lightbox is a full-screen overlay at `z-index: 200` against * this card's 175 and announces itself with NO body class, so it left the * launcher measurable but buried: ESC dismissed a card the visitor could not * see — and burned the session flag — behind a lightbox that stayed open. * * Watching one more class would have fixed one more overlay. Hit-testing the * card's own centre answers it for ANY overlay, classed or not, shipped or * future, which is why this is the general guard and the class list stays the * yield mechanism rather than the visibility test. * * Inconclusive answers count as UNCOVERED on purpose — no elementFromPoint, a * zero-sized box, a centre outside the viewport, a null hit. This guard exists * to stop the launcher acting while something is demonstrably on top of it; it * must never become the reason ESC quietly stops working. */ const coveredByOverlay = () => { if (typeof documentRef.elementFromPoint !== 'function') return false; const rect = root.getBoundingClientRect(); if (!(rect.width > 0 && rect.height > 0)) return false; try { const hit = documentRef.elementFromPoint( rect.left + rect.width / 2, rect.top + rect.height / 2, ); return Boolean(hit) && !root.contains(hit); } catch { return false; } }; /** * Is the launcher REALLY the thing on screen right now? Not "did we add the * class" — the class survives while CSS hides the card behind an exclusive * surface, and a handler that trusts the class alone consumes keys for an * invisible element. getClientRects() is empty under `display: none`, which * is exactly how every one of those surfaces hides this card; the hit test * then covers the overlays that leave the box intact and simply sit on top. */ const isTopmost = () => root.isConnected && root.classList.contains('visible') && root.getClientRects().length > 0 && !coveredByOverlay(); const dismiss = ({ restoreFocus = true } = {}) => { if (closing) return; closing = true; rememberFirstRunSessionDismissed(sessionStorageRef); root.classList.remove('visible'); root.setAttribute('aria-hidden', 'true'); globalThis.removeEventListener?.('resize', onViewportResize); surfaceObserver?.disconnect(); const remove = () => root.remove(); root.addEventListener('transitionend', remove, { once: true }); // `transitionend` never fires under prefers-reduced-motion (no transition), // so a bounded fallback is what actually removes the node there. globalThis.setTimeout?.(remove, 400); // Return the keyboard where it was, not to a node that is being removed — // but never when yielding, because the surface taking over owns focus now. keyboard.deactivate({ restoreFocus }); }; const setBusy = (next, choice = '') => { busy = next; root.dataset.state = next ? 'loading' : 'ready'; root.setAttribute('aria-busy', String(next)); // aria-disabled, not `disabled`: disabling the focused button drops focus to // mid-flight and strands a keyboard visitor outside the launcher. for (const button of buttons) button.setAttribute('aria-disabled', String(next)); if (!status) return; if (next) status.textContent = t( FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'), ); else if (status.dataset.sticky !== 'true') status.textContent = defaultStatus; }; const onChoice = async (event) => { if (busy || closing) return; const choice = event.currentTarget?.dataset?.firstRunChoice; if (!FIRST_RUN_MISSIONS[choice]) return; if (status) delete status.dataset.sticky; setBusy(true, choice); let outcome = null; try { outcome = await runFirstRunChoice(choice, { setContextMode: async (mode) => { const result = await styleManager.setContextMode(mode); if (result?.ok) { // setContextMode is also a voice/internal facade and deliberately // does not decide panel chrome. This first-run click is an explicit // visual choice, so reveal the result exactly as the visible // Contacts / Space Missions tabs do. styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true, }); } return result; }, // `origin: 'user'` on purpose: a mission tile is a real person choosing // these layers, so it persists exactly as clicking those rows would. setLayerEnabled: (layerId) => dataManager.setEnabled(layerId, true, { origin: 'user' }), flyToGlobe: () => styleManager.resetToGlobeView(), }); } catch (error) { // A thrown mission is a real defect worth seeing in a bug report; the // ordinary "could not enable" path returns ok:false and stays quiet. console.warn('[First run] Mission launch failed:', error); } if (closing) return; if (outcome?.ok) { dismiss(); return; } const failed = outcome?.failedLayerIds?.length ? outcome.failedLayerIds : outcome?.result?.failedLayerIds; const detail = Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : ''; if (status) { status.dataset.sticky = 'true'; status.textContent = t( 'Could not open that mission{detail}. Retry or explore manually.', { detail }, ); } setBusy(false); }; const onSuppressChange = (event) => { const box = event.currentTarget; const wanted = Boolean(box?.checked); if (setFirstRunSuppressed(wanted, storage)) return; // The write is best-effort; the TICK is not. A box left checked after a // refused write tells the visitor "never again" about a launcher that is // already guaranteed to come back next session. Put the box back where the // truth is, and say why rather than leaving a control that undoes itself. if (box) box.checked = !wanted; if (!status) return; status.dataset.sticky = 'true'; status.textContent = t( 'This browser is blocking storage, so that could not be saved.', ); }; const keyboard = createSurfaceKeyboard({ root, documentRef, // A measurable card may still be hidden by another surface. Check on the // key itself, before the observer has had a chance to process that change. isActive: () => !closing && isTopmost(), onEscape: () => dismiss(), fallbackFocus: () => documentRef.body, }); for (const button of buttons) button.addEventListener('click', onChoice); // The same dismissal as ESC, for screens without a keyboard. root .querySelector('[data-first-run-dismiss]') ?.addEventListener('click', () => dismiss()); suppressBox?.addEventListener('change', onSuppressChange); // Capture phase: the app binds its own global hotkeys (including bare letters // that cycle detection and styles), and the launcher owns the keyboard first. keyboard.activate(); // The scroll fade is an affordance, so it may only appear when the list really // overflows. On a viewport where all five tiles fit, a faded bottom edge would // promise a sixth mission that does not exist. const choiceList = root.querySelector('.first-run-choices'); const syncScrollAffordance = () => { if (!choiceList) return; const overflows = choiceList.scrollHeight > choiceList.clientHeight + 1; choiceList.dataset.scrollable = String(overflows); }; let revealed = false; const reveal = () => { if (revealed || closing) return; revealed = true; root.hidden = false; globalThis.requestAnimationFrame?.(() => { if (closing) return; root.classList.add('visible'); syncScrollAffordance(); buttons[0]?.focus?.({ preventScroll: true }); }); }; /* * ESC ARBITRATION — the launcher never competes for the key. * * Two real defects motivated this. Cockpit registers its capture-phase * keydown listener at construction, long before this module runs, and calls * stopImmediatePropagation() — so with both up, ESC exited Cockpit BEHIND the * card. And a Scene starting merely hid the launcher in CSS while its handler * stayed armed, so ESC dismissed an invisible launcher (writing the session * flag) while the Scene played on. * * Rather than fight over listener order, the launcher YIELDS. Every exclusive * surface announces itself with a body class that already hides this card, so * one observer turns "something else took the screen" into "step aside for * this session". The stacking question then never arises: the launcher is * gone before the other surface can receive a key. And if a surface is * already up when this runs, the launcher waits rather than appearing over it. * * Yielding covers the surfaces that TAKE THE SCREEN and say so. Two more kinds * of contender exist, and each is answered where it actually lives: * * an overlay that takes the screen SILENTLY — the attribution lightbox sits * at z-index 200 with no class to watch. isTopmost() hit-tests the card's * own centre, so an unclassed overlay disarms the handler exactly like a * classed one, with nothing to keep in step; * a small control that claims only the KEY — a disclosure or popover the * launcher is not hiding behind and must not yield to. Whoever handles ESC * first marks it (preventDefault) and stops the rest (stopImmediatePropagation); * the launcher honours the mark. One key, one action, no ordering fight. */ const yieldToExclusiveSurface = () => { if (closing) return; // Session-scoped, like any other dismissal: it returns next session. Focus // stays with whatever just took the screen. dismiss({ restoreFocus: false }); }; /* * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears * means the launcher never appears in that page load. * * A "reveal anyway after N seconds" timer was considered and rejected. None of * the four classes is restored at startup — every one is toggled by a live * action (cockpit entry, a scene run, the recording toggle, the clean-view * toggle) — so an already-blocked init is an error path, while a genuinely * long recording or clean-view session is completely ordinary. A timer would * trade a benign no-show for the launcher punching through a recording in * progress, which is the worse of the two failures. * * And the no-show IS benign: the card stays hidden, the key handler is inert * (isTopmost() is false), no session flag is written, and the observer is * still watching — so it appears the moment the class clears, and returns next * session regardless. Documented in docs/CURRENT-STATE.md. */ const syncToExclusiveSurfaces = () => { if (closing) return; const blocked = exclusiveSurfaceActive(documentRef); if (revealed && blocked) yieldToExclusiveSurface(); else if (!revealed && !blocked) reveal(); }; // A rotated phone changes which tiles fit, so the affordance re-measures. const onViewportResize = () => syncScrollAffordance(); globalThis.addEventListener?.('resize', onViewportResize); const surfaceObserver = typeof globalThis.MutationObserver === 'function' ? new globalThis.MutationObserver(syncToExclusiveSurfaces) : null; // Attributes only, no subtree: this is a class watch on one element, so it // costs nothing per frame and never asks the render governor for a frame. if (documentRef.body) { surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'], }); } syncToExclusiveSurfaces(); // Teardown is not a user dismissal and must not change the show preference. const destroy = () => { closing = true; keyboard.destroy(); globalThis.removeEventListener?.('resize', onViewportResize); surfaceObserver?.disconnect(); root.remove(); }; return { dismiss, isTopmost, destroy }; }