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
+445
-177
No files matched your search
@@ -86,6 +86,11 @@
|
|||||||
writing `#hud-ais-vessel`; the application passes `src/hudReadouts.js`,
|
writing `#hud-ais-vessel`; the application passes `src/hudReadouts.js`,
|
||||||
which IntelHUD follows (samehkhamis).
|
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
|
## [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
|
- 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
|
readouts, the typing summary, the REC blink, visibility, layout variant
|
||||||
and style colors; the vessel layer presents the selected vessel's
|
and style colors; the vessel layer presents the selected vessel's
|
||||||
readout through `src/hudReadouts.js` instead of writing into the HUD),
|
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
|
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
|
||||||
pages, cycle toggle, regional news and local conditions, and
|
pages, cycle toggle, regional news and local conditions, and
|
||||||
`CockpitContext` the Contact card's scope, navigation, counts, nearest
|
`CockpitContext` the Contact card's scope, navigation, counts, nearest
|
||||||
|
|||||||
@@ -1157,5 +1157,8 @@
|
|||||||
"src/hudReadouts.js",
|
"src/hudReadouts.js",
|
||||||
"src/ui/stores/hud.js",
|
"src/ui/stores/hud.js",
|
||||||
"src/ui/views/intelHudView.test.mjs",
|
"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/stores/awarenessDirection.js",
|
||||||
"src/ui/views/panels/AwarenessDirection.vue",
|
"src/ui/views/panels/AwarenessDirection.vue",
|
||||||
"src/ui/stores/hud.js",
|
"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"]
|
"external": ["vue", "pinia"]
|
||||||
},
|
},
|
||||||
@@ -3045,7 +3047,9 @@
|
|||||||
"src/ui/stores/worldOverlayActions.js",
|
"src/ui/stores/worldOverlayActions.js",
|
||||||
"src/ui/stores/awarenessDirection.js",
|
"src/ui/stores/awarenessDirection.js",
|
||||||
"src/hudReadouts.js",
|
"src/hudReadouts.js",
|
||||||
"src/ui/stores/hud.js"
|
"src/ui/stores/hud.js",
|
||||||
|
"src/ui/stores/firstRun.js",
|
||||||
|
"src/firstRunCopy.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|||||||
const TESTS = [
|
const TESTS = [
|
||||||
'src/firstRunExperience.test.mjs',
|
'src/firstRunExperience.test.mjs',
|
||||||
'src/standalone/startupChrome.test.mjs',
|
'src/standalone/startupChrome.test.mjs',
|
||||||
|
'src/firstRunLauncherView.test.mjs',
|
||||||
];
|
];
|
||||||
|
|
||||||
const FILES = {
|
const FILES = {
|
||||||
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
||||||
module: path.join(ROOT, 'src', 'firstRunExperience.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'),
|
html: path.join(ROOT, 'src', 'ui', 'views', 'WelcomeView.vue'),
|
||||||
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
|
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
|
||||||
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
||||||
@@ -146,7 +149,7 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect:
|
defect:
|
||||||
'cockpit drops off the exclusive list and can stack with the launcher',
|
'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',",
|
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
|
||||||
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
|
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
|
||||||
},
|
},
|
||||||
@@ -219,8 +222,8 @@ const MUTATIONS = [
|
|||||||
defect:
|
defect:
|
||||||
'a refused write leaves the box ticked, promising a suppression nobody stored',
|
'a refused write leaves the box ticked, promising a suppression nobody stored',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: ' if (setFirstRunSuppressed(wanted, storage)) return;',
|
from: ' if (setFirstRunSuppressed(Boolean(wanted), storage)) return true;',
|
||||||
to: ' setFirstRunSuppressed(wanted, storage);\n return;',
|
to: ' setFirstRunSuppressed(Boolean(wanted), storage);\n return true;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'a missing storage area reports the write as saved',
|
defect: 'a missing storage area reports the write as saved',
|
||||||
@@ -232,8 +235,8 @@ const MUTATIONS = [
|
|||||||
defect:
|
defect:
|
||||||
'the scroll fade promises more list on a card where everything fits',
|
'the scroll fade promises more list on a card where everything fits',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: 'choiceList.dataset.scrollable = String(overflows);',
|
from: 'present({ scrollable: String(overflows) });',
|
||||||
to: "choiceList.dataset.scrollable = 'true'; void overflows;",
|
to: "present({ scrollable: 'true' }); void overflows;",
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -274,8 +277,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the tile stops promising the fires it actually turns on',
|
defect: 'the tile stops promising the fires it actually turns on',
|
||||||
file: 'html',
|
file: 'html',
|
||||||
from: '<small>Live earthquakes and active fires, from USGS and NASA</small>',
|
from: " 'Live earthquakes and active fires, from USGS and NASA',",
|
||||||
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
|
to: " 'Live earthquakes worldwide, straight from USGS',",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the owner-authored first-run line is quietly rewritten',
|
defect: 'the owner-authored first-run line is quietly rewritten',
|
||||||
@@ -322,9 +325,9 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect:
|
defect:
|
||||||
'the fires/quakes tile name stops being switchable from one constant',
|
'the fires/quakes tile name stops being switchable from one constant',
|
||||||
file: 'module',
|
file: 'copy',
|
||||||
from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
from: 'ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
|
||||||
to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
to: 'ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Defaults interplay (the ruling this whole integration turns on) ────────
|
// ── Defaults interplay (the ruling this whole integration turns on) ────────
|
||||||
@@ -367,9 +370,9 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect:
|
defect:
|
||||||
'disabling the focused tile drops the keyboard to <body> mid-flight',
|
'disabling the focused tile drops the keyboard to <body> mid-flight',
|
||||||
file: 'module',
|
file: 'html',
|
||||||
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
|
from: ':aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose(\'contacts\')"',
|
||||||
to: ' for (const button of buttons) button.disabled = next;',
|
to: ':disabled="launcher?.busy === \'true\'" @click="controls?.choose(\'contacts\')"',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'Tab escapes into an app the visitor has not seen yet',
|
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',
|
defect: 'a mission tile is dropped from the menu',
|
||||||
file: 'html',
|
file: 'html',
|
||||||
from: ' <button type="button" data-first-run-choice="space-missions">',
|
from: ' <button type="button" data-first-run-choice="space-missions"',
|
||||||
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
|
to: ' <button type="button" data-first-run-choice-disabled="space-missions"',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: "the menu order stops matching the owner's",
|
defect: "the menu order stops matching the owner's",
|
||||||
file: 'html',
|
file: 'html',
|
||||||
from: '<button type="button" data-first-run-choice="environmental">',
|
from: '<button type="button" data-first-run-choice="environmental"',
|
||||||
to: '<button type="button" data-first-run-choice="zzz-environmental">',
|
to: '<button type="button" data-first-run-choice="zzz-environmental"',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the "don\'t show again" checkbox is removed',
|
defect: 'the "don\'t show again" checkbox is removed',
|
||||||
file: 'html',
|
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>',
|
to: '<span data-first-run-suppress-removed></span>',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -466,8 +469,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the launcher is no longer announced as a dialog',
|
defect: 'the launcher is no longer announced as a dialog',
|
||||||
file: 'html',
|
file: 'html',
|
||||||
from: '<aside id="first-run-launcher" role="dialog"',
|
from: 'id="first-run-launcher" role="dialog"',
|
||||||
to: '<aside id="first-run-launcher"',
|
to: 'id="first-run-launcher"',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the launcher is revealed before the loading cover yields',
|
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.',
|
||||||
|
);
|
||||||
+97
-97
@@ -23,36 +23,24 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
|||||||
|
|
||||||
import { t, msg } from './i18n/index.js';
|
import { t, msg } from './i18n/index.js';
|
||||||
import { EXCLUSIVE_SURFACE_CLASSES } from './exclusiveSurfaces.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. */
|
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
|
||||||
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
|
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
|
||||||
/** Per-session dismissal. Written by every close path; scoped to sessionStorage. */
|
/** Per-session dismissal. Written by every close path; scoped to sessionStorage. */
|
||||||
export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1';
|
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
|
* 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.storage]
|
||||||
* @param {Storage} [input.sessionStorageRef]
|
* @param {Storage} [input.sessionStorageRef]
|
||||||
* @param {Location} [input.location]
|
* @param {Location} [input.location]
|
||||||
|
* @param {object} [input.store] The welcome view's `gev-first-run` store.
|
||||||
* @returns {null|{dismiss: Function}}
|
* @returns {null|{dismiss: Function}}
|
||||||
*/
|
*/
|
||||||
export function initFirstRunExperience({
|
export function initFirstRunExperience({
|
||||||
@@ -337,10 +326,42 @@ export function initFirstRunExperience({
|
|||||||
storage,
|
storage,
|
||||||
sessionStorageRef,
|
sessionStorageRef,
|
||||||
location = globalThis.location,
|
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');
|
const root = documentRef?.getElementById?.('first-run-launcher');
|
||||||
if (!root || root.dataset.initialized === 'true') return null;
|
if (!root || initializedLaunchers.has(root)) return null;
|
||||||
root.dataset.initialized = 'true';
|
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 (
|
if (
|
||||||
!shouldShowFirstRun({
|
!shouldShowFirstRun({
|
||||||
@@ -350,35 +371,14 @@ export function initFirstRunExperience({
|
|||||||
location,
|
location,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
root.remove();
|
present({ removed: true });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
present({});
|
||||||
|
|
||||||
// The tile name is owner-switchable from one constant, so paint it from the
|
const defaultStatus = view.status;
|
||||||
// module rather than trusting the markup to match. The description and the
|
// An error or refused save stays on the status line until the next choice.
|
||||||
// tile subcopy are data-i18n-runtime in welcome.html and translated here.
|
let stickyStatus = false;
|
||||||
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 || '';
|
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let closing = false;
|
let closing = false;
|
||||||
|
|
||||||
@@ -424,7 +424,7 @@ export function initFirstRunExperience({
|
|||||||
*/
|
*/
|
||||||
const isTopmost = () =>
|
const isTopmost = () =>
|
||||||
root.isConnected &&
|
root.isConnected &&
|
||||||
root.classList.contains('visible') &&
|
view.visible &&
|
||||||
root.getClientRects().length > 0 &&
|
root.getClientRects().length > 0 &&
|
||||||
!coveredByOverlay();
|
!coveredByOverlay();
|
||||||
|
|
||||||
@@ -432,11 +432,13 @@ export function initFirstRunExperience({
|
|||||||
if (closing) return;
|
if (closing) return;
|
||||||
closing = true;
|
closing = true;
|
||||||
rememberFirstRunSessionDismissed(sessionStorageRef);
|
rememberFirstRunSessionDismissed(sessionStorageRef);
|
||||||
root.classList.remove('visible');
|
present({ visible: false, closing: true });
|
||||||
root.setAttribute('aria-hidden', 'true');
|
|
||||||
globalThis.removeEventListener?.('resize', onViewportResize);
|
globalThis.removeEventListener?.('resize', onViewportResize);
|
||||||
surfaceObserver?.disconnect();
|
surfaceObserver?.disconnect();
|
||||||
const remove = () => root.remove();
|
const remove = () => {
|
||||||
|
store.connect(null);
|
||||||
|
present({ removed: true });
|
||||||
|
};
|
||||||
root.addEventListener('transitionend', remove, { once: true });
|
root.addEventListener('transitionend', remove, { once: true });
|
||||||
// `transitionend` never fires under prefers-reduced-motion (no transition),
|
// `transitionend` never fires under prefers-reduced-motion (no transition),
|
||||||
// so a bounded fallback is what actually removes the node there.
|
// so a bounded fallback is what actually removes the node there.
|
||||||
@@ -448,26 +450,20 @@ export function initFirstRunExperience({
|
|||||||
|
|
||||||
const setBusy = (next, choice = '') => {
|
const setBusy = (next, choice = '') => {
|
||||||
busy = next;
|
busy = next;
|
||||||
root.dataset.state = next ? 'loading' : 'ready';
|
// `busy` is also aria-disabled on the choices, not `disabled`: disabling
|
||||||
root.setAttribute('aria-busy', String(next));
|
// the focused button drops focus to <body> mid-flight and strands a
|
||||||
// aria-disabled, not `disabled`: disabling the focused button drops focus to
|
// keyboard visitor outside the launcher.
|
||||||
// <body> mid-flight and strands a keyboard visitor outside the launcher.
|
const patch = { state: next ? 'loading' : 'ready', busy: String(next) };
|
||||||
for (const button of buttons)
|
|
||||||
button.setAttribute('aria-disabled', String(next));
|
|
||||||
if (!status) return;
|
|
||||||
if (next)
|
if (next)
|
||||||
status.textContent = t(
|
patch.status = t(FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'));
|
||||||
FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'),
|
else if (!stickyStatus) patch.status = defaultStatus;
|
||||||
);
|
present(patch);
|
||||||
else if (status.dataset.sticky !== 'true')
|
|
||||||
status.textContent = defaultStatus;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChoice = async (event) => {
|
const onChoice = async (choice) => {
|
||||||
if (busy || closing) return;
|
if (busy || closing) return;
|
||||||
const choice = event.currentTarget?.dataset?.firstRunChoice;
|
|
||||||
if (!FIRST_RUN_MISSIONS[choice]) return;
|
if (!FIRST_RUN_MISSIONS[choice]) return;
|
||||||
if (status) delete status.dataset.sticky;
|
stickyStatus = false;
|
||||||
setBusy(true, choice);
|
setBusy(true, choice);
|
||||||
let outcome = null;
|
let outcome = null;
|
||||||
try {
|
try {
|
||||||
@@ -506,30 +502,31 @@ export function initFirstRunExperience({
|
|||||||
: outcome?.result?.failedLayerIds;
|
: outcome?.result?.failedLayerIds;
|
||||||
const detail =
|
const detail =
|
||||||
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
||||||
if (status) {
|
stickyStatus = true;
|
||||||
status.dataset.sticky = 'true';
|
present({
|
||||||
status.textContent = t(
|
status: t(
|
||||||
'Could not open that mission{detail}. Retry or explore manually.',
|
'Could not open that mission{detail}. Retry or explore manually.',
|
||||||
{ detail },
|
{ detail },
|
||||||
);
|
),
|
||||||
}
|
});
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSuppressChange = (event) => {
|
/** @returns {boolean} Whether the tick may stay as the visitor set it. */
|
||||||
const box = event.currentTarget;
|
const onSuppressChange = (wanted) => {
|
||||||
const wanted = Boolean(box?.checked);
|
if (setFirstRunSuppressed(Boolean(wanted), storage)) return true;
|
||||||
if (setFirstRunSuppressed(wanted, storage)) return;
|
|
||||||
// The write is best-effort; the TICK is not. A box left checked after a
|
// 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
|
// 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
|
// already guaranteed to come back next session. The view puts the box back
|
||||||
// truth is, and say why rather than leaving a control that undoes itself.
|
// where the truth is; say why rather than leaving a control that undoes
|
||||||
if (box) box.checked = !wanted;
|
// itself.
|
||||||
if (!status) return;
|
stickyStatus = true;
|
||||||
status.dataset.sticky = 'true';
|
present({
|
||||||
status.textContent = t(
|
status: t(
|
||||||
'This browser is blocking storage, so that could not be saved.',
|
'This browser is blocking storage, so that could not be saved.',
|
||||||
);
|
),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const keyboard = createSurfaceKeyboard({
|
const keyboard = createSurfaceKeyboard({
|
||||||
@@ -542,12 +539,12 @@ export function initFirstRunExperience({
|
|||||||
fallbackFocus: () => documentRef.body,
|
fallbackFocus: () => documentRef.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const button of buttons) button.addEventListener('click', onChoice);
|
store.connect({
|
||||||
|
choose: (choice) => void onChoice(choice),
|
||||||
// The same dismissal as ESC, for screens without a keyboard.
|
// The same dismissal as ESC, for screens without a keyboard.
|
||||||
root
|
dismiss: () => dismiss(),
|
||||||
.querySelector('[data-first-run-dismiss]')
|
suppress: onSuppressChange,
|
||||||
?.addEventListener('click', () => dismiss());
|
});
|
||||||
suppressBox?.addEventListener('change', onSuppressChange);
|
|
||||||
// Capture phase: the app binds its own global hotkeys (including bare letters
|
// Capture phase: the app binds its own global hotkeys (including bare letters
|
||||||
// that cycle detection and styles), and the launcher owns the keyboard first.
|
// that cycle detection and styles), and the launcher owns the keyboard first.
|
||||||
keyboard.activate();
|
keyboard.activate();
|
||||||
@@ -559,19 +556,21 @@ export function initFirstRunExperience({
|
|||||||
const syncScrollAffordance = () => {
|
const syncScrollAffordance = () => {
|
||||||
if (!choiceList) return;
|
if (!choiceList) return;
|
||||||
const overflows = choiceList.scrollHeight > choiceList.clientHeight + 1;
|
const overflows = choiceList.scrollHeight > choiceList.clientHeight + 1;
|
||||||
choiceList.dataset.scrollable = String(overflows);
|
present({ scrollable: String(overflows) });
|
||||||
};
|
};
|
||||||
|
|
||||||
let revealed = false;
|
let revealed = false;
|
||||||
const reveal = () => {
|
const reveal = () => {
|
||||||
if (revealed || closing) return;
|
if (revealed || closing) return;
|
||||||
revealed = true;
|
revealed = true;
|
||||||
root.hidden = false;
|
present({ shown: true });
|
||||||
globalThis.requestAnimationFrame?.(() => {
|
globalThis.requestAnimationFrame?.(() => {
|
||||||
if (closing) return;
|
if (closing) return;
|
||||||
root.classList.add('visible');
|
present({ visible: true });
|
||||||
syncScrollAffordance();
|
syncScrollAffordance();
|
||||||
buttons[0]?.focus?.({ preventScroll: true });
|
root
|
||||||
|
.querySelector('[data-first-run-choice]')
|
||||||
|
?.focus?.({ preventScroll: true });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -659,7 +658,8 @@ export function initFirstRunExperience({
|
|||||||
keyboard.destroy();
|
keyboard.destroy();
|
||||||
globalThis.removeEventListener?.('resize', onViewportResize);
|
globalThis.removeEventListener?.('resize', onViewportResize);
|
||||||
surfaceObserver?.disconnect();
|
surfaceObserver?.disconnect();
|
||||||
root.remove();
|
store.connect(null);
|
||||||
|
present({ visible: false, removed: true });
|
||||||
};
|
};
|
||||||
return { dismiss, isTopmost, destroy };
|
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"', () => {
|
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
|
// 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
|
// ticked after a refused write tells the visitor the launcher is gone for good
|
||||||
// while it is already guaranteed to return next session.
|
// 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(working.read(), 'suppressed');
|
||||||
assert.equal(setFirstRunSuppressed(false, working), true);
|
assert.equal(setFirstRunSuppressed(false, working), true);
|
||||||
assert.equal(working.read(), null);
|
assert.equal(working.read(), null);
|
||||||
|
// The rendered checkbox reverting is firstRunLauncherView.test.mjs.
|
||||||
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',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a surface class that never clears is an ACCEPTED no-show, not a timer', () => {
|
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));
|
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
|
// 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.
|
// not exist, which is worse than no affordance at all. The measured flag is
|
||||||
assert.match(
|
// firstRunLauncherView.test.mjs; the CSS must be gated on that flag, never on the media query alone.
|
||||||
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.
|
|
||||||
assert.match(
|
assert.match(
|
||||||
css,
|
css,
|
||||||
/\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/,
|
/\.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(
|
const html = await renderApplicationHtml(
|
||||||
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
|
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.
|
// 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 css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
const footer = html.slice(
|
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>ESC to dismiss</);
|
||||||
assert.match(footer, /data-i18n>DISMISS</);
|
assert.match(footer, /data-i18n>DISMISS</);
|
||||||
// It closes the way ESC does, through the one dismiss path, so the session
|
// It closes the way ESC does, through the one dismiss path, so the session
|
||||||
// flag and focus handling are the same.
|
// flag and focus handling are the same: firstRunLauncherView.test.mjs.
|
||||||
assert.match(
|
|
||||||
module,
|
|
||||||
/querySelector\('\[data-first-run-dismiss\]'\)\s*\?\.addEventListener\('click', \(\) => dismiss\(\)\)/,
|
|
||||||
);
|
|
||||||
// Phones used to hide the hint, leaving no way out but picking a mission.
|
// 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-footer > span:last-child/);
|
||||||
assert.doesNotMatch(css, /\.first-run-dismiss\s*\{[^}]*display:\s*none/);
|
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',
|
'utf8',
|
||||||
);
|
);
|
||||||
// aria-disabled, never the `disabled` property: disabling a focused button
|
// aria-disabled, never the `disabled` property: disabling a focused button
|
||||||
// drops the keyboard to <body> and strands the visitor outside the launcher.
|
// drops the keyboard to <body> and strands the visitor outside the launcher
|
||||||
assert.match(
|
// (firstRunLauncherView.test.mjs renders it).
|
||||||
module,
|
assert.doesNotMatch(module, /\.disabled = /);
|
||||||
/button\.setAttribute\('aria-disabled', String\(next\)\)/,
|
|
||||||
);
|
|
||||||
assert.doesNotMatch(module, /button\.disabled = /);
|
|
||||||
// Tab is confined to the launcher, and ESC always releases it.
|
// Tab is confined to the launcher, and ESC always releases it.
|
||||||
const keyboard = fs.readFileSync(
|
const keyboard = fs.readFileSync(
|
||||||
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
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>
|
<template>
|
||||||
<!-- Explicit mission choice on a fresh session. Live feeds are not auto-enabled
|
<!-- 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
|
until the visitor asks; shared links skip this surface entirely. It returns
|
||||||
every session until "Don't show this again" is ticked — see
|
every session until "Don't show this again" is ticked — see
|
||||||
src/firstRunExperience.js for the full show policy. -->
|
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>
|
<div class="first-run-scanline" aria-hidden="true"></div>
|
||||||
<header class="first-run-header">
|
<header class="first-run-header">
|
||||||
<span class="first-run-kicker" data-i18n>MISSION CONTROL · FIRST LAUNCH</span>
|
<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
|
— 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
|
like something you should not have access to → the sources are all
|
||||||
public → the data is real. Tile subcopy stays purely functional. -->
|
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>
|
<p id="first-run-description" data-i18n-runtime>{{ launcher?.description ?? DESCRIPTION }}</p>
|
||||||
<div class="first-run-choices ui-scroll">
|
<div class="first-run-choices ui-scroll" :data-scrollable="launcher?.scrollable ?? undefined">
|
||||||
<button type="button" data-first-run-choice="contacts">
|
<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 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><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>
|
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||||
</button>
|
</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 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><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>
|
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||||
</button>
|
</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>
|
<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
|
<!-- Subcopy names BOTH feeds this tile turns on, because the launcher
|
||||||
optimizes for the fully configured experience (owner ruling,
|
optimizes for the fully configured experience (owner ruling,
|
||||||
2026-08-23) — see the mission table in src/firstRunExperience.js. -->
|
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>
|
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||||
</button>
|
</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 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><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>
|
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||||
@@ -42,19 +68,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="first-run-footer">
|
<div class="first-run-footer">
|
||||||
<label class="first-run-suppress">
|
<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>
|
<span data-i18n>Don't show this again</span>
|
||||||
</label>
|
</label>
|
||||||
<!-- A button, not just a hint: phones and tablets have no ESC key. It
|
<!-- 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
|
closes exactly as ESC does, for this session only. Touch screens
|
||||||
see DISMISS; a keyboard keeps the ESC hint (first-run.css). -->
|
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-key" data-i18n>ESC to dismiss</span>
|
||||||
<span class="first-run-dismiss-touch" data-i18n>DISMISS</span>
|
<span class="first-run-dismiss-touch" data-i18n>DISMISS</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
|
<!-- 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. -->
|
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>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
Reference in new issue
Block a user