feat(ui): render the status chips and their split-flap labels from a store

ShellFeedback publishes the loading status and the road network chip, and
CctvControls the camera grid chip, to the gev-status store; the scene
chrome renders them. SplitFlapText plays the departure-board cascade that
splitFlap.js plans, writing only the label's one text node, and rests when
its owner is destroyed. setSplitFlapText and disposeSplitFlap are gone.
This commit is contained in:
Sameh Khamis committed 2026-10-31 19:51:39 -07:00
1 parent 492b719f19
commit 3c391482f6
21 files changed
+838 -428

No files matched your search

+15 -289
View File
@@ -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 });
}