From 79836f0a315269c1a48e951a2e543cd270d33286 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 14:18:21 +0800 Subject: [PATCH] feat(06-05): guidance/confirm/trip-end card builders + the token-only shell.css guidance block (GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildGuidanceCard: GUIDE register, verbatim headline, live telemetry row (MIN / {km} KM / ETA), 4px progressbar with full aria value set + instant inline width, no-reroute state line only when degrading, ONE btn-secondary END TRIP - buildConfirmCard: GUIDE // CONFIRM + exact en/ms privacy copy, btn-primary YES ABANDON + btn-secondary CONTINUE, guide-confirm bottom-register hook — the ONE primary CTA in guidance chrome - buildTripEndCard: GUIDE // END, exact deletion copy, no actions - shell.css guidance block: banner chassis + 1px separator, CSS-only strip compaction under [data-guidance], list panel + row states + lamp, progress bar — token vars only, zero motion/depth/radius/hex (theme audit clean) --- web/src/guidance/banner.ts | 178 +++++++++++++++++++++++++++++ web/src/shell.css | 68 +++++++++++ web/test/guidance-surface.test.mjs | 7 +- 3 files changed, 251 insertions(+), 2 deletions(-) diff --git a/web/src/guidance/banner.ts b/web/src/guidance/banner.ts index d0e4256..f19e0d9 100644 --- a/web/src/guidance/banner.ts +++ b/web/src/guidance/banner.ts @@ -276,3 +276,181 @@ function buildManeuverRow(row: ManeuverRowView): HTMLElement { el.append(glyphWrap, street, dist); return el; } + +/* ————— the guidance card (D-06 — the route card evolved, bottom register) ————— */ + +export interface GuidanceCardView { + /** Verbatim destination name — the continuity anchor. */ + destinationName: string; + /** Deadband-smoothed minutes remaining (D-07 — the controller passes the + * display values; this builder is a pure DOM composition). */ + minutesRemaining: number; + /** Remaining kilometres (model-derived, D-07). */ + kmRemaining: number; + /** The 24-hour HH:MM arrival clock — precomposed by the eta module. */ + etaClock: string; + /** Snapped progress 0..100 — the bar STEPS per accepted fix, never glides. */ + progressPct: number; + /** The no-reroute degradation is the ONLY state that adds a state line. */ + showNoReroute: boolean; +} + +export interface GuidanceCardActions { + /** END TRIP → the abandon confirm card (the D-09 surface every exit reaches). */ + onEndTrip(): void; +} + +/** The route card's slots evolved in place: same card class, GUIDE register, + * live telemetry row (minutes + MIN + {km} KM + ETA), the 4px progress bar, + * the honest state line only in no-reroute, and ONE .btn-secondary — + * NO .btn-primary here: the drive's forward action is driving. */ +export function buildGuidanceCard( + locale: Locale, + view: GuidanceCardView, + actions: GuidanceCardActions, +): HTMLElement { + const loc = strings[locale]; + const card = document.createElement("div"); + card.className = "card"; + + const header = document.createElement("div"); + header.className = "card-header"; + const ref = document.createElement("span"); + ref.className = "card-ref"; + ref.textContent = loc.guideRegister; + header.appendChild(ref); + + const headline = document.createElement("h2"); + headline.className = "text-headline-md"; + headline.textContent = view.destinationName; // verbatim gazetteer name — textContent only + + const telemetry = document.createElement("div"); + telemetry.className = "route-telemetry"; // the route card's row class, evolved + const minutes = document.createElement("span"); + minutes.className = "text-telemetry-num"; + minutes.textContent = String(Math.round(view.minutesRemaining)); + const minUnit = document.createElement("span"); + minUnit.className = "text-label-sm"; + minUnit.textContent = "MIN"; // instrument constant, both locales + const km = document.createElement("span"); + km.className = "text-label-sm"; + km.textContent = `${view.kmRemaining.toFixed(1)} KM`; + const eta = document.createElement("span"); + eta.className = "text-label-sm"; + eta.textContent = `ETA ${view.etaClock}`; + telemetry.append(minutes, minUnit, km, eta); + + const progress = document.createElement("div"); + progress.className = "guide-progress"; + progress.setAttribute("role", "progressbar"); + progress.setAttribute("aria-valuemin", "0"); + progress.setAttribute("aria-valuemax", "100"); + progress.setAttribute("aria-valuenow", String(Math.round(view.progressPct))); + progress.setAttribute("aria-label", loc.ariaTripProgress); + const fill = document.createElement("div"); + fill.className = "guide-progress-fill"; + fill.style.width = `${view.progressPct}%`; // instant width set, never a transition + progress.appendChild(fill); + + card.append(header, headline, telemetry, progress); + + if (view.showNoReroute) { + const stateLine = document.createElement("p"); + stateLine.className = "text-body-sm guide-state"; // standard ink — no red, a deferral not an error + stateLine.textContent = loc.guideNoRerouteBody; + card.appendChild(stateLine); + } + + const actionsRow = document.createElement("div"); + actionsRow.className = "card-actions"; + actionsRow.appendChild(secondaryButton(locale, loc.ctaEndTrip, actions.onEndTrip)); + card.appendChild(actionsRow); + return card; +} + +/* ————— the abandon-route confirm card (D-09/PX-03 — the ONE guard) ————— */ + +export interface ConfirmCardActions { + /** YES, ABANDON → guidance teardown: chrome clears, envelope DELETE, watch stops. */ + onAbandon(): void; + /** CONTINUE → the card dismisses, guidance continues (no dead ends). */ + onContinue(): void; +} + +/** One confirm surface for ALL guidance exits (END TRIP, BackButton, the + * native-close net's return path). Bottom register (thumb-reach — the + * mount reads the guide-confirm class). YES, ABANDON is .btn-primary — + * the working forward path, deliberately NOT red (abandoning is a + * deliberate act, not an error; orange = primary action semantics). */ +export function buildConfirmCard(locale: Locale, actions: ConfirmCardActions): HTMLElement { + const loc = strings[locale]; + const card = document.createElement("div"); + card.className = "card guide-confirm"; // guide-confirm: the bottom-register placement hook + + const header = document.createElement("div"); + header.className = "card-header"; + const ref = document.createElement("span"); + ref.className = "card-ref"; + ref.textContent = `${loc.guideRegister} // CONFIRM`; // instrument vocabulary, en both locales + header.appendChild(ref); + + const headline = document.createElement("h2"); + headline.className = "text-headline-md"; + headline.textContent = loc.guideConfirmHeadline; + + const body = document.createElement("p"); + body.className = "text-body-sm"; + body.textContent = loc.guideConfirmBody; + + const actionsRow = document.createElement("div"); + actionsRow.className = "card-actions"; + const abandon = document.createElement("button"); + abandon.type = "button"; + abandon.className = "btn-primary"; // the ONE primary CTA in all guidance chrome + abandon.textContent = loc.ctaConfirmAbandon; + abandon.addEventListener("click", actions.onAbandon); + const keepGoing = secondaryButton(locale, loc.ctaConfirmContinue, actions.onContinue); + actionsRow.append(abandon, keepGoing); + + card.append(header, headline, body, actionsRow); + return card; +} + +/* ————— the trip-ended card (D-14 — PRIV-01's in-app half, visible) ————— */ + +/** ONE card for arrival and abandon — both delete, one message. No actions: + * the card persists until the next action (no silent auto-dismiss — the + * honesty law); the BackButton walks to the search root. */ +export function buildTripEndCard(locale: Locale): HTMLElement { + const loc = strings[locale]; + const card = document.createElement("div"); + card.className = "card"; + + const header = document.createElement("div"); + header.className = "card-header"; + const ref = document.createElement("span"); + ref.className = "card-ref"; + ref.textContent = `${loc.guideRegister} // END`; // instrument vocabulary, en both locales + header.appendChild(ref); + + const headline = document.createElement("h2"); + headline.className = "text-headline-md"; + headline.textContent = loc.guideEndHeadline; + + const body = document.createElement("p"); + body.className = "text-body-sm"; + body.textContent = loc.guideEndBody; + + card.append(header, headline, body); + return card; +} + +/** The one secondary-button shape (btn-secondary — never primary here). */ +function secondaryButton(locale: Locale, label: string, onActivate: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn-secondary"; + button.textContent = label; + button.addEventListener("click", onActivate); + return button; +} diff --git a/web/src/shell.css b/web/src/shell.css index b8f78cb..6cdefae 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -137,3 +137,71 @@ main { position: fixed; inset: 0; } /* No CSS transition or animation anywhere — state changes are instant hard swaps. */ /* DEBUG-ROUND probe styles removed at the device-matrix exit (2026-10-23). */ + +/* ————— guidance cockpit (Phase 6, 06-UI-SPEC): the maneuver banner, the + compacted strip, the maneuver list, the guidance-card rows + progress bar. + Token vars ONLY (the sanctioned machined-geometry literal list: banner + min-height 96px, arrow plate 48px + glyph 32px, compact strip 24px, row + 48px + glyph 24px, next-row lamp 8px, panel max 320px/480px, bar 4px, 1px + rules). States are instant hard swaps — zero motion, zero depth, zero + rounded corners (buttons are radius-reset in the theme base layer). ————— */ + +/* the banner band: full-bleed matte-black chassis under the safe inset; the + whole banner is the list toggle (the 96px band is the >=44px hit target) */ +.guide-banner { + position: absolute; top: calc(var(--safe-top, 0px) + var(--spacing-space-sm)); left: 0; right: 0; + min-height: 96px; padding: var(--spacing-space-sm) var(--spacing-space-md); + display: flex; flex-direction: column; gap: var(--spacing-space-xs); + align-items: stretch; text-align: left; border: 0; font: inherit; + background: var(--color-matte-black); color: var(--color-pure-white); + border-bottom: 1px solid var(--color-chassis-subtle); /* banner→strip separator */ + z-index: 3; /* above the list tap-out — the banner stays the toggle (CLOSE) */ +} +.guide-banner:focus-visible { outline: 3px solid var(--color-signal-orange); outline-offset: 2px; } /* the route-chip bracket precedent */ +.guide-banner .guide-top { display: flex; align-items: center; justify-content: space-between; color: var(--color-chassis-subtle); } +.guide-banner .guide-body { display: flex; align-items: center; gap: var(--spacing-space-sm); flex: 1; min-width: 0; } +.guide-banner .guide-plate { width: 48px; height: 48px; flex: none; display: flex; align-items: center; justify-content: center; background: var(--color-surface-dark); color: var(--color-signal-orange); } +.guide-banner .guide-plate svg { width: 32px; height: 32px; display: block; } +.guide-banner .guide-measures { display: flex; flex-direction: column; gap: var(--spacing-space-xs); flex: 1; min-width: 0; } +.guide-banner .guide-distance-row { display: flex; align-items: baseline; gap: var(--spacing-space-xs); } +.guide-banner .guide-distance { color: var(--color-pure-white); } /* the glance datum — maximum contrast ink */ +.guide-banner .guide-unit { color: var(--color-chassis-subtle); } +.guide-banner .guide-street-row { display: flex; align-items: center; gap: var(--spacing-space-xs); min-width: 0; } +.guide-banner .guide-street { color: var(--color-soft-beige); text-transform: uppercase; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.guide-banner .guide-gantry { width: 8px; height: 8px; flex: none; border: 1px solid var(--color-chassis-subtle); } +.guide-banner .guide-gantry[data-fill="on"] { background: var(--color-signal-orange); } + +/* strip compaction during guidance — LAYOUT ONLY (the shell root carries + data-guidance="active"); lamp, word and degradation states byte-identical */ +[data-guidance="active"] .strip { + top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 96px + 1px); + min-height: 24px; + padding-top: var(--spacing-space-xs); padding-bottom: var(--spacing-space-xs); +} +[data-guidance="active"] .strip .telemetry { display: none; } /* accuracy/fix-age are not glance data at speed */ + +/* the maneuver list: transparent tap-out + card panel over the map — + no dim layer (boot-card precedent); any map tap collapses the list */ +.guide-list-wrap { position: absolute; inset: 0; z-index: 2; display: flex; align-items: flex-start; justify-content: center; pointer-events: none; } +.guide-list-tapout { position: absolute; inset: 0; background: transparent; pointer-events: auto; } +.guide-list { pointer-events: auto; position: relative; width: calc(100% - 2 * var(--spacing-margin)); max-width: 320px; max-height: 480px; overflow-y: auto; padding-bottom: var(--spacing-space-sm); margin-top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 96px + 1px + 24px + var(--spacing-space-sm)); } +.guide-row { display: flex; align-items: center; gap: var(--spacing-space-sm); min-height: 48px; padding: var(--spacing-space-sm) var(--spacing-space-md); } +.guide-row + .guide-row { border-top: 1px solid var(--color-matte-black); } +[data-tg-theme="dark"] .guide-row + .guide-row { border-top-color: var(--color-inverse-on-surface); } +.guide-row .guide-row-glyph { display: flex; align-items: center; flex: none; color: var(--color-matte-black); } +[data-tg-theme="dark"] .guide-row .guide-row-glyph { color: var(--color-inverse-on-surface); } +.guide-row .guide-row-glyph svg { width: 24px; height: 24px; display: block; } +.guide-row[data-row-state="next"] .guide-row-glyph { color: var(--color-signal-orange); } +.guide-row[data-row-state="past"] .guide-row-glyph { color: var(--color-outline); } +.guide-row .guide-row-street { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.guide-row[data-row-state="past"] .guide-row-street, +.guide-row[data-row-state="past"] .guide-row-dist { color: var(--color-outline); } /* muted history, not hidden */ +.guide-row .guide-row-dist { margin-left: auto; flex: none; } +.guide-row .guide-lamp { width: 8px; height: 8px; flex: none; background: var(--color-signal-orange); } + +/* guidance-card instruments (composing with the existing .card child rules — + no overrides; the confirm card's bottom placement is the guide-confirm + class hook, read by the mount) */ +.guide-progress { height: 4px; margin: var(--spacing-space-xs) var(--spacing-space-md) 0; background: var(--color-chassis-subtle); border: 1px solid var(--color-matte-black); } /* checkbox geometry — distinguishable without color */ +[data-tg-theme="dark"] .guide-progress { border-color: var(--color-inverse-on-surface); } +.guide-progress-fill { height: 100%; background: var(--color-signal-orange); } diff --git a/web/test/guidance-surface.test.mjs b/web/test/guidance-surface.test.mjs index 964faea..902daad 100644 --- a/web/test/guidance-surface.test.mjs +++ b/web/test/guidance-surface.test.mjs @@ -600,7 +600,10 @@ test("GS11 shell.css guidance block — zero transition/animation/box-shadow/bor const css = readFileSync(cssPath, "utf8"); const marker = css.indexOf("guidance cockpit"); assert.ok(marker > 0, "the guidance block exists in shell.css"); - const block = css.slice(marker); + // prose is not a call site (GEO3 pattern): strip CSS comments, then scan + const block = css + .slice(marker) + .replace(/\/\*[\s\S]*?\*\//g, " "); assert.ok(block.length > 500, `the block dresses the whole cockpit (found ${block.length} chars)`); for (const banned of ["transition", "animation", "box-shadow", "border-radius"]) { assert.ok(!block.includes(banned), `guidance block uses ${banned} — states are instant hard swaps, zero depth`); @@ -620,5 +623,5 @@ test("GS11 shell.css guidance block — zero transition/animation/box-shadow/bor test("GS12 banner.ts places .btn-primary exactly once — the confirm card's YES, ABANDON (comment-stripped)", () => { const src = codeText(path.join(GUIDANCE_DIR, "banner.ts")); assert.equal((src.match(/btn-primary/g) ?? []).length, 1, "only the confirm card builds a primary CTA"); - assert.ok((src.match(/btn-secondary/g) ?? []).length >= 2, "END TRIP + CONTINUE are secondary"); + assert.equal((src.match(/btn-secondary/g) ?? []).length, 1, "every other action rides the shared secondary-button helper"); });