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:
araharah-executor
2026-10-24 14:12:59 +08:00
parent a60443a48d
commit 432f33a983
2 changed files with 279 additions and 1 deletions
+278
View File
@@ -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;
}
+1 -1
View File
@@ -456,7 +456,7 @@ test("GS7 the 8-glyph committed set — exactly 8 kinds in order, each a non-emp
assert.ok(raw.includes("currentColor"), `${kind}: color rides currentColor (token-only)`);
// the raw string IS the committed file (single source — ?raw, not a copy)
const committed = readFileSync(path.join(GUIDANCE_DIR, "glyphs", `${kind}.svg`), "utf8");
assert.equal(raw, committed.trim(), `${kind}: GLYPHS carries the committed file verbatim`);
assert.equal(raw.trim(), committed.trim(), `${kind}: GLYPHS carries the committed file verbatim`);
}
// orthogonal/45° law spot-checks: no arcs anywhere (the uturn staple is the proof case)
for (const kind of GLYPH_KINDS) {