diff --git a/web/src/guidance/banner.ts b/web/src/guidance/banner.ts new file mode 100644 index 0000000..d0e4256 --- /dev/null +++ b/web/src/guidance/banner.ts @@ -0,0 +1,278 @@ +/* + * banner.ts — the guidance cockpit surface builders (06-UI-SPEC). + * ---------------------------------------------------------------------------- + * The maneuver banner (D-06 top register), the collapsible maneuver list, + * the machine-blink gantry, and — in the card half of this module — the + * guidance card (the route card evolved), the abandon-route confirm card + * (D-09/PX-03) and the trip-ended card (D-14). + * + * Builder law (route-card.ts precedent): every export is a pure DOM + * composition `(locale, view, actions) => HTMLElement` built with + * createElement + textContent ONLY — a name-derived string never reaches + * innerHTML (the shell's ONE sanctioned innerHTML site lives in glyphs.ts's + * svgHost, parsing the STATIC committed glyph import). Every word comes from + * the strings module in en + ms; register lines (GUIDE // NEXT, GUIDE // + * REROUTING, …) and unit formats ({n} M / {n.n} KM) are instrument constants + * composed in code, identical under both locales. NO .btn-primary anywhere + * except the confirm card — the drive's forward action is driving. + * + * States are instant hard swaps (no transitions — the shell law). The banner + * never blanks mid-swap: EVERY state renders all five slots. + */ +import { strings, type Locale } from "../i18n/strings.ts"; +import { svgHost, type GlyphKind } from "./glyphs.ts"; +import type { GuidanceState } from "./guidance.ts"; + +/** The maneuver list panel's DOM id — the banner's aria-controls target. */ +export const MANEUVER_LIST_ID = "guide-maneuver-list"; + +/* ————— the machine-blink gantry (the boot-card precedent) ————— */ + +/** Blink timers keyed by host element: start is idempotent, stop clears. */ +const blinkTimers = new WeakMap>(); + +/** JS-timed two-state toggle at ~500ms — an indicator duty cycle, never a + * stylesheet-driven motion (REROUTING/RESUMING loading states, D-04). */ +export function startGantryBlink(el: HTMLElement): void { + stopGantryBlink(el); // idempotent: re-starting never stacks two timers + el.dataset.fill = "on"; + const timer = setInterval(() => { + el.dataset.fill = el.dataset.fill === "on" ? "off" : "on"; + }, 500); + blinkTimers.set(el, timer); +} + +/** Clear the element's blink timer (state swaps always stop the previous + * blink — the threat model's DoS disposition for the interval). */ +export function stopGantryBlink(el: HTMLElement): void { + const timer = blinkTimers.get(el); + if (timer !== undefined) clearInterval(timer); + blinkTimers.delete(el); +} + +/* ————— instrument formats (composed in code — identical under en/ms) ————— */ + +/** Distance → telemetry number + unit label: {n} M under 1000 (integer), + * {n.n} KM at/over (one decimal) — the UI-SPEC unit contract. */ +function distanceParts(m: number): { num: string; unit: string } { + if (m < 1000) return { num: String(Math.round(m)), unit: "M" }; + return { num: (m / 1000).toFixed(1), unit: "KM" }; +} + +/** The banner's register word per guidance state. NEXT covers on_route AND + * the off_route grace window (no premature signal — the machine acts, not + * warns, D-04); the calm wait states and the degradation name themselves. */ +function stateWord(state: GuidanceState): string { + switch (state) { + case "rerouting": + return "REROUTING"; + case "resuming": + return "RESUMING"; + case "no_reroute": + return "NO REROUTE"; + default: + return "NEXT"; + } +} + +/** The calm-state street-slot copy (rerouting / resuming / no-reroute); + * null in the NEXT states where the street name itself renders. */ +function calmLine(loc: (typeof strings)[Locale], state: GuidanceState): string | null { + if (state === "rerouting") return loc.guideReroutingLine; + if (state === "resuming") return loc.guideResumingLine; + if (state === "no_reroute") return loc.guideNoRerouteLine; + return null; +} + +/* ————— the maneuver banner (D-06 — the top register's occupant) ————— */ + +export interface GuidanceBannerView { + /** The guidance machine's state — drives the register word + calm swaps. */ + state: GuidanceState; + /** The next maneuver's committed glyph (32×32 on the plate). */ + glyph: GlyphKind; + /** Remaining distance to the maneuver (model-derived, D-07); null = unknown + * → the honest "—" dash (REROUTING / projection-less RESUMING / NO REROUTE). */ + distanceM: number | null; + /** Street/designation verbatim from the route step; null renders the + * streetUnnamed fallback. Ignored while a calm-state line shows. */ + street: string | null; + /** Whether the maneuver list is expanded (LIST ↔ CLOSE + aria-expanded). */ + listOpen: boolean; +} + +export interface GuidanceBannerActions { + /** Any banner tap toggles the maneuver list (the whole band is the target). */ + onToggle(): void; +} + +/** The five-slot banner: register line, 48×48 arrow plate (32×32 glyph), + * distance (telemetry-num + unit), street line, real-word toggle. The root + * is a full-width