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;
|
||||||
|
}
|
||||||
@@ -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)`);
|
assert.ok(raw.includes("currentColor"), `${kind}: color rides currentColor (token-only)`);
|
||||||
// the raw string IS the committed file (single source — ?raw, not a copy)
|
// the raw string IS the committed file (single source — ?raw, not a copy)
|
||||||
const committed = readFileSync(path.join(GUIDANCE_DIR, "glyphs", `${kind}.svg`), "utf8");
|
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)
|
// orthogonal/45° law spot-checks: no arcs anywhere (the uturn staple is the proof case)
|
||||||
for (const kind of GLYPH_KINDS) {
|
for (const kind of GLYPH_KINDS) {
|
||||||
|
|||||||
Reference in New Issue
Block a user