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, /