feat(first-run): render the mission launcher from a store

initFirstRunExperience no longer writes the launcher card's classes,
attributes and text, wires its listeners or removes it: it presents the
card's visibility, busy state, status line, copy and scroll fade to the
gev-first-run store and connects the controls the welcome view's choices,
DISMISS and "Don't show this again" call. A refused save hands the tick
back to the view to revert. The launcher still reads the rendered card for
its hit test, keyboard trap, focus and overflow measurement.

The owner-chosen copy moves to src/firstRunCopy.js, shared by both. The
source-text tests give way to component tests, and the first-run mutation
QA follows the new code (67/67 caught).
This commit is contained in:
Sameh Khamis committed 2026-10-31 22:53:03 -07:00
1 parent c03b4a7643
commit 0adcd61184
11 files changed
+447 -179

No files matched your search

+5
View File
@@ -86,6 +86,11 @@
writing `#hud-ais-vessel`; the application passes `src/hudReadouts.js`,
which IntelHUD follows (samehkhamis).
- `initFirstRunExperience` (via `./application/chrome`) no longer writes the
launcher's classes, attributes or text, or removes the card: it presents
them to the welcome view's `gev-first-run` store (its new `store` option)
and connects the controls the view's buttons call (samehkhamis).
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
+7 -1
View File
@@ -200,7 +200,13 @@ a feature supplying only its accent.
readouts, the typing summary, the REC blink, visibility, layout variant
and style colors; the vessel layer presents the selected vessel's
readout through `src/hudReadouts.js` instead of writing into the HUD),
then
the first-run mission launcher (the welcome view renders the card's
visibility, busy state, status line, copy and scroll fade from the
`gev-first-run` store that `initFirstRunExperience` publishes to, and its
choices, DISMISS and "Don't show this again" call the launcher's
controls; a refused save hands the tick back to the view to revert. The
launcher still reads the rendered card for its hit test, keyboard trap,
focus and overflow measurement), then
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
pages, cycle toggle, regional news and local conditions, and
`CockpitContext` the Contact card's scope, navigation, counts, nearest
+4 -1
View File
@@ -1157,5 +1157,8 @@
"src/hudReadouts.js",
"src/ui/stores/hud.js",
"src/ui/views/intelHudView.test.mjs",
"src/testSupport/hudReadouts.mjs"
"src/testSupport/hudReadouts.mjs",
"src/ui/stores/firstRun.js",
"src/firstRunLauncherView.test.mjs",
"src/firstRunCopy.js"
]
+6 -2
View File
@@ -117,7 +117,9 @@
"src/ui/stores/awarenessDirection.js",
"src/ui/views/panels/AwarenessDirection.vue",
"src/ui/stores/hud.js",
"src/ui/views/IntelHudView.vue"
"src/ui/views/IntelHudView.vue",
"src/ui/stores/firstRun.js",
"src/firstRunCopy.js"
],
"external": ["vue", "pinia"]
},
@@ -3045,7 +3047,9 @@
"src/ui/stores/worldOverlayActions.js",
"src/ui/stores/awarenessDirection.js",
"src/hudReadouts.js",
"src/ui/stores/hud.js"
"src/ui/stores/hud.js",
"src/ui/stores/firstRun.js",
"src/firstRunCopy.js"
],
"external": [
"hls.js",
+23 -20
View File
@@ -28,11 +28,14 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TESTS = [
'src/firstRunExperience.test.mjs',
'src/standalone/startupChrome.test.mjs',
'src/firstRunLauncherView.test.mjs',
];
const FILES = {
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
copy: path.join(ROOT, 'src', 'firstRunCopy.js'),
surfaces: path.join(ROOT, 'src', 'exclusiveSurfaces.js'),
html: path.join(ROOT, 'src', 'ui', 'views', 'WelcomeView.vue'),
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
@@ -146,7 +149,7 @@ const MUTATIONS = [
{
defect:
'cockpit drops off the exclusive list and can stack with the launcher',
file: 'module',
file: 'surfaces',
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
},
@@ -219,8 +222,8 @@ const MUTATIONS = [
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;',
from: ' if (setFirstRunSuppressed(Boolean(wanted), storage)) return true;',
to: ' setFirstRunSuppressed(Boolean(wanted), storage);\n return true;',
},
{
defect: 'a missing storage area reports the write as saved',
@@ -232,8 +235,8 @@ const MUTATIONS = [
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;",
from: 'present({ scrollable: String(overflows) });',
to: "present({ scrollable: 'true' }); void overflows;",
},
{
@@ -274,8 +277,8 @@ const MUTATIONS = [
{
defect: 'the tile stops promising the fires it actually turns on',
file: 'html',
from: '<small>Live earthquakes and active fires, from USGS and NASA</small>',
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
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',
@@ -322,9 +325,9 @@ const MUTATIONS = [
{
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;',
file: 'copy',
from: 'ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
to: 'ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
},
// ── Defaults interplay (the ruling this whole integration turns on) ────────
@@ -367,9 +370,9 @@ const MUTATIONS = [
{
defect:
'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'module',
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
to: ' for (const button of buttons) button.disabled = next;',
file: 'html',
from: ':aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose(\'contacts\')"',
to: ':disabled="launcher?.busy === \'true\'" @click="controls?.choose(\'contacts\')"',
},
{
defect: 'Tab escapes into an app the visitor has not seen yet',
@@ -442,19 +445,19 @@ const MUTATIONS = [
{
defect: 'a mission tile is dropped from the menu',
file: 'html',
from: ' <button type="button" data-first-run-choice="space-missions">',
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
from: ' <button type="button" data-first-run-choice="space-missions"',
to: ' <button type="button" data-first-run-choice-disabled="space-missions"',
},
{
defect: "the menu order stops matching the owner's",
file: 'html',
from: '<button type="button" data-first-run-choice="environmental">',
to: '<button type="button" data-first-run-choice="zzz-environmental">',
from: '<button type="button" data-first-run-choice="environmental"',
to: '<button type="button" data-first-run-choice="zzz-environmental"',
},
{
defect: 'the "don\'t show again" checkbox is removed',
file: 'html',
from: '<input type="checkbox" data-first-run-suppress />',
from: '<input type="checkbox" data-first-run-suppress @change="onSuppress" />',
to: '<span data-first-run-suppress-removed></span>',
},
{
@@ -466,8 +469,8 @@ const MUTATIONS = [
{
defect: 'the launcher is no longer announced as a dialog',
file: 'html',
from: '<aside id="first-run-launcher" role="dialog"',
to: '<aside id="first-run-launcher"',
from: 'id="first-run-launcher" role="dialog"',
to: 'id="first-run-launcher"',
},
{
defect: 'the launcher is revealed before the loading cover yields',
+32
View File
@@ -0,0 +1,32 @@
// The first-run launcher's owner-chosen copy, shared by the launcher and the
// welcome view that renders it.
import { msg, t } from './i18n/index.js';
/**
* 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) };
}
/** The status line between missions. */
export const FIRST_RUN_TIP = msg(
'Tip: the GEV MIC button in the dock lets you talk to the map.',
);
+99 -99
View File
@@ -23,36 +23,24 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
import { t, msg } from './i18n/index.js';
import { EXCLUSIVE_SURFACE_CLASSES } from './exclusiveSurfaces.js';
import { FIRST_RUN_TIP, environmentalLabel } from './firstRunCopy.js';
import { useFirstRunStore } from './ui/stores/firstRun.js';
import { applicationPinia } from './ui/vueRuntime.js';
export {
ENVIRONMENTAL_LABEL_CHOICE,
FIRST_RUN_TIP,
environmentalLabel,
} from './firstRunCopy.js';
/** Launchers already wired, so a second call is a no-op. */
const initializedLaunchers = new WeakSet();
/** 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
* ─────────────────────────────────────────────────────────────────────────────
@@ -328,6 +316,7 @@ export function exclusiveSurfaceActive(documentRef = globalThis.document) {
* @param {Storage} [input.storage]
* @param {Storage} [input.sessionStorageRef]
* @param {Location} [input.location]
* @param {object} [input.store] The welcome view's `gev-first-run` store.
* @returns {null|{dismiss: Function}}
*/
export function initFirstRunExperience({
@@ -337,10 +326,42 @@ export function initFirstRunExperience({
storage,
sessionStorageRef,
location = globalThis.location,
store = useFirstRunStore(applicationPinia()),
} = {}) {
// The welcome view renders the card; it is read here for its geometry, the
// keyboard and focus.
const root = documentRef?.getElementById?.('first-run-launcher');
if (!root || root.dataset.initialized === 'true') return null;
root.dataset.initialized = 'true';
if (!root || initializedLaunchers.has(root)) return null;
initializedLaunchers.add(root);
const view = {
removed: false,
shown: false,
visible: false,
closing: false,
state: null,
busy: null,
status: t(FIRST_RUN_TIP),
// The tile name is owner-switchable from one constant, so paint it from
// the module rather than trusting the markup to match.
environmentalTitle: environmentalLabel().title,
environmentalSub: t(
'Live earthquakes and active fires, from USGS and NASA',
),
description: t(
'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
),
scrollable: null,
};
let signature = '';
const present = (patch) => {
Object.assign(view, patch);
// A resize that changes nothing re-renders nothing.
const next = JSON.stringify(view);
if (next === signature) return;
signature = next;
store.publish({ ...view });
};
if (
!shouldShowFirstRun({
@@ -350,35 +371,14 @@ export function initFirstRunExperience({
location,
})
) {
root.remove();
present({ removed: true });
return null;
}
present({});
// 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 || '';
const defaultStatus = view.status;
// An error or refused save stays on the status line until the next choice.
let stickyStatus = false;
let busy = false;
let closing = false;
@@ -424,7 +424,7 @@ export function initFirstRunExperience({
*/
const isTopmost = () =>
root.isConnected &&
root.classList.contains('visible') &&
view.visible &&
root.getClientRects().length > 0 &&
!coveredByOverlay();
@@ -432,11 +432,13 @@ export function initFirstRunExperience({
if (closing) return;
closing = true;
rememberFirstRunSessionDismissed(sessionStorageRef);
root.classList.remove('visible');
root.setAttribute('aria-hidden', 'true');
present({ visible: false, closing: true });
globalThis.removeEventListener?.('resize', onViewportResize);
surfaceObserver?.disconnect();
const remove = () => root.remove();
const remove = () => {
store.connect(null);
present({ removed: true });
};
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.
@@ -448,26 +450,20 @@ export function initFirstRunExperience({
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
// <body> mid-flight and strands a keyboard visitor outside the launcher.
for (const button of buttons)
button.setAttribute('aria-disabled', String(next));
if (!status) return;
// `busy` is also aria-disabled on the choices, not `disabled`: disabling
// the focused button drops focus to <body> mid-flight and strands a
// keyboard visitor outside the launcher.
const patch = { state: next ? 'loading' : 'ready', busy: String(next) };
if (next)
status.textContent = t(
FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'),
);
else if (status.dataset.sticky !== 'true')
status.textContent = defaultStatus;
patch.status = t(FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'));
else if (!stickyStatus) patch.status = defaultStatus;
present(patch);
};
const onChoice = async (event) => {
const onChoice = async (choice) => {
if (busy || closing) return;
const choice = event.currentTarget?.dataset?.firstRunChoice;
if (!FIRST_RUN_MISSIONS[choice]) return;
if (status) delete status.dataset.sticky;
stickyStatus = false;
setBusy(true, choice);
let outcome = null;
try {
@@ -506,30 +502,31 @@ export function initFirstRunExperience({
: outcome?.result?.failedLayerIds;
const detail =
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
if (status) {
status.dataset.sticky = 'true';
status.textContent = t(
stickyStatus = true;
present({
status: 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;
/** @returns {boolean} Whether the tick may stay as the visitor set it. */
const onSuppressChange = (wanted) => {
if (setFirstRunSuppressed(Boolean(wanted), storage)) return true;
// 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.',
);
// already guaranteed to come back next session. The view puts the box back
// where the truth is; say why rather than leaving a control that undoes
// itself.
stickyStatus = true;
present({
status: t(
'This browser is blocking storage, so that could not be saved.',
),
});
return false;
};
const keyboard = createSurfaceKeyboard({
@@ -542,12 +539,12 @@ export function initFirstRunExperience({
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);
store.connect({
choose: (choice) => void onChoice(choice),
// The same dismissal as ESC, for screens without a keyboard.
dismiss: () => dismiss(),
suppress: 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();
@@ -559,19 +556,21 @@ export function initFirstRunExperience({
const syncScrollAffordance = () => {
if (!choiceList) return;
const overflows = choiceList.scrollHeight > choiceList.clientHeight + 1;
choiceList.dataset.scrollable = String(overflows);
present({ scrollable: String(overflows) });
};
let revealed = false;
const reveal = () => {
if (revealed || closing) return;
revealed = true;
root.hidden = false;
present({ shown: true });
globalThis.requestAnimationFrame?.(() => {
if (closing) return;
root.classList.add('visible');
present({ visible: true });
syncScrollAffordance();
buttons[0]?.focus?.({ preventScroll: true });
root
.querySelector('[data-first-run-choice]')
?.focus?.({ preventScroll: true });
});
};
@@ -659,7 +658,8 @@ export function initFirstRunExperience({
keyboard.destroy();
globalThis.removeEventListener?.('resize', onViewportResize);
surfaceObserver?.disconnect();
root.remove();
store.connect(null);
present({ visible: false, removed: true });
};
return { dismiss, isTopmost, destroy };
}
+7 -44
View File
@@ -413,11 +413,6 @@ test('one ESC does one thing — a Cockpit utility disclosure stops later docume
});
test('a refused write takes the tick back instead of promising "never again"', () => {
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// The write stays best-effort; the OUTCOME is now reported, because a box left
// ticked after a refused write tells the visitor the launcher is gone for good
// while it is already guaranteed to return next session.
@@ -442,20 +437,7 @@ test('a refused write takes the tick back instead of promising "never again"', (
assert.equal(working.read(), 'suppressed');
assert.equal(setFirstRunSuppressed(false, working), true);
assert.equal(working.read(), null);
const handler = module.slice(
module.indexOf('const onSuppressChange = (event) => {'),
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
);
assert.match(
handler,
/if \(setFirstRunSuppressed\(wanted, storage\)\) return;/,
);
assert.match(
handler,
/box\.checked = !wanted;/,
'a refused write must revert the tick',
);
// The rendered checkbox reverting is firstRunLauncherView.test.mjs.
});
test('a surface class that never clears is an ACCEPTED no-show, not a timer', () => {
@@ -506,16 +488,8 @@ test('the scroll fade only appears when the list really overflows', () => {
);
const css = readStylesheet(new URL('../style.css', import.meta.url));
// A fade on a card where all five tiles fit promises a sixth mission that does
// not exist, which is worse than no affordance at all.
assert.match(
module,
/const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/,
);
assert.match(
module,
/choiceList\.dataset\.scrollable = String\(overflows\);/,
);
// ...and the CSS must be gated on that flag, never on the media query alone.
// not exist, which is worse than no affordance at all. The measured flag is
// firstRunLauncherView.test.mjs; the CSS must be gated on that flag, never on the media query alone.
assert.match(
css,
/\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/,
@@ -919,10 +893,6 @@ test('the card closes without a keyboard: the ESC hint is a button, kept on phon
const html = await renderApplicationHtml(
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// The whole stylesheet, so a hide rule added in any other file is caught too.
const css = readStylesheet(new URL('../style.css', import.meta.url));
const footer = html.slice(
@@ -936,11 +906,7 @@ test('the card closes without a keyboard: the ESC hint is a button, kept on phon
assert.match(footer, /data-i18n>ESC to dismiss</);
assert.match(footer, /data-i18n>DISMISS</);
// It closes the way ESC does, through the one dismiss path, so the session
// flag and focus handling are the same.
assert.match(
module,
/querySelector\('\[data-first-run-dismiss\]'\)\s*\?\.addEventListener\('click', \(\) => dismiss\(\)\)/,
);
// flag and focus handling are the same: firstRunLauncherView.test.mjs.
// Phones used to hide the hint, leaving no way out but picking a mission.
assert.doesNotMatch(css, /first-run-footer > span:last-child/);
assert.doesNotMatch(css, /\.first-run-dismiss\s*\{[^}]*display:\s*none/);
@@ -963,12 +929,9 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
'utf8',
);
// aria-disabled, never the `disabled` property: disabling a focused button
// drops the keyboard to <body> and strands the visitor outside the launcher.
assert.match(
module,
/button\.setAttribute\('aria-disabled', String\(next\)\)/,
);
assert.doesNotMatch(module, /button\.disabled = /);
// drops the keyboard to <body> and strands the visitor outside the launcher
// (firstRunLauncherView.test.mjs renders it).
assert.doesNotMatch(module, /\.disabled = /);
// Tab is confined to the launcher, and ESC always releases it.
const keyboard = fs.readFileSync(
new URL('./ui/surfaceKeyboard.js', import.meta.url),
+195
View File
@@ -0,0 +1,195 @@
// src/firstRunLauncherView.test.mjs — the welcome view renders the mission
// launcher initFirstRunExperience presents, and its buttons and checkbox call
// the launcher's controls.
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import {
FIRST_RUN_SESSION_KEY,
FIRST_RUN_STORAGE_KEY,
initFirstRunExperience,
} from './firstRunExperience.js';
import { useFirstRunStore } from './ui/stores/firstRun.js';
import WelcomeView from './ui/views/WelcomeView.vue';
function memoryStorage(key, { refuse = false } = {}) {
const values = new Map();
return {
getItem: (name) => values.get(name) ?? null,
setItem: (name, next) => {
if (refuse) throw new Error('blocked');
values.set(name, next);
},
removeItem: (name) => {
if (refuse) throw new Error('blocked');
values.delete(name);
},
read: () => values.get(key) ?? null,
};
}
/** The welcome view and an initialized launcher, revealed and painted. */
async function launch(t, { styleManager = {}, refuse = false } = {}) {
document.body.replaceChildren();
// Where focus was before the launcher opened.
const opener = document.body.appendChild(document.createElement('button'));
opener.focus();
const frames = [];
const previousFrame = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
const pinia = createPinia();
const store = useFirstRunStore(pinia);
const app = createApp(WelcomeView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const storage = memoryStorage(FIRST_RUN_STORAGE_KEY, { refuse });
const sessionStorageRef = memoryStorage(FIRST_RUN_SESSION_KEY);
const launcher = initFirstRunExperience({
styleManager,
dataManager: { setEnabled: async () => true },
documentRef: document,
storage,
sessionStorageRef,
location: { search: '' },
store,
});
t.after(() => {
launcher?.destroy();
app.unmount();
globalThis.requestAnimationFrame = previousFrame;
});
await nextTick();
const root = document.getElementById('first-run-launcher');
// The list overflows its box until a test resizes it.
const list = root.querySelector('.first-run-choices');
const box = { scrollHeight: 400, clientHeight: 200 };
Object.defineProperty(list, 'scrollHeight', { get: () => box.scrollHeight });
Object.defineProperty(list, 'clientHeight', { get: () => box.clientHeight });
for (const frame of frames.splice(0)) frame();
await nextTick();
return { launcher, root, list, box, opener, storage, sessionStorageRef };
}
test('the card is hidden until initialized, then revealed with its copy', async (t) => {
document.body.replaceChildren();
const pinia = createPinia();
const app = createApp(WelcomeView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const hidden = document.getElementById('first-run-launcher');
assert.equal(hidden.hidden, true);
assert.equal(hidden.className, '');
app.unmount();
const { root, list } = await launch(t);
assert.equal(root.hidden, false);
assert.equal(root.classList.contains('visible'), true);
assert.equal(root.hasAttribute('aria-hidden'), false);
assert.equal(list.dataset.scrollable, 'true');
assert.equal(
root.querySelector('[data-first-run-status]').textContent,
'Tip: the GEV MIC button in the dock lets you talk to the map.',
);
assert.equal(
root.querySelector('[data-first-run-environmental-title]').textContent,
'ENVIRONMENTAL',
);
assert.ok(
document.activeElement === root.querySelector('[data-first-run-choice]'),
'the first mission has focus',
);
});
test('a busy mission marks the choices aria-disabled, never disabled', async (t) => {
let finish;
const { root } = await launch(t, {
styleManager: {
setContextMode: () => new Promise((resolve) => (finish = resolve)),
},
});
const contacts = root.querySelector('[data-first-run-choice="contacts"]');
contacts.focus();
contacts.click();
await nextTick();
assert.equal(root.dataset.state, 'loading');
assert.equal(root.getAttribute('aria-busy'), 'true');
for (const button of root.querySelectorAll('[data-first-run-choice]')) {
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.disabled, false);
}
assert.ok(document.activeElement === contacts, 'focus stays on the choice');
assert.equal(
root.querySelector('[data-first-run-status]').textContent,
'Starting live contacts…',
);
// A failed mission says so and stays on the card.
finish({ ok: false });
await new Promise((resolve) => setTimeout(resolve));
await nextTick();
assert.equal(root.dataset.state, 'ready');
assert.equal(contacts.getAttribute('aria-disabled'), 'false');
assert.match(
root.querySelector('[data-first-run-status]').textContent,
/^Could not open that mission/,
);
});
test('a refused write takes the tick back and says why', async (t) => {
const { root, storage } = await launch(t, { refuse: true });
const box = root.querySelector('[data-first-run-suppress]');
box.click();
await nextTick();
assert.equal(box.checked, false, 'the tick is taken back');
assert.equal(storage.read(), null);
assert.equal(
root.querySelector('[data-first-run-status]').textContent,
'This browser is blocking storage, so that could not be saved.',
);
});
test('a saved tick stays ticked', async (t) => {
const { root, storage } = await launch(t);
const box = root.querySelector('[data-first-run-suppress]');
box.click();
await nextTick();
assert.equal(box.checked, true);
assert.equal(storage.read(), 'suppressed');
});
test('the DISMISS button closes the card the way ESC does', async (t) => {
const { root, sessionStorageRef } = await launch(t);
root.querySelector('[data-first-run-dismiss]').click();
await nextTick();
assert.equal(root.classList.contains('visible'), false);
assert.equal(root.getAttribute('aria-hidden'), 'true');
assert.notEqual(sessionStorageRef.read(), null, 'dismissed for the session');
root.dispatchEvent(new Event('transitionend'));
await nextTick();
assert.equal(document.getElementById('first-run-launcher'), null);
});
test('the scroll fade clears when the tiles fit after a resize', async (t) => {
const { list, box } = await launch(t);
assert.equal(list.dataset.scrollable, 'true');
box.scrollHeight = 200;
window.dispatchEvent(new Event('resize'));
await nextTick();
assert.equal(list.dataset.scrollable, 'false');
});
test('ESC closes the card, returns focus, and removes it without a transition', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { root, opener, sessionStorageRef } = await launch(t);
assert.ok(document.activeElement !== opener, 'the launcher took focus');
document.activeElement.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
await nextTick();
assert.equal(root.getAttribute('aria-hidden'), 'true');
assert.notEqual(sessionStorageRef.read(), null);
assert.ok(document.activeElement === opener, 'focus returns where it was');
// Reduced motion: no transitionend, so the bounded fallback removes it.
t.mock.timers.tick(400);
await nextTick();
assert.equal(document.getElementById('first-run-launcher'), null);
});
+31
View File
@@ -0,0 +1,31 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* The first-run mission launcher as the welcome view renders it.
* initFirstRunExperience presents it and connects the controls its buttons
* and checkbox call.
*/
export const useFirstRunStore = defineStore('gev-first-run', () => {
/**
* `{removed, shown, visible, closing, state, busy, status, description,
* environmentalTitle, environmentalSub, scrollable}`, or null until the
* launcher is initialized (the card stays hidden).
*/
const launcher = shallowRef(null);
/** @param {?object} next A fresh presentation; every publish re-renders. */
function publish(next) {
launcher.value = next && markRaw(next);
}
/** `{choose(choice), dismiss(), suppress(wanted) → boolean}`, or null. */
const controls = shallowRef(null);
/** @param {?object} next The launcher's controls, or null to disconnect. */
function connect(next) {
controls.value = next && markRaw(next);
}
return { launcher, publish, controls, connect };
});
+38 -12
View File
@@ -1,9 +1,35 @@
<script setup>
import { storeToRefs } from 'pinia';
import { msg } from '../../i18n/index.js';
import {
FIRST_RUN_TIP,
environmentalLabel,
} from '../../firstRunCopy.js';
import { useFirstRunStore } from '../stores/firstRun.js';
// The launcher initFirstRunExperience presents, and the controls it connects.
const { launcher, controls } = storeToRefs(useFirstRunStore());
// The card stays hidden until it is initialized; these show in the markup.
const DESCRIPTION = msg(
'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
);
const ENVIRONMENTAL_SUB = msg(
'Live earthquakes and active fires, from USGS and NASA',
);
// A refused save puts the box back where the stored preference is.
function onSuppress(event) {
const box = event.currentTarget;
if (controls.value?.suppress(box.checked) === false) box.checked = !box.checked;
}
</script>
<template>
<!-- Explicit mission choice on a fresh session. Live feeds are not auto-enabled
until the visitor asks; shared links skip this surface entirely. It returns
every session until "Don't show this again" is ticked — see
src/firstRunExperience.js for the full show policy. -->
<aside id="first-run-launcher" role="dialog" aria-labelledby="first-run-title" aria-describedby="first-run-description" hidden>
<aside v-if="!launcher?.removed" id="first-run-launcher" role="dialog" aria-labelledby="first-run-title" aria-describedby="first-run-description" :class="{ visible: launcher?.visible }" :hidden="!launcher?.shown" :aria-hidden="launcher?.closing ? 'true' : undefined" :aria-busy="launcher?.busy ?? undefined" :data-state="launcher?.state ?? undefined">
<div class="first-run-scanline" aria-hidden="true"></div>
<header class="first-run-header">
<span class="first-run-kicker" data-i18n>MISSION CONTROL · FIRST LAUNCH</span>
@@ -13,28 +39,28 @@
— including the unspaced em dash. The arc it has to carry: this looks
like something you should not have access to → the sources are all
public → the data is real. Tile subcopy stays purely functional. -->
<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit—then you realize the sources are public and the data is real.</p>
<div class="first-run-choices ui-scroll">
<button type="button" data-first-run-choice="contacts">
<p id="first-run-description" data-i18n-runtime>{{ launcher?.description ?? DESCRIPTION }}</p>
<div class="first-run-choices ui-scroll" :data-scrollable="launcher?.scrollable ?? undefined">
<button type="button" data-first-run-choice="contacts" :aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose('contacts')">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span><strong data-i18n>LIVE CONTACTS</strong><small data-i18n>Aircraft, vessels and nearby intelligence</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" data-first-run-choice="space-missions">
<button type="button" data-first-run-choice="space-missions" :aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose('space-missions')">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span><strong data-i18n>SPACE MISSIONS</strong><small data-i18n>Launches, spacecraft and orbital context</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" data-first-run-choice="environmental">
<button type="button" data-first-run-choice="environmental" :aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose('environmental')">
<span class="material-symbols-outlined" aria-hidden="true">local_fire_department</span>
<!-- Title text is painted from ENVIRONMENTAL_LABEL_CHOICE at init. -->
<!-- The title is ENVIRONMENTAL_LABEL_CHOICE's label once initialized. -->
<!-- Subcopy names BOTH feeds this tile turns on, because the launcher
optimizes for the fully configured experience (owner ruling,
2026-08-23) — see the mission table in src/firstRunExperience.js. -->
<span data-i18n-runtime><strong data-first-run-environmental-title>ENVIRONMENTAL</strong><small>Live earthquakes and active fires, from USGS and NASA</small></span>
<span data-i18n-runtime><strong data-first-run-environmental-title>{{ launcher?.environmentalTitle ?? environmentalLabel().title }}</strong><small>{{ launcher?.environmentalSub ?? ENVIRONMENTAL_SUB }}</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" class="first-run-explore" data-first-run-choice="explore">
<button type="button" class="first-run-explore" data-first-run-choice="explore" :aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose('explore')">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span><strong data-i18n>EXPLORE MANUALLY</strong><small data-i18n>Begin with a clean globe</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
@@ -42,19 +68,19 @@
</div>
<div class="first-run-footer">
<label class="first-run-suppress">
<input type="checkbox" data-first-run-suppress />
<input type="checkbox" data-first-run-suppress @change="onSuppress" />
<span data-i18n>Don't show this again</span>
</label>
<!-- A button, not just a hint: phones and tablets have no ESC key. It
closes exactly as ESC does, for this session only. Touch screens
see DISMISS; a keyboard keeps the ESC hint (first-run.css). -->
<button type="button" class="first-run-dismiss" data-first-run-dismiss>
<button type="button" class="first-run-dismiss" data-first-run-dismiss @click="controls?.dismiss()">
<span class="first-run-dismiss-key" data-i18n>ESC to dismiss</span>
<span class="first-run-dismiss-touch" data-i18n>DISMISS</span>
</button>
</div>
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
a live region that also holds layout makes the row jump as it speaks. -->
<p class="first-run-note" data-first-run-status role="status" aria-live="polite" data-i18n>Tip: the GEV MIC button in the dock lets you talk to the map.</p>
<p class="first-run-note" data-first-run-status role="status" aria-live="polite">{{ launcher?.status ?? FIRST_RUN_TIP }}</p>
</aside>
</template>