diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index dda5a86e3..c32945e05 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -5551,10 +5551,10 @@ pins and share links already supply complete boxes.
### Split-flap status chips (August 2026)
-- The three status chips flip their LABELS over character by character when the text changes, like a departure board: `#global-loading-label` ("LOADING LIVE DATA" → "LOAD COMPLETE"), `#traffic-sync-label`, and `#cctv-sync-label` ("loading frames" → "camera grid ready"). All three route through `setSplitFlapText()` in `src/splitFlap.js`; there is no other writer of those three elements. The progress counters (`#*-sync-progress`, `#global-loading-detail`) are deliberately left as plain `textContent` — they tick several times a second, and flapping them reads as a slot machine.
-- `#global-loading-label` doubles as the universal top-center status banner, so anything routed through `_showGlobalStatusNotice()` flaps as well — in particular the share-link restore notices, of which "Shared military flight could not be restored — feed unavailable" is the longest at 63 characters. That needs no special case: `planSplitFlap()` compresses the stagger to hold the 620 ms budget (26 ms → 6.9 ms per column at that length), every column is reserved for the whole cascade, and `element.textContent` is the complete notice at every instant, so the `aria-live` region announces the whole sentence rather than a fragment. A notice deferred minutes past boot is equally safe: `ensureHost()` re-validates the shell on every call, and the long-lived `Text` node is never replaced.
-- **DOM text is the truth, and its node NEVER moves — do not "fix" this.** The first call upgrades a chip label into a permanent shell (`ensureHost`): a `.gev-flap-text` span holding one long-lived `Text` node, plus an `aria-hidden` `.gev-flap-cells` sibling. After that the ONLY text operation for the life of the chip is `node.data = next`. Nothing is reparented, so the label is never transiently empty and the `aria-live` region never sees a removal/reinsertion pair it could announce twice. `element.textContent` is the settled string at every instant, because the cells carry no text at all: both glyphs are CSS generated content (`::before` from `data-flap-prev` = outgoing, `::after` from `data-flap-next` = incoming), which never reaches `textContent`. This keeps QA pins honest and lets `_updateTrafficSyncChip`'s own `textContent !==` guard keep working. The shell is built on a tick where the text is NOT changing, so no real label change ever carries a structural mutation.
-- **No animation loop, and exactly ONE `setTimeout` per change.** CSS `animation`/`transition` only, triggered once per text change and staggered through a per-cell `--gev-flap-delay`. The single timer is the settle that strips the cells; the width ease ends on a `transitionend`/`transitioncancel` listener, never a second timer. Idle cost is zero, there is no periodic work, and nothing requests a Cesium render or takes a render-governor hold. `setSplitFlapText` is a no-op on unchanged text, which is required — the chips are repainted by a 60 ms and a 500 ms ticker.
+- The three status chips flip their LABELS over character by character when the text changes, like a departure board: `#global-loading-label` ("LOADING LIVE DATA" → "LOAD COMPLETE"), `#traffic-sync-label`, and `#cctv-sync-label` ("loading frames" → "camera grid ready"). ShellFeedback and CctvControls publish them to the `gev-status` store, and the scene chrome renders each label with `SplitFlapText.vue`, which plays the cascade `src/splitFlap.js` plans; there is no other writer of those three labels. When an owner is destroyed it publishes the label `stopped`, which rests it at once. The progress counters (`#*-sync-progress`, `#global-loading-detail`) are deliberately left as plain `textContent` — they tick several times a second, and flapping them reads as a slot machine.
+- `#global-loading-label` doubles as the universal top-center status banner, so anything routed through `_showGlobalStatusNotice()` flaps as well — in particular the share-link restore notices, of which "Shared military flight could not be restored — feed unavailable" is the longest at 63 characters. That needs no special case: `planSplitFlap()` compresses the stagger to hold the 620 ms budget (26 ms → 6.9 ms per column at that length), every column is reserved for the whole cascade, and `element.textContent` is the complete notice at every instant, so the `aria-live` region announces the whole sentence rather than a fragment. A notice deferred minutes past boot is equally safe: the long-lived `Text` node is never replaced.
+- **DOM text is the truth, and its node NEVER moves — do not "fix" this.** The label is a `.gev-flap-text` span holding one long-lived `Text` node, plus an `aria-hidden` `.gev-flap-cells` sibling; the component's `v-flap-text` directive writes it, because Vue would patch a lone `{{ text }}` child by setting `textContent` and swap the node. The ONLY text operation for the life of the chip is `node.data = next`. Nothing is reparented, so the label is never transiently empty and the `aria-live` region never sees a removal/reinsertion pair it could announce twice. `element.textContent` is the settled string at every instant, because the cells carry no text at all: both glyphs are CSS generated content (`::before` from `data-flap-prev` = outgoing, `::after` from `data-flap-next` = incoming), which never reaches `textContent`. This keeps QA pins honest. Whether the label is on screen is read once the change has rendered, after its chip shows or hides with it; the cells go in before the browser paints.
+- **No animation loop, and exactly ONE `setTimeout` per change.** CSS `animation`/`transition` only, triggered once per text change and staggered through a per-cell `--gev-flap-delay`. The single timer is the settle that strips the cells; the width ease ends on a `transitionend`/`transitioncancel` listener, never a second timer. Idle cost is zero, there is no periodic work, and nothing requests a Cesium render or takes a render-governor hold. An unchanged label is not republished, which is required — the chips are recomputed by a 60 ms and a 500 ms ticker.
- **Only what was visible flaps away.** An interrupted cascade (A→B cut short by C) derives each column's outgoing glyph from `visibleGlyphs()` — what that column is actually SHOWING at that instant, which for a column whose stagger has not elapsed is still A, not the pending B. `FLAP_TURN_RATIO` must track the `gev-flap-out`/`gev-flap-in` keyframe crossover in style.css.
- **Columns never renumber mid-cascade — do not "optimise" this away.** For the whole cascade the board keeps one column per index of the LONGER string, each holding its own width; a column the new string does not reach flaps to a BLANK in place (`data-flap-next=" "`) rather than collapsing. Collapsing stacks the absolutely-positioned outgoing glyphs on one x AND lets a later glyph slide into an earlier column, which makes `visibleGlyphs()` lie and the interrupt rule flap the wrong glyph away. Pinned by "a cleared column holds its place instead of letting later glyphs slide left".
- Length changes are eased, never snapped, and the ease is placed so it never fights the flaps (`.gev-flap-sizing`): a GROWING label reserves its columns as the cells go in and eases at the START; a SHRINKING one holds full width for the whole cascade and eases at SETTLEMENT.
diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md
index 5caa59f1c..759e454b0 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -143,7 +143,11 @@ a feature supplying only its accent.
imagery dividers (`ImagerySplitView`, in the scene chrome, teleports each
before/after divider that `createImagerySplit` publishes to the
`gev-imagery-split` store to the body; the split keeps the
- drag, keyboard, CSS position and scene split), then
+ drag, keyboard, CSS position and scene split), the status chips
+ (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
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 36a7ce514..d44430021 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -1127,5 +1127,8 @@
"build/vueLoader.js",
"build/registerVue.js",
"src/ui/panelNavigation.test.mjs",
- "src/ui/stores/panels.test.mjs"
+ "src/ui/stores/panels.test.mjs",
+ "src/ui/stores/status.js",
+ "src/ui/statusChipsView.test.mjs",
+ "src/ui/statusChipOwners.test.mjs"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 1173730e4..31759cc44 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -94,7 +94,10 @@
"src/ui/rail/statusText.js",
"src/voice/control.js",
"src/ui/views/panels/ImagerySplitView.vue",
- "src/ui/stores/imagerySplit.js"
+ "src/ui/stores/imagerySplit.js",
+ "src/ui/stores/status.js",
+ "src/ui/views/panels/SplitFlapText.vue",
+ "src/splitFlap.js"
],
"external": ["vue", "pinia"]
},
@@ -702,7 +705,8 @@
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
"src/ui/vueRuntime.js",
- "src/ui/stores/cctv.js"
+ "src/ui/stores/cctv.js",
+ "src/ui/stores/status.js"
],
"external": ["vue", "pinia"]
},
@@ -847,7 +851,6 @@
"src/navigationPolicy.js",
"src/panelStackLayout.js",
"src/rightRailPolicy.js",
- "src/splitFlap.js",
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/retro.js",
@@ -1012,7 +1015,8 @@
"src/ui/stores/sceneSharing.js",
"src/ui/stores/voice.js",
"src/ui/stores/location.js",
- "src/ui/stores/display.js"
+ "src/ui/stores/display.js",
+ "src/ui/stores/status.js"
],
"external": ["cesium", "vue", "pinia"]
},
@@ -2995,7 +2999,8 @@
"src/ui/stores/voice.js",
"src/ui/stores/location.js",
"src/ui/stores/display.js",
- "src/ui/stores/imagerySplit.js"
+ "src/ui/stores/imagerySplit.js",
+ "src/ui/stores/status.js"
],
"external": [
"hls.js",
diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs
index ac89ec5d0..f3d82e01e 100644
--- a/scripts/qa-ui-disposal.mjs
+++ b/scripts/qa-ui-disposal.mjs
@@ -118,10 +118,18 @@ try {
if (type === 'resize' && callback === resizeHandler) counts.resize++;
return removeEventListener.call(this, type, callback, options);
};
- const { setSplitFlapText } = await import('/src/splitFlap.js');
+ // Through the owner, so its copy of the label and the store agree.
const feedbackLabel = document.getElementById('global-loading-label');
- setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA');
- setSplitFlapText(feedbackLabel, 'LOAD COMPLETE');
+ for (const label of ['CHECKING LIVE DATA', 'LOAD COMPLETE']) {
+ ui._feedback._publishLoading({
+ hidden: false,
+ state: 'loading',
+ label,
+ detail: '',
+ });
+ // Vue renders on a microtask; a task later the label shows it.
+ await new Promise((resolve) => setTimeout(resolve));
+ }
const permanentText =
feedbackLabel.querySelector('.gev-flap-text')?.firstChild;
let stateNotifications = 0;
@@ -202,6 +210,7 @@ try {
feedbackLabel.querySelector('.gev-flap-text')?.firstChild ===
permanentText &&
permanentText.data === 'LOAD COMPLETE' &&
+ document.getElementById('global-loading-status').hidden &&
!feedbackLabel.classList.contains('gev-flap-active') &&
feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length ===
0,
diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs
index f838e3b9c..d892ddf1c 100644
--- a/src/loadingFeedback.test.mjs
+++ b/src/loadingFeedback.test.mjs
@@ -209,7 +209,7 @@ test('universal notice lifecycle clears on dispose and uses the one top-center l
const disposeEnd = ui.indexOf('\n }\n', disposeStart);
const dispose = ui.slice(disposeStart, disposeEnd);
- assert.match(dispose, /this\._feedback\._globalStatusNotice = null;/);
+ assert.match(dispose, /this\._feedback\.clearLoadingStatus\(\);/);
assert.match(dispose, /this\._shareRestoration\.destroy\(\)/);
assert.match(ShareRestoration.prototype.destroy.toString(), /this\._shareTrackingNoticeGeneration \+= 1;/);
assert.match(html, /
/);
@@ -539,20 +539,10 @@ test('the settled traffic chip shows exactly one percentage — the coverage it
assert.doesNotMatch(rendered, /100%/);
});
-test('the chip renderer clears the progress slot instead of stranding the last value', () => {
- const ui = readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8');
+test('an emptied progress slot collapses rather than leaving a min-width stub', () => {
+ // ShellFeedback publishes the empty settled progress
+ // (src/ui/statusChipOwners.test.mjs); the slot must then take no room.
const css = readStylesheet(new URL('../style.css', import.meta.url));
- const start = ui.indexOf(' _updateTrafficSyncChip(');
- assert.ok(start > 0, '_updateTrafficSyncChip is missing');
- const body = ui.slice(start, ui.indexOf('\n }', start));
- // A truthiness guard here would leave the busy "..." sitting beside the
- // settled label, which is the contradiction wearing a different hat.
- assert.doesNotMatch(body, /if \(presentation\.progressText\s*\n?\s*&&/);
- assert.match(
- body,
- /if \(this\._trafficSyncProgress\.textContent !== presentation\.progressText\) \{/,
- );
- // …and the emptied slot must collapse rather than leave a min-width stub.
assert.match(css, /#traffic-sync-progress:empty \{\s*display: none;\s*\}/);
});
diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs
index 41babb96a..c4a480b48 100644
--- a/src/radioMarkup.test.mjs
+++ b/src/radioMarkup.test.mjs
@@ -359,7 +359,6 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
assert.doesNotMatch(enableMethod, /contextRadio/);
assert.match(enableMethod, /toggleRadio\(this\._radioEnableBtn\)/);
assert.match(ui, /setAttribute\('aria-expanded', String\(/);
- assert.match(readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'), /\.hidden = !/);
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
});
diff --git a/src/splitFlap.js b/src/splitFlap.js
index 5b365471f..9d57e30c1 100644
--- a/src/splitFlap.js
+++ b/src/splitFlap.js
@@ -5,21 +5,21 @@
* "loading frames" → "camera grid ready" — the characters flip over
* mechanically, left to right, like a Solari board at a ferry terminal.
*
- * FOUR INVARIANTS HOLD THIS TOGETHER — do not "fix" any of them:
+ * This module plans the cascade; the SplitFlapText view
+ * (src/ui/views/panels/SplitFlapText.vue) renders it. FOUR INVARIANTS HOLD
+ * THIS TOGETHER — do not "fix" any of them:
*
- * 1. DOM TEXT IS THE TRUTH, AND ITS NODE NEVER MOVES. The first call upgrades
- * a chip label into a permanent two-child shell: a `.gev-flap-text` span
- * holding one long-lived `Text` node, and an `aria-hidden`
- * `.gev-flap-cells` sibling. After that the ONLY text operation for the
- * life of the chip is `node.data = next` — a single `characterData`
- * mutation. Nothing is ever reparented, so the label is never
- * transiently empty and the `aria-live` region never sees a removal /
+ * 1. DOM TEXT IS THE TRUTH, AND ITS NODE NEVER MOVES. The label is a
+ * `.gev-flap-text` span holding one long-lived `Text` node, beside an
+ * `aria-hidden` `.gev-flap-cells` sibling. The ONLY text operation for the
+ * life of the label is a change to that node's data — a single
+ * `characterData` mutation. Nothing is ever reparented, so the label is
+ * never transiently empty and the `aria-live` region never sees a removal /
* reinsertion pair it could announce twice. `element.textContent` is the
* settled string at every instant, because the cells carry NO text at all:
* both glyphs are CSS generated content (`::before` from `data-flap-prev`,
* `::after` from `data-flap-next`), which never reaches `textContent`.
- * QA pins and the traffic chip's own `textContent !==` guard in ui.js
- * therefore always read the truth; the flaps are purely visual.
+ * QA pins therefore always read the truth; the flaps are purely visual.
*
* 2. NO ANIMATION LOOP, AND EXACTLY ONE TIMER PER CHANGE. The motion is CSS
* `animation`/`transition` only, triggered once per text change and
@@ -72,7 +72,7 @@ export const FLAP_STAGGER_MS = 26;
*/
export const FLAP_MAX_TOTAL_MS = 620;
/** Slack after the last character lands before the cells are stripped. */
-const FLAP_SETTLE_SLACK_MS = 60;
+export const FLAP_SETTLE_SLACK_MS = 60;
/**
* Fraction of a character's flip at which the outgoing glyph has rotated
* away and the incoming one takes over as what the eye reads. MUST track the
@@ -81,38 +81,18 @@ const FLAP_SETTLE_SLACK_MS = 60;
*/
export const FLAP_TURN_RATIO = 0.5;
-const HOST_CLASS = 'gev-flap-host';
-const ACTIVE_CLASS = 'gev-flap-active';
-const TEXT_CLASS = 'gev-flap-text';
-const CELLS_CLASS = 'gev-flap-cells';
-const CELL_CLASS = 'gev-flap-cell';
-const FLAPPING_CLASS = 'is-flapping';
-const SIZING_CLASS = 'gev-flap-sizing';
-
/**
* What a reserved-but-empty column shows. A column the new string does not
* reach is not removed mid-cascade — it flaps to a blank and holds its place,
* which is both what a real board does and what keeps column indices stable.
*/
-const BLANK = ' ';
-
-/** In-flight cascade state, keyed by element so nothing is stored on the node. */
-const flapStates = new WeakMap();
-/** Teardown for an in-flight width ease (listeners, not timers). */
-const widthEases = new WeakMap();
+export const FLAP_BLANK = ' ';
function positiveNumber(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : fallback;
}
-function nowMs() {
- return typeof performance !== 'undefined' &&
- typeof performance.now === 'function'
- ? performance.now()
- : Date.now();
-}
-
/**
* Diff two strings into per-character flap cells with staggered delays.
*
@@ -207,7 +187,7 @@ export function planSplitFlap(fromText, toText, options = {}) {
*/
function displayedGlyph(cell, turned) {
if (!cell.changed) return cell.to;
- return (turned ? cell.to : cell.from) || BLANK;
+ return (turned ? cell.to : cell.from) || FLAP_BLANK;
}
/**
@@ -248,7 +228,7 @@ export function visibleGlyphs(plan, elapsedMs, options = {}) {
}
/** Whether the viewer asked for reduced motion. */
-function prefersReducedMotion() {
+export function prefersReducedMotion() {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
@@ -266,7 +246,7 @@ function prefersReducedMotion() {
* would animate a chip nobody can see. `checkVisibility` walks ancestors for
* exactly these properties; the fallback walks them by hand.
*/
-function isVisible(element) {
+export function isOnScreen(element) {
if (!element?.isConnected) return false;
if (typeof element.checkVisibility === 'function') {
return element.checkVisibility({
@@ -290,257 +270,3 @@ function isVisible(element) {
}
return true;
}
-
-function measureWidth(element) {
- return typeof element.getBoundingClientRect === 'function'
- ? element.getBoundingClientRect().width
- : 0;
-}
-
-/**
- * The chip label's permanent shell.
- *
- * Built once per element and then reused forever: the `Text` node inside
- * `.gev-flap-text` is the same object for the life of the chip, so a label
- * change never reparents anything (invariant 1). Rebuilt only if something
- * outside this module has clobbered the label's children.
- */
-function ensureHost(element) {
- // O(1) happy path: this runs on every chip tick, including the ones where
- // nothing changed.
- const text = element.firstElementChild;
- const cells = text?.nextElementSibling;
- if (
- text?.classList?.contains(TEXT_CLASS) &&
- text.firstChild?.nodeType === 3 &&
- cells?.classList?.contains(CELLS_CLASS) &&
- !cells.nextElementSibling
- ) {
- return { text, cells };
- }
-
- const doc = element.ownerDocument || globalThis.document;
- const carried = element.textContent ?? '';
- const nextText = doc.createElement('span');
- nextText.className = TEXT_CLASS;
- nextText.append(doc.createTextNode(carried));
- const nextCells = doc.createElement('span');
- nextCells.className = CELLS_CLASS;
- // Decorative only. Assistive tech ignores this subtree entirely, which is
- // why rebuilding it every change cannot disturb the aria-live region.
- nextCells.setAttribute('aria-hidden', 'true');
- element.classList.add(HOST_CLASS);
- element.replaceChildren(nextText, nextCells);
- return { text: nextText, cells: nextCells };
-}
-
-/** Drop the width transition, leaving the element at its natural width. */
-function clearSizing(element) {
- element.classList?.remove(SIZING_CLASS);
- element.style?.removeProperty('width');
- element.style?.removeProperty('--gev-flap-total');
-}
-
-function cancelWidthEase(element) {
- const teardown = widthEases.get(element);
- if (!teardown) return;
- widthEases.delete(element);
- teardown();
-}
-
-/**
- * Ease the element between two measured widths.
- *
- * Called twice per change, and exactly one of the two ever has work to do: a
- * GROWING label reserves its new columns the moment the cells go in, so the
- * ease happens at the start; a SHRINKING label holds every column for the
- * whole cascade, so the slack is only taken up once the flaps have landed.
- *
- * The end of the ease is a `transitionend` listener rather than a timer, so a
- * change still schedules exactly one `setTimeout` (invariant 2). A transition
- * that never fires is harmless: the pinned width equals the natural width it
- * was easing to, and the next change clears it regardless.
- */
-function easeWidth(element, fromWidth, toWidth, durationMs) {
- cancelWidthEase(element);
- if (!(fromWidth > 0.5) || Math.abs(toWidth - fromWidth) <= 0.5) {
- clearSizing(element);
- return false;
- }
- element.style.setProperty('--gev-flap-total', `${durationMs}ms`);
- element.style.width = `${fromWidth}px`;
- element.classList.add(SIZING_CLASS);
- void element.offsetWidth; // flush the start value so the transition runs
- element.style.width = `${toWidth}px`;
-
- const finish = (event) => {
- if (event && (event.target !== element || event.propertyName !== 'width'))
- return;
- cancelWidthEase(element);
- clearSizing(element);
- };
- const teardown = () => {
- element.removeEventListener('transitionend', finish);
- element.removeEventListener('transitioncancel', finish);
- };
- element.addEventListener('transitionend', finish);
- element.addEventListener('transitioncancel', finish);
- widthEases.set(element, teardown);
- return true;
-}
-
-function clearFlapTimer(element) {
- const state = flapStates.get(element);
- if (!state) return;
- clearTimeout(state.timer);
- flapStates.delete(element);
-}
-
-/**
- * Put the label back in its resting state: real text visible, no cells.
- *
- * The `Text` node is untouched apart from its data, and neither permanent span
- * is removed — only the decorative cells are emptied.
- */
-function rest(element, host) {
- element.classList.remove(ACTIVE_CLASS);
- host.cells.replaceChildren();
- element.style?.removeProperty('--gev-flap-dur');
- // The real text is the accessible name; a stale aria-label from an earlier
- // render would otherwise mask it.
- element.removeAttribute?.('aria-label');
-}
-
-/**
- * Strip the cells once the cascade lands, then take up any width slack.
- *
- * A shrinking label has been holding every column at full width for the whole
- * cascade (invariant 4), so this is where the board narrows — as one eased
- * transition, never a single-frame snap.
- */
-function settle(element, expected, easeMs) {
- flapStates.delete(element);
- // A newer label won the race — leave its cells alone.
- if (element.textContent !== expected) return;
- const host = ensureHost(element);
- const cascadeWidth = measureWidth(element);
- cancelWidthEase(element);
- clearSizing(element);
- rest(element, host);
- const naturalWidth = measureWidth(element);
- easeWidth(element, cascadeWidth, naturalWidth, easeMs);
-}
-
-/**
- * Set a chip label, flipping the changed characters into place.
- *
- * Safe to call on every tick: the chips are repainted by a 500 ms ticker and
- * a 60 ms one, so an unchanged write must be a no-op or the animation would
- * restart forever. Returns whether a flap was actually started.
- *
- * @param {HTMLElement|null} element - Chip label element.
- * @param {string} text - Text to settle on.
- * @param {{immediate?: boolean, charMs?: number, staggerMs?: number,
- * maxTotalMs?: number}} [options]
- * @returns {boolean} True when a flap animation was started.
- */
-export function setSplitFlapText(element, text, options = {}) {
- if (!element) return false;
- const next = String(text ?? '');
- // Upgrade to the permanent shell FIRST, so that one-time structural change
- // happens on a tick where the text is not changing. Every subsequent label
- // change is then a lone characterData mutation (invariant 1).
- const host = ensureHost(element);
- // textContent is the SETTLED string even mid-cascade, so this is the right
- // idempotence check against the repeating chip tickers.
- const settled = element.textContent ?? '';
- if (settled === next) return false;
-
- // What the viewer can SEE right now. Mid-cascade that is not the settled
- // string: columns whose stagger has not elapsed are still showing the
- // previous label's glyphs, and those are what must flap away.
- const state = flapStates.get(element);
- const displayed = state
- ? visibleGlyphs(state.plan, nowMs() - state.startedAt, {
- charMs: state.charMs,
- })
- : settled;
- clearFlapTimer(element);
-
- const beforeWidth = measureWidth(element);
-
- const animate =
- SPLIT_FLAP_ENABLED &&
- options.immediate !== true &&
- !prefersReducedMotion() &&
- isVisible(element);
-
- const plan = animate ? planSplitFlap(displayed, next, options) : null;
-
- // THE ONLY TEXT OPERATION. One characterData mutation, no reparenting, so
- // the label is never transiently empty and the live region cannot see a
- // removal/reinsertion pair (invariant 1).
- host.text.firstChild.data = next;
-
- if (!plan?.changedCount) {
- cancelWidthEase(element);
- clearSizing(element);
- rest(element, host);
- return false;
- }
-
- const doc = element.ownerDocument || globalThis.document;
- const charMs = positiveNumber(options.charMs, FLAP_CHAR_MS);
-
- const cellNodes = [];
- for (const cell of plan.cells) {
- const node = doc.createElement('span');
- node.className = CELL_CLASS;
- // Every column carries an in-flow incoming face, so every column holds a
- // width and its index for the whole cascade (invariant 4). A column the
- // new string does not reach flaps to a blank rather than collapsing.
- node.dataset.flapNext = cell.to || BLANK;
- if (cell.changed) {
- node.classList.add(FLAPPING_CLASS);
- node.dataset.flapPrev = cell.from || BLANK;
- node.style.setProperty('--gev-flap-delay', `${cell.delayMs}ms`);
- }
- cellNodes.push(node);
- }
-
- element.removeAttribute('aria-label');
- cancelWidthEase(element);
- clearSizing(element);
- // Only the decorative sibling is rebuilt; the text node is not involved.
- host.cells.replaceChildren(...cellNodes);
- element.classList.add(ACTIVE_CLASS);
- element.style.setProperty('--gev-flap-dur', `${charMs}ms`);
-
- // Width, phase one. A GROWING label reserves its new columns the instant the
- // cells go in, so ease from what was on screen to the cascade width. A
- // shrinking one is already at cascade width, so this is a no-op and the ease
- // happens in settle() instead, once the flaps have landed.
- easeWidth(element, beforeWidth, measureWidth(element), plan.durationMs);
-
- // The one and only timer this change schedules (invariant 2).
- flapStates.set(element, {
- plan,
- charMs,
- startedAt: nowMs(),
- timer: setTimeout(
- () => settle(element, next, plan.durationMs),
- plan.durationMs + FLAP_SETTLE_SLACK_MS,
- ),
- });
- return true;
-}
-
-/** Stop decoration without replacing the label's permanent accessible text. */
-export function disposeSplitFlap(element) {
- if (!element) return;
- clearFlapTimer(element);
- cancelWidthEase(element);
- clearSizing(element);
- const cells = element.querySelector?.('.gev-flap-cells');
- if (cells) rest(element, { cells });
-}
diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js
index 3bf2c9322..d0b1b3897 100644
--- a/src/ui/applicationShell.js
+++ b/src/ui/applicationShell.js
@@ -10,7 +10,6 @@ import { NavigationController } from './navigationController.js';
import { ShareRestoration } from './shareRestoration.js';
import { DisplayBindings } from './displayBindings.js';
import { createStateChannel } from '../app/stateChannel.js';
-import { setSplitFlapText } from '../splitFlap.js';
import { UiLifetime } from './uiLifetime.js';
import { RecordingControls } from './recordingControls.js';
import { readShellElements } from './shellElements.js';
@@ -1196,9 +1195,6 @@ export class StyleManager extends ShellFacade {
_cctvSelect: this._cctvSelect,
_cctvSourceBadge: this._cctvSourceBadge,
_cctvSummary: this._cctvSummary,
- _cctvSyncChip: this._cctvSyncChip,
- _cctvSyncLabel: this._cctvSyncLabel,
- _cctvSyncProgress: this._cctvSyncProgress,
},
cctv: cctvLayer,
actions: {
@@ -1210,7 +1206,6 @@ export class StyleManager extends ShellFacade {
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
showToast: (message) => this._showToast(message),
syncViewport: () => this._syncCctvPanelViewport(),
- setSplitFlapText,
},
});
}
@@ -1760,8 +1755,7 @@ export class StyleManager extends ShellFacade {
this.services;
if (this._disposed) return;
this._shareRestoration.destroy();
- this._feedback._globalStatusNotice = null;
- if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
+ this._feedback.clearLoadingStatus();
this._disposed = true;
this._navigation.stop();
this._shareState.destroy();
diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js
index c6858267f..ab2999cf3 100644
--- a/src/ui/cctvControls.js
+++ b/src/ui/cctvControls.js
@@ -14,17 +14,20 @@ import {
import {
_calBadgeLabel,
_renderCctvState,
+ _publishCctvChip,
_typeCctvSummary,
_updateCctvSyncChip,
} from './cctvPresentation.js';
import { _initCctvPanel } from './cctvBindings.js';
import { useCctvStore } from './stores/cctv.js';
+import { useStatusStore } from './stores/status.js';
import { applicationPinia } from './vueRuntime.js';
/**
* Own camera-panel interaction and presentation; receive the camera port and
* application actions. The panel view renders the buttons, CAL chip and meta
- * line from what this publishes (`gev-cctv` store).
+ * line from what this publishes (`gev-cctv` store), and the scene chrome the
+ * camera grid chip (`gev-status` store).
*/
export class CctvControls {
constructor({
@@ -32,9 +35,11 @@ export class CctvControls {
cctv,
actions,
store = useCctvStore(applicationPinia()),
+ statusStore = useStatusStore(applicationPinia()),
}) {
Object.assign(this, elements);
this.store = store;
+ this.statusStore = statusStore;
this.cctv = cctv;
this.actions = actions;
this.destroyed = false;
@@ -46,6 +51,8 @@ export class CctvControls {
this._lastSeenCctvActiveId = null;
this._cctvChipHideTimer = null;
this._cctvChipWasBusy = false;
+ // The camera grid chip as last published; its label stays while hidden.
+ this._cctvChip = null;
this._cctvFrameRequestToken = 0;
this._cctvFramePreloader = null;
this._frame = EMPTY_CCTV_FRAME;
@@ -117,6 +124,9 @@ export class CctvControls {
_typeCctvSummary(...args) {
return _typeCctvSummary.call(this, ...args);
}
+ _publishCctvChip(...args) {
+ return _publishCctvChip.call(this, ...args);
+ }
_updateCctvSyncChip(...args) {
return _updateCctvSyncChip.call(this, ...args);
}
@@ -138,6 +148,6 @@ export class CctvControls {
clearTimeout(this._cctvChipHideTimer);
this._cctvSummaryTypingTimer = null;
this._cctvChipHideTimer = null;
- this._cctvSyncChip?.classList.remove('visible');
+ this._publishCctvChip({ visible: false, stopped: true });
}
}
diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js
index 9b5ed82e0..ed5da4057 100644
--- a/src/ui/cctvPresentation.js
+++ b/src/ui/cctvPresentation.js
@@ -301,10 +301,27 @@ export function _typeCctvSummary(text) {
}, 20);
}
+/** Publish a change to the camera grid chip; its label stays while hidden. */
+export function _publishCctvChip(patch) {
+ const current = this._cctvChip ?? {
+ visible: false,
+ label: t('loading frames'),
+ immediate: false,
+ progress: '0/0',
+ stopped: false,
+ };
+ const next = { ...current, ...patch };
+ if (
+ this._cctvChip &&
+ Object.keys(next).every((key) => next[key] === current[key])
+ )
+ return;
+ this._cctvChip = next;
+ this.statusStore.publishCctv(next);
+}
+
export function _updateCctvSyncChip(loading, enabled) {
if (this.destroyed) return;
- if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress)
- return;
const total = Number(loading?.total) || 0;
const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total));
const busy = !!enabled && !!loading?.active && total > 0;
@@ -313,31 +330,35 @@ export function _updateCctvSyncChip(loading, enabled) {
clearTimeout(this._cctvChipHideTimer);
this._cctvChipHideTimer = null;
this._cctvChipWasBusy = true;
- this.actions.setSplitFlapText(this._cctvSyncLabel, t('loading frames'));
// The counter is left plain on purpose: it ticks every few frames
// during a grid load, and flapping it would read as a slot machine.
- this._cctvSyncProgress.textContent = `${loaded}/${total}`;
- this._cctvSyncChip.classList.add('visible');
+ this._publishCctvChip({
+ visible: true,
+ label: t('loading frames'),
+ progress: `${loaded}/${total}`,
+ });
return;
}
if (this._cctvChipWasBusy && enabled && total > 0) {
// Load just completed — flash the final count, then auto-hide.
this._cctvChipWasBusy = false;
- this.actions.setSplitFlapText(this._cctvSyncLabel, t('camera grid ready'));
- this._cctvSyncProgress.textContent = `${total}/${total}`;
- this._cctvSyncChip.classList.add('visible');
+ this._publishCctvChip({
+ visible: true,
+ label: t('camera grid ready'),
+ progress: `${total}/${total}`,
+ });
clearTimeout(this._cctvChipHideTimer);
this._cctvChipHideTimer = window.setTimeout(() => {
if (this.destroyed) return;
this._cctvChipHideTimer = null;
- this._cctvSyncChip.classList.remove('visible');
+ this._publishCctvChip({ visible: false });
}, 1500);
return;
}
if (!this._cctvChipHideTimer) {
this._cctvChipWasBusy = false;
- this._cctvSyncChip.classList.remove('visible');
+ this._publishCctvChip({ visible: false });
}
}
diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js
index f60213841..bf2ac0ceb 100644
--- a/src/ui/shellElements.js
+++ b/src/ui/shellElements.js
@@ -100,18 +100,9 @@ export function readShellElements(document = globalThis.document) {
_tiltMapBtn: document.getElementById('tilt-map-view'),
_northUpBtn: document.getElementById('north-up-view'),
_clearSelectedLayersBtn: document.getElementById('clear-selected-layers'),
- _globalLoadingStatus: document.getElementById('global-loading-status'),
- _globalLoadingLabel: document.getElementById('global-loading-label'),
- _globalLoadingDetail: document.getElementById('global-loading-detail'),
_resetGlobeBtn: document.getElementById('reset-globe-view'),
_cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'),
_styleButtons: document.getElementById('style-buttons'),
- _trafficSyncChip: document.getElementById('traffic-sync-chip'),
- _trafficSyncLabel: document.getElementById('traffic-sync-label'),
- _trafficSyncProgress: document.getElementById('traffic-sync-progress'),
- _cctvSyncChip: document.getElementById('cctv-sync-chip'),
- _cctvSyncLabel: document.getElementById('cctv-sync-label'),
- _cctvSyncProgress: document.getElementById('cctv-sync-progress'),
_toast: document.getElementById('toast'),
_locationSearch: document.getElementById('location-search'),
_searchToggle: document.getElementById('search-toggle'),
diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js
index 332fa50af..22c2346b2 100644
--- a/src/ui/shellFeedback.js
+++ b/src/ui/shellFeedback.js
@@ -8,10 +8,11 @@ import {
reduceLoadingFeedback,
reduceTrafficSyncFeedback,
} from '../loadingFeedback.js';
-import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
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 { applicationPinia } from './vueRuntime.js';
// The server also loads loadingFeedback.js (through the HUD summary), so its
// labels stay English. Translate the known ones; pass anything else (a live
@@ -116,8 +117,14 @@ export function localizeLoadingPresentation(presentation) {
};
}
export class ShellFeedback {
- constructor({ readLayers }) {
+ /**
+ * @param {object} options
+ * @param {() => object[]} options.readLayers The data layers' states.
+ * @param {object} [options.store] The status store the chips render from.
+ */
+ constructor({ readLayers, store = useStatusStore(applicationPinia()) }) {
this.readLayers = readLayers;
+ this.store = store;
this.destroyed = false;
this._loadingFeedbackState = createLoadingFeedbackState();
this._trafficSyncFeedbackState = createTrafficSyncFeedbackState();
@@ -131,18 +138,21 @@ export class ShellFeedback {
this._lastLoadingFeedbackUpdateAt = 0;
this._lastTrafficChipUpdateAt = 0;
this._lastTrafficGlobalUpdateAt = 0;
- this._globalLoadingStatus = document.getElementById(
- 'global-loading-status',
- );
- this._globalLoadingLabel = document.getElementById('global-loading-label');
- this._globalLoadingDetail = document.getElementById(
- 'global-loading-detail',
- );
- this._trafficSyncChip = document.getElementById('traffic-sync-chip');
- this._trafficSyncLabel = document.getElementById('traffic-sync-label');
- this._trafficSyncProgress = document.getElementById(
- 'traffic-sync-progress',
- );
+ // What the chips last showed; a label stays while its chip is hidden.
+ this._loadingView = {
+ hidden: true,
+ state: null,
+ label: t('LOADING LIVE DATA'),
+ detail: '',
+ stopped: false,
+ };
+ this._trafficView = {
+ visible: false,
+ label: t('syncing road network'),
+ immediate: false,
+ progress: '0%',
+ stopped: false,
+ };
this._toast = document.getElementById('toast');
}
observeVisibility() {
@@ -158,12 +168,6 @@ export class ShellFeedback {
}
_updateTrafficSyncChip(forceShow = false, now = performance.now()) {
if (this.destroyed) return;
- if (
- !this._trafficSyncChip ||
- !this._trafficSyncLabel ||
- !this._trafficSyncProgress
- )
- return;
const layers = this.readLayers();
const traffic = Array.isArray(layers)
? layers.find((layer) => layer.id === 'traffic')
@@ -178,30 +182,22 @@ export class ShellFeedback {
now,
);
const presentation = this._trafficSyncFeedbackState;
- // setSplitFlapText carries the same unchanged-text guard internally, and
- // the flap keeps textContent equal to the settled label throughout, so
- // this stays a no-op on the repeat ticks exactly as it did before.
- if (presentation.label)
- setSplitFlapText(
- this._trafficSyncLabel,
- localizeStatusLabel(presentation.label),
- {
- // Completion is a timing signal, not another animated loading stage.
- immediate: !presentation.busy,
- },
- );
- // Written on every change INCLUDING the empty settled value — the reducer
- // clears the progress number once the sync lands, and a truthiness guard
- // here would strand the last "..." beside the settled label.
- if (this._trafficSyncProgress.textContent !== presentation.progressText) {
- this._trafficSyncProgress.textContent = presentation.progressText;
- }
- this._trafficSyncChip.classList.toggle('visible', presentation.visible);
+ // The label flaps only when it changes; completion swaps it in, a timing
+ // signal rather than another animated loading stage. The progress is
+ // written on every change INCLUDING the empty settled value: the reducer
+ // clears it once the sync lands.
+ this._publishTraffic({
+ visible: presentation.visible,
+ ...(presentation.label && {
+ label: localizeStatusLabel(presentation.label),
+ immediate: !presentation.busy,
+ }),
+ progress: presentation.progressText,
+ });
}
_updateGlobalLoadingFeedback(now = performance.now()) {
if (this.destroyed) return;
- if (!this._globalLoadingStatus) return;
const summary = aggregateLayerLoading(this.readLayers() || []);
this._loadingFeedbackState = reduceLoadingFeedback(
this._loadingFeedbackState,
@@ -230,20 +226,43 @@ export class ShellFeedback {
if (this._loadingFeedbackState?.phase !== 'idle' || noticeNeedsTicker) {
this._armLoadingFeedbackTicker();
}
- const localized = localizeLoadingPresentation(presentation);
- this._globalLoadingStatus.hidden = !presentation;
if (!presentation) {
- delete this._globalLoadingStatus.dataset.state;
+ this._publishLoading({ hidden: true, state: null });
return;
}
- this._globalLoadingStatus.dataset.state = presentation.state;
- // Split-flap the LABEL only ("LOADING LIVE DATA" -> "LOAD COMPLETE").
- // setSplitFlapText is a no-op when the text is unchanged, which matters
- // here: this runs on every 60 ms and 500 ms tick. The detail line is the
- // live layer roster inside an ellipsised, width-capped span — flapping a
- // list that churns as layers join would be noise, not delight.
- setSplitFlapText(this._globalLoadingLabel, localized.label);
- this._globalLoadingDetail.textContent = localized.detail;
+ const localized = localizeLoadingPresentation(presentation);
+ // The LABEL flaps ("LOADING LIVE DATA" -> "LOAD COMPLETE") only when it
+ // changes, which matters here: this runs on every 60 ms and 500 ms tick.
+ // The detail is the live layer roster inside an ellipsised, width-capped
+ // span; flapping a list that churns as layers join would be noise.
+ this._publishLoading({
+ hidden: false,
+ state: presentation.state,
+ label: localized.label,
+ detail: localized.detail,
+ });
+ }
+
+ _publishLoading(patch) {
+ const next = { ...this._loadingView, ...patch };
+ if (Object.keys(next).every((key) => next[key] === this._loadingView[key]))
+ return;
+ this._loadingView = next;
+ this.store.publishLoading(next);
+ }
+
+ _publishTraffic(patch) {
+ const next = { ...this._trafficView, ...patch };
+ if (Object.keys(next).every((key) => next[key] === this._trafficView[key]))
+ return;
+ this._trafficView = next;
+ this.store.publishTraffic(next);
+ }
+
+ /** Drop any retained notice and hide the loading status. */
+ clearLoadingStatus() {
+ this._globalStatusNotice = null;
+ this._publishLoading({ hidden: true, state: null });
}
_showGlobalStatusNotice(message, options = {}) {
@@ -327,9 +346,9 @@ export class ShellFeedback {
this._loadingVisibilityHandler,
);
this._loadingVisibilityHandler = null;
- disposeSplitFlap(this._globalLoadingLabel);
- disposeSplitFlap(this._trafficSyncLabel);
this._toast?.classList.remove('visible');
- if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
+ // 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/shellFeedbackStatusLocalization.test.mjs b/src/ui/shellFeedbackStatusLocalization.test.mjs
index b0e931a65..b05ceb54f 100644
--- a/src/ui/shellFeedbackStatusLocalization.test.mjs
+++ b/src/ui/shellFeedbackStatusLocalization.test.mjs
@@ -10,12 +10,8 @@ import {
reduceLoadingFeedback,
} from '../loadingFeedback.js';
-const elements = {
- 'global-loading-status': { hidden: true, dataset: {} },
- 'global-loading-detail': { textContent: '' },
-};
globalThis.document ??= {
- getElementById: (id) => elements[id] ?? null,
+ getElementById: () => null,
addEventListener() {},
removeEventListener() {},
// The layer panel reaches Vue, whose DOM runtime creates one element as it
@@ -38,10 +34,14 @@ function alprChipDetail(locale, retryInMs) {
retryAt: Date.now() + retryInMs,
},
};
- const feedback = new ShellFeedback({ readLayers: () => [layer] });
+ let loading = null;
+ const feedback = new ShellFeedback({
+ readLayers: () => [layer],
+ store: { publishLoading: (view) => (loading = view) },
+ });
feedback._armLoadingFeedbackTicker = () => {};
feedback._updateGlobalLoadingFeedback(0);
- return elements['global-loading-detail'].textContent;
+ return loading.detail;
}
test.afterEach(() => setLocale('en'));
diff --git a/src/ui/shellLifecycle.test.mjs b/src/ui/shellLifecycle.test.mjs
index ca94a4e60..94c955c38 100644
--- a/src/ui/shellLifecycle.test.mjs
+++ b/src/ui/shellLifecycle.test.mjs
@@ -54,8 +54,6 @@ function fixture() {
'right-context-rail',
'pp-toggles',
'toast',
- 'global-loading-status',
- 'global-loading-detail',
'safe-frame-overlay',
'safe-frame-box',
'hud-toggle',
@@ -176,11 +174,20 @@ test('panel layout coalesces each rail and cannot rearm after destruction', () =
test('feedback disposal clears toast and polling work and rejects retained notices', () => {
const f = fixture();
try {
- const owner = new ShellFeedback({ readLayers: () => [] });
+ const loading = [];
+ const owner = new ShellFeedback({
+ readLayers: () => [],
+ store: {
+ publishLoading: (view) => loading.push(view),
+ publishTraffic() {},
+ },
+ });
owner.observeVisibility();
owner._startTrafficChipTicker();
owner._showToast('Saved');
assert.equal(f.timers.size, 2);
+ owner._showGlobalStatusNotice('Saving');
+ assert.equal(loading.at(-1).hidden, false);
owner.destroy();
owner._showToast('Late');
owner._showGlobalStatusNotice('Late');
@@ -189,7 +196,8 @@ test('feedback disposal clears toast and polling work and rejects retained notic
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.equal(f.nodes.get('global-loading-status').hidden, true);
+ assert.equal(loading.at(-1).hidden, true);
+ assert.equal(loading.at(-1).label, 'Saving', 'the hidden label stays');
} finally {
f.restore();
}
diff --git a/src/ui/statusChipOwners.test.mjs b/src/ui/statusChipOwners.test.mjs
new file mode 100644
index 000000000..b3cc7f091
--- /dev/null
+++ b/src/ui/statusChipOwners.test.mjs
@@ -0,0 +1,119 @@
+// src/ui/statusChipOwners.test.mjs — what ShellFeedback and the CCTV
+// presentation publish for the road network and camera grid chips.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+globalThis.document ??= {
+ getElementById: () => null,
+ addEventListener() {},
+ removeEventListener() {},
+ // The layer panel reaches Vue, whose DOM runtime creates one element as it
+ // loads.
+ createElement: () => ({}),
+ hidden: false,
+};
+const { ShellFeedback } = await import('./shellFeedback.js');
+const { _publishCctvChip, _updateCctvSyncChip } =
+ await import('./cctvPresentation.js');
+
+test('the road network chip clears its progress once the sync lands and keeps its label', () => {
+ const published = [];
+ let stats = { loading: true, phaseProgressPct: 40, phaseLabel: 'LIVE' };
+ const feedback = new ShellFeedback({
+ readLayers: () => [{ id: 'traffic', enabled: true, stats }],
+ store: {
+ publishTraffic: (view) => published.push(view),
+ publishLoading() {},
+ },
+ });
+ feedback._updateTrafficSyncChip(false, 0);
+ assert.deepEqual(published.at(-1), {
+ visible: true,
+ label: 'LIVE',
+ immediate: false,
+ progress: '40%',
+ stopped: false,
+ });
+ feedback._updateTrafficSyncChip(false, 10);
+ assert.equal(published.length, 1, 'an unchanged chip is not republished');
+ stats = { phaseProgressPct: 100, phaseLabel: 'LIVE · 80% cov' };
+ feedback._updateTrafficSyncChip(false, 20);
+ assert.deepEqual(published.at(-1), {
+ visible: true,
+ label: 'LIVE · 80% cov',
+ immediate: true,
+ progress: '',
+ stopped: false,
+ });
+ feedback._updateTrafficSyncChip(false, 60_000);
+ assert.deepEqual(published.at(-1), {
+ visible: false,
+ label: 'LIVE · 80% cov',
+ immediate: true,
+ progress: '',
+ stopped: false,
+ });
+ feedback.destroy();
+ assert.equal(published.at(-1).stopped, true, 'destroy rests the label');
+});
+
+function cctvOwner() {
+ const published = [];
+ const owner = {
+ destroyed: false,
+ _cctvChip: null,
+ _cctvChipWasBusy: false,
+ _cctvChipHideTimer: null,
+ statusStore: { publishCctv: (view) => published.push(view) },
+ };
+ owner._publishCctvChip = _publishCctvChip.bind(owner);
+ const update = _updateCctvSyncChip.bind(owner);
+ return { owner, published, update };
+}
+
+test('the camera grid chip counts frames, flashes the total, then hides', (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ globalThis.window ??= globalThis;
+ const { published, update } = cctvOwner();
+ update({ active: true, loaded: 3, total: 12 }, true);
+ assert.deepEqual(published.at(-1), {
+ visible: true,
+ label: 'loading frames',
+ immediate: false,
+ progress: '3/12',
+ stopped: false,
+ });
+ update({ active: false, loaded: 12, total: 12 }, true);
+ assert.deepEqual(published.at(-1), {
+ visible: true,
+ label: 'camera grid ready',
+ immediate: false,
+ progress: '12/12',
+ stopped: false,
+ });
+ update({ active: false, loaded: 12, total: 12 }, true);
+ assert.equal(published.length, 2, 'the flash holds until its timer');
+ t.mock.timers.tick(1500);
+ assert.deepEqual(published.at(-1), {
+ visible: false,
+ label: 'camera grid ready',
+ immediate: false,
+ progress: '12/12',
+ stopped: false,
+ });
+});
+
+test('a camera grid chip that never showed publishes its first hide once', () => {
+ const { published, update } = cctvOwner();
+ update(null, false);
+ update(null, false);
+ assert.deepEqual(published, [
+ {
+ visible: false,
+ label: 'loading frames',
+ immediate: false,
+ progress: '0/0',
+ stopped: false,
+ },
+ ]);
+});
diff --git a/src/ui/statusChipsView.test.mjs b/src/ui/statusChipsView.test.mjs
new file mode 100644
index 000000000..057dfeeb0
--- /dev/null
+++ b/src/ui/statusChipsView.test.mjs
@@ -0,0 +1,286 @@
+// src/ui/statusChipsView.test.mjs — the split-flap label and the scene's
+// status chips, rendered from the status store over happy-dom.
+import '../testSupport/registerDom.mjs';
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createApp, h, nextTick, ref } from 'vue';
+import { createPinia } from 'pinia';
+import { FLAP_SETTLE_SLACK_MS, planSplitFlap } from '../splitFlap.js';
+import { useStatusStore } from './stores/status.js';
+import SceneChromeView from './views/SceneChromeView.vue';
+import SplitFlapText from './views/panels/SplitFlapText.vue';
+
+// happy-dom lays nothing out: the label counts as on screen unless a test
+// says otherwise, and is wider while its cells stand in for the text.
+let onScreen = true;
+HTMLElement.prototype.checkVisibility = function () {
+ return onScreen && !this.closest('[hidden]');
+};
+HTMLElement.prototype.getBoundingClientRect = function () {
+ const width = this.querySelector('.gev-flap-cell') ? 80 : 50;
+ return { width, height: 10, left: 0, top: 0, right: width, bottom: 10 };
+};
+
+// A change renders, then its cells go in once the label is seen on screen.
+const rendered = async () => {
+ for (let flush = 0; flush < 4; flush += 1) await nextTick();
+};
+
+function mountLabel(t, initial = 'LOADING LIVE DATA') {
+ onScreen = true;
+ document.body.replaceChildren();
+ const text = ref(initial);
+ const immediate = ref(false);
+ const app = createApp({
+ render: () =>
+ h(SplitFlapText, {
+ id: 'label',
+ text: text.value,
+ immediate: immediate.value,
+ }),
+ });
+ app.mount(document.body.appendChild(document.createElement('div')));
+ t.after(() => app.unmount());
+ const label = document.getElementById('label');
+ return {
+ text,
+ immediate,
+ label,
+ node: () => label.querySelector('.gev-flap-text').firstChild,
+ cells: () => [...label.querySelectorAll('.gev-flap-cell')],
+ };
+}
+
+test('the label is one text node beside empty decorative cells', (t) => {
+ const { label, node, cells } = mountLabel(t);
+ assert.equal(label.className, 'gev-flap-host');
+ assert.equal(node().nodeType, Node.TEXT_NODE);
+ assert.equal(node().data, 'LOADING LIVE DATA');
+ assert.equal(label.textContent, 'LOADING LIVE DATA');
+ assert.equal(
+ label.querySelector('.gev-flap-cells').getAttribute('aria-hidden'),
+ 'true',
+ );
+ assert.equal(cells().length, 0);
+});
+
+test('a change flaps its changed columns over the same text node, then settles', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const { text, label, node, cells } = mountLabel(t);
+ const permanent = node();
+ text.value = 'LOAD COMPLETE';
+ await rendered();
+ assert.ok(node() === permanent, 'the text node never moves');
+ assert.equal(permanent.data, 'LOAD COMPLETE');
+ assert.equal(label.textContent, 'LOAD COMPLETE', 'cells carry no text');
+ assert.equal(label.classList.contains('gev-flap-active'), true);
+ assert.equal(label.style.getPropertyValue('--gev-flap-dur'), '190ms');
+ const plan = planSplitFlap('LOADING LIVE DATA', 'LOAD COMPLETE');
+ assert.equal(cells().length, plan.cells.length);
+ assert.deepEqual(
+ cells().map((cell) => cell.dataset.flapNext),
+ plan.cells.map((cell) => cell.to || ' '),
+ );
+ const flapping = cells().filter((cell) =>
+ cell.classList.contains('is-flapping'),
+ );
+ assert.equal(flapping.length, plan.changedCount);
+ const first = plan.cells.find((cell) => cell.changed);
+ assert.equal(cells()[first.index].dataset.flapPrev, first.from);
+ assert.equal(
+ cells()[first.index].style.getPropertyValue('--gev-flap-delay'),
+ `${first.delayMs}ms`,
+ );
+ assert.equal(cells()[0].dataset.flapPrev, undefined, 'L stays put');
+ t.mock.timers.tick(plan.durationMs + FLAP_SETTLE_SLACK_MS);
+ await rendered();
+ assert.equal(cells().length, 0);
+ assert.equal(label.classList.contains('gev-flap-active'), false);
+ assert.equal(label.style.getPropertyValue('--gev-flap-dur'), '');
+ assert.ok(node() === permanent);
+});
+
+test('an interrupted cascade rebuilds its cells so every flap restarts', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const { text, cells } = mountLabel(t, 'AAAA');
+ text.value = 'BBBB';
+ await rendered();
+ const before = cells();
+ text.value = 'CCCC';
+ await rendered();
+ assert.equal(cells().length, 4);
+ assert.ok(cells().every((cell) => !before.includes(cell)));
+ // Nothing had turned yet, so the ORIGINAL label flaps away (invariant 3).
+ assert.deepEqual(
+ cells().map((cell) => cell.dataset.flapPrev),
+ ['A', 'A', 'A', 'A'],
+ );
+});
+
+test('a completion, a hidden label or an unchanged label swaps without flaps', async (t) => {
+ const { text, immediate, label, cells } = mountLabel(t);
+ immediate.value = true;
+ text.value = 'LOAD COMPLETE';
+ await rendered();
+ assert.equal(label.textContent, 'LOAD COMPLETE');
+ assert.equal(cells().length, 0);
+ immediate.value = false;
+ onScreen = false;
+ text.value = 'LOAD FAILED';
+ await rendered();
+ assert.equal(label.textContent, 'LOAD FAILED');
+ assert.equal(cells().length, 0);
+ assert.equal(label.classList.contains('gev-flap-active'), false);
+});
+
+test('a growing label eases to its cascade width and ends on transitionend', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const { text, label } = mountLabel(t, 'OFF');
+ text.value = 'LOADING';
+ // The ease renders its start width, lays it out, then renders the end.
+ for (let flush = 0; flush < 2; flush += 1) await rendered();
+ assert.equal(label.classList.contains('gev-flap-sizing'), true);
+ assert.equal(label.style.width, '80px');
+ const other = new Event('transitionend');
+ other.propertyName = 'opacity';
+ label.dispatchEvent(other);
+ await rendered();
+ assert.equal(label.style.width, '80px', 'only the width ease ends it');
+ const end = new Event('transitionend');
+ end.propertyName = 'width';
+ label.dispatchEvent(end);
+ await rendered();
+ assert.equal(label.classList.contains('gev-flap-sizing'), false);
+ assert.equal(label.style.width, '');
+});
+
+test('a stopped label rests at once and changes without flaps from then on', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const stopped = ref(false);
+ const text = ref('AAAA');
+ document.body.replaceChildren();
+ onScreen = true;
+ const app = createApp({
+ render: () =>
+ h(SplitFlapText, {
+ id: 'label',
+ text: text.value,
+ stopped: stopped.value,
+ }),
+ });
+ app.mount(document.body.appendChild(document.createElement('div')));
+ t.after(() => app.unmount());
+ const label = document.getElementById('label');
+ text.value = 'BBBB';
+ await rendered();
+ await rendered();
+ assert.equal(label.querySelectorAll('.gev-flap-cell').length, 4);
+ stopped.value = true;
+ await rendered();
+ assert.equal(label.querySelectorAll('.gev-flap-cell').length, 0);
+ assert.equal(label.classList.contains('gev-flap-active'), false);
+ text.value = 'CCCC';
+ await rendered();
+ await rendered();
+ assert.equal(label.textContent, 'CCCC');
+ assert.equal(label.querySelectorAll('.gev-flap-cell').length, 0);
+});
+
+function mountChrome(t) {
+ onScreen = false;
+ document.body.replaceChildren();
+ const pinia = createPinia();
+ const app = createApp(SceneChromeView).use(pinia);
+ app.mount(document.body.appendChild(document.createElement('div')));
+ t.after(() => app.unmount());
+ return { store: useStatusStore(pinia) };
+}
+
+const $ = (selector) => document.querySelector(selector);
+
+test('before anything publishes, the chips hold their resting labels', (t) => {
+ mountChrome(t);
+ assert.equal($('#global-loading-status').hidden, true);
+ assert.equal($('#global-loading-status').hasAttribute('data-state'), false);
+ assert.equal($('#global-loading-label').textContent, 'LOADING LIVE DATA');
+ assert.equal($('#global-loading-detail').textContent, '');
+ assert.equal($('#traffic-sync-chip').classList.contains('visible'), false);
+ assert.equal($('#traffic-sync-label').textContent, 'syncing road network');
+ assert.equal($('#traffic-sync-progress').textContent, '0%');
+ assert.equal($('#cctv-sync-chip').classList.contains('visible'), false);
+ assert.equal($('#cctv-sync-label').textContent, 'loading frames');
+ assert.equal($('#cctv-sync-progress').textContent, '0/0');
+});
+
+test('the chips render what the status store publishes', async (t) => {
+ const { store } = mountChrome(t);
+ store.publishLoading({
+ hidden: false,
+ state: 'complete',
+ label: 'LOAD COMPLETE',
+ detail: 'Flights · Ships',
+ });
+ store.publishTraffic({
+ visible: true,
+ label: 'road network ready',
+ immediate: true,
+ progress: '',
+ });
+ store.publishCctv({
+ visible: true,
+ label: 'camera grid ready',
+ immediate: false,
+ progress: '12/12',
+ });
+ await rendered();
+ assert.equal($('#global-loading-status').hidden, false);
+ assert.equal($('#global-loading-status').dataset.state, 'complete');
+ assert.equal($('#global-loading-label').textContent, 'LOAD COMPLETE');
+ assert.equal($('#global-loading-detail').textContent, 'Flights · Ships');
+ assert.equal($('#traffic-sync-chip').classList.contains('visible'), true);
+ assert.equal($('#traffic-sync-label').textContent, 'road network ready');
+ assert.equal($('#traffic-sync-progress').textContent, '');
+ assert.equal($('#cctv-sync-chip').classList.contains('visible'), true);
+ assert.equal($('#cctv-sync-progress').textContent, '12/12');
+ $('#traffic-sync-chip').classList.add('panel-avoid');
+ store.publishTraffic({
+ visible: false,
+ label: 'road network ready',
+ immediate: true,
+ progress: '',
+ });
+ store.publishLoading({
+ hidden: true,
+ state: null,
+ label: 'LOAD COMPLETE',
+ detail: 'Flights · Ships',
+ });
+ await rendered();
+ assert.equal($('#traffic-sync-chip').classList.contains('visible'), false);
+ assert.equal(
+ $('#traffic-sync-chip').classList.contains('panel-avoid'),
+ true,
+ 'classes other code sets survive',
+ );
+ assert.equal($('#global-loading-status').hidden, true);
+ assert.equal($('#global-loading-status').hasAttribute('data-state'), false);
+ assert.equal($('#global-loading-label').textContent, 'LOAD COMPLETE');
+});
+
+test('a label that changes as its status shows flaps once the status is visible', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const { store } = mountChrome(t);
+ onScreen = true;
+ store.publishLoading({
+ hidden: false,
+ state: 'error',
+ label: 'LOAD FAILED',
+ detail: '',
+ });
+ await rendered();
+ await rendered();
+ const label = $('#global-loading-label');
+ assert.equal($('#global-loading-status').hidden, false);
+ assert.equal(label.classList.contains('gev-flap-active'), true);
+ assert.ok(label.querySelectorAll('.gev-flap-cell.is-flapping').length > 0);
+});
diff --git a/src/ui/stores/status.js b/src/ui/stores/status.js
new file mode 100644
index 000000000..efe0cbe03
--- /dev/null
+++ b/src/ui/stores/status.js
@@ -0,0 +1,44 @@
+import { defineStore } from 'pinia';
+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
+ * last text while hidden, so the next change flaps from what was shown.
+ */
+export const useStatusStore = defineStore('gev-status', () => {
+ /**
+ * `{hidden, state, label, detail, stopped}`; null until first published.
+ * `stopped` rests a label for good once its owner is destroyed.
+ */
+ const loading = shallowRef(null);
+ /** `{visible, label, immediate, progress, stopped}`, or null. */
+ const traffic = shallowRef(null);
+ /** `{visible, label, immediate, progress, stopped}`, or null. */
+ const cctv = shallowRef(null);
+
+ /** @param {?object} next A fresh presentation of the loading status. */
+ function publishLoading(next) {
+ loading.value = next && markRaw(next);
+ }
+
+ /** @param {?object} next A fresh presentation of the road network chip. */
+ function publishTraffic(next) {
+ traffic.value = next && markRaw(next);
+ }
+
+ /** @param {?object} next A fresh presentation of the camera grid chip. */
+ function publishCctv(next) {
+ cctv.value = next && markRaw(next);
+ }
+
+ return {
+ loading,
+ publishLoading,
+ traffic,
+ publishTraffic,
+ cctv,
+ publishCctv,
+ };
+});
diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css
index 2b3ff7e62..bba1ef788 100644
--- a/src/ui/styles/status.css
+++ b/src/ui/styles/status.css
@@ -243,20 +243,20 @@ html:not([lang='en']) #global-loading-detail {
/* ── Split-flap chip text ─────────────────────────────────────────
Per-character departure-board flip when a chip label changes.
- Driven entirely by src/splitFlap.js, which rebuilds the label as
- one span per character on the change and strips them again once
- the cascade lands.
+ Planned by src/splitFlap.js and rendered by SplitFlapText.vue, which
+ lays out one cell per character on the change and strips them again
+ once the cascade lands.
The DOM text is the truth and its node never moves. A chip label is
- upgraded once into a permanent shell — a .gev-flap-text span holding
- one long-lived Text node, plus an aria-hidden .gev-flap-cells
- sibling — and from then on a label change is only `node.data = next`.
+ a .gev-flap-text span holding one long-lived Text node, plus an
+ aria-hidden .gev-flap-cells sibling, and a label change is only
+ `node.data = next`.
During a cascade the host takes .gev-flap-active, which clips the
real text out of view and lets the cells stand in for it. The cells
carry no text at all: both glyphs are generated content (::before =
outgoing, ::after = incoming), and generated content never reaches
- textContent. That is what keeps QA pins and the traffic chip's own
- `textContent !==` guard honest, and it lets the cells be rebuilt
+ textContent. That is what keeps QA pins honest, and it lets the
+ cells be rebuilt
freely while the aria-live region sees only one characterData
change per label update.
diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue
index 1bf8c5a74..4ba941e7d 100644
--- a/src/ui/views/SceneChromeView.vue
+++ b/src/ui/views/SceneChromeView.vue
@@ -2,10 +2,15 @@
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { useDisplayStore } from '../stores/display.js';
+import { useStatusStore } from '../stores/status.js';
+import { vClassToggle } from '../rail/classToggle.js';
import ImagerySplitView from './panels/ImagerySplitView.vue';
+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());
@@ -50,17 +55,17 @@ const { style } = storeToRefs(useDisplayStore());
public
-
-
LOADING LIVE DATA
-
+
+
+ {{ loading ? loading.detail : '' }}
-
-
syncing road network
-
0%
+
+
+ {{ traffic ? traffic.progress : '0%' }}
-
-
loading frames
-
0/0
+
+
+ {{ cctv ? cctv.progress : '0/0' }}
diff --git a/src/ui/views/panels/SplitFlapText.vue b/src/ui/views/panels/SplitFlapText.vue
new file mode 100644
index 000000000..63742eea3
--- /dev/null
+++ b/src/ui/views/panels/SplitFlapText.vue
@@ -0,0 +1,177 @@
+
+
+
+
+