---
phase: 06-turn-by-turn-guidance-live-driving
plan: 05
type: execute
wave: 2
depends_on: [06-02]
files_modified:
- web/src/guidance/glyphs/depart.svg
- web/src/guidance/glyphs/straight.svg
- web/src/guidance/glyphs/left.svg
- web/src/guidance/glyphs/right.svg
- web/src/guidance/glyphs/slight-left.svg
- web/src/guidance/glyphs/slight-right.svg
- web/src/guidance/glyphs/uturn.svg
- web/src/guidance/glyphs/arrive.svg
- web/src/guidance/glyphs.ts
- web/src/guidance/banner.ts
- web/src/guidance/haptics.ts
- web/src/guidance/close-protection.ts
- web/src/telegram/webapp.ts
- web/src/i18n/strings.ts
- web/src/shell.css
- web/test/guidance-surface.test.mjs
- web/test/route-surface.test.mjs
- web/test/build-artifact.test.mjs
- web/test/copy-gate.test.mjs
autonomous: true
requirements: [GUID-01, GUID-04, PX-03]
must_haves:
truths:
- "The maneuver banner renders distance + arrow + street per the UI-SPEC anatomy, with REROUTING/RESUMING/NO REROUTE calm states and the machine-blink gantry (GUID-01, D-04/D-06)"
- "The maneuver list collapses/expands over the map with NEXT/FUTURE/PAST row states and tap-out collapse (GUID-01)"
- "The guidance card shows ETA + remaining + progress bar + END TRIP — the route card evolved (D-06)"
- "The abandon-route confirm card guards every guidance exit with the exact en/ms copy (D-09/PX-03)"
- "Haptics fire through capability-guarded calls and no-op cleanly on old clients (GUID-04/D-08)"
- "Close protection engages on guidance start and releases at trip end (disableVerticalSwipes 7.7+, closing confirmation 6.2+) (PX-03)"
- "All guidance copy lives in the strings module in en + ms — never inline (the copy gates)"
artifacts:
- path: "web/src/guidance/glyphs.ts"
provides: "the 8-glyph committed set as raw SVG (single source; PNGs rasterize from these files in plan 07)"
- path: "web/src/guidance/banner.ts"
provides: "banner + maneuver list + guidance card + confirm card + trip-end card builders (createElement + textContent)"
- path: "web/src/guidance/haptics.ts"
provides: "capability-guarded haptic dispatch (approach/maneuver/arrive)"
exports: ["fireHaptic"]
- path: "web/src/guidance/close-protection.ts"
provides: "engage/release close protection (version-gated)"
exports: ["engageCloseProtection", "releaseCloseProtection"]
- path: "web/src/i18n/strings.ts"
provides: "the 18 new ShellStrings keys, en + ms, exact UI-SPEC copy"
key_links:
- from: "web/src/guidance/banner.ts"
to: "web/src/i18n/strings.ts"
via: "every label via strings[locale] — zero inline copy"
pattern: "strings\\[locale\\]"
- from: "web/src/guidance/banner.ts"
to: "web/src/guidance/glyphs.ts"
via: "inline SVG from the committed glyph sources"
pattern: "GLYPHS"
- from: "web/src/guidance/close-protection.ts"
to: "web/src/telegram/webapp.ts"
via: "optional members + isVersionAtLeast guards"
pattern: "isVersionAtLeast"
---
The guidance cockpit surface per 06-UI-SPEC: the 8-glyph committed set, the maneuver banner + collapsible list + guidance card + abandon-confirm + trip-end builders, the close-protection and haptics capability guards, and all copy in the strings module — every element token-only, radius-0, no shadows, machine-instant state swaps.
Purpose: GUID-01/PX-03's visible behavior and GUID-04's haptic channel. Builders are pure DOM composition (Node-testable, the route-card law); plan 06 wires them to the state machine.
Output: glyphs + 5 builders + 2 guard modules + webapp contract members + strings keys + shell.css guidance rules + the guidance-surface suite + RS10/BA9/copy-gate bumps.
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md
task 1: The 8-glyph committed set + glyphs.ts + strings keysweb/src/guidance/glyphs/*.svg, web/src/guidance/glyphs.ts, web/src/i18n/strings.ts, web/test/copy-gate.test.mjs
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md "The 8-glyph set" table (the EXACT geometry per glyph) + "Maneuver Banner" ink hierarchy
- web/src/i18n/strings.ts (the ShellStrings interface + en/ms records — append, never reorder)
- web/test/copy-gate.test.mjs (what it enumerates — extend in the same change)
- theme/src/tokens.css (verify --color-signal-orange #EB8008 — SVGs use currentColor; PNG rasterization in plan 07 injects the literals)
- glyphs.ts exports GLYPHS: Record for exactly ["depart","straight","left","right","slight-left","slight-right","uturn","arrive"], each value the raw SVG string of the committed file (?raw import — the committed SVGs are the single source)
- every SVG: viewBox "0 0 32 32", stroke currentColor, stroke-width 3, square linecaps, orthogonal/45° segments only (the geometry table verbatim: depart = 4×4 filled square + vertical shaft; uturn = machined staple, no arc; arrive = shaft ending on a 12×12 filled square)
- every new ShellStrings key exists in BOTH en and ms with the exact UI-SPEC values (the interface gains the 18 keys with doc comments)
1. Create the 8 SVG files in `web/src/guidance/glyphs/` per the UI-SPEC geometry table — hand-authored, 32×32 grid, `fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"`, filled squares as ``. Keep every segment orthogonal or 45° (the orthogonal law — the uturn staple is the proof case).
2. `web/src/guidance/glyphs.ts`: `import depart from "./glyphs/depart.svg?raw"; …` (Vite ?raw), `export type GlyphKind = "depart" | "straight" | "left" | "right" | "slight-left" | "slight-right" | "uturn" | "arrive";`, `export const GLYPH_KINDS: readonly GlyphKind[]` (the 8 in order), `export const GLYPHS: Record`, and `export function svgHost(kind: GlyphKind): SVGSVGElement` — a `` whose innerHTML is set from the STATIC `GLYPHS[kind]` constant (the ONE sanctioned innerHTML site: a static import value, never a name-derived or runtime string — the surface gate whitelists exactly this line), returning the parsed `npm test -w web && npm run web:typecheck
- all 8 SVG files exist under web/src/guidance/glyphs/; each contains `viewBox="0 0 32 32"` and `stroke-width="3"` and `stroke-linecap="square"`
- glyphs.ts exports GLYPHS with exactly 8 kinds (test asserts GLYPH_KINDS.length === 8 and each raw string non-empty)
- grep "Unnamed road" and "Tinggalkan laluan\?" and "YA, TINGGALKAN" all match web/src/i18n/strings.ts (en+ms pairs)
- copy-gate.test.mjs green and asserting the new keys exist in both locales
- typecheck clean (the 18 keys are non-optional interface members — a missing locale entry fails the build)
The committed glyph set exists once (SVG source of truth, PNGs come later from the same files), and every guidance word the app will ever show is in the strings module in both localestask 2: Banner + maneuver-list builders + gantry blinkweb/src/guidance/banner.ts, web/test/guidance-surface.test.mjs
- web/src/route/route-card.ts (THE builder law: actions interface first, createElement+textContent only, aria on interactive groups, instrument formats composed in code)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md sections: Maneuver Banner, Maneuver List, State Coexistence (the banner's four registers + calm-state slot swaps)
- web/test/route-surface.test.mjs RS5/RS9 (the structural-gate style guidance-surface mirrors)
- buildGuidanceBanner renders all five slots for EVERY state (never blanks mid-swap): register line (GUIDE // NEXT | GUIDE // REROUTING | GUIDE // RESUMING | GUIDE // NO REROUTE), 48×48 arrow plate with the glyph, distance (telemetry-num + label unit; "—" when unknown during REROUTING/RESUMING/NO REROUTE), street line (uppercase, ellipsis; streetUnnamed fallback), toggle word LIST/CLOSE with aria-expanded
- the banner root is a
1. `web/src/guidance/banner.ts` — create the module with the banner-surface exports, each `(locale, view, actions) => HTMLElement` with a view interface per the behavior list (copy route-card.ts's actions-interface-first structure):
- `buildGuidanceBanner(locale, view: { state: GuidanceState; glyph: GlyphKind; distanceM: number | null; street: string | null; listOpen: boolean }, actions: { onToggle(): void })`
- `buildManeuverList(locale, rows: Array<{ kind: GlyphKind; street: string; distanceM: number; rowState: "next" | "future" | "past" }>, actions: { onDismiss(): void })` (the tap-out layer dismisses)
- `startGantryBlink(el: HTMLElement): void` / `stopGantryBlink(el: HTMLElement): void` (setInterval 500 ms toggling a data attribute; one interval per element, stop clears)
All copy via `strings[locale]`; registers composed in code (`\`${strings[locale].guideRegister} // NEXT\`` style instrument constants); formats via a local `fillTemplate`-style composer (route-card precedent).
2. RED→GREEN `web/test/guidance-surface.test.mjs` (the RS style: Node-24 dynamic .ts imports, minimal document fake — reuse the route-surface DOM-stub approach): behavioral tests per the behavior list (five-slot-every-state banner, list row states, gantry blink start/stop, banner/list aria) + structural gates — innerHTML ban with the svgHost whitelist (exactly one sanctioned site), inline-copy ban (RS9-analog grep of banner.ts for English/Malay literals outside strings imports — allowed: the instrument register words NEXT/REROUTING/RESUMING/NO REROUTE/CONFIRM/END and unit formats).
npm test -w web
- guidance-surface.test.mjs green with: five-slot-every-state banner test, list row-states test, gantry blink start/stop test, aria assertions (banner button aria-expanded/controls, list region label)
- structural: banner.ts innerHTML appears ONLY in svgHost against GLYPHS[kind]
- typecheck clean
The banner and list render: five-slot discipline with the calm REROUTING/RESUMING/NO REROUTE states and the honest collapsible list — Node-tested DOM with the single sanctioned innerHTML sitetask 3: Guidance/confirm/trip-end card builders + shell.css guidance blockweb/src/guidance/banner.ts, web/src/shell.css, web/test/guidance-surface.test.mjs
- web/src/guidance/banner.ts (task 2's module — the card builders APPEND to it; banner/list builders untouched)
- web/src/ui/strip.ts + the strip section of web/src/shell.css (what [data-guidance="active"] compacts — layout only, strip.ts logic untouched)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md sections: Shell Layout, Guidance Card, Abandon-Route Confirm Card, Trip-Ended Card
- buildGuidanceCard: GUIDE register, destination headline, telemetry row (minutes telemetry-num + MIN + {km} KM + ETA HH:MM), 4px progress bar (role="progressbar", aria-valuemin 0 / max 100 / now pct, aria-label ariaTripProgress), state line ONLY in no_reroute (guideNoRerouteBody), actions = ONE .btn-secondary END TRIP — no .btn-primary anywhere in guidance chrome except the confirm card
- buildConfirmCard: GUIDE // CONFIRM + exact en/ms headline/body + .btn-primary "YES, ABANDON" + .btn-secondary "CONTINUE"; bottom register placement class
- buildTripEndCard: GUIDE // END + exact headline/body, no actions
- distance/ETA formatting instrument constants in code: "{n} M" under 1000 (integer), "{n.n} KM" at/over (one decimal), 24-hour ETA clock — identical under en/ms
- structural: zero "transition"/"animation" CSS in the new shell.css block; zero hex literals (token vars only); zero border-radius; zero box-shadow
1. `web/src/guidance/banner.ts` — APPEND the three card builders (signatures per the behavior list; route-card.ts actions-interface-first law):
- `buildGuidanceCard(locale, view: { destinationName: string; minutesRemaining: number; kmRemaining: number; etaClock: string; progressPct: number; showNoReroute: boolean }, actions: { onEndTrip(): void })`
- `buildConfirmCard(locale, actions: { onAbandon(): void; onContinue(): void })` and `buildTripEndCard(locale): HTMLElement`
All copy via `strings[locale]`; registers composed in code; instrument formats via the fillTemplate-style composer from task 2.
2. `web/src/shell.css` — append the guidance block, token vars ONLY: banner band (matte-black chassis, min-height 96px, padding space-sm space-md, full-bleed, below safe-top + space-sm), 1px chassis-subtle banner→strip separator, `[data-guidance="active"]` strip compaction (min-height 24px, padding-block space-xs, telemetry cluster display:none — layout only), list panel + row anatomy + separators + lamp, guidance-card telemetry row + 4px progress bar (chassis-subtle track, 1px matte-black border, signal-orange fill, width set by inline style from the pct), ink colors per the hierarchy (register chassis-subtle, street soft-beige, distance pure-white). NO transition/animation/box-shadow/border-radius/hex anywhere in the block.
3. RED→GREEN: extend `web/test/guidance-surface.test.mjs` with the card tests (confirm/trip-end copy asserting the exact strings-module keys, END TRIP is btn-secondary and the only card action, progressbar aria) + the CSS block scan (no transition|animation|box-shadow|border-radius|#[0-9a-f]{3,6} in the appended guidance section) + the strip.ts-untouched structural check.
npm test -w web
- guidance-surface.test.mjs green with: confirm/trip-end copy tests asserting the exact strings-module keys, END TRIP is btn-secondary and the only card action, progressbar aria valueattrs
- structural: grep "btn-primary" in banner.ts matches ONLY the confirm card builder
- shell.css guidance block: grep `transition|animation|box-shadow|border-radius` → 0 matches in the appended section; grep `#[0-9a-fA-F]{3,6}` → 0 matches (token vars only)
- strip.ts untouched (grep confirms no changes; compaction is pure CSS under [data-guidance="active"])
- typecheck clean
The cockpit's instruments complete: evolved guidance card with progress bar, guarded confirm surface, trip-end card, and the token-pure shell.css block that dresses the whole cockpittask 4: Capability guards — haptics + close protection + webapp members + gate bumpsweb/src/guidance/haptics.ts, web/src/guidance/close-protection.ts, web/src/telegram/webapp.ts, web/test/guidance-surface.test.mjs, web/test/route-surface.test.mjs, web/test/build-artifact.test.mjs
- web/src/telegram/webapp.ts lines 31–65 (the contract: optional members only — grow it, never break existing fakes)
- web/src/telegram/safe-area.ts lines 35–50 (the isVersionAtLeast-gate shape to copy)
- web/test/build-artifact.test.mjs lines 185–230 (BA9's input list + printed budget — extend in the same change)
- web/test/route-surface.test.mjs RS10 (the floor bump — PATTERNS caution 4)
- fireHaptic(wa, "approach") → wa.HapticFeedback.selectionChanged(); "maneuver" → impactOccurred("medium"); "arrive" → notificationOccurred("success")
- fireHaptic on a wa WITHOUT HapticFeedback → no-op, no throw (D-08 graceful)
- engageCloseProtection(wa): calls disableVerticalSwipes() only when isVersionAtLeast("7.7"), enableClosingConfirmation() only when isVersionAtLeast("6.2"); releaseCloseProtection inverts both; capability-missing → silent no-op (Pitfall 4's layered defense: body-swipe off + native confirm net; the header swipe cannot be disabled — documented in the module header)
- webapp.ts fakes in EXISTING tests still typecheck/run (optional members only)
- BA9: input list includes the guidance modules, budget printed, ≤ 204800 gz bytes
- RS10: floor raised to 24 src files
1. `web/src/telegram/webapp.ts`: add the optional members from the context box to TelegramWebAppLike (each with the Bot-API-version doc comment). No existing member changes shape.
2. `web/src/guidance/haptics.ts`: `fireHaptic(wa: TelegramWebAppLike | null, cue: "approach" | "maneuver" | "arrive"): void` — `?.` chains only; module header cites D-08 + the no-haptic-on-reroute/confirm law (UI-SPEC: a reroute haptic would announce blame).
3. `web/src/guidance/close-protection.ts`: `engageCloseProtection(wa)` / `releaseCloseProtection(wa)` — version gates `typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("7.7")` / `("6.2")` before each call, `?.` on the member; header documents the Pitfall-4 layering (the Rams card is the designed surface; the native dialog is the net; header swipe undisableable per official docs).
4. RED→GREEN in `web/test/guidance-surface.test.mjs`: recording-fake tests for both modules (calls recorded, version gates respected, no-throw on capability-less fakes).
5. Gate bumps in the same change: `web/test/route-surface.test.mjs` RS10 floor `>= 16` → `>= 24`; `web/test/build-artifact.test.mjs` BA9 input list += the web/src/guidance .ts modules (glyphs, banner, haptics, close-protection, guidance, eta, watch — camera lands in plan 06 and rides the same list then). Run the full web suite; if the bundle budget busts, the guidance modules are DOM strings — investigate before touching the budget (the gate is not negotiable without a recorded call).
npm test -w web && npm run web:typecheck
- webapp.ts gains ONLY optional members (existing interface members unchanged; existing suites green)
- guidance-surface.test.mjs asserts all haptic/close-protection behaviors incl. version gates (7.7 / 6.2) and no-throw no-ops
- route-surface.test.mjs RS10 asserts >= 24 files and passes
- build-artifact.test.mjs BA9 lists the guidance modules, prints its measurement, passes ≤ 204800
- grep `isVersionAtLeast("7.7")` and `isVersionAtLeast("6.2")` match web/src/guidance/close-protection.ts
- typecheck clean
The eyes-free channel and the three-layer close protection exist behind version guards that degrade to honest no-ops; the storage and bundle gates grew with the family and stayed green
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Telegram client → WebApp APIs | Client capabilities vary across Bot API 6.1–8.0+; every new call is capability-guarded |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-06 | Tampering (crash/doS on old clients) | haptics.ts, close-protection.ts | mitigate | Version gates BEFORE every call + `?.` optional-member chains + no-throw fakes proven in tests (RESEARCH Pitfall 5). Graceful degradation is the D-08 law. |
| — | Spoofing (UI overlay confusion) | confirm card | accept | The confirm card is plain DOM in the app's own register band; no privileged surface to spoof; Telegram chrome is out of scope. |
| — | Information Disclosure | banner/list/card | mitigate-by-design | Builders render only route-model data the user is already looking at; no coordinates are ever logged (no console in builders — structural gate). |
| — | DoS | gantry blink interval | mitigate | startGantryBlink stores one interval per element and stopGantryBlink clears it; state swaps always stop the previous blink (wired in plan 06; the API makes a leak visible in review). |
- `npm test -w web && npm run web:typecheck` green (existing suites + guidance-surface + copy gate)
- RS10 (≥24) and BA9 (budget printed, ≤204800) green with the guidance family counted
- Zero inline copy, zero off-token CSS values, zero red in guidance chrome
The guidance cockpit exists as pure, tested builders + guards: banner five-slot discipline with the calm REROUTING/RESUMING/NO REROUTE states, collapsible honest list, evolved guidance card with progress bar, ONE confirm surface with the exact privacy copy, trip-end card, capability-safe haptics and close protection — every word in strings.ts, every pixel from tokens.