feat(06-05): guidance/confirm/trip-end card builders + the token-only shell.css guidance block (GREEN)

- 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)
This commit is contained in:
araharah-executor
2026-10-24 14:18:21 +08:00
parent f0931d6195
commit 79836f0a31
3 changed files with 251 additions and 2 deletions
+178
View File
@@ -276,3 +276,181 @@ function buildManeuverRow(row: ManeuverRowView): HTMLElement {
el.append(glyphWrap, street, dist); el.append(glyphWrap, street, dist);
return el; 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;
}
+68
View File
@@ -137,3 +137,71 @@ main { position: fixed; inset: 0; }
/* No CSS transition or animation anywhere — state changes are instant hard swaps. */ /* 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). */ /* 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); }
+5 -2
View File
@@ -600,7 +600,10 @@ test("GS11 shell.css guidance block — zero transition/animation/box-shadow/bor
const css = readFileSync(cssPath, "utf8"); const css = readFileSync(cssPath, "utf8");
const marker = css.indexOf("guidance cockpit"); const marker = css.indexOf("guidance cockpit");
assert.ok(marker > 0, "the guidance block exists in shell.css"); 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)`); 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"]) { 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`); 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)", () => { 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")); 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.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");
}); });