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

+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>