#!/usr/bin/env node /** * Are the first-run launcher's pins actually load-bearing? * * A pin that only goes red when you delete the whole feature proves very little. * This reverts each decision INDIVIDUALLY — the smallest edit that reintroduces * the original defect or contradicts the owner's ruling — and requires * src/firstRunExperience.test.mjs to go red for it. Every entry names what it * restores, so the count is reproducible rather than asserted in a commit * message. * * node scripts/qa-firstrun-mutations.mjs * * Every touched file is restored on exit, including on failure. * * NOTE: two mutations edit vite.config.js, and a running dev server watches that * file and restarts on every write. Writes are therefore content-guarded below * so the file is touched exactly twice per mutation instead of on every * iteration — enough that a dev server survives, but expect it to restart. If * you are mid-QA on a live server, run this before or after, not during. */ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const TESTS = 'src/firstRunExperience.test.mjs'; const FILES = { module: path.join(ROOT, 'src', 'firstRunExperience.js'), html: path.join(ROOT, 'index.html'), css: path.join(ROOT, 'style.css'), vite: path.join(ROOT, 'vite.config.js'), main: path.join(ROOT, 'src', 'main.js'), ui: path.join(ROOT, 'src', 'ui.js'), docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'), }; /** @type {Array<{defect: string, file: keyof FILES, from: string, to: string}>} */ const MUTATIONS = [ // ── Show policy (owner ruling: session-scoped dismiss vs durable checkbox) ── { defect: 'dismissing writes the DURABLE key, so the launcher never returns', file: 'module', from: "writeStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY, 'dismissed');", to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');", }, { defect: 'the checkbox is ignored, so nothing can ever suppress the launcher', file: 'module', from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')", to: ' ? false', }, { defect: 'unticking the box cannot take the suppression back', file: 'module', from: " : removeStored('local', storage, FIRST_RUN_STORAGE_KEY);", to: ' : true;', }, { defect: 'the session flag is never read, so it re-nags on every reload', file: 'module', from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;", to: '// session check removed', }, { defect: 'the durable flag is never read, so the checkbox does nothing', file: 'module', from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;", to: '// durable check removed', }, { defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it', file: 'module', from: "if (params.get('welcome') === '1') return true;", to: '// replay hatch removed', }, { defect: '?welcome=0 stops suppressing', file: 'module', from: "if (params.get('welcome') === '0') return false;", to: '// suppression param removed', }, { defect: 'a share link gets the launcher over the view its author chose', file: 'module', from: 'if (hasShareState) return false;', to: '// share bypass removed', }, { defect: 'storage is read from a DEFAULT PARAMETER, outside every guard', file: 'module', from: 'export function rememberFirstRunSessionDismissed(sessionStorageRef) {', to: 'export function rememberFirstRunSessionDismissed(sessionStorageRef = globalThis.sessionStorage) {', }, { defect: 'shouldShowFirstRun resolves storage in its parameter list again', file: 'module', from: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage,\n sessionStorageRef,', to: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage = globalThis.localStorage,\n sessionStorageRef = globalThis.sessionStorage,', }, { defect: 'the guarded resolver stops catching, so a hostile getter escapes', file: 'module', from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }", to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;", }, { defect: 'a blocked getItem throws instead of reading as "nothing stored"', file: 'module', from: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } catch {\n return null;\n }', to: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } finally {\n // no catch\n }', }, // ── ESC arbitration ─────────────────────────────────────────────────────── { defect: 'the key handler trusts the CLASS, so an invisible card eats ESC', file: 'module', from: 'if (closing || !isTopmost()) return;', to: 'if (closing) return;', }, { defect: 'the launcher stops yielding and fights Cockpit/Scenes for ESC', file: 'module', from: 'if (revealed && blocked) yieldToExclusiveSurface();\n else if (!revealed && !blocked) reveal();', to: 'if (!revealed) reveal();', }, { defect: 'nothing watches for another surface taking the screen', file: 'module', from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });", to: '/* observer never attached */', }, { defect: 'cockpit drops off the exclusive list and can stack with the launcher', file: 'module', from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',", to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([', }, { defect: 'a scene hides the launcher in CSS but the JS never learns of it', file: 'css', from: 'body.scene-playback-mode #first-run-launcher {', to: 'body.scene-playback-mode-typo #first-run-launcher {', }, { defect: 'yielding steals focus back from the surface that just took over', file: 'module', from: 'dismiss({ restoreFocus: false });', to: 'dismiss();', }, { defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again', file: 'module', from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();', to: ' && root.getClientRects().length > 0;', }, { defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working', file: 'module', from: ' return Boolean(hit) && !root.contains(hit);', to: ' return !root.contains(hit);', }, { defect: 'the launcher stops honouring a key another surface already claimed', file: 'module', from: ' if (event.defaultPrevented) return;', to: ' /* belt removed */', }, { // Anchored past the call so it cannot land on the cockpit disclosure's own // stopImmediatePropagation() a few lines below and prove nothing about this. defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things', file: 'ui', from: ' event.stopImmediatePropagation();\n setRadioDisclosure(false, { returnFocus: true });', to: ' event.stopPropagation();\n setRadioDisclosure(false, { returnFocus: true });', }, { defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind', file: 'module', from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears', to: ' * (note removed)', }, { defect: 'a bounded reveal timer punches the card through a recording in progress', file: 'module', from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;', to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);', }, { defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug', file: 'docs', from: 'a surface class that never clears means no launcher for that page', to: 'the launcher always turns up eventually for that page', }, // ── The checkbox may not promise what storage refused ───────────────────── { defect: 'a refused write leaves the box ticked, promising a suppression nobody stored', file: 'module', from: ' if (setFirstRunSuppressed(wanted, storage)) return;', to: ' setFirstRunSuppressed(wanted, storage);\n return;', }, { defect: 'a missing storage area reports the write as saved', file: 'module', from: " if (typeof store?.setItem !== 'function') return false;\n store.setItem(key, value);\n return true;", to: ' store?.setItem?.(key, value);\n return true;', }, { defect: 'the scroll fade promises more list on a card where everything fits', file: 'module', from: 'choiceList.dataset.scrollable = String(overflows);', to: "choiceList.dataset.scrollable = 'true'; void overflows;", }, { defect: 'the DISPLAY rail opens on first run again, stealing the impression', file: 'ui', from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;", to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;", }, { defect: 'the first-run rail default overrides a stored user choice', file: 'ui', from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;", to: "if (panelId === 'pp-toggles') collapsed = true;", }, // ── Mission definitions ──────────────────────────────────────────────────── { defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump', file: 'module', from: " environmental: Object.freeze({", to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({", }, { defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key', file: 'module', from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),", to: "layerIds: Object.freeze(['local-firms']),", }, { defect: 'FIRMS is dropped again from the tile whose subcopy promises it', file: 'module', from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),", to: "layerIds: Object.freeze(['earthquakes']),", }, { defect: 'the tile stops promising the fires it actually turns on', file: 'html', from: 'Live earthquakes and active fires, from USGS and NASA', to: 'Live earthquakes worldwide, straight from USGS', }, { defect: "the owner-authored first-run line is quietly rewritten", file: 'html', from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.', to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.", }, { defect: 'the ledgered global-chip defect loses its pointer to the real fix', file: 'module', from: ' // means a KEY REQUIRED terminal state in src/loadingFeedback.js, a state', to: ' // (note removed)', }, { defect: 'the globe missions never pull the camera out', file: 'module', from: ' .then(() => flyToGlobe())', to: ' .then(() => null)', }, { defect: 'a refused layer is reported as a successful mission', file: 'module', from: 'return { ok: failedLayerIds.length === 0, choice, failedLayerIds };', to: 'return { ok: true, choice, failedLayerIds };', }, { defect: 'a cancelled globe flight fails the whole mission', file: 'module', from: ' .catch(() => null);', to: ' .catch(() => { throw new Error("flight failed"); });', }, { defect: 'Explore manually quietly runs a mission instead of nothing', file: 'module', from: "if (mission.kind === 'none') return { ok: true, choice };", to: "if (mission.kind === 'none') return { ok: true, choice, ran: await setContextMode('contacts') };", }, { defect: 'an unknown choice is treated as a success', file: 'module', from: 'if (!mission) return { ok: false, choice };', to: 'if (!mission) return { ok: true, choice };', }, { defect: 'the fires/quakes tile name stops being switchable from one constant', file: 'module', from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;', to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;', }, // ── Defaults interplay (the ruling this whole integration turns on) ──────── { defect: "mission layer enables stop persisting, unlike the clicks they stand for", file: 'module', from: "dataManager.setEnabled(layerId, true, { origin: 'user' })", to: 'dataManager.setEnabled(layerId, true)', }, { defect: 'a mission hand-edits detection and kills the style auto-preset contract', file: 'module', from: ' flyToGlobe: () => styleManager.resetToGlobeView(),', to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },', }, { defect: 'a mission persists a 3D-models choice nobody made', file: 'module', from: ' const result = await styleManager.setContextMode(mode);', to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);', }, { defect: 'the Context panel is left collapsed, hiding the mode that just started', file: 'module', from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });", to: '// panel reveal removed', }, { defect: 'the decision table is deleted, so the next editor re-litigates it blind', file: 'module', from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST', to: ' * (notes removed)', }, // ── Accessibility and the transplant defects ────────────────────────────── { defect: 'disabling the focused tile drops the keyboard to
mid-flight', file: 'module', from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));", to: 'for (const button of buttons) button.disabled = next;', }, { defect: 'Tab escapes into an app the visitor has not seen yet', file: 'module', from: "if (event.key !== 'Tab') return;", to: 'return;', }, { defect: 'ESC no longer dismisses the launcher', file: 'module', from: "if (event.key === 'Escape') {", to: 'if (false) {', }, { defect: 'focus is never returned to where the visitor left it', file: 'module', from: "if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {", to: 'if (false) {', }, { defect: 'app hotkeys eat the launcher keys (bubble instead of capture)', file: 'module', from: "documentRef.addEventListener('keydown', onKeyDown, true);", to: "documentRef.addEventListener('keydown', onKeyDown);", }, { defect: 'the card stays clickable through its fade-out (double-launch)', file: 'css', from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;', to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);', }, { defect: 'the launcher survives Clean-UI and recording mode', file: 'css', from: 'body.ui-clean-view #first-run-launcher,', to: 'body.never-matches-anything #first-run-launcher,', }, { defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal', file: 'css', from: '#first-run-launcher[hidden] {\n display: none;\n}', to: '/* [hidden] rule removed */', }, { defect: 'the card loses its viewport cap and clips on a short landscape phone', file: 'css', from: ' max-height: calc(100dvh - 1.5rem);', to: ' /* cap removed */', }, { defect: 'the mission list stops scrolling, so tiles overflow the capped card', file: 'css', from: ' min-height: 0;\n overflow-y: auto;', to: ' /* scroll removed */', }, { // Anchored to the launcher's OWN selectors: style.css has four other // reduced-motion blocks, and a bare `@media` anchor would mutate one of // those instead and prove nothing about this feature. defect: "the launcher's reduced-motion opt-out is dropped", file: 'css', from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }', to: ' .first-run-nothing { transition: none; }', }, // ── Markup and startup ordering ─────────────────────────────────────────── { defect: 'a mission tile is dropped from the menu', file: 'html', from: '