diff --git a/CHANGELOG.md b/CHANGELOG.md
index 905de92a2..6f319ba18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md
index 8965938f1..734801db2 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -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
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 501c50540..927a58c41 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -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"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index e69cfaa6b..bf6824c17 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -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",
diff --git a/scripts/qa-firstrun-mutations.mjs b/scripts/qa-firstrun-mutations.mjs
index 78eb8dfef..b15befc87 100644
--- a/scripts/qa-firstrun-mutations.mjs
+++ b/scripts/qa-firstrun-mutations.mjs
@@ -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: 'Live earthquakes and active fires, from USGS and NASA ',
- to: 'Live earthquakes worldwide, straight from USGS ',
+ 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
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: ' ',
- to: ' ',
+ from: ' ',
- to: '',
+ from: ' ',
+ from: ' ',
to: ' ',
},
{
@@ -466,8 +469,8 @@ const MUTATIONS = [
{
defect: 'the launcher is no longer announced as a dialog',
file: 'html',
- from: ' {
+ 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
- // 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 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 };
}
diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs
index 0729bff99..caaca46fa 100644
--- a/src/firstRunExperience.test.mjs
+++ b/src/firstRunExperience.test.mjs
@@ -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 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 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),
diff --git a/src/firstRunLauncherView.test.mjs b/src/firstRunLauncherView.test.mjs
new file mode 100644
index 000000000..633d9de30
--- /dev/null
+++ b/src/firstRunLauncherView.test.mjs
@@ -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);
+});
diff --git a/src/ui/stores/firstRun.js b/src/ui/stores/firstRun.js
new file mode 100644
index 000000000..af90ac2bd
--- /dev/null
+++ b/src/ui/stores/firstRun.js
@@ -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 };
+});
diff --git a/src/ui/views/WelcomeView.vue b/src/ui/views/WelcomeView.vue
index 2051e5e17..2daa0d4d7 100644
--- a/src/ui/views/WelcomeView.vue
+++ b/src/ui/views/WelcomeView.vue
@@ -1,9 +1,35 @@
+
+
-