From f846aaed4ce610267ea4579157e5abcc0835d166 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 19:58:59 -0700 Subject: [PATCH] feat(ui): render the page toast from the status store ShellFeedback and the scene director show toasts through createToast(), each on its own timer as before, and the scene chrome renders the toast. Every show rewrites the text, so a repeated message is announced again. --- docs/VUE-MIGRATION.md | 4 +- scripts/format-scope.json | 4 +- scripts/package-boundaries.json | 10 ++-- scripts/qa-context-arrivals.mjs | 14 +++++- src/scenes/director.js | 18 ++------ src/scenes/director.test.mjs | 5 ++ src/ui/shellElements.js | 1 - src/ui/shellFeedback.js | 20 +++----- src/ui/shellLifecycle.test.mjs | 16 +++++-- src/ui/stores/status.js | 16 ++++++- src/ui/toast.js | 42 +++++++++++++++++ src/ui/toastView.test.mjs | 79 ++++++++++++++++++++++++++++++++ src/ui/views/SceneChromeView.vue | 17 +++++-- 13 files changed, 203 insertions(+), 43 deletions(-) create mode 100644 src/ui/toast.js create mode 100644 src/ui/toastView.test.mjs diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 759e454b0..e12681ca6 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -147,7 +147,9 @@ a feature supplying only its accent. (the top-center loading status and the road network and camera grid chips render what ShellFeedback and CctvControls publish to the `gev-status` store; `SplitFlapText` plays the departure-board flip that - `splitFlap.js` plans, writing only the label's one text node), then + `splitFlap.js` plans, writing only the label's one text node; the toast + renders what each owner's `createToast()` publishes there, on that + owner's own timer), then Cockpit (in progress: `CockpitBriefing` renders the briefing card's pages, cycle toggle, regional news and local conditions, and `CockpitContext` the Contact card's scope, navigation, counts, nearest diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d44430021..c4cb057aa 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1130,5 +1130,7 @@ "src/ui/stores/panels.test.mjs", "src/ui/stores/status.js", "src/ui/statusChipsView.test.mjs", - "src/ui/statusChipOwners.test.mjs" + "src/ui/statusChipOwners.test.mjs", + "src/ui/toast.js", + "src/ui/toastView.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 31759cc44..d3f80f6dd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1016,7 +1016,8 @@ "src/ui/stores/voice.js", "src/ui/stores/location.js", "src/ui/stores/display.js", - "src/ui/stores/status.js" + "src/ui/stores/status.js", + "src/ui/toast.js" ], "external": ["cesium", "vue", "pinia"] }, @@ -1144,7 +1145,9 @@ "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/visualStylePolicy.js", - "src/ui/vueRuntime.js" + "src/ui/vueRuntime.js", + "src/ui/stores/status.js", + "src/ui/toast.js" ], "external": ["cesium", "vue", "pinia"] }, @@ -3000,7 +3003,8 @@ "src/ui/stores/location.js", "src/ui/stores/display.js", "src/ui/stores/imagerySplit.js", - "src/ui/stores/status.js" + "src/ui/stores/status.js", + "src/ui/toast.js" ], "external": [ "hls.js", diff --git a/scripts/qa-context-arrivals.mjs b/scripts/qa-context-arrivals.mjs index 0128f44e5..72a6ffab8 100644 --- a/scripts/qa-context-arrivals.mjs +++ b/scripts/qa-context-arrivals.mjs @@ -474,12 +474,22 @@ async function contacts() { // SEARCH NEARBY SITES, as the user presses it. await page.evaluate(() => { - document.getElementById('toast').textContent = ''; + const toast = document.getElementById('toast'); + const before = toast.firstChild; document.getElementById('installations-search-btn').click(); + window.__qaToastBefore = before; }); + // Every toast show writes a new text node, even for a repeated message. await page .waitForFunction( - () => document.getElementById('toast').textContent.trim().length > 0, + () => { + const toast = document.getElementById('toast'); + return ( + toast.classList.contains('visible') && + toast.firstChild !== window.__qaToastBefore && + toast.textContent.trim().length > 0 + ); + }, { timeout: 20_000 }, ) .catch(() => {}); diff --git a/src/scenes/director.js b/src/scenes/director.js index a0c61cec1..88e4e3a8f 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -17,6 +17,7 @@ import { resolveCameraPose, resolveCameraMove } from '../director/camera.js'; import { createCameraMotion } from './cameraMotion.js'; import { createStateChannel } from '../app/stateChannel.js'; import { SceneControls } from '../ui/scenes.js'; +import { createToast } from '../ui/toast.js'; import { buildPlaybackQueue, playSceneQueue } from '../director/playback.js'; import { createScenePlaybackAdapter } from './playbackAdapter.js'; import * as Cesium from 'cesium'; @@ -252,7 +253,7 @@ export class SceneDirector { this._loadAbort?.abort(); this._loadGeneration++; this.viewer.camera.cancelFlight(); - clearTimeout(this._storageToastTimer); + this._storageToast?.cancel(); await Promise.allSettled(this._pendingWork || []); this._clock.destroy(); this._cancelActiveSceneTravel(); @@ -365,19 +366,8 @@ export class SceneDirector { message = t('Scene not saved — browser storage unavailable'), ) { this._updateStatus(message); - try { - const toast = document.getElementById('toast'); - if (toast) { - toast.textContent = message; - toast.classList.add('visible'); - clearTimeout(this._storageToastTimer); - this._storageToastTimer = setTimeout(() => { - toast.classList.remove('visible'); - }, 2600); - } - } catch { - /* toast is best-effort */ - } + this._storageToast ??= createToast({ durationMs: 2600 }); + this._storageToast.show(message); } /** Immutable playback snapshots and completed editing actions. */ diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 7b59507be..aad01054d 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -1,4 +1,6 @@ import { readShellSource } from '../testSupport/readShellSource.mjs'; +import { useStatusStore } from '../ui/stores/status.js'; +import { applicationPinia } from '../ui/vueRuntime.js'; // Director-level pins for scene playback. // // scenePolicy.test.mjs pins the pure decisions; these pin the wiring, which is @@ -1995,6 +1997,9 @@ test('a capture, update or import that cannot be saved keeps the "Scene not save { prepared: { project: structuredClone(PROJECT_FIXTURE), assets: [] } }, ), true); assert.match(status, /^Scene not saved/, 'failed import keeps the save failure'); + const toast = useStatusStore(applicationPinia()).toast; + assert.equal(toast.visible, true); + assert.match(toast.text, /^Scene not saved/, 'the page toast says so too'); } finally { console.warn = warn; await director.destroy(); restore(); } }); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index bf2ac0ceb..c1944b995 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -103,7 +103,6 @@ export function readShellElements(document = globalThis.document) { _resetGlobeBtn: document.getElementById('reset-globe-view'), _cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'), _styleButtons: document.getElementById('style-buttons'), - _toast: document.getElementById('toast'), _locationSearch: document.getElementById('location-search'), _searchToggle: document.getElementById('search-toggle'), _safeFrameOverlay: document.getElementById('safe-frame-overlay'), diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js index 22c2346b2..6de4f62a8 100644 --- a/src/ui/shellFeedback.js +++ b/src/ui/shellFeedback.js @@ -12,6 +12,7 @@ import { getLocale, t, msg } from '../i18n/index.js'; import { localizeLayerName } from './layerPanel.js'; import { translateFeedError } from './statusText.js'; import { useStatusStore } from './stores/status.js'; +import { createToast } from './toast.js'; import { applicationPinia } from './vueRuntime.js'; // The server also loads loadingFeedback.js (through the HUD summary), so its @@ -130,10 +131,7 @@ export class ShellFeedback { this._trafficSyncFeedbackState = createTrafficSyncFeedbackState(); this._loadingFeedbackEvent = null; this._globalStatusNotice = null; - this._loadingFeedbackTicker = - this._trafficChipTicker = - this._toastTimer = - null; + this._loadingFeedbackTicker = this._trafficChipTicker = null; this._loadingVisibilityHandler = null; this._lastLoadingFeedbackUpdateAt = 0; this._lastTrafficChipUpdateAt = 0; @@ -153,7 +151,7 @@ export class ShellFeedback { progress: '0%', stopped: false, }; - this._toast = document.getElementById('toast'); + this._toast = createToast({ durationMs: 2000, store }); } observeVisibility() { if (this.destroyed || this._loadingVisibilityHandler) return; @@ -326,27 +324,21 @@ export class ShellFeedback { _showToast(message) { if (this.destroyed) return; - this._toast.textContent = message; - this._toast.classList.add('visible'); - clearTimeout(this._toastTimer); - this._toastTimer = setTimeout(() => { - this._toast.classList.remove('visible'); - }, 2000); + this._toast.show(message); } destroy() { if (this.destroyed) return; this.destroyed = true; this._stopLoadingFeedbackTicker(); clearInterval(this._trafficChipTicker); - clearTimeout(this._toastTimer); - this._trafficChipTicker = this._toastTimer = null; + this._trafficChipTicker = null; if (this._loadingVisibilityHandler) document.removeEventListener( 'visibilitychange', this._loadingVisibilityHandler, ); this._loadingVisibilityHandler = null; - this._toast?.classList.remove('visible'); + this._toast.hide(); // The labels rest at once; a cascade does not outlive its owner. this._publishLoading({ hidden: true, state: null, stopped: true }); this._publishTraffic({ stopped: true }); diff --git a/src/ui/shellLifecycle.test.mjs b/src/ui/shellLifecycle.test.mjs index 94c955c38..fac9316a0 100644 --- a/src/ui/shellLifecycle.test.mjs +++ b/src/ui/shellLifecycle.test.mjs @@ -53,7 +53,6 @@ function fixture() { 'left-panel-stack', 'right-context-rail', 'pp-toggles', - 'toast', 'safe-frame-overlay', 'safe-frame-box', 'hud-toggle', @@ -175,9 +174,15 @@ test('feedback disposal clears toast and polling work and rejects retained notic const f = fixture(); try { const loading = []; + const toasts = []; const owner = new ShellFeedback({ readLayers: () => [], store: { + toast: null, + publishToast(view) { + this.toast = view; + toasts.push(view); + }, publishLoading: (view) => loading.push(view), publishTraffic() {}, }, @@ -194,8 +199,13 @@ test('feedback disposal clears toast and polling work and rejects retained notic owner._startTrafficChipTicker(); document.dispatchEvent(new Event('visibilitychange')); assert.equal(f.timers.size, 0); - assert.equal(f.nodes.get('toast').textContent, 'Saved'); - assert.equal(f.nodes.get('toast').classList.contains('visible'), false); + assert.deepEqual( + toasts.map(({ text, visible }) => ({ text, visible })), + [ + { text: 'Saved', visible: true }, + { text: 'Saved', visible: false }, + ], + ); assert.equal(loading.at(-1).hidden, true); assert.equal(loading.at(-1).label, 'Saving', 'the hidden label stays'); } finally { diff --git a/src/ui/stores/status.js b/src/ui/stores/status.js index efe0cbe03..599b320b7 100644 --- a/src/ui/stores/status.js +++ b/src/ui/stores/status.js @@ -4,7 +4,8 @@ import { markRaw, shallowRef } from 'vue'; /** * The scene's status chips as their views render them. ShellFeedback * (src/ui/shellFeedback.js) publishes the top-center loading status and the - * road network chip, CctvControls the camera grid chip. Each label keeps its + * road network chip, CctvControls the camera grid chip; the toast shows + * what any owner's createToast() (src/ui/toast.js) publishes. Each label keeps its * last text while hidden, so the next change flaps from what was shown. */ export const useStatusStore = defineStore('gev-status', () => { @@ -18,6 +19,12 @@ export const useStatusStore = defineStore('gev-status', () => { /** `{visible, label, immediate, progress, stopped}`, or null. */ const cctv = shallowRef(null); + /** + * The page toast, `{text, visible, shown}`; null until the first one. + * `shown` changes with every show, which rewrites the text. + */ + const toast = shallowRef(null); + /** @param {?object} next A fresh presentation of the loading status. */ function publishLoading(next) { loading.value = next && markRaw(next); @@ -33,7 +40,14 @@ export const useStatusStore = defineStore('gev-status', () => { cctv.value = next && markRaw(next); } + /** @param {?{text: string, visible: boolean, shown: number}} next */ + function publishToast(next) { + toast.value = next && markRaw(next); + } + return { + toast, + publishToast, loading, publishLoading, traffic, diff --git a/src/ui/toast.js b/src/ui/toast.js new file mode 100644 index 000000000..0df985467 --- /dev/null +++ b/src/ui/toast.js @@ -0,0 +1,42 @@ +import { useStatusStore } from './stores/status.js'; +import { applicationPinia } from './vueRuntime.js'; + +// Counts every show on the page, so a repeated message is announced again. +let shown = 0; + +/** + * One owner's use of the page toast, which the scene chrome renders from the + * status store. Each owner keeps its own timer, so a later toast from another + * owner is hidden when this one's time is up, as the shared element was. + * @param {object} options + * @param {number} options.durationMs How long a toast stays up. + * @param {object} [options.store] The status store. + */ +export function createToast({ + durationMs, + store = useStatusStore(applicationPinia()), +}) { + let timer = null; + // The text stays while the toast fades out. + const hide = () => { + clearTimeout(timer); + timer = null; + if (store.toast?.visible) + store.publishToast({ ...store.toast, visible: false }); + }; + return { + /** @param {string} message */ + show(message) { + store.publishToast({ text: message, visible: true, shown: ++shown }); + clearTimeout(timer); + timer = setTimeout(hide, durationMs); + }, + /** Hide the toast now and drop the timer. */ + hide, + /** Drop the timer, leaving the toast as it is. */ + cancel() { + clearTimeout(timer); + timer = null; + }, + }; +} diff --git a/src/ui/toastView.test.mjs b/src/ui/toastView.test.mjs new file mode 100644 index 000000000..e82dd0b4a --- /dev/null +++ b/src/ui/toastView.test.mjs @@ -0,0 +1,79 @@ +// src/ui/toastView.test.mjs — the page toast renders what its owners' +// createToast() publish, each owner on its own timer. +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 { useStatusStore } from './stores/status.js'; +import { createToast } from './toast.js'; +import SceneChromeView from './views/SceneChromeView.vue'; + +function fixture(t) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(SceneChromeView).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + const store = useStatusStore(pinia); + const toast = () => document.getElementById('toast'); + return { store, toast }; +} + +test('a toast shows its message, then fades out keeping its text', async (t) => { + const { store, toast } = fixture(t); + assert.equal(toast().textContent, ''); + assert.equal(toast().classList.contains('visible'), false); + const owner = createToast({ durationMs: 2000, store }); + owner.show('Link copied'); + await nextTick(); + assert.equal(toast().textContent, 'Link copied'); + assert.equal(toast().classList.contains('visible'), true); + assert.equal(toast().getAttribute('role'), 'status'); + t.mock.timers.tick(1999); + await nextTick(); + assert.equal(toast().classList.contains('visible'), true); + t.mock.timers.tick(1); + await nextTick(); + assert.equal(toast().classList.contains('visible'), false); + assert.equal(toast().textContent, 'Link copied'); +}); + +test('a newer toast restarts the time, and another owner hides it on its own time', async (t) => { + const { store, toast } = fixture(t); + const shell = createToast({ durationMs: 2000, store }); + const scenes = createToast({ durationMs: 2600, store }); + scenes.show('Scene not saved'); + t.mock.timers.tick(1000); + shell.show('Saved'); + t.mock.timers.tick(1600); + await nextTick(); + assert.equal(toast().textContent, 'Saved'); + assert.equal(toast().classList.contains('visible'), false); + shell.show('Again'); + scenes.cancel(); + shell.hide(); + await nextTick(); + assert.equal(toast().classList.contains('visible'), false); + assert.equal(toast().textContent, 'Again'); + t.mock.timers.tick(5000); + assert.equal(store.toast.text, 'Again'); + assert.equal(store.toast.visible, false); +}); + +test('a repeated message is written again so the live region announces it', async (t) => { + const { store, toast } = fixture(t); + const owner = createToast({ durationMs: 2000, store }); + owner.show('Fly to a POI first'); + await nextTick(); + const first = toast().firstChild; + t.mock.timers.tick(2000); + await nextTick(); + assert.ok(toast().firstChild === first, 'fading out leaves the text alone'); + owner.show('Fly to a POI first'); + await nextTick(); + assert.equal(toast().textContent, 'Fly to a POI first'); + assert.ok(toast().firstChild !== first, 'the repeat is a new text node'); + assert.equal(toast().classList.contains('visible'), true); +}); diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index 4ba941e7d..552880233 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -9,8 +9,19 @@ import SplitFlapText from './panels/SplitFlapText.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); -// The status chips show what ShellFeedback and CctvControls publish. -const { loading, traffic, cctv } = storeToRefs(useStatusStore()); +// The status chips and toast show what ShellFeedback, CctvControls and +// createToast() publish. +const { loading, traffic, cctv, toast } = storeToRefs(useStatusStore()); +// Every show rewrites the toast's text, so the live region announces a +// repeated message again; hiding leaves it as it fades. +const vToastText = { + mounted(el, { value }) { + el.textContent = value?.text ?? ''; + }, + updated(el, { value, oldValue }) { + if (value?.shown !== oldValue?.shown) el.textContent = value?.text ?? ''; + }, +};