mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
c03b4a7643
commit
0adcd61184
11 files changed
+447
-179
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user