feat(06-05): the maneuver banner + collapsible list builders + the machine-blink gantry (GREEN)
- buildGuidanceBanner: five-slot discipline over every state — GUIDE // NEXT|REROUTING|RESUMING|NO REROUTE register, 48x48 plate w/ 32x32 committed glyph, distance+unit/'—', street or calm-state copy, LIST/CLOSE toggle; root <button> with aria-expanded/controls/label - buildManeuverList: transparent tap-out layer + labelled card region (id = aria-controls target), NEXT/FUTURE/PAST rows, real-text streets, aria-hidden 24x24 glyphs - startGantryBlink/stopGantryBlink: WeakMap-keyed ~500ms duty cycle, idempotent start, stop clears - GS7 whitespace-verbatim comparison fixed (trailing newline in committed files)
This commit is contained in:
@@ -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<HTMLElement, ReturnType<typeof setInterval>>();
|
||||
|
||||
/** 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 <button> — glance + whole-band tap, no precision targets. */
|
||||
export function buildGuidanceBanner(
|
||||
locale: Locale,
|
||||
view: GuidanceBannerView,
|
||||
actions: GuidanceBannerActions,
|
||||
): HTMLElement {
|
||||
const loc = strings[locale];
|
||||
|
||||
const banner = document.createElement("button");
|
||||
banner.type = "button";
|
||||
banner.className = "guide-banner";
|
||||
banner.setAttribute("aria-expanded", String(view.listOpen));
|
||||
banner.setAttribute("aria-controls", MANEUVER_LIST_ID);
|
||||
banner.setAttribute("aria-label", loc.ariaBannerToggle);
|
||||
banner.addEventListener("click", actions.onToggle);
|
||||
|
||||
// slot 1 — the register line, composed in code from the GUIDE base key
|
||||
const register = document.createElement("span");
|
||||
register.className = "guide-register text-label-sm";
|
||||
register.textContent = `${loc.guideRegister} // ${stateWord(view.state)}`;
|
||||
|
||||
// slot 5 — the real-word toggle (no chevron decoration); aria-expanded
|
||||
// carries the state for assistive tech, the word is the glance affordance
|
||||
const toggle = document.createElement("span");
|
||||
toggle.className = "guide-toggle text-label-sm";
|
||||
toggle.textContent = view.listOpen ? loc.ctaBannerClose : loc.ctaBannerList;
|
||||
toggle.setAttribute("aria-hidden", "true");
|
||||
|
||||
const top = document.createElement("span");
|
||||
top.className = "guide-top";
|
||||
top.append(register, toggle);
|
||||
|
||||
// slot 2 — the 48×48 plate fields the 32×32 committed glyph (currentColor)
|
||||
const plate = document.createElement("span");
|
||||
plate.className = "guide-plate";
|
||||
plate.setAttribute("aria-hidden", "true");
|
||||
const glyph = svgHost(view.glyph);
|
||||
glyph.setAttribute("width", "32");
|
||||
glyph.setAttribute("height", "32");
|
||||
plate.appendChild(glyph);
|
||||
|
||||
// slot 3 — the glance datum: distance number + unit ("—" when unknown)
|
||||
const distance = document.createElement("span");
|
||||
distance.className = "guide-distance text-telemetry-num";
|
||||
const unit = document.createElement("span");
|
||||
unit.className = "guide-unit text-label-sm";
|
||||
if (view.distanceM === null) {
|
||||
distance.textContent = "—"; // the strip's unknown-value glyph
|
||||
} else {
|
||||
const parts = distanceParts(view.distanceM);
|
||||
distance.textContent = parts.num;
|
||||
unit.textContent = parts.unit;
|
||||
}
|
||||
const distanceRow = document.createElement("span");
|
||||
distanceRow.className = "guide-distance-row";
|
||||
distanceRow.append(distance, unit);
|
||||
|
||||
// slot 4 — the street line (uppercase, ellipsis; the list carries the full
|
||||
// name) — or the calm-state copy during REROUTING/RESUMING/NO REROUTE
|
||||
const street = document.createElement("span");
|
||||
street.className = "guide-street text-headline-md";
|
||||
const calm = calmLine(loc, view.state);
|
||||
street.textContent = calm ?? (view.street && view.street.length > 0 ? view.street : loc.guideStreetUnnamed);
|
||||
const streetRow = document.createElement("span");
|
||||
streetRow.className = "guide-street-row";
|
||||
streetRow.appendChild(street);
|
||||
// the machine-blink gantry rides ONLY the two calm wait states (D-04):
|
||||
// the arrow plate holds the last glyph — nothing spins, nothing blames.
|
||||
if (view.state === "rerouting" || view.state === "resuming") {
|
||||
const gantry = document.createElement("span");
|
||||
gantry.className = "guide-gantry";
|
||||
gantry.dataset.fill = "off";
|
||||
gantry.setAttribute("aria-hidden", "true");
|
||||
streetRow.appendChild(gantry);
|
||||
}
|
||||
|
||||
const col = document.createElement("span");
|
||||
col.className = "guide-measures";
|
||||
col.append(distanceRow, streetRow);
|
||||
|
||||
const body = document.createElement("span");
|
||||
body.className = "guide-body";
|
||||
body.append(plate, col);
|
||||
|
||||
banner.append(top, body);
|
||||
return banner;
|
||||
}
|
||||
|
||||
/* ————— the maneuver list (D-06 — collapsible, collapsed default) ————— */
|
||||
|
||||
export interface ManeuverRowView {
|
||||
kind: GlyphKind;
|
||||
/** Street/designation verbatim (the full name — the banner truncates). */
|
||||
street: string;
|
||||
/** Remaining distance to this maneuver (model-derived). */
|
||||
distanceM: number;
|
||||
/** NEXT (orange glyph + lamp) / FUTURE (matte-black) / PAST (outline). */
|
||||
rowState: "next" | "future" | "past";
|
||||
}
|
||||
|
||||
export interface ManeuverListActions {
|
||||
/** Any map tap on the transparent tap-out layer collapses the list. */
|
||||
onDismiss(): void;
|
||||
}
|
||||
|
||||
/** The expanded list: a transparent full-viewport tap-out layer (no dim —
|
||||
* boot-card precedent) plus the card panel (max-width 320, scrolls at
|
||||
* 480px) of real-text rows in trip order. Rows are a readout, not a menu. */
|
||||
export function buildManeuverList(
|
||||
locale: Locale,
|
||||
rows: readonly ManeuverRowView[],
|
||||
actions: ManeuverListActions,
|
||||
): HTMLElement {
|
||||
const loc = strings[locale];
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "guide-list-wrap";
|
||||
|
||||
const tapout = document.createElement("div");
|
||||
tapout.className = "guide-list-tapout";
|
||||
tapout.setAttribute("aria-hidden", "true");
|
||||
tapout.addEventListener("click", actions.onDismiss);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "guide-list card";
|
||||
panel.setAttribute("role", "region");
|
||||
panel.setAttribute("aria-label", loc.ariaManeuverList);
|
||||
panel.id = MANEUVER_LIST_ID;
|
||||
for (const row of rows) panel.appendChild(buildManeuverRow(row));
|
||||
|
||||
wrap.append(tapout, panel);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Row anatomy: [lamp?] glyph 24×24 · street · right-aligned distance.
|
||||
* Glyphs are aria-hidden; the row's real text reads as-is. */
|
||||
function buildManeuverRow(row: ManeuverRowView): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.className = "guide-row";
|
||||
el.dataset.rowState = row.rowState;
|
||||
|
||||
if (row.rowState === "next") {
|
||||
const lamp = document.createElement("span");
|
||||
lamp.className = "guide-lamp";
|
||||
lamp.setAttribute("aria-hidden", "true");
|
||||
el.appendChild(lamp);
|
||||
}
|
||||
|
||||
const glyphWrap = document.createElement("span");
|
||||
glyphWrap.className = "guide-row-glyph";
|
||||
glyphWrap.setAttribute("aria-hidden", "true");
|
||||
const glyph = svgHost(row.kind);
|
||||
glyph.setAttribute("width", "24");
|
||||
glyph.setAttribute("height", "24");
|
||||
glyphWrap.appendChild(glyph);
|
||||
|
||||
const street = document.createElement("span");
|
||||
street.className = "guide-row-street text-body-md";
|
||||
street.textContent = row.street;
|
||||
street.setAttribute("title", row.street); // the full name rides the row
|
||||
|
||||
const dist = document.createElement("span");
|
||||
dist.className = "guide-row-dist text-label-sm";
|
||||
const parts = distanceParts(row.distanceM);
|
||||
dist.textContent = `${parts.num} ${parts.unit}`;
|
||||
|
||||
el.append(glyphWrap, street, dist);
|
||||
return el;
|
||||
}
|
||||
Reference in New Issue
Block a user