chore(planning): phase 5/6/7 planning artifacts + milestone-completion state sync
This commit is contained in:
@@ -23,6 +23,7 @@ must_haves:
|
||||
- "A tampered/truncated/mismatched graph binary decodes to null — never a throw, never a half-built graph (T-06-03)"
|
||||
- "A GPS fix snaps to the nearest graph node via edge-segment projection with cos(lat)-scaled equirectangular math (D-05)"
|
||||
- "A* finds the same optimal path as brute-force Dijkstra on the fixture mini-graph for every node pair (GUID-02 engine)"
|
||||
- "The backend ladder records the engine decision trail in index.ts: fixture #1, engine A #2 (D-01); the Valhalla-to-JS port ruled infeasible and geojson-path-finder rejected (D-02); Engine B documented as the post-v1 evolution of A (D-02)"
|
||||
- "One-way edges are respected by A* and ignored by snapping"
|
||||
- "Route steps carry turn kind, street name, distance, and polyline6 geometry derived from the node path"
|
||||
artifacts:
|
||||
@@ -161,20 +162,21 @@ Street names are NOT optional: GUID-01's banner renders `RouteStep.name` verbati
|
||||
<behavior>
|
||||
- snapToGraph at a node's exact coordinates → that node, distanceM ≈ 0
|
||||
- snapToGraph mid-edge → the CLOSER endpoint node, distanceM = haversine distance to it (never a fabricated mid-node)
|
||||
- SnapResult carries `nameId` = the MATCHED segment's edgeNameId (null when UNNAMED_NAME_ID) — ADDITIVE field plan 07's cron street hop consumes (edges carry names; nodes don't)
|
||||
- snapToGraph outside bbox + SNAP_BBOX_MARGIN_DEG margin → null (honest miss — router-pending upstream, D-03)
|
||||
- oneway edges: snapping IGNORES direction (a fix near a oneway segment still snaps to its endpoints)
|
||||
- two snaps for the same RoadGraph instance reuse the memoized grid (WeakMap — observable via a build-counter or timing assertion kept loose)
|
||||
</behavior>
|
||||
<action>
|
||||
1. RED: `routing/test/snap.test.mjs` loads `mini-graph.bin` via decodeGraph and asserts the behaviors above.
|
||||
2. GREEN: `routing/src/snap.ts` — `snapToGraph(graph: RoadGraph, point: RoutePoint): SnapResult | null` where `SnapResult = { node: number; distanceM: number }`. Implementation: coarse grid index (cell ~0.002°) mapping cell-key → node ids, built ONCE per graph and memoized in a module-scope `WeakMap<RoadGraph, Grid>` (LAW 1 lazy memoize — PATTERNS shared pattern); candidate nodes from the 3×3 cell neighborhood; final distance via point-to-SEGMENT equirectangular projection (lng scaled by cos(lat)) over each candidate's edges, returning the closer endpoint of the best segment. `SNAP_BBOX_MARGIN_DEG = 0.01` named export. All distances through haversineM / cos-scaled math — never raw Δdegrees (Pitfall 8).
|
||||
2. GREEN: `routing/src/snap.ts` — `snapToGraph(graph: RoadGraph, point: RoutePoint): SnapResult | null` where `SnapResult = { node: number; distanceM: number; nameId: number | null }` — `nameId` is the best segment's `edgeNameId` found by the sweep (null when UNNAMED_NAME_ID); an ADDITIVE field, so `{ node, distanceM }` destructuring consumers keep working. Implementation: coarse grid index (cell ~0.002°) mapping cell-key → node ids, built ONCE per graph and memoized in a module-scope `WeakMap<RoadGraph, Grid>` (LAW 1 lazy memoize — PATTERNS shared pattern); candidate nodes from the 3×3 cell neighborhood; final distance via point-to-SEGMENT equirectangular projection (lng scaled by cos(lat)) over each candidate's edges, returning the closer endpoint of the best segment TOGETHER WITH that segment's `nameId`. `SNAP_BBOX_MARGIN_DEG = 0.01` named export. All distances through haversineM / cos-scaled math — never raw Δdegrees (Pitfall 8).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run routing:test</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- routing/src/snap.ts exports snapToGraph and SNAP_BBOX_MARGIN_DEG
|
||||
- snap.test.mjs asserts: exact-node hit, mid-edge closer-endpoint, outside-bbox → null, oneway-direction ignored
|
||||
- snap.test.mjs asserts: exact-node hit, mid-edge closer-endpoint, outside-bbox → null, oneway-direction ignored, nameId = the best segment's edgeNameId (null for the unnamed segment)
|
||||
- grep `cos(` matches routing/src/snap.ts (the Pitfall-8 lng scaling)
|
||||
- grep `WeakMap` matches routing/src/snap.ts (the per-graph memo)
|
||||
</acceptance_criteria>
|
||||
|
||||
@@ -2,41 +2,41 @@
|
||||
phase: 06-turn-by-turn-guidance-live-driving
|
||||
plan: 06
|
||||
subsystem: web-guidance-wiring
|
||||
tags: [guidance-pump, d10-locked-camera, px01-full-graph, px03-confirm-exits, d11-envelope-lifecycle, keepalive, resume-get, d14-trip-end-delete, composed-ladder, engine-a-fixture, tdd, concurrent-dispatch-merge]
|
||||
tags: [guidance-pump, px01-backbutton-graph, d10-locked-follow-camera, d11-envelope-lifecycle, guid05-resume, guid02-reroute-wiring, d14-trip-end, composed-ladder, tdd, gate-repin]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 06-turn-by-turn-guidance-live-driving/02
|
||||
provides: "the pure D-04 machine (createGuidanceSession/stepGuidance/effects), projectAlongRoute, GUIDANCE_START_ALONG_M, the D-15 watch + shouldAcceptFix, the D-07 eta module — pumped, never re-implemented"
|
||||
- phase: 06-turn-by-turn-guidance-live-driving/05
|
||||
provides: "the seven chrome builders, fireHaptic, engage/releaseCloseProtection, GLYPHS, the 18 strings keys, data-guidance CSS compaction — mounted, never re-styled"
|
||||
- phase: 06-turn-by-turn-guidance-live-driving/04
|
||||
provides: "createHttpRoutingSource — the ladder's engine-a stage"
|
||||
provides: "THE pure D-04 machine (createGuidanceSession/stepGuidance + effects), projectAlongRoute, the named constants (GUIDANCE_START_ALONG_M et al.) — the pump wires it and never re-decides it"
|
||||
- phase: 06-turn-by-turn-guidance-live-driving/03
|
||||
provides: "POST/GET/DELETE /api/guidance — the frozen envelope endpoints this plan's client half calls"
|
||||
provides: "POST/GET/DELETE /api/guidance + the frozen envelope snapshot shape (the six client-minted fields) — the minimize/resume/trip-end flows ride it byte-identically"
|
||||
- phase: 06-turn-by-turn-guidance-live-driving/04
|
||||
provides: "createHttpRoutingSource (backend #2, engine-a) — the composed ladder's first rung; reroutes route through it for real"
|
||||
- phase: 06-turn-by-turn-guidance-live-driving/05
|
||||
provides: "the seven chrome builders, haptics, close protection, the machine-blink gantry, [data-guidance] CSS compaction — the cockpit this plan makes ALIVE"
|
||||
- phase: 05-routing-route-display/05
|
||||
provides: "the route controller's injected-deps shape, the RS gate family, the walkBack total function this plan extends"
|
||||
provides: "the route controller's injected-deps shape, the route stack registers, RS1–RS10 gate family — evolved, not replaced"
|
||||
- phase: 03-mini-app-shell-geolocation/03
|
||||
provides: "the follow controller (ONE easing discipline + the originalEvent unlock law) and the recenter control the guidance pose rides"
|
||||
provides: "the follow controller's originalEvent unlock law + the recenter control — the guidance camera rides both"
|
||||
provides:
|
||||
- "web/src/guidance/camera.ts — the D-10 locked-follow camera: GUIDANCE_CAMERA_PADDING {320,64,64,64} / GUIDANCE_ZOOM 16 / GUIDANCE_EASE_MS 400 / GUIDANCE_CAMERA_PITCH 35, easeToSnapped over the follow controller, onRerouteApplied = the documented camera no-op, bearing NEVER passed"
|
||||
- "web/src/telegram/backbutton.ts — the context-aware wire: BackAction = hidden|close|callback, resolveAction consulted per click + wire.sync() per state change, the legacy two-arg shape composed internally, close() only at the true root"
|
||||
- "web/src/route/route.ts — the guidance pump: watch-arms-with-route, the start gate, effects dispatch (haptics/reroute/arrival), the cockpit chrome, silent reroute through the injected source (setData snap-in, NO camera jump; calm NO REROUTE), confirm/CONTINUE exits, the D-14 teardown, the envelope lifecycle (keepalive POST / DELETE-on-activated / idempotent trip-end DELETE), resume() with controller-side projection, backAction() — the PX-01 resolver's single authority"
|
||||
- "web/src/guidance/guidance.ts — guidanceView(snap, listOpen) + resumingView(...): ALL banner/card/list view-data composition beside the machine's own maneuver marks (the RS6 boundary's living proof: route/ imports and calls, never re-derives)"
|
||||
- "web/src/main.ts — the composed ladder (engine-a first, fixture fallback, code-constructed, zero env reads), the capability-guarded activated/deactivated wiring, the UUID-validated ?g= resume boot with defensive envelope validation, the recenter re-lock at the guidance pose, the chain-fix camera skip while driving, the no-double-consult resume guard"
|
||||
- "web/test/route-surface.test.mjs — RS7b PX-01 full walk, RS11-13 camera laws, G1-G12 + E1-E6 the pump/envelope/resume suites, RS6/RS8 re-pins"
|
||||
affects: [06-07 cron turn-cards (the envelope lifecycle is live; cron pushes pause on the resume DELETE), 06-08 real-drive gate (the full client behavior is replay-tested; captures ride the guide-* classes), phase-6 verification (PX-01/PX-03 observable behaviors claimed)]
|
||||
- "web/src/guidance/camera.ts — the D-10 locked-follow guidance camera: GUIDANCE_CAMERA_PADDING {320,64,64,64} / GUIDANCE_ZOOM 16 / GUIDANCE_EASE_MS 400 / GUIDANCE_CAMERA_PITCH 35, easeToSnapped riding the follow controller (bearing NEVER passed), onRerouteApplied as the documented no-op"
|
||||
- "web/src/map/follow.ts — follow(point, opts?) pose overrides spread over the Phase 3/5 defaults (no-opts byte-identical), the re-lock announced to the recenter notch"
|
||||
- "web/src/telegram/backbutton.ts — the context-aware BackAction resolver ({hidden|close|callback}) + wire.sync() visibility re-apply; the legacy (hasOverlay, walkBack) shape composes internally; close() only at the true root"
|
||||
- "web/src/route/route.ts — the guidance pump: watch-armed on draw, the ≥30 m start gate, effects dispatch (haptics/reroute/arrival), cockpit chrome re-render per accepted fix, silent self-healing reroute through the injected source, the D-14 teardown, the envelope lifecycle (keepalive POST at minimize, DELETE on activate + idempotent trip-end delete), resume() with the honest projection, guidancePhase + backAction (the PX-01 graph's controller half)"
|
||||
- "web/src/guidance/guidance.ts — guidanceView (ALL chrome view data composed from the snapshot: banner slots, card fields, list rows; the arrive mark names the arrival road) — route/ stays token-free under the re-pinned RS6"
|
||||
- "web/src/main.ts — the composed ladder (engine-a first, fixture fallback, code-constructed), the guidance deps wiring (banner/list hosts, watch factory, findGantry), the resolver wire + onEvent lifecycle guards, the ?g= UUID-validated resume boot, the driving-phase camera guard"
|
||||
- "web/test/route-surface.test.mjs — RS7b full walk, RS11–RS13 camera gates, G1–G8 pump gates, G9–G12 envelope gates, E1–E6 complementary gates, RS6/RS8 re-pins — 146 web tests green"
|
||||
affects: [06-07 cron turn-cards (the envelope lifecycle's other half), 06-08 real-drive gate (the live pump is the drive surface), phase-6 verification (PX-01/PX-03 observable behaviors now claimed)]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: [] # zero new dependencies — the pump composes 06-02 + 06-05 + 06-04/03 surfaces only
|
||||
added: [] # zero new dependencies — everything composes committed workspace modules
|
||||
patterns:
|
||||
- "the orthogonal-phase pattern: guidance rides BESIDE the RouteState graph (guidancePhase: none|driving|confirm|ended) — the RouteState stays 'route' while driving; the BackButton resolver reads the phase, never the route state alone"
|
||||
- "ONE resolver authority: the controller owns backAction(); main.ts's wire delegates to it and only applies visibility — per-click and per-state-change through wire.sync()"
|
||||
- "the envelope lifecycle as structural flow: POST on deactivated (keepalive), DELETE on activated AND at teardown (idempotent pair — the id is consumed), resume DELETEs immediately (a resumed boot is foregrounded by definition), a live envelope never double-mints"
|
||||
- "projection authority in the controller: resume(trip) takes the envelope's RAW anchors (distanceM/durationS/startedAtSec) and computes remaining from startedAtSec + avg speed — the same math the cron runs, one formula across surfaces"
|
||||
- "synthesized terminal marks: a steps-less resume model gets {depart totalM}+{arrive 0} so the machine's arrive mark sits at the line's END (a steps-less model would put it at 0 — any in-threshold fix would instantly arrive)"
|
||||
- "RS6's boundary made real: guidanceView/resumingView compose ALL view data beside the machine's own marks; route.ts contains zero banned tokens outside comments and zero maneuver/progress math"
|
||||
- "the pump delegates EVERYTHING: machine states/effects from 06-02, view data from guidanceView, DOM from 06-05 builders, pose from camera.ts — route.ts only wires (the re-pinned RS6: zero progress/maneuver/btn-primary tokens outside ../guidance/ import lines)"
|
||||
- "guidancePhase (none/driving/confirm/ended) rides BESIDE the Phase 5 RouteState — main.ts's stale/live branches keep their route-state semantics while the BackButton resolver reads the phase"
|
||||
- "the envelope lifecycle is structural: POST at deactivated (keepalive belt), DELETE at activated + at trip end (id retained, second delete 404-tolerant), resume inherits the id and deletes at the foreground flip — no flag, no key, no push (D-11)"
|
||||
- "resume() synthesizes terminal marks for steps-less envelope models (arrive lands at the line's END, not 0 — an in-threshold first fix can never instantly 'arrive') and seeds its session mid-route from the first live fix"
|
||||
- "the committed RED suites pin machine-true semantics only after executor test fixes: the reroute origin is the fix that COMPLETED the grace (the request-carrying fix, R-series semantics), G4 hangs the request to assert the calm state, G12 greps the validator's pattern source (a character class cannot match its own source)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
@@ -44,168 +44,140 @@ key-files:
|
||||
modified:
|
||||
- web/src/map/follow.ts
|
||||
- web/src/telegram/backbutton.ts
|
||||
- web/src/guidance/guidance.ts
|
||||
- web/src/route/route.ts
|
||||
- web/src/guidance/guidance.ts
|
||||
- web/src/main.ts
|
||||
- web/test/route-surface.test.mjs
|
||||
|
||||
key-decisions:
|
||||
- "TWO executor sessions executed this plan on one tree (see the merge note): the camera/backbutton task-1 GREEN and the E-family tests carry the other session's authorship; the families were reconciled into one contract — resume (not resumeGuidance), the controller-side projection, backAction() on the controller, the built-in fetch stub in steppedDeps — every test in the file runs green against one implementation"
|
||||
- "guidance activation re-purposes nothing: the watch arms at the route draw, the session opens on the first accepted fix, and the start gate (>= GUIDANCE_START_ALONG_M along) mounts the cockpit — below the gate the Phase 5 route state is untouched and PX-03 stays unbound (G1)"
|
||||
- "the reroute request's origin is the fix that COMPLETED the grace (the honest current position, D-04) — the new line starts there and the camera never jumps (G4/G6 pin both)"
|
||||
- "walkBack refuses while driving/confirm (PX-03: the confirm card is the only guidance exit) and vacates the route stack on the walk — fixing the Phase 5 leftover where the route card stayed visible under the destination card"
|
||||
- "ETA remaining time derives proportionally from the route summary duration and snapped remaining distance (never a raw fix-to-destination measure, D-07); startedAtSec is guidance-activation time for fresh trips, the envelope anchor for resumed ones"
|
||||
- "the chain's fix no longer fights the guidance camera: handleLive skips follow() while driving — marker/strip still refresh, the camera settles on the next throttled ease; the recenter tap re-locks at the GUIDANCE pose, not the Phase 3 padding"
|
||||
- "Two executors converged on this plan through the shared tree (the 06-02/06-04 concurrent-writer phenomenon, both sessions committing): tasks landed as three interleaved RED→GREEN pairs — 77b62da→2f12c41 (camera+wire), 0316be4→14e8dfc (pump), 6448e31→d5e104b (envelope) — with each pass md5-verifying and adopting the other's coherent work as the base of record. This SUMMARY is the merged record; no work was redone or lost."
|
||||
- "RS6's strict form won: banned tokens ONLY on ../guidance/ import lines (stricter than the plan's call-site allowance) — made possible by guidanceView composing ALL view data in the guidance family, so route.ts never writes the tokens at all. An intermediate looser re-pin was reverted in favor of the committed strict gate."
|
||||
- "BackButton init semantics split by form: the legacy (hasOverlay, walkBack) shape blind-hides at init (RS7's pinned Phase 5 law); the resolver form syncs once at construction (the resolver is the truth-sayer — RS7b's close-state shows the button). wire.sync() is the one visibility re-apply main.ts calls from onStateChange/onGuidanceChange."
|
||||
- "follow() announces the re-lock unconditionally (RS12): the recenter notch's return signal is the point of a re-lock; same-value announces are invisible downstream."
|
||||
- "The banner's arrive mark names the ARRIVAL ROAD (the last named step) — an arrive step carries no name, so the owning-step rule would render the unnamed fallback and lie; G6 pins 'Jalan Baru'."
|
||||
- "Resume projection lives in the controller (computed from distanceM/durationS/startedAtSec) with an optional projectedRemainingM override (G11's pre-computed form) — one formula, two callers' shapes; the cron (plan 07) reuses the same math."
|
||||
|
||||
patterns-established:
|
||||
- "the pump test harness: steppedDeps wraps the REAL controller with the REAL machine + REAL builders under the document fake, a stepped corridor fixture (steps summing to the geometry total so marks land right), an injected clock, a recording watch factory, a built-in envelope fetch stub (fetchMode 404 for the idempotent-delete tolerance), and a recording source.route wrapper — extend THIS for future pump gates, never a new suite"
|
||||
- "gantry-interval discipline in Node tests: any test ending with a blink running stops it explicitly (the interval would hold the runner's event loop open)"
|
||||
- "structural main.ts gates (G12/E6): the ladder region between createFixtureRoutingSource and createRouteController( must be env-read-free; regex-literal UUID patterns are asserted by literal-substring (a source regex /hex{8}/ cannot match a hex-run regex)"
|
||||
- "committed-RED + working-tree-GREEN under concurrent executors: every pass re-ran the suite, md5-checked files before staging, and treated the other's coherent committed work as the base of record (the 06-04 precedent, now bilateral)"
|
||||
- "the wire-sync pattern: a visibility wire exposes sync() so ANY state source re-applies 'visible iff actionable' without owning the state"
|
||||
- "view-data composition module (guidanceView): the controller passes the machine's snapshot and receives ready view objects — the structural gate (RS6 strict) enforces the boundary mechanically"
|
||||
|
||||
requirements-completed: [GUID-02, GUID-05, GUID-06, PX-01, PX-03] # the client/wiring halves: reroute UX + envelope lifecycle + resume + the full BackButton graph + the confirm-guarded exits (engine side 06-04, surface half 06-05)
|
||||
requirements-completed: [GUID-02, GUID-05, GUID-06, PX-01, PX-03] # client halves: reroute wiring, minimize handoff, resume, the full BackButton graph, layered close protection wired to the live drive
|
||||
|
||||
# Metrics
|
||||
duration: 50min
|
||||
duration: 65min
|
||||
completed: 2026-10-24
|
||||
---
|
||||
|
||||
# Phase 6 Plan 6: The Guidance Pump — Camera, BackButton Graph, Envelope Flows Summary
|
||||
# Phase 6 Plan 6: Guidance Wiring — Pump, PX-01 Graph, D-10 Camera, Envelope Flows Summary
|
||||
|
||||
**Guidance is ALIVE in the shell: the drawn route arms the watch, the start gate mounts the cockpit (PX-03 protections, banner/card, the camera locked to the snapped line at the UI-SPEC pose), fixes drive banner/ETA/progress through the real machine, excursions self-heal through the composed engine-a→fixture ladder with a calm banner and exactly one request per storm-guard window, the BackButton walks search→routes→guidance→(confirm)→routes→search with zero dead ends and zero premature closes, minimizing hands the frozen six-field envelope to the cron with keepalive, a ?g= re-open resumes at the controller-computed progressed position, and both trip ends delete the envelope and say so — 146 web tests green (27 net-new this plan), typecheck clean, bot 103 and routing 65 untouched-green.**
|
||||
**Guidance is ALIVE in the shell: the ≥30 m start gate mounts the cockpit on the drawn route, accepted fixes drive banner/ETA/progress/haptics through the 06-02 machine, missed turns self-heal through the composed engine-a ladder with a calm banner, minimizing hands off via the keepalive envelope POST (the cron's structural gate), a killed webview resumes at the projected position from ?g=, the BackButton walks search → routes → guidance → confirm → routes → search with zero dead ends and close() only at the true root, and both trip ends delete the envelope and say so — 146 web tests green, every gate re-pinned with the change that invalidated it.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~50 min (wall clock across the merged sessions; this session's active span 06:42–07:32 UTC)
|
||||
- **Started:** 2026-10-24T06:42:13Z
|
||||
- **Completed:** 2026-10-24T07:32Z
|
||||
- **Duration:** 65 min (wall clock across two interleaved executor passes)
|
||||
- **Started:** 2026-10-24T06:29:27Z
|
||||
- **Completed:** 2026-10-24T07:34:13Z
|
||||
- **Tasks:** 3/3 (all TDD RED→GREEN)
|
||||
- **Files:** 6 (1 created, 5 modified)
|
||||
|
||||
## The Concurrent-Dispatch Merge (read this first)
|
||||
|
||||
**Two executor sessions ran this plan on one working tree.** The dispatch note declared the prior session dead, but its work flushed during this session's execution:
|
||||
|
||||
1. **Task 1:** this session authored the RED tests (RS7b walk, RS11-13 camera laws, the fakeMap flyTo/emit extension, mkRecordingWa); the other session's header re-pin notes and its task-1 GREEN (`camera.ts`, `follow.ts` opts, `backbutton.ts` resolver) landed as commits `77b62da` (RED — absorbing this session's test edits verbatim) + `2f12c41` (GREEN). This session verified the merged task 1 end-to-end against the plan before building on it: exact constants, both call shapes, byte-identical no-opts follow, TDD gate order, typecheck, 129/129.
|
||||
2. **Task 3:** mid-GREEN, the other session flushed its own E-family tests (E1-E6) into the shared suite — overlapping G9-G12's scope with a diverging contract (`resumeGuidance` vs `resume`, `backAction()` on the controller, a built-in fetch stub, controller-side projection). It then **converged to this session's API in real time** (its E-tests came to call `controller.resume(...)` with the raw-anchor shape) while this session adopted its working-tree edits as the base of record. The final reconciliation (this session's edits): E1's pre-gate probe rebuilt on a genuinely pre-gate scenario (the draft probed after `driveTo` had already activated); E4's fixture given terminal marks (its `{arrive, 0}` steps would instant-arrive and crash the abandon clicks); G12's UUID assert taught to match a regex-literal in source. Both test families now pin ONE implementation.
|
||||
3. **Net:** every commit is coherent, every test green, no work lost or duplicated in the commits. The SUMMARY is the merged record, per the 06-03 continuation precedent.
|
||||
- **Files:** 6 (1 created, 5 modified) + the test suite
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **The D-10 locked-follow camera is real** (task 1): `camera.ts` pins the UI-SPEC pose as named constants (padding {320,64,64,64} — center ≈65% down-screen, pitch 35, zoom 16, 400 ms) and eases to the SNAPPED foot through the Phase 3 follow controller — ONE easing discipline, ONE unlock law. Bearing is NEVER passed (north-up, RS11 pins the key's absence); `onRerouteApplied` is the documented no-op (setData happens in the route layer, G6 pins zero camera moves). `follow(fix, opts?)` spreads over the defaults so every Phase 3/5 call site stays byte-identical (RS13 pins the exact no-opts key set), and the originalEvent law is untouched (RS12: programmatic moves never unlock, a user gesture returns the recenter notch).
|
||||
- **The PX-01 graph has no dead ends** (task 1 + task 3): the wire grew a context-aware resolver (`BackAction = hidden|close|callback`) consulted per click, with `wire.sync()` re-applying visibility on every state change; the legacy two-arg shape composes internally so RS7 stayed valid unchanged. The controller owns the decision (`backAction()`): driving → the D-09 confirm card (NOT teardown — E5/RS7b pin guidance alive after Back), confirm open → CONTINUE, ended → the walk to the search root, route → the Phase 5 walk, true root → close (and close() is recorded ZERO times across every pushed-state click).
|
||||
- **The pump makes navigation behavior** (task 2): the watch arms with the route draw; the first accepted fix opens the 06-02 session; the start gate (≥30 m along) mounts the cockpit — close protection engaged, `[data-guidance="active"]` on the shell root (the strip compacts by CSS), banner + guidance card + collapsed list, the camera eased to the snapped line, `ARAHARAH-GUIDE state=on_route` logged (G1). Accepted fixes drive the five-slot banner (model-derived distance to the next mark, the turn's verbatim street), the deadband ETA + HH:MM clock, and the stepping progress bar (G2). The OFF_ROUTE grace shows no premature signal (G3); REROUTING swaps calm — dash + gantry + exactly ONE `source.route` call, storm guard holding under an immediate second excursion (G4); failure lands the honest NO REROUTE + card state line (G5); success snaps the new line in via setData with NO camera jump and the gantry stops (G6).
|
||||
- **Both exits execute D-14** (tasks 2-3): END TRIP and BackButton land on the ONE confirm card; YES, ABANDON and arrival both stop the watch, release close protection, clear `[data-guidance]`, empty the banner/list, DELETE the envelope (idempotent pair), and seat the trip-ended card in the center register with `state=ended` (G7/G8).
|
||||
- **The envelope lifecycle is structural** (task 3): minimize POSTs the FROZEN six-field snapshot with `keepalive: true` and the Bearer header — id/chat_id/created_at are server-minted (G9 pins the exact key set and the polyline6 round-trip); a live envelope never double-mints; activated DELETEs (the cron pauses) while RETAINING the id; teardown deletes the SAME id (the idempotent pair, 404-tolerated — G10/E2/E4). Below the start gate nothing ever mints (E1).
|
||||
- **Resume works from a killed webview** (task 3): the `?g=` carrier is UUID-validated before any GET; the envelope passes defensive field validation; the controller projects remaining distance/minutes from startedAtSec + avg speed (≈1167 m / 140 s pinned), renders GUIDE // RESUMING + gantry, draws the rebuilt line, arms the watch, and DELETEs immediately (a resumed boot IS foregrounded — the cron pauses now). The first accepted fix flips the register live through the machine's own resuming path (G11/E3') — and the synthesized terminal marks keep the arrive mark at the line's end (the steps-less-envelope instant-arrival bug, fixed in-implementation).
|
||||
- **The composed ladder is code-constructed** (task 3): `main.ts` builds engine-a first with the fixture fallback — `(await http.route(req)) ?? (await fixture.route(req))` — no env read in the ladder region (G12/E6 structural), the golden-demo path preserved.
|
||||
- **Gates re-pinned with their invalidating change:** RS6 is now the guidance-implementation boundary (banned tokens only on `../guidance/` import lines — and route.ts genuinely contains none outside comments, because `guidanceView`/`resumingView` moved ALL view-data math into guidance/); RS8 admits the ARAHARAH-GUIDE token family (exactly three console lines, no interpolation, no coordinate field names).
|
||||
- **Suite state:** web 146 passed / 0 failed (124 pre-existing untouched-green + 27 this plan incl. the merged E-family), bot 103, routing 65; web typecheck clean; zero new dependencies.
|
||||
- **The camera rides the snapped line at the UI-SPEC pose** (D-10, task 1): `GUIDANCE_CAMERA_PADDING {top:320, bottom:64, left:64, right:64}`, pitch 35, zoom 16, 400 ms — bearing NEVER passed (RS11 pins the key's absence). The ease executes through the follow controller (one easing discipline, one unlock law): a programmatic move never reads as a user pan, a user gesture unlocks and the recenter notch clears (RS12), and the no-opts call stays byte-identical for the Phase 3/5 call sites (RS13). `onRerouteApplied` is the documented no-op — the new line snaps in via setData and the next per-fix ease settles the viewport.
|
||||
- **The BackButton walks the full graph** (PX-01, tasks 1+3): the wire grew into a context-aware resolver — `BackAction = hidden | close | callback` consulted per click, `wire.sync()` re-applying visibility on every state change. RS7b proves search → routes → guidance → confirm (Back means CONTINUE there) → ended → root with close() recorded ZERO times from pushed states; the legacy (hasOverlay, walkBack) shape composes internally so RS7 and the Phase 5 call sites stay valid. The controller's `backAction()` is the resolver's one authority (E5): driving → the D-09 confirm, confirm → CONTINUE, ended → the search-root walk, idle → Telegram's close.
|
||||
- **The pump is live** (task 2): watch armed at the route draw (never over pending — G5a), the machine's ≥30 m start gate activates the cockpit (close protection engaged, `[data-guidance="active"]`, banner + card + collapsed list, the locked pose, `state=on_route`), accepted fixes re-render the five-slot banner + deadband ETA card + stepping progress bar, OFF_ROUTE keeps the banner calm (no premature signal, G3), REROUTING swaps to the dash + gantry with exactly ONE source.route call and the storm guard holding (G4), failure lands NO REROUTE + the card state line (G5), success snaps the new line in via setData with NO camera jump and the list re-rendered (G6), and END TRIP → confirm → YES ABANDON and the machine's arrival both run the same teardown (G7/G8): watch stopped, protection released, strip restored, trip-ended card centered, `state=ended`.
|
||||
- **The envelope lifecycle is structural** (task 3): `deactivated` POSTs the frozen six-field snapshot (keepalive — the minimize race's belt, G9/E1; EXACTLY the six client-minted fields, id/chat_id/created_at server-minted; never double-minted while live), `activated` DELETEs so the cron pauses with no key and no flag while the id is retained (G10), trip end deletes the same id idempotently (404-tolerant, E4), and the `?g=` boot UUID-validates, GETs, rebuilds the model (synthesizing terminal marks for steps-less envelopes), draws the line, renders GUIDE // RESUMING with the projected numbers (G11/E3' — 100 s elapsed at avg speed ⇒ ~1167 m / 140 s, honestly labeled by the register), pauses the cron NOW (the resume DELETE), arms the watch, and flips live on the first accepted fix through the machine's own resuming path.
|
||||
- **The composed ladder is real and code-constructed** (task 3): engine-a first, fixture fallback — `(await httpSource.route(req)) ?? (await fixtureSource.route(req))` — no env reads in the selection region (G12/E6), so the golden-demo path survives a token-less boot and reroutes hit the real router for the first time.
|
||||
- **Gates grew with the change that invalidated them**: RS6 re-pinned to the strict guidance-implementation boundary (tokens only on `../guidance/` import lines — mechanical teeth via `guidanceView`), RS8 re-pinned to the ARAHARAH-ROUTE/GUIDE token families (exactly three console lines: two ROUTE tokens + the one deduping GUIDE funnel).
|
||||
- **Suite state:** web 146 passed / 0 failed (124 pre-existing untouched-green), bot 103, routing 65; web typecheck clean; zero deletions in any of the six plan commits.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **task 1: guidance camera + BackButton full state graph** (TDD, merged authorship)
|
||||
1. **task 1: guidance camera (locked follow) + BackButton full state graph** (TDD)
|
||||
- `77b62da` test(06-06): failing camera-law + PX-01 full-walk gates — RS7b walk, RS11 pose, RS12 unlock, RS13 no-opts byte-identity (RED)
|
||||
- `2f12c41` feat(06-06): the D-10 locked-follow guidance camera + the context-aware BackButton resolver (GREEN)
|
||||
2. **task 2: the guidance pump — start, effects, chrome, reroute** (TDD)
|
||||
2. **task 2: the guidance pump in route.ts — start, effects, chrome, reroute** (TDD)
|
||||
- `0316be4` test(06-06): failing guidance-pump suite G1-G8 + RS6/RS8 re-pins (RED)
|
||||
- `14e8dfc` feat(06-06): the guidance pump — start gate, effects dispatch, cockpit chrome, silent reroute (GREEN)
|
||||
3. **task 3: envelope flows + composed ladder** (TDD, merged authorship)
|
||||
- `6448e31` test(06-06): failing envelope-flow suite G9-G12 (RED)
|
||||
- `d5e104b` feat(06-06): envelope flows + composed ladder — minimize POST, resume GET, trip-end DELETE (GREEN, includes the reconciled E1-E6)
|
||||
3. **task 3: envelope flows — minimize POST, resume GET, trip-end DELETE + composed ladder** (TDD)
|
||||
- `6448e31` test(06-06): failing envelope-flow suite G9-G12 — minimize POST/resume GET/trip-end DELETE + the composed-ladder gate (RED)
|
||||
- `d5e104b` feat(06-06): envelope flows + composed ladder — minimize POST, resume GET, trip-end DELETE (GREEN)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `web/src/guidance/camera.ts` — the locked-follow pose (created; the other session's implementation, verified against the plan's constants and behaviors)
|
||||
- `web/src/map/follow.ts` — `FollowOptions` + opts-over-defaults follow (byte-identical no-opts key set)
|
||||
- `web/src/telegram/backbutton.ts` — the BackAction resolver + sync(), legacy shape composed
|
||||
- `web/src/guidance/guidance.ts` — `guidanceView` + `resumingView` (the view-data authority; RS6's boundary)
|
||||
- `web/src/route/route.ts` — the pump, effects dispatch, cockpit chrome, confirm/trip-end flows, envelope lifecycle, resume + projection, backAction
|
||||
- `web/src/main.ts` — the ladder, the resolver wire, ?g= resume, envelope events, guidance-aware recenter + camera skip
|
||||
- `web/test/route-surface.test.mjs` — RS7b/RS11-13, G1-G12, E1-E6, RS6/RS8 re-pins, the extended fakeMap + document fake + stepped-corridor harness
|
||||
- `web/src/guidance/camera.ts` — the D-10 locked-follow camera: the four named pose constants + easeToSnapped/onRerouteApplied over the follow controller
|
||||
- `web/src/map/follow.ts` — FollowOptions (padding/pitch/zoom/durationMs) spread over the Phase 3/5 defaults; the re-lock announce
|
||||
- `web/src/telegram/backbutton.ts` — BackAction + the resolver wire + sync(); legacy shape composed internally
|
||||
- `web/src/route/route.ts` — the pump (watch, start gate, effects, chrome re-renders, reroute, teardown), the envelope lifecycle (POST/DELETE/resume-delete), resume(), guidancePhase, backAction, the logGuideState funnel
|
||||
- `web/src/guidance/guidance.ts` — guidanceView: banner/card/list view data composed from the snapshot (arrive marks name the arrival road)
|
||||
- `web/src/main.ts` — the composed ladder, the guidance deps wiring (hosts/watch/findGantry/onGuidanceChange), the resolver wire + onEvent guards, the ?g= resume boot, the driving-phase camera guard, the recenter re-lock at the guidance pose
|
||||
- `web/test/route-surface.test.mjs` — RS7b/RS11–RS13 + G1–G12 + E1–E6 + the RS6/RS8 re-pins; fakeMap grew flyTo/on/emit; fakeDeps' follow rides the REAL follow controller
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions. The three with teeth: the orthogonal guidancePhase beside RouteState (the BackButton resolver's second axis); the reroute origin being the grace-completing fix (the honest current position — the new line starts where the car IS); resume's terminal-mark synthesis (the instant-arrival bug caught by the merged tests before it could ship).
|
||||
See key-decisions: the two-pass merged record, RS6's strict form over the plan's call-site allowance, the split BackButton init semantics, the re-lock announce, the arrive mark's arrival-road rule, the resume projection's dual input shape.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] View-data composition added to `web/src/guidance/guidance.ts` (not in the plan's files list)**
|
||||
- **Found during:** task 2 RED authoring
|
||||
- **Issue:** the plan mandates "zero maneuver math inline" in route/ (RS6 re-pin) but the banner/list/card views need next-maneuver marks, per-row states, and progress pct — data only the machine's private `maneuverMarksOf` can author correctly. Wiring it in route/ would either duplicate maneuver math or trip the re-pin.
|
||||
- **Fix:** `guidanceView(snap, listOpen)` (+ `resumingView` in task 3) exported from guidance.ts beside the marks — guidance implementation living in guidance/ exactly as the re-pin's rule states; route/ imports, spreads the results into the builders, and contains zero banned tokens.
|
||||
- **Files modified:** web/src/guidance/guidance.ts
|
||||
- **Commit:** 14e8dfc, d5e104b
|
||||
|
||||
**2. [Rule 1 - Bug] A steps-less resume model would instantly "arrive"**
|
||||
- **Found during:** task 3 GREEN (the merged E-tests' steps-less fixtures exposed it)
|
||||
- **Issue:** the envelope carries geometry only, so the rebuilt model has `steps: []` — the machine's arrive mark lands at along 0, and the FIRST in-threshold fix would fire arrival, tear the trip down, and strand the resume flow.
|
||||
- **Fix:** `resume()` synthesizes the terminal pair `{depart, totalM} + {arrive, 0}` for steps-less models — the arrive mark sits at the line's end; models with real steps are untouched (the replay suite's pins unaffected).
|
||||
- **Files modified:** web/src/route/route.ts
|
||||
- **Commit:** d5e104b
|
||||
|
||||
**3. [Rule 1 - Bug] walkBack left the route card visible under the destination card**
|
||||
- **Found during:** task 2 (walkBack redesign)
|
||||
- **Issue:** the Phase 5 walk cleared layers/endpoints and restored the destination card but never vacated the route stack — the route card stayed mounted below it (a UI-SPEC "card swaps to destination card" violation that RS4's fake shower could not see).
|
||||
- **Fix:** the walk calls `showRouteCard(null, "center")` before restoring the destination card.
|
||||
- **Files modified:** web/src/route/route.ts
|
||||
**1. [Rule 1 - Test bug] The committed RED suites pinned machine-false reroute semantics**
|
||||
- **Found during:** task 2/3 GREEN verification (G4/G6 failed against a machine-true implementation)
|
||||
- **Issue:** G4/G6 asserted the reroute origin against the FIRST excursion fix and counted the consult's source.route call as a reroute; G4 expected the REROUTING register to persist after the reroute had applied. The 06-02 machine (replay-pinned R2/R9) sends the request-carrying fix (the one that completed the grace) and lands reroute-applied directly on_route.
|
||||
- **Fix:** the origin asserts now pin the request-carrying fix; the call count measures the reroute delta; G4 hangs the request to assert the calm state honestly (and stops the blink so no interval leaks into the runner).
|
||||
- **Files modified:** web/test/route-surface.test.mjs
|
||||
- **Commit:** 14e8dfc
|
||||
|
||||
**4. [Rule 2 - Missing critical] The chain's fix fought the guidance camera**
|
||||
- **Found during:** task 3 (main.ts integration)
|
||||
- **Issue:** `handleLive`'s staleness re-run calls `follow(fix)` with Phase 3 padding — during a live trip it would yank the locked camera to the raw fix with the wrong pose.
|
||||
- **Fix:** handleLive skips the camera while `guidancePhase === "driving"` (marker/strip still refresh; the watch's next throttled ease settles the viewport); the recenter tap re-locks at the GUIDANCE pose.
|
||||
- **Files modified:** web/src/main.ts
|
||||
**2. [Rule 1 - Test bug] G5(a) fed a fix with no watch armed; G12 grepped for a UUID where only the validator's pattern source can exist**
|
||||
- **Found during:** tasks 2/3 GREEN
|
||||
- **Issue:** the pending precondition fed the pump before any watch existed (crash); G12's UUID-shaped grep can never match — a character class in source text is the PATTERN, not eight hex characters.
|
||||
- **Fix:** G5(a) asserts watchStarted === 0 over a pending consult (the real invariant); G12 greps the validator's pattern source (`[0-9a-f]{8}-…`), which is both honest and stronger.
|
||||
- **Files modified:** web/test/route-surface.test.mjs
|
||||
- **Commit:** d5e104b
|
||||
|
||||
### Reconciliation edits to the co-executor's tests (not plan deviations — merged-suite repairs, documented per the merge note)
|
||||
**3. [Rule 2 - Missing critical functionality] guidanceView's arrive mark rendered the unnamed fallback**
|
||||
- **Found during:** task 2 GREEN (G6 "the new street reads" expected "Jalan Baru")
|
||||
- **Issue:** the arrive step carries no name; the owning-step rule produced an empty street slot, so the banner showed "Unnamed road" for a named arrival road.
|
||||
- **Fix:** arrive marks name the ARRIVAL ROAD — the last named step of the route (the road being arrived on); turn marks keep the owning-step rule.
|
||||
- **Files modified:** web/src/guidance/guidance.ts
|
||||
- **Commit:** 14e8dfc
|
||||
|
||||
- E1's pre-gate probe ran after `driveTo` had already activated guidance (the POST it expected absent was mandatory) — rebuilt on a genuinely pre-gate scenario (`feed(10)` below the gate).
|
||||
- E4's fixture `{arrive, 0}` steps instant-arrived on the first fix (the trip-end card has no buttons — the abandon clicks crashed); given the terminal pair.
|
||||
- G12's UUID assert now accepts the regex-literal form (a source regex `/[0-9a-f]{8}/` cannot match a hex-run regex).
|
||||
- The E-draft's raw-number banner asserts (meters/decimal minutes) were aligned to the 06-05 builders' pinned instrument formats by the co-executor itself before this session touched them (`1.2` KM, rounded MIN — GS2/GS8's frozen formats).
|
||||
**4. [Rule 3 - Blocking gate] The plan's RS6 re-pin form was superseded mid-flight**
|
||||
- **Found during:** task 2 planning (the concurrent pass committed the strict import-lines-only form; my looser call-site form was briefly in the working tree)
|
||||
- **Issue:** two competing re-pins of the same gate; the strict form initially looked un-implementable (the card view's progressPct field).
|
||||
- **Fix:** adopted the strict form and made it implementable by moving ALL view-data composition into guidanceView (guidance family) — route.ts never writes the banned tokens; the looser re-pin was reverted to the committed strict text.
|
||||
- **Files modified:** web/test/route-surface.test.mjs (reverted to the committed strict gate), web/src/guidance/guidance.ts
|
||||
- **Commit:** 14e8dfc
|
||||
|
||||
### Judgment calls within plan laws (not deviations)
|
||||
### Session Continuation Note
|
||||
|
||||
- Guidance deps on `RouteControllerDeps` are optional in the type — a cards-only boot (no watch factory) degrades to exactly the Phase 5 controller (the injection law; main.ts always wires them in production).
|
||||
- `startedAtSec` = guidance-activation time for fresh trips; the envelope anchor for resumed ones (either honors D-11's write-timing discretion).
|
||||
- ETA remaining seconds derive proportionally from the selected summary duration × snapped remaining fraction (model + projection only, D-07); the first fold seeds at activation so the card is never blank.
|
||||
- `createGuidanceCamera(map, followController)` keeps the plan's two-arg signature; the map parameter names the viewport and stays unused (the pose rides the follow controller — ONE unlock law).
|
||||
- The guidance logger dedupes consecutive states — activation logs `on_route` once, REROUTING once, NO REROUTE once, `ended` once (RS8's exactly-three-lines form).
|
||||
Two executor passes ran this plan concurrently on the shared tree (the dispatch's "quiet tree" verification did not hold). The passes converged rather than conflicted: each committed its RED/GREEN pairs with the same TDD discipline, adopted the other's coherent committed work as the base of record, and md5-verified files before staging. Pass A (this session) authored task 1 end-to-end, the slim E-suite, the guidanceView arrive-street fix, and this merged record; the interleaved pass authored the G-suite bodies, the pump/resume/envelope GREEN, and two of the four test fixes above. All six commits verified: zero deletions, correct RED→GREEN interleaving per task, 146/146 web + 103 bot + 65 routing green, typecheck clean.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. Every export is fully wired: the pump drives the real machine and real builders, the envelope flows hit the real endpoints through the injected fetch, resume rebuilds a real model from the frozen snapshot. The `startWatch` injection point is the plan's own testability seam (production passes `createGuidanceWatch(navigator.geolocation, ...)`).
|
||||
None. Every export is fully wired: the pump drives the real machine through the real builders; the envelope flows hit the injected fetch with the frozen contract; resume renders from the envelope's own numbers. The cron consumer (plan 07) and the real-drive gate (plan 08) are future consumers of THIS plan's surface, by design.
|
||||
|
||||
## Threat Surface Scan
|
||||
|
||||
No new surface beyond the plan's `<threat_model>`: the envelope POST/DELETE and the ?g= GET are exactly the planned boundary — three positions leave the device only as the D-04 excursion origin, the user-initiated minimize handoff (frozen snapshot, no live stream, keepalive-belted), and nothing else (T-06-05 by construction; G9 pins the exact six fields). T-06-04: the console carries only ARAHARAH-ROUTE/GUIDE state tokens (RS8's three lines, no interpolation, no lat/lng on any console line). T-06-06: `onEvent` wiring is capability-guarded (`typeof wa.onEvent === "function"`, E6) and the haptic/close-protection calls ride the 06-05 guards. The ?g= probe disposition holds client-side: a foreign/failed GET renders an honest boot with zero guidance chrome (G5a/E-block asserts; ownership enforced server-side by 06-03's claim check). The camera hijack row: only originalEvent-bearing moves unlock (RS12). The self-DoS row: the storm guard bounds reroute rate (G4's exactly-one-call pin). No threat flags.
|
||||
No new surface beyond the plan's `<threat_model>`: exactly three positions ever leave the device (reroute origins on real excursions, the minimize envelope, nothing else) — the envelope POST carries the frozen route snapshot only (T-06-05, G9's six-field pin); console carries only ARAHARAH-ROUTE/GUIDE state tokens with a structural no-coordinate scan (T-06-04, RS8); `wa.onEvent` wiring rides `typeof` guards + optional chains (T-06-06, E6); foreign/absent ?g= ids fall through to the honest Phase 4 boot client-side while the claim check holds server-side (06-03's GW7/GW10, unchanged); only originalEvent-bearing moves unlock the camera (T-03-16, RS12). No threat flags.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **The concurrent dispatch** (the merge note above): its task-1 flush landed between this session's read and first edit; its E-tests landed mid-task-3-GREEN. Handling per the 06-02/06-03/06-04 precedents: tree watched and md5-stable checks before every commit; the flushed content adopted as base of record after verification; both test families reconciled into one contract; the final state committed atomically and re-verified stable.
|
||||
- One transient test-suite hang: a G4 draft left the gantry blink interval running (the runner's event loop never drained) — the test now stops the blink explicitly at its end (the harness pattern note above).
|
||||
- Node lives under nvm; every verification command sourced `~/.nvm/nvm.sh` first (all prior plans' precedent). No project impact.
|
||||
- The concurrent-executor situation documented above required three working windows and md5 verification before every stage; one commit attempt lost the race cleanly (the other pass committed the same content first) and was abandoned without duplication.
|
||||
- Node lives under nvm; every shell command sourced `~/.nvm/nvm.sh` first (all prior plans' precedent). No project impact.
|
||||
- The committed G4 initially leaked a gantry interval into the runner (a hung request leaves the blink running); the fix stops the blink at the test's end (GS5's law).
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — client-side wiring only; the endpoints (06-03/06-04), the R2 graph, and the session flow are already provisioned. The real-drive gate (06-08) is the remaining device verification.
|
||||
None — no new bindings, no env vars, no deploy in this plan. The envelope flows target the already-deployed /api/guidance surface (06-03); the ladder hits /api/route (06-04) with the fixture fallback keeping token-less boots golden.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- **06-07 (cron turn-cards):** the envelope lifecycle is LIVE — minimize mints, resume/trip-end delete; the cron's projection math is the controller's own formula (startedAtSec + avg speed) and `projectAlongRoute` is the one projection authority foreground and cron share.
|
||||
- **06-08 (real drive + captures):** the `guide-*` classes, the `?drive=` knob's state tokens (`ARAHARAH-GUIDE state=<state>`), and the confirm/trip-end surfaces are all capture-ready; the BackButton/confirm walks are replay-proven and need only the device pass.
|
||||
- **The verifier:** PX-01/PX-03's observable behaviors are claimed AND tested (RS7b/E5 walk, G7/G8 teardowns); GUID-02's client half is replay-proven through the REAL source seam (the ladder), complementing 06-04's engine side.
|
||||
- **06-07 (cron turn-cards):** the envelope lifecycle is live end-to-end from the client side — minimize POSTs, foreground/trip-end DELETEs — so the cron's key scan sees exactly the minimized drives. Reuse the resume projection formula (same avg-speed math) for the turn-card progress line.
|
||||
- **06-08 (real drive):** the pump is the drive surface; the capture knobs (`?drive=`) plus the ARAHARAH-GUIDE state tokens are the observability plane. The minimize-race belt (keepalive) and the resume path get their first real-world exercise there.
|
||||
- **Verifiers:** PX-01's full graph is claimed by RS7b + E5 (wire + controller halves); PX-03's wiring by G1/G7/G8; GUID-02's client half by G3–G6; GUID-05/06 by G9–G11 + E3'.
|
||||
|
||||
---
|
||||
|
||||
*Phase: 06-turn-by-turn-guidance-live-driving*
|
||||
*Completed: 2026-10-24*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All 6 created/modified files verified on disk and committed (camera.ts created; follow/backbutton/guidance/route/main + the test suite modified).
|
||||
- All 6 plan commits verified in git log: 77b62da→2f12c41 (task 1 RED→GREEN), 0316be4→14e8dfc (task 2 RED→GREEN), 6448e31→d5e104b (task 3 RED→GREEN) — TDD gate sequence intact for all three tdd tasks; zero file deletions in any plan commit.
|
||||
- `npm test -w web`: 146 passed / 0 failed; `npm run web:typecheck` exit 0; bot 103 and routing 65 untouched-green.
|
||||
- Acceptance greps re-run at close: the four camera constants pinned by RS11; `ARAHARAH-GUIDE state=` present in route.ts with exactly three console lines, no `${` and no lat/lng (RS8); `createHttpRoutingSource` + `?? (await ` in main.ts with zero env reads in the ladder region (G12/E6); zero banned tokens outside comments in web/src/route/ (RS6); zero TODO/FIXME/placeholder stub markers.
|
||||
- STATE.md and ROADMAP.md untouched by this executor (orchestrator-owned writes; their working-tree modifications predate this session).
|
||||
|
||||
@@ -26,7 +26,7 @@ requirements: [GUID-05]
|
||||
must_haves:
|
||||
truths:
|
||||
- "A Worker Cron Trigger scans guidance:* envelopes every 60 s, projects progress along the snapshot, and pushes a glyph+text turn-card on its 3-minute cadence (D-11/D-12)"
|
||||
- "Card maneuver lines carry the STREET by DEFAULT — reconstructed by snapping the carded turn's vertex to the memoized R2 graph (snapToGraph + nameAt, per D-12's ETA/distance/street text); street-less bare-verb cards ONLY when the graph is absent in the isolate or the snap misses (D-03-style honest degradation)"
|
||||
- "Card maneuver lines carry the STREET by DEFAULT — reconstructed by snapping the carded turn's vertex to the memoized R2 graph (snapToGraph → the matched segment's nameId → nameAt(nameId), per D-12's ETA/distance/street text); street-less bare-verb cards ONLY when the graph is absent in the isolate or the snap misses (D-03-style honest degradation)"
|
||||
- "Cards push ONLY to envelope.chat_id read from KV — no client-supplied chat id exists anywhere in the cron path (T-06-02)"
|
||||
- "The cron performs ZERO KV writes — the stateless cadence holds the 1,000-writes/day free budget (Pitfall 3)"
|
||||
- "Card copy never promises live tracking — 'best-effort projection' is the strongest claim (D-13)"
|
||||
@@ -45,8 +45,8 @@ must_haves:
|
||||
key_links:
|
||||
- from: "bot/src/guidance/cron.ts"
|
||||
to: "routing/src (classifyTurn, decodePolyline6, deriveCumulativeDistanceM, snapToGraph, nameAt)"
|
||||
via: "projection + street reconstruction over shared primitives — one turn/street authority with engine A"
|
||||
pattern: "classifyTurn|snapToGraph"
|
||||
via: "projection + street reconstruction over shared primitives (snapToGraph → SnapResult.nameId → nameAt(nameId)) — one turn/street authority with engine A"
|
||||
pattern: "classifyTurn|snapToGraph|nameId"
|
||||
- from: "bot/src/webhook.ts scheduled()"
|
||||
to: "bot/src/routing/worker-router.ts"
|
||||
via: "loadGraph(env) — the exported isolate-memoized R2 graph feeding makeStreetAt (no second R2 loader)"
|
||||
@@ -86,8 +86,10 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
|
||||
|
||||
<!-- From 06-01 (routing workspace — bot already depends on it): classifyTurn(prevDeg, nextDeg),
|
||||
decodePolyline6(s), deriveCumulativeDistanceM(points), snapToGraph(graph, point) →
|
||||
{ node, distanceM } | null (grid-indexed, per-graph memoized — cheap enough for a
|
||||
one-vertex snap per carded tick), nameAt(id) on RoadGraph, haversineM.
|
||||
{ node, distanceM, nameId } | null (grid-indexed, per-graph memoized — cheap enough for a
|
||||
one-vertex snap per carded tick; nameId = the MATCHED SEGMENT's edge-name id, null when
|
||||
unnamed — 06-01's additive field), nameAt(nameId) on RoadGraph consumes a NAME-POOL id
|
||||
(edges carry names; nodes don't — never feed it the node index), haversineM.
|
||||
Subpath-with-extension imports only. -->
|
||||
|
||||
<!-- From 06-04 (bot/src/routing/worker-router.ts — EXPORTED there): loadGraph(env):
|
||||
@@ -101,7 +103,7 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
|
||||
The FROZEN envelope carries geometry only (GE2 field freeze holds — no steps field,
|
||||
no envelope extension: path (b) is user-gated and NOT implemented), so the cron
|
||||
reconstructs the street itself: snap the carded turn's vertex to the memoized R2
|
||||
graph (snapToGraph) → nameAt — the SAME one-authority argument the app-side banner
|
||||
graph (snapToGraph) → the matched segment's nameId → nameAt(nameId) — the SAME one-authority argument the app-side banner
|
||||
relies on. DEGRADATION (D-03's designed-degradation pattern): graph absent/corrupt
|
||||
in the cron isolate, or a snap miss → street-less composition with the bare verbs —
|
||||
honest absence, never a guessed name. CPU tradeoff (cron-side snap per due tick vs
|
||||
@@ -173,13 +175,13 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
|
||||
- caption: exactly 4 lines per the anatomy, HTML parse mode, ≤ 1024 chars, contains the honesty line verbatim, register zero-padded
|
||||
- street-bearing DEFAULT (D-12): with a streetAt resolver returning a name, the maneuver line is "{dist} — {cardAction[action]} {STREET}" (the verb carries ONTO/KE; street uppercased); nameAt's unnamed id → cardStreetUnnamed ("UNNAMED ROAD"/"JALAN TANPA NAMA")
|
||||
- street-less DEGRADATION: streetAt omitted or null for the carded vertex → "{dist} — {cardActionBare[action]}" — bare verbs, honest absence, same 4-line anatomy
|
||||
- makeStreetAt(graph) is PURE (graph in → resolver out; snapToGraph + nameAt imported from @araharah/routing, zero I/O): against decodeGraph of the committed mini-graph fixture, a vertex on the fixture's named way resolves that way's name; a vertex off-graph → null
|
||||
- makeStreetAt(graph) is PURE (graph in → resolver out; snapToGraph + nameAt imported from @araharah/routing, zero I/O): against decodeGraph of the committed mini-graph fixture, a vertex on the fixture's named way resolves that way's name via the snap's matched-segment nameId (the node index is never fed to nameAt); a vertex off-graph → null
|
||||
- copy law: caption contains none of "live", "real-time", "guaranteed" (case-insensitive) — D-13's strongest-claim gate
|
||||
</behavior>
|
||||
<action>
|
||||
1. Bot copy: append the 23 keys from the context box to `BotStrings` + both locale records (verbatim values); extend the copy gate in `bot/test/commands.test.mjs` to assert them in en + ms.
|
||||
2. RED: `bot/test/guidance-cron.test.mjs` — build a synthetic envelope (geometry via encodePolyline6 of a known 3-leg path with 90° and 45° turns), assert every behavior above with fixed nowSec values. Street cases: (a) fake resolver `streetAt = () => "JALAN SULTAN ISMAIL"` → the ONTO line composes with that street; (b) `streetAt` omitted / returns null → the bare-verb line; (c) mini-graph integration — construct the synthetic envelope geometry ALONG the mini-graph fixture's named way so the carded turn's vertex snaps deterministically onto it, run the REAL `makeStreetAt(decodeGraph(miniGraphBuffer))`, and assert the caption contains the fixture way's name.
|
||||
3. GREEN: `bot/src/guidance/cron.ts` — header states the pure law ("projectTurnCards and makeStreetAt are PURE: injected nowSec/graph, no KV, no R2, no Telegram, no clock reads — Node-testable"); `projectTurnCards(envelope: GuidanceSessionEnvelope, nowSec: number, streetAt?: (vertexIdx: number) => string | null): { due: boolean; glyph: GlyphKindName | null; caption: string | null; maneuverIdx: number | null }` — decode geometry → cumulative → progressM (clamped to distanceM) → walk bearings (bearing changes between segments; skip degenerate sub-m points) → classifyTurn → first turn ≥ progressM (none → "straight" glyph, index 00) → compose caption via strings + the exact anatomy: street string present → `{cardAction[action]} {street}`; null/absent resolver → `{cardActionBare[action]}`. Also export `makeStreetAt(graph: RoadGraph): (vertexIdx: number) => string | null` — snap the geometry vertex at vertexIdx (snapToGraph) → `graph.nameAt(node)`; snap miss → null; unnamed id → the cardStreetUnnamed constant. GlyphKindName is cron.ts's local union mirroring the 8 PNG names. HTML-escape surface stays ZERO: all caption parts are code-composed from numbers + string-table constants — assert no envelope-derived string (e.g. destinationName) enters the caption; street text arrives only via makeStreetAt's graph lookup. CATEGORY LAW: the module has no console at all (pure), the wiring logs (task 2).
|
||||
3. GREEN: `bot/src/guidance/cron.ts` — header states the pure law ("projectTurnCards and makeStreetAt are PURE: injected nowSec/graph, no KV, no R2, no Telegram, no clock reads — Node-testable"); `projectTurnCards(envelope: GuidanceSessionEnvelope, nowSec: number, streetAt?: (vertexIdx: number) => string | null): { due: boolean; glyph: GlyphKindName | null; caption: string | null; maneuverIdx: number | null }` — decode geometry → cumulative → progressM (clamped to distanceM) → walk bearings (bearing changes between segments; skip degenerate sub-m points) → classifyTurn → first turn ≥ progressM (none → "straight" glyph, index 00) → compose caption via strings + the exact anatomy: street string present → `{cardAction[action]} {street}`; null/absent resolver → `{cardActionBare[action]}`. Also export `makeStreetAt(graph: RoadGraph): (vertexIdx: number) => string | null` — snap the geometry vertex at vertexIdx (snapToGraph) → the matched segment's `nameId` from SnapResult → `graph.nameAt(nameId)`; snap miss → null; nameId null/UNNAMED_NAME_ID → the cardStreetUnnamed constant. (Edges carry names; nodes don't — 06-01's SnapResult was extended additively with `nameId` for exactly this hop; never pass the node index to nameAt.) GlyphKindName is cron.ts's local union mirroring the 8 PNG names. HTML-escape surface stays ZERO: all caption parts are code-composed from numbers + string-table constants — assert no envelope-derived string (e.g. destinationName) enters the caption; street text arrives only via makeStreetAt's graph lookup. CATEGORY LAW: the module has no console at all (pure), the wiring logs (task 2).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w bot</automated>
|
||||
@@ -187,6 +189,7 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
|
||||
<acceptance_criteria>
|
||||
- guidance-cron.test.mjs green: projection math, next-turn kind + zero-padded index, cadence table, dead-trip no-card, 4-line caption ≤ 1024, honesty-line present, street-bearing line asserted against the mini-graph fixture (real makeStreetAt), street-less bare-verb case asserted in the graph-absent test, unnamed-id → cardStreetUnnamed, D-13 banned-words scan
|
||||
- bot/src/guidance/cron.ts imports classifyTurn + snapToGraph from @araharah/routing (grep) — no local turn classifier, no local snapper
|
||||
- street hop is id-based: grep `nameId` matches bot/src/guidance/cron.ts AND grep `nameAt\(node` → 0 matches (nameAt consumes the snap's matched-segment nameId, never the node index)
|
||||
- bot/src/i18n/strings.ts carries cardAction* (8, UI-SPEC verbatim) + cardActionBare* (8) + cardStreetUnnamed + cardEtaTemplate + cardHonesty + cardOpenButton in en + ms; copy gate green
|
||||
- grep `console\.` → 0 matches in bot/src/guidance/cron.ts (pure module)
|
||||
</acceptance_criteria>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Phase 6: Turn-by-Turn Guidance (Live Driving) - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions captured in CONTEXT.md — this log preserves the analysis.
|
||||
|
||||
**Date:** 2026-10-24
|
||||
**Phase:** 06-turn-by-turn-guidance-live-driving
|
||||
**Mode:** default (interactive)
|
||||
|
||||
## Areas Discussed
|
||||
|
||||
### Routing engine stack — THE PIVOT #2 (user-added area)
|
||||
The user added: "The kind of tech (stack) used to achieve the hard $0 no-vps rule, eg. WASM (sql.js), lunr.js, if need to port valhalla so it's hostable on cloudflare pages as js, etc."
|
||||
|
||||
Straight answers given:
|
||||
| Claim | Verdict |
|
||||
|---|---|
|
||||
| Port Valhalla to JS/WASM | **Infeasible** — C++/Boost/protobuf, memory-mapped multi-GB tile stores; multi-month build-system surgery; recorded as D-02 so it is never re-proposed |
|
||||
| geojson-path-finder | Rejected — demo-grade (distance-only, no alternatives/one-ways) |
|
||||
| **Engine A: Worker + R2 graph + A\*** | **CHOSEN, built this phase** — KL graph (~30–80MB) in R2, typed-array/WASM A\* in the Worker; $0, no VPS, fork-uploads its own graph, privacy-preserving; RoutingSource backend #2 |
|
||||
| Engine B (fully client-side webview) | Documented evolution of A, post-v1 |
|
||||
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Engine powering guidance rerouting | Engine A in phase 6 / fixture-corridor now + engine next phase / quick engine C interim | **Engine A in phase 6** — GUID-02 gets TRUE rerouting; fixture-corridor compromise unnecessary |
|
||||
|
||||
### Reroute semantics
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Trigger machine | thresholds+grace (ON_ROUTE→OFF_ROUTE→REROUTING→ON_ROUTE, storm guard) / naive per-fix / research decides | Thresholds + grace machine |
|
||||
| Snapping authority | router snaps / client pre-snap | Router snaps |
|
||||
|
||||
### Guidance UX surface
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Layout | top banner + bottom guidance card / single bottom sheet | Top banner + bottom guidance card |
|
||||
| ETA smoothing | EMA + deadband / raw per-fix | EMA + deadband |
|
||||
| Haptics + PX-03 | approach+arrive haptics + confirm + swipe-disable / defer haptics | Full set |
|
||||
| Camera | locked follow + user-unlock / free camera | Locked follow + user-unlock |
|
||||
|
||||
### Backgrounded turn-cards
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Push loop | envelope + Cron push / app-queued sends | Envelope + Cron push (60–120s) |
|
||||
| Card rendering | glyph+text card / bitmap map snapshot / pre-minimize client raster | Glyph + text card (bitmaps deferred v1.x) |
|
||||
|
||||
### Replay verification + battery
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| GUID-02 verification | scripted replays + 1 real drive / real drives only | Scripted replays + 1 real drive |
|
||||
| Battery cadence | distance-throttled high-acc / adaptive accuracy | Distance-throttled high-acc (≥10m or ≥5s) |
|
||||
|
||||
## Corrections Made
|
||||
|
||||
User's added stack area redirected the phase: the phase-5 deferral of the WASM-router
|
||||
was superseded by building engine A now. The reconciliation round from phase 5 (SC3
|
||||
amendment) was extended, not reversed: fixtures remain backend #1 (demo path); engine A
|
||||
becomes a real backend #2; the router-pending state survives as graph-edge degradation.
|
||||
|
||||
## Auto-Resolved
|
||||
|
||||
Not applicable.
|
||||
|
||||
## External Research
|
||||
|
||||
Roadmap recommends /gsd-research-phase framing for "best-effort projection" + snapshots —
|
||||
resolved in-discussion (glyph+text cards; cron push loop). Remaining research surface for
|
||||
planning's light pass: graph build toolchain, A\* in-Worker feasibility numbers, cron/rate
|
||||
limits.
|
||||
@@ -0,0 +1,423 @@
|
||||
# Phase 6: Turn-by-Turn Guidance (Live Driving) - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-24
|
||||
**Files analyzed:** 25 new/modified files across `routing/`, `web/src/guidance/`, `web/src/location|map|route|telegram/`, `bot/src/`, `bot/wrangler.jsonc`, and the three test workspaces
|
||||
**Analogs found:** 22 / 25 (3 partial — the CSR/A\* data plane, the glyph rasterizer script, and the Worker `scheduled()` handler are repo firsts; RESEARCH.md Patterns 1–2 + Code Examples are the fallback)
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `routing/src/graph.ts` (new) | model (binary decode) | transform (ArrayBuffer → typed arrays) | `routing/src/polyline.ts` codec + `fixture-source.ts` lazy index | role-match |
|
||||
| `routing/src/snap.ts` (new) | utility (geo math) | transform | `routing/src/geo.ts` | exact |
|
||||
| `routing/src/astar.ts` (new) | service (path computation) | batch | none — pure-math posture of `routing/src/geo.ts`; layout per RESEARCH Patterns 1–2 | partial |
|
||||
| `routing/src/http-source.ts` (new) | service (RoutingSource backend #2) | request-response | `routing/src/fixture-source.ts` | exact |
|
||||
| `routing/scripts/build-road-graph.mjs` (new) | build tool (maintainer) | batch + file-I/O | `routing/scripts/fetch-route-fixtures.mjs` | exact |
|
||||
| `routing/scripts/build-glyphs.mjs` (new) | build tool (maintainer, sharp) | file-I/O | `fetch-route-fixtures.mjs` shell (collect-then-write, exit codes) | role-match |
|
||||
| `bot/src/routing/worker-router.ts` (new) | service (R2 graph load + route handler) | request-response | `fixture-source.ts` lazy-memoize posture (LAW 1) | role-match |
|
||||
| `bot/src/guidance/cron.ts` (new) | service (scheduled projection + push) | event-driven (cron tick) | `webhook.ts` handler shape + `bot/src/bot/bot.ts` `createBot(env, {baseFetch})` | role-match (first `scheduled()` in repo) |
|
||||
| `bot/src/kv/guidance-sessions.ts` (MODIFY: types → service) | model + service | CRUD (KV) | `bot/src/kv/location-pin.ts` + `bot/src/kv/sessions.ts` | exact |
|
||||
| `bot/src/webhook.ts` (MODIFY: +`scheduled()`, +`/api/route`, +`/api/guidance`) | controller (Worker routes) | request-response + event | itself (its own fetch-handler pattern) | exact |
|
||||
| `bot/src/config.ts` (MODIFY: +`ROAD_GRAPH: R2Bucket`) | config | — | itself (typed Env contract) | exact |
|
||||
| `bot/wrangler.jsonc` (MODIFY: +`r2_buckets`, +`triggers.crons`) | config | — | itself | exact |
|
||||
| `bot/package.json` (MODIFY: +`@araharah/routing` dep) | config | — | itself (`@araharah/search` workspace-dep precedent) | exact |
|
||||
| `web/src/guidance/guidance.ts` (new) | service (pure state machine) | event-driven (fix stream reducer) | `web/src/route/route.ts` controller shape + `web/src/location/chain.ts` pure-injected-clock posture | role-match |
|
||||
| `web/src/guidance/eta.ts` (new) | utility (EMA + deadband) | transform | `routing/src/geo.ts` (pure, named constants) | exact |
|
||||
| `web/src/guidance/banner.ts` (+ list + guidance-card builders) (new) | component builders | request-response (DOM compose) | `web/src/route/route-card.ts` | exact |
|
||||
| `web/src/guidance/watch.ts` (new) | hook (watchPosition + ≥10 m/≥5 s throttle) | streaming | `web/src/location/webview.ts` + `telegram.ts` Fix normalization | exact |
|
||||
| `web/src/guidance/` capability guards (haptics, `disableVerticalSwipes`, closing confirmation) | middleware (guards) | request-response | `web/src/telegram/webapp.ts` optional members + `safe-area.ts` version gate | exact |
|
||||
| `web/src/telegram/backbutton.ts` (MODIFY: 3-state graph) | middleware (BackButton wire) | request-response | itself (`wireBackButton` — predicate+handler-as-one-change) | exact |
|
||||
| guidance camera locked-follow (new, in `web/src/guidance/` or `map/`) | component | request-response | `web/src/map/follow.ts` + `web/src/ui/recenter.ts` | exact |
|
||||
| `web/src/route/route.ts` (MODIFY: evolves into guidance chrome) | controller | request-response | itself | exact |
|
||||
| `routing/test/{graph,astar,snap}.test.mjs` + mini-graph fixture (new) | test | batch | `routing/test/fixture-source.test.mjs` + `contract.test.mjs` | exact |
|
||||
| `web/test/guidance-surface.test.mjs` (new) | test (behavioral + structural gates) | — | `web/test/route-surface.test.mjs` | exact |
|
||||
| `web/test/guidance-replay.test.mjs` + `web/test/fixtures/drives/*.json` (new) | test + fixture corpus | streaming replay | `web/test/location-chain.test.mjs` stubs + `routing/test/fixtures/golden-pairs.json` corpus | role-match |
|
||||
| `web/test/guidance-eta.test.mjs` (new) | test | — | replay-fixture style of the two above | role-match |
|
||||
| `bot/test/guidance-cron.test.mjs` (new) | test | — | `bot/test/webhook.test.mjs` (undici offline) + KV-fake style of `bot/test/sessions.test.mjs` | role-match |
|
||||
| `bot/test/guidance-envelope.test.mjs` (MODIFY: GE4–GE7 re-pin), `bot/test/route-privacy.test.mjs` (MODIFY: RP1/RP2 re-pin), `bot/test/privacy.test.mjs` (MODIFY: PG1 module list) | test gates | — | themselves (each gate's doc-comment names Phase 6 as the re-pinning owner) | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `routing/src/http-source.ts` + `bot/src/routing/worker-router.ts` (service, request-response)
|
||||
|
||||
**Analog:** `routing/src/fixture-source.ts` — the backend-ladder construct every new RoutingSource implements.
|
||||
|
||||
**The seam being implemented** (`routing/src/types.ts` lines 89–100):
|
||||
```typescript
|
||||
// THE NULL CONTRACT IS LOAD-BEARING (D-03): route() resolving to NULL is
|
||||
// the honest "router pending" state — an unknown destination, or an origin
|
||||
// outside the fixture corpus's tolerance. Never a blank map, never a fake
|
||||
// route, never a thrown error for a normal miss.
|
||||
export interface RoutingSource {
|
||||
readonly name: string;
|
||||
route(req: RouteRequest): Promise<RouteResult | null>;
|
||||
}
|
||||
```
|
||||
|
||||
**Backend factory shape — copy this skeleton** (`fixture-source.ts` lines 156–189):
|
||||
```typescript
|
||||
export function createFixtureRoutingSource(data?: RouteFixturesFile): RoutingSource {
|
||||
let index: PairIndex | null = null;
|
||||
const load = (): PairIndex => {
|
||||
if (index === null) {
|
||||
index = data === undefined ? loadDefaultIndex() : buildPairIndex(data);
|
||||
}
|
||||
return index;
|
||||
};
|
||||
return {
|
||||
name: "fixture",
|
||||
async route(req: RouteRequest): Promise<RouteResult | null> {
|
||||
// Slug guard FIRST — a hostile shape never reaches the index (T-05-03).
|
||||
if (!ID_PATTERN.test(req.destinationId)) return null;
|
||||
const idx = load();
|
||||
const candidates = idx.byDestination.get(req.destinationId);
|
||||
if (candidates === undefined) return null; // D-03: honest pending
|
||||
// ... match → RouteResult; else return null
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Lazy memoize at module scope (LAW 1 — pay nothing at startup)** (`fixture-source.ts` lines 134–141, mirrored in the doc header lines 7–10):
|
||||
```typescript
|
||||
let defaultIndex: PairIndex | null = null;
|
||||
function loadDefaultIndex(): PairIndex {
|
||||
if (defaultIndex === null) {
|
||||
defaultIndex = buildPairIndex(fixturesData as RouteFixturesFile);
|
||||
}
|
||||
return defaultIndex;
|
||||
}
|
||||
```
|
||||
`worker-router.ts` transposes this to the R2 graph — RESEARCH.md "R2 graph load + isolate memoization" gives the exact promise-memo form:
|
||||
```typescript
|
||||
let graphP: Promise<Graph> | null = null; // module scope = per-isolate memo
|
||||
function loadGraph(env: Env): Promise<Graph> {
|
||||
graphP ??= env.ROAD_GRAPH.get(GRAPH_KEY)
|
||||
.then(o => o === null ? null : decodeGraph(await o.arrayBuffer()));
|
||||
return graphP;
|
||||
}
|
||||
```
|
||||
Absence of the graph object → `createHttpRoutingSource` resolves `null` (D-03's designed degradation), never throws.
|
||||
|
||||
**Defensive validation posture** (`fixture-source.ts` lines 66–95, `isUsablePair`): every field type-checked, non-finite numbers refused, a decode that throws marks the entry unusable (`catch { return false; }` lines 88–92) — the malformed entry renders as an honest null downstream, never a throw in the render path. `http-source.ts` applies the same shape to the Worker's JSON response; `graph.ts` applies it to the binary header (magic + version + size check, fail-honest per RESEARCH §Security).
|
||||
|
||||
**Imports convention** (`fixture-source.ts` lines 35–46): static import for data (`import fixturesData from "./data/route-fixtures.json" with { type: "json" }`), relative imports WITH `.ts` extension, types via `import type`. The package has no `exports`/`main` — consumers import the explicit subpath: `import { … } from "@araharah/routing/src/index.ts"` (`routing/src/index.ts` lines 5–11). New exports (`decodeGraph`, `snapToGraph`, `routeAstar`, `createHttpRoutingSource`) are added to `index.ts` as named exports only — no default export.
|
||||
|
||||
### `routing/src/snap.ts`, `routing/src/astar.ts`, `routing/src/graph.ts` (pure modules)
|
||||
|
||||
**Analog:** `routing/src/geo.ts` — the pure-module law.
|
||||
|
||||
**Header contract** (`geo.ts` lines 1–17): spherical math with named constants (`EARTH_RADIUS_M = 6_371_000`), "Pure: no I/O, no logging, no storage (the D-15 analog — nothing about a request outlives the caller's frame)". `haversineM` (lines 25–34) is the admissibility heuristic's distance oracle; `deriveCumulativeDistanceM` (lines 38–49) is the progress backbone the client snaps against:
|
||||
```typescript
|
||||
export function deriveCumulativeDistanceM(points: readonly RoutePoint[]): number[] {
|
||||
if (points.length === 0) return [];
|
||||
const cumulative: number[] = [0];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
// cumulative.push(prevMeters + haversineM(prev, curr));
|
||||
}
|
||||
return cumulative;
|
||||
}
|
||||
```
|
||||
Equirectangular point-to-segment math for `snap.ts` must scale lng by `cos(lat)` (RESEARCH Pitfall 8) and route all displayed distances through `cumulativeDistanceM`/`haversineM` — never raw Δdegrees.
|
||||
|
||||
**No analog exists for CSR layout / binary heap A\*** — RESEARCH.md Pattern 1 (CSR binary layout: header/nodes/offsets/edges/weights, zero-copy `DataView` views) and Pattern 2 (typed-array A\*, binary heap of indices, haversine÷maxSpeed heuristic, bidirectional variant) are the authoritative references. Graph **decode** (`graph.ts`) follows the codec posture of `routing/src/polyline.ts` (pure functions, decode/encode pair, header contract at `polyline.ts`'s doc block; round-trip proven by tests).
|
||||
|
||||
### `routing/scripts/build-road-graph.mjs` + `build-glyphs.mjs` (maintainer build tools)
|
||||
|
||||
**Analog:** `routing/scripts/fetch-route-fixtures.mjs` — the exact maintainer-tool pattern (D-12 boundary).
|
||||
|
||||
**Header/usage contract** (lines 1–47): `#!/usr/bin/env node`, a dense header stating "A MAINTAINER-TIME REPO TOOL … NOT a runtime dependency. Nothing in bot/src/, web/src/, or routing/src/ imports it", plus Usage / Exit codes sections.
|
||||
|
||||
**Named constants up top** (lines 57–64): `USER_AGENT`, `INTER_REQUEST_DELAY_MS = 1200`, `REQUEST_TIMEOUT_MS = 30_000`, tolerance values — never inline magic numbers.
|
||||
|
||||
**Pre-flight validation before any request leaves the machine** (lines 110–142): validate every input against committed data, `fail()` with the offending id named on the first violation.
|
||||
```typescript
|
||||
function fail(message) {
|
||||
console.error(`fetch-route-fixtures: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
**Collect-then-write** (lines 185–242): results accumulate in memory; the output file is written ONLY after every unit succeeds; a hard failure exits 1 with the committed file UNTOUCHED. `build-road-graph.mjs` copies this verbatim: Overpass query → validate → CSR encode → stats print (node/edge counts vs the D-01 30–80 MB envelope — a stats gate, like line 244–248's post-write summary + "next:" hint) → write `kl-roadgraph.v1.bin`. `build-glyphs.mjs` keeps the same shell around sharp's SVG→PNG (sharp is a NEW devDependency: `npm install -D -w routing sharp` — zero runtime deps added, per RESEARCH §Installation).
|
||||
|
||||
### `web/src/guidance/guidance.ts` (service — pure state machine, event-driven)
|
||||
|
||||
**Analog:** `web/src/route/route.ts` for the state-machine/controller shape; `web/src/location/chain.ts` for the pure-injected-clock posture.
|
||||
|
||||
**Injected-deps state machine with a total walk-back** (`route.ts` lines 55–82 + 174–187): every DOM/map concern is injected so tests run in Node ("All DOM/map concerns are INJECTED so the RS gates run in Node … the module itself touches no DOM and no maplibre value import"). The state transitions funnel through one `setState` that fires `onStateChange` (lines 88–91) — main.ts derives the BackButton predicate from it. `stepGuidance(state, fix, routeModel, nowMs) → {state, effects[]}` (RESEARCH Pattern 3) is the same idea, purer: no DOM concerns at all, injected clock.
|
||||
|
||||
**Fail-honest error posture** (`route.ts` lines 95–104):
|
||||
```typescript
|
||||
const result: RouteResult | null = await deps.source
|
||||
.route({ origin: { lat: fix.lat, lng: fix.lng }, destinationId: destination.id })
|
||||
.catch(() => null); // T-05-04: an exception renders pending — never a half-drawn line
|
||||
```
|
||||
REROUTING failures resolve to a calm on-route-holds / pending posture, never a thrown error in the fix loop.
|
||||
|
||||
**Console discipline** (`route.ts` lines 30–31 + RS8 gate at `route-surface.test.mjs` lines 498–513): the ONLY console lines are state tokens (`ARAHARAH-ROUTE state=drawn`), never template interpolation, never coordinates. Guidance adds its own `ARAHARAH-GUIDANCE state=…` family under the same law (D-14 category-only extended client-side).
|
||||
|
||||
**Named constants exported at top** (`route.ts` lines 46–52): `ROUTE_CAMERA_PADDING`, `ROUTE_FIT_DURATION_MS`, `ROUTE_CAMERA_PITCH` — guidance's `OFF_ROUTE_THRESHOLD_M`, `OFF_ROUTE_CONSECUTIVE_FIXES`, `OFF_ROUTE_GRACE_MS`, `STORM_GUARD_MS`, `MISSED_TURN_PAST_MANEUVER_M`, `ARRIVAL_THRESHOLD_M` (RESEARCH Pattern 3 table) follow this export-and-pin convention; a test asserts the exported values (RS4 camera constants, `route-surface.test.mjs` lines 563–568).
|
||||
|
||||
**Fix shape consumed** (`web/src/location/types.ts` lines 20–33) — replay fixtures are Fix-shaped JSON (same six fields):
|
||||
```typescript
|
||||
export type Fix = {
|
||||
lat: number; lng: number;
|
||||
accuracyM: number | null;
|
||||
headingDeg: number | null;
|
||||
ts: number;
|
||||
source: FixSource;
|
||||
};
|
||||
```
|
||||
|
||||
### `web/src/guidance/watch.ts` (hook, streaming)
|
||||
|
||||
**Analog:** `web/src/location/webview.ts` + `web/src/location/telegram.ts`.
|
||||
|
||||
**The wrapper pattern** (`webview.ts` lines 18–44): promise-wrapped callback, `settled` latch, hard timeout (a wedged client can never wedge the caller), errors resolve `null` — "denial / unavailability = null; the chain's normal signal (no error object escapes)". Options: `{ enableHighAccuracy: true, timeout, maximumAge: 0 }`. The guidance watch extends this to `watchPosition` (same `maximumAge: 0`, high-accuracy) with the D-15 distance throttle applied app-side:
|
||||
```typescript
|
||||
import { haversineM } from "@araharah/routing/src/geo.ts";
|
||||
const moved = last === null ? Infinity : haversineM(last, fix);
|
||||
if (moved >= 10 || fix.ts - lastAcceptedTs >= 5000) accept(fix); // ≥10 m OR ≥5 s
|
||||
```
|
||||
**Fix normalization precedent** (`telegram.ts` lines 48–56): map the platform payload into the six-field Fix with `source: "…"`, `accuracyM ?? null`, `headingDeg ?? null`. `chain.ts` lines 10–15 record the cadence contract this phase finally flips: "a Fix-shaped abstraction with a PLUGGABLE cadence and hard-codes NO continuous-mode interval … Phase 6 chooses between bounded getLocation() polling and watchPosition() without a rewrite."
|
||||
|
||||
### `web/src/guidance/banner.ts` + list + guidance-card (component builders)
|
||||
|
||||
**Analog:** `web/src/route/route-card.ts` — the builder law.
|
||||
|
||||
**createElement + textContent ONLY** (`route-card.ts` lines 11–15 header, enforced by RS5 at `route-surface.test.mjs` lines 424–434): "a name-derived string never reaches innerHTML". Structure: actions interface first (lines 26–31), then a pure builder returning an `HTMLElement`:
|
||||
```typescript
|
||||
export interface RouteCardActions {
|
||||
onSelect(index: number): void;
|
||||
onSearchAgain(): void;
|
||||
}
|
||||
export function buildRouteCard(locale, destinationName, options, selectedIndex, actions): HTMLElement {
|
||||
const loc = strings[locale];
|
||||
const card = document.createElement("div");
|
||||
card.className = "card";
|
||||
// ... header / headline / telemetry / diff appended via createElement+textContent
|
||||
return card;
|
||||
}
|
||||
```
|
||||
**Theme classes only, no local restyle** (`cards.ts` lines 2–8): `.card, .card-header, .card-ref, .btn-primary, .btn-secondary, .text-headline-md, .text-body-md, .text-body-sm` from the theme/shell — never restyled locally. Telemetry numbers use `.text-telemetry-num` (line 69), the first-consumer precedent for the banner's distance/ETA digits.
|
||||
|
||||
**Copy lives ONLY in `web/src/i18n/strings.ts`** (`strings.ts` lines 17–63 interface + COPY5 gate; RS9 at `route-surface.test.mjs` lines 517–542 greps the route family for inline English/Malay). New banner/confirm/trip-end copy keys are added to `ShellStrings` in both `en` and `ms`; clinical-honest tone. **Instrument formats are composed in code, not locale strings** — `fillTemplate` (lines 193–195) + `chip.textContent = \`${String(index + 1).padStart(2, "0")} // ${minutes} MIN · ${km} KM\`` (line 172). The banner's "480 m → JALAN AMPANG" format and the turn-card caption follow this.
|
||||
|
||||
**aria attributes** on interactive groups (`route-card.ts` lines 88–89): `role="group"` + `aria-label` from the strings module.
|
||||
|
||||
### Guidance camera (locked follow + recenter return)
|
||||
|
||||
**Analog:** `web/src/map/follow.ts` + `web/src/ui/recenter.ts`.
|
||||
|
||||
**Mode machine with a programmatic-move lock** (`follow.ts` lines 14–43):
|
||||
```typescript
|
||||
export const CAMERA_PADDING_PX = 96; // 12 x space-sm
|
||||
export const RECENTER_DURATION_MS = 400;
|
||||
export type FollowMode = "following" | "free";
|
||||
export function createFollowController(map, onModeChange) {
|
||||
map.on("movestart", (e) => {
|
||||
if (lock) return;
|
||||
if (e.originalEvent) set("free"); // a user gesture only, never a programmatic move
|
||||
});
|
||||
return { follow(fix) { lock = true; map.flyTo({...}); set("following"); lock = false; } };
|
||||
}
|
||||
```
|
||||
Locked-follow (D-10: center ≈65% down-screen via MapLibre padding, pitch 35°) extends `follow(fix)` to ease along the snapped line; user pan still unlocks via `originalEvent` (T-03-16) → the Phase 3 recenter control returns. **Control state is data-attributes + CSS** (`recenter.ts` lines 27–47): `button.dataset.follow = mode; button.dataset.fix = state;` — instant swaps, zero easing; the click handler is `addEventListener`, never inline onclick. `route.ts` lines 8–14 document the camera law the guidance camera must keep: pitch/bearing passed EXPLICITLY; fit stays center+zoom only.
|
||||
|
||||
### Capability guards (haptics, vertical swipes, closing confirmation, BackButton)
|
||||
|
||||
**Analog:** `web/src/telegram/webapp.ts` + `web/src/telegram/safe-area.ts` + `web/src/telegram/backbutton.ts`.
|
||||
|
||||
**The contract grows by OPTIONAL members only** (`webapp.ts` lines 31–65 — the hand-rolled `TelegramWebAppLike` IS the contract; `@types/telegram-web-app` stays deliberately out). RESEARCH.md "Telegram capability guards" names the exact additions:
|
||||
```typescript
|
||||
HapticFeedback?: { impactOccurred(style): unknown; notificationOccurred(type): unknown; selectionChanged(): unknown };
|
||||
disableVerticalSwipes?: () => void; // Bot API 7.7+
|
||||
enableVerticalSwipes?: () => void;
|
||||
enableClosingConfirmation?: () => void; // Bot API 6.2+
|
||||
isActive?: boolean; // 8.0+
|
||||
// "activated" | "deactivated" via the existing onEvent?(event, cb)
|
||||
```
|
||||
|
||||
**Version-gate BEFORE touching the API** (`safe-area.ts` lines 35–43, the established shape; `telegram.ts` lines 23–26 shows the null-safe variant):
|
||||
```typescript
|
||||
const tg =
|
||||
wa && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0")
|
||||
? wa.safeAreaInset
|
||||
: null;
|
||||
if (!tg) return base; // old-client fallback
|
||||
```
|
||||
Haptics are graceful no-ops where unsupported (D-08) — `?.` chains on the optional member.
|
||||
|
||||
**BackButton graph extension** (`backbutton.ts` lines 1–27): "its predicate and its handler ship as ONE change so Phase 6 extends the state graph rather than reinventing the wire". The 3-state walk (search → routes → guidance → search) makes `walkBack` context-aware; guidance-exit routes through the D-09 confirm card; never `close()` from a pushed state (RS7 proves the wire with a recording fake, `route-surface.test.mjs` lines 449–494).
|
||||
|
||||
**Safe areas during guidance:** `resolveSafeInsets` + `applySafeArea` (`safe-area.ts` lines 35–58) already write `--safe-top/bottom/left/right` custom properties on `:root` — the banner's top register padding consumes the existing tokens.
|
||||
|
||||
### `bot/src/kv/guidance-sessions.ts` (MODIFY: frozen types → TTL-only service)
|
||||
|
||||
**Analog:** `bot/src/kv/location-pin.ts` (the exact write/read/delete trio) + `bot/src/kv/sessions.ts` (multi-key rotation).
|
||||
|
||||
**The frozen contract being implemented** (`guidance-sessions.ts` lines 15–17, 49–58):
|
||||
```typescript
|
||||
export const GUIDANCE_SESSION_TTL_SECONDS = 7200;
|
||||
export interface GuidanceSessionEnvelope {
|
||||
id: string;
|
||||
chat_id: number;
|
||||
route_snapshot: GuidanceRouteSnapshot;
|
||||
created_at: number;
|
||||
}
|
||||
```
|
||||
Key scheme `guidance:<id>`; EXACTLY four fields — "a 5th property means drift (the SessionClaim law)".
|
||||
|
||||
**TTL-on-the-same-line write** (`location-pin.ts` lines 42–46):
|
||||
```typescript
|
||||
export async function writePin(kv: LocationPinKv, chatId: number, pin: LocationPin): Promise<void> {
|
||||
await kv.put(`loc:${chatId}`, JSON.stringify(pin), { expirationTtl: PIN_TTL_SECONDS });
|
||||
}
|
||||
```
|
||||
PG1 (`privacy.test.mjs` lines 32–49) mechanically enforces: every `.put(` line carries `expirationTtl`, referencing an ALL-CAPS named constant — never an inline literal. The new service's writes land in `privacy.test.mjs`'s `storageModules` list.
|
||||
|
||||
**Injected KvLike seam** (`sessions.ts` lines 53–57): `interface KvLike { get/put({expirationTtl})/delete }` — the service takes `kv: GuidanceSessionKv extends KvLike`, so tests inject a Map-backed fake (the `bot/test/sessions.test.mjs` pattern). `crypto.randomUUID()` ids (line 74, T-02-05).
|
||||
|
||||
**Defensive read with exact-field + range validation** (`location-pin.ts` lines 48–73): parse in try/catch, check every field's presence and type, range-check numerics, return `null` on any mismatch — "a corrupt or tampered record can never fly the client camera to null island." `getGuidanceSession` mirrors this against the frozen envelope shape (the cron must never push a card from a malformed record).
|
||||
|
||||
**Explicit delete for trip end (D-14):** `location-pin.ts` lines 75–79 `clearPin` is the model — one `await kv.delete(\`loc:${chatId}\`)`; guidance adds `deleteGuidanceSession(kv, id)` called on BOTH arrival and abandon paths.
|
||||
|
||||
### `bot/src/webhook.ts` (MODIFY: +`scheduled()`, +`/api/route`, +`/api/guidance`)
|
||||
|
||||
**Analog:** its own established handler pattern.
|
||||
|
||||
**Route-dispatch skeleton** (lines 54–84): pathname match → method check (405) → handler; `satisfies ExportedHandler<Env>` (line 84). RESEARCH.md's "Cron config + scheduled handler" example extends exactly this object:
|
||||
```typescript
|
||||
export default {
|
||||
async fetch(req, env, ctx): Promise<Response> { /* existing + /api/route + /api/guidance */ },
|
||||
async scheduled(controller: ScheduledController, env: Env, ctx): Promise<void> {
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await env.chat_sessions.list({ prefix: "guidance:", cursor });
|
||||
for (const key of page.keys) { /* get → projectTurnCards → maybe sendPhoto */ }
|
||||
cursor = page.list_complete ? undefined : page.cursor;
|
||||
} while (cursor !== undefined);
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
```
|
||||
|
||||
**Bearer-claim derivation — NEVER client-supplied chat ids** (`webhook.ts` lines 143–161, `handleLocation` — the T-03-01 precedent both new endpoints must copy):
|
||||
```typescript
|
||||
const header = req.headers.get("authorization") ?? "";
|
||||
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
|
||||
const token = match !== null && match[1] ? match[1] : null;
|
||||
const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token);
|
||||
if (claim === null) {
|
||||
console.log("[location] rejected: unauthorized"); // category ONLY (D-14)
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, ... });
|
||||
}
|
||||
```
|
||||
`/api/guidance` mints the envelope with `chat_id: claim.chat_id`; `/api/route` is gated identically. Body-size caps follow `MAX_INITDATA_CHARS` + the pre/post-read double check (lines 91–119, WR-01) for request-body validation; lat/lng validation is hand-rolled (no zod — RESEARCH §V5).
|
||||
|
||||
**Category-only logging** (lines 37–43 law + call sites): `console.log("[initdata] rejected: malformed")` — never payload, never ids, never timestamps. Cron and routing paths extend this (D-14): `[guidance-cron] …` categories only.
|
||||
|
||||
**Fail-closed secret posture** (lines 58–71): reject with a constant response BEFORE any framework/library path runs when a required binding is missing — the shape to mirror if the R2 binding or graph key is absent (honest degradation, D-03).
|
||||
|
||||
### `bot/src/bot/bot.ts` (MODIFY-adjacent: cron-side sends)
|
||||
|
||||
**Analog:** `createBot(env, opts)` (lines 41–44) — the env-bound factory with an injectable `baseFetch` for offline tests:
|
||||
```typescript
|
||||
export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot {
|
||||
const bot = opts.baseFetch
|
||||
? new Bot(env.BOT_TOKEN, { client: { fetch: opts.baseFetch } })
|
||||
: new Bot(env.BOT_TOKEN);
|
||||
```
|
||||
`cron.ts` calls `createBot(env)` (works identically inside `scheduled()`); `guidance-cron.test.mjs` injects `baseFetch` to record `sendPhoto` calls without network — the turn-card send is `bot.api.sendPhoto(chat_id, url, { caption, parse_mode: "HTML" })` (RESEARCH "Turn-card send").
|
||||
|
||||
### `bot/src/config.ts` + `bot/wrangler.jsonc` (config MODIFY)
|
||||
|
||||
**Analog:** themselves — the typed-Env / config-comment style.
|
||||
|
||||
`config.ts` (lines 4–17): each field one line, trailing `//` comment naming its law (e.g. `chat_sessions: KVNamespace; // TTL-only namespace`). Add `ROAD_GRAPH: R2Bucket; // D-01 graph binary — R2 binding, first in the repo` plus any cron constants as named exports, NOT env reads. `wrangler.jsonc` (lines 1–24): JSONC with law-bearing comments; add:
|
||||
```jsonc
|
||||
"r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }],
|
||||
"triggers": { "crons": ["*/1 * * * *"] }
|
||||
```
|
||||
(60 s tick; app-side logic decides whether a card is due — D-11's 60–120 s discretion.) KV namespace stays EXACTLY ONE (`chat_sessions`, line 23) — GE5 re-pins to "exactly one" still; the R2 bucket is NOT a KV namespace.
|
||||
|
||||
### `bot/package.json` (MODIFY)
|
||||
|
||||
**Analog:** itself — `bot/package.json` already carries the workspace-dep precedent: `"dependencies": { "grammy": "^1.46.0", "@araharah/search": "*" }`. Add `"@araharah/routing": "*"` (the research-named first routing import). Import form inside bot/src follows the subpath rule: `import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"`.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### TTL-only KV (every write carries `expirationTtl`)
|
||||
**Source:** `bot/src/kv/location-pin.ts` lines 42–46, `bot/src/kv/sessions.ts` lines 73–90; enforced by `bot/test/privacy.test.mjs` PG1 (lines 32–49)
|
||||
**Apply to:** `bot/src/kv/guidance-sessions.ts` service functions; extend PG1's `storageModules` list in the same change.
|
||||
```typescript
|
||||
await kv.put(`guidance:${id}`, JSON.stringify(envelope), { expirationTtl: GUIDANCE_SESSION_TTL_SECONDS });
|
||||
```
|
||||
|
||||
### Bearer-claim → server-derived key (V4 access control)
|
||||
**Source:** `bot/src/webhook.ts` lines 143–161 (`handleLocation`)
|
||||
**Apply to:** `/api/route`, `POST /api/guidance`, `DELETE /api/guidance`; the cron reads `envelope.chat_id` from KV only — never a client-supplied chat id anywhere.
|
||||
|
||||
### Category-only logging
|
||||
**Source:** `bot/src/webhook.ts` lines 37–43, 112, 150, 196; client-side twin: `route.ts` state tokens + RS8 gate
|
||||
**Apply to:** all cron + routing paths (bot) and all `web/src/guidance/` modules (state tokens only, no `${…}` interpolation — coordinates and chat ids never appear in any log line).
|
||||
|
||||
### Lazy memoize at module scope (LAW 1)
|
||||
**Source:** `routing/src/fixture-source.ts` lines 134–141, 57–63
|
||||
**Apply to:** `worker-router.ts` graph load (1 R2 GET per cold isolate), `graph.ts` decode cache, and any snap-grid index (built once per isolate, never per request).
|
||||
|
||||
### Fail-honest nulls (never fabricate, never throw for a normal miss)
|
||||
**Source:** `routing/src/types.ts` lines 13–16 + `fixture-source.ts` lines 175–186; client twin `route.ts` lines 97–104
|
||||
**Apply to:** `http-source.ts` (network/absent-graph → null → router-pending), `graph.ts` header check, `getGuidanceSession` (malformed → null), reroute failure inside the state machine.
|
||||
|
||||
### Pure module + injected deps (Node-testable without a browser)
|
||||
**Source:** `web/src/location/chain.ts` lines 27–36 (injected `nowMs`, injected source functions); `route.ts` lines 33–36 (injected DOM/map); `geo.ts` (no I/O at all)
|
||||
**Apply to:** `guidance.ts` (injected clock + RoutingSource), `eta.ts`, `cron.ts`'s `projectTurnCards()` (pure projection, injected now), `graph/astar/snap.ts`. Module header comments state the law (the `chain.ts` lines 1–20 style).
|
||||
|
||||
### Capability-guarded Telegram WebApp APIs
|
||||
**Source:** `web/src/telegram/webapp.ts` lines 31–65 (optional members only) + `safe-area.ts` lines 35–50 (`isVersionAtLeast` gate) + `telegram.ts` lines 23–26 (null-safe support probe)
|
||||
**Apply to:** haptics, `disableVerticalSwipes`, `enableClosingConfirmation`, `isActive`/`activated`/`deactivated` events, envelope POST-on-minimize.
|
||||
|
||||
### createElement + textContent builders; strings-module copy; theme classes only
|
||||
**Source:** `web/src/route/route-card.ts` lines 11–15, 37–103, 193–195; `web/src/i18n/strings.ts` lines 17–63; gated by RS5/RS9 + COPY5
|
||||
**Apply to:** banner, maneuver list, guidance card, "abandon route?" confirm card, "trip ended, data deleted" messaging — all copy keys land in `ShellStrings` (en + ms), all instrument formats composed in code via `fillTemplate`.
|
||||
|
||||
### Version-gated constants tables (camera + thresholds exported and pinned)
|
||||
**Source:** `route.ts` lines 46–52 (`ROUTE_CAMERA_PADDING/…`), pinned by `route-surface.test.mjs` lines 563–568
|
||||
**Apply to:** the D-04/D-07 constants (`OFF_ROUTE_THRESHOLD_M` … `ARRIVAL_THRESHOLD_M`, EMA α, deadband) — named exports, asserted by the replay/eta suites, replay-tuned without renaming.
|
||||
|
||||
### Hand-rolled test harness family
|
||||
**Source:** `routing/test/harness.mjs` (10 lines: `test()` + registry), `routing/test/runner.mjs` (auto-discovers `*.test.mjs`, sorted; load-failure becomes one failing case; PASS/FAIL + exit code). `bot/test/` and `web/test/` are the same shape verbatim (confirmed by diff).
|
||||
**Apply to:** every new suite registers via `import { test } from "./harness.mjs"`; new suites are dropped into the existing `test/` dirs — runner discovery picks them up with zero config.
|
||||
|
||||
### Behavioral fakes + structural gates (the two test styles)
|
||||
**Source (behavioral):** `web/test/route-surface.test.mjs` — `fakeMap()` recording fake (lines 77–104), `fakeDeps()` full dep set capturing actions (lines 266–306), `withConsole()` capture helper (lines 309–318), Node-24 type-stripping dynamic imports of `.ts` sources (lines 37–46).
|
||||
**Source (behavioral, DOM-free):** `web/test/location-chain.test.mjs` — `stubNavigator`/`stubFetch` install+restore-in-finally (lines 30–62), `fakeWebApp` with real `isVersionAtLeast` comparison (lines 68–89).
|
||||
**Source (structural):** comment-stripped source introspection — `stripComments` + banned-token scans (`route-surface.test.mjs` lines 50–58; `guidance-envelope.test.mjs` lines 54–70; `route-privacy.test.mjs` lines 42–50).
|
||||
**Apply to:** `guidance-surface.test.mjs` (copy of the RS style), `guidance-replay.test.mjs` (Fix-shaped JSON streams through `stepGuidance` — the chain-test stub style, no DOM at all), `guidance-cron.test.mjs` (undici Request/Response offline pattern of `bot/test/webhook.test.mjs`; KV Map-fake; `baseFetch`-captured `sendPhoto`).
|
||||
|
||||
### Gate re-pins ship WITH the change that invalidates them
|
||||
**Source (the handoff notes):** `bot/test/guidance-envelope.test.mjs` lines 36–40 ("when the turn-card service legitimately adds the kv namespace binding, the Env field, and the consumer, updating GE4–GE7 is THAT plan's responsibility"); `bot/test/route-privacy.test.mjs` lines 1–24 (RP1 bans `/api/route` tokens, RP2 bans `@araharah/routing` in bot/src).
|
||||
**Apply to:** the plans that add the envelope service re-pin GE4 (types-only → service), GE6 (Env gains `ROAD_GRAPH`), GE7 (bot/src may import guidance-sessions), and keep GE1 (TTL 7200), GE2 (field freeze), GE3 (inline snapshot) UNCHANGED — the frozen shape stays frozen. The plan adding `/api/route` + the routing import re-pins RP1 (ban *unauthenticated/proxy-shaped* routing) and RP2 (routing import now allowed, listed explicitly). GE5 stays: exactly ONE KV namespace (R2 is a separate binding class).
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason | Fallback |
|
||||
|------|------|-----------|--------|----------|
|
||||
| `routing/src/astar.ts` + CSR layout in `graph.ts` | service | batch | No graph-search code exists anywhere in the repo | RESEARCH.md Pattern 1 (CSR binary layout) + Pattern 2 (typed-array A\*, binary heap, bidirectional); correctness gate = A\* equals brute-force Dijkstra on a committed mini-graph (`routing/test/` fixture, Wave 0) |
|
||||
| `routing/scripts/build-glyphs.mjs` (sharp SVG→PNG) | build tool | file-I/O | No rasterization code exists; sharp is a new devDependency | Keep `fetch-route-fixtures.mjs`'s maintainer-tool shell (header/usage/exit codes/collect-then-write); rasterizer choice per RESEARCH Standard Stack (sharp preferred, `@resvg/resvg-js` fallback) |
|
||||
| `bot/src/webhook.ts` `scheduled()` + `bot/src/guidance/cron.ts` | service | event-driven | First Cron Trigger in the repo — no scheduled-handler precedent | RESEARCH.md "Cron config + scheduled handler" code example (KV `list({prefix, cursor})` + `list_complete` pagination); local test via `curl "http://localhost:8787/cdn-cgi/local/scheduled"`; send shape from `bot.ts` `createBot(env)` |
|
||||
| `web/test/fixtures/drives/*.json` corpus | fixture corpus | — | No position-stream fixtures exist (phase 3/4 used capture profiles, not recorded drives) | Shape = the six-field `Fix` (`web/src/location/types.ts` lines 22–33) as JSON streams; corpus-commit conventions from `routing/test/fixtures/golden-pairs.json` (committed data + header/attribution fields where licensing applies) |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `routing/{src,test,scripts}/`, `web/src/{route,map,location,telegram,ui,i18n}/`, `web/test/`, `bot/src/{kv,bot}/`, `bot/test/`, `bot/wrangler.jsonc`, workspace `package.json`s
|
||||
**Files read in full:** 24 source/test/config files (~2,700 lines); runners trio diff-confirmed identical in shape
|
||||
**Pattern extraction date:** 2026-10-24
|
||||
|
||||
**Cross-cutting cautions for the planner (each pinned by an existing gate):**
|
||||
1. **GE5 stays green:** the R2 bucket is a `r2_buckets` binding, NOT a second KV namespace — `guidance-envelope.test.mjs` GE5 still asserts exactly one `kv_namespaces` entry.
|
||||
2. **PG1 scope grows:** `privacy.test.mjs` `storageModules` (line 25) must list `kv/guidance-sessions.ts` the moment it gains a write path.
|
||||
3. **RS6 inversion:** `route-surface.test.mjs` RS6 (lines 438–445) currently BANS the tokens `progress`/`maneuver` in `web/src/route/` — the plans that move guidance chrome into `web/src/route/` must re-pin RS6 in the same change (the phase-5 header comment reserved this: `route.ts` D-10 "no start-guidance button … Phase 6" note).
|
||||
4. **RS10 floor bump:** the no-storage scan floor (≥16 files, lines 546–559) grows with `web/src/guidance/` — bump it in the plan that creates the family.
|
||||
5. **Workspace-import rule:** subpath-with-extension only (`@araharah/routing/src/index.ts`); a bare `@araharah/routing` specifier resolves to nothing (`routing/src/index.ts` lines 5–11) — and bot/package.json needs the dep added before bot/src imports it.
|
||||
@@ -0,0 +1,440 @@
|
||||
---
|
||||
phase: 6
|
||||
slug: turn-by-turn-guidance-live-driving
|
||||
status: draft
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: 2026-10-24
|
||||
---
|
||||
|
||||
# Phase 6 — UI Design Contract (Turn-by-Turn Guidance, Live Driving)
|
||||
|
||||
> Visual and interaction contract for the guidance experience. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
|
||||
>
|
||||
> **Source posture:** the design system is IN USE — `theme/dist/rams.css` tokens and the live shell (strip, card stack, recenter control, gantry, destination card, route card + chips) shipped in Phases 3–5 and passed their UI-SPECs + device gates. This contract specifies **extension** of that shell into the guidance cockpit: the top maneuver banner, the compacted location register, the collapsible maneuver list, the guidance card (the route card evolving), the abandon-route confirm card, trip-end messaging, the locked-follow camera, and the chat turn-card (a Telegram message, not app chrome). It invents no new token values, no new colors, no roundedness, no shadows. Binding upstream: 06-CONTEXT.md D-01..D-16 (layout, ETA deadband, haptics, PX-03/PX-01, camera, glyphs, honest copy, trip-end deletion), 05-UI-SPEC.md (the route shell this extends — ITS CONVENTIONS BIND), 03-UI-SPEC.md (strip/card/control/copy/a11y law), telemetry_rams/DESIGN.md §Navigation Heads-Up Display (the banner's component spec), DESIGN.md palette semantics (orange = navigation/primary, red = hazards/errors only). The `active_route_guidance` mockup is layout reference only; its lane matrix, incident band, speedometer deck, report/audio buttons, backdrop-blur header, and rounded puck are scrubbed residue (deferred features or existing-law violations — recorded in Fidelity Artifact).
|
||||
>
|
||||
> **Map-law restated:** the MapLibre style is never re-set; additions only (`addLayer`/`addSource`/`setData`). The route line stack from Phase 5 is guidance's route rendering — unchanged paint (orange 5px on black 9px casing). No route-line progress split this phase: the puck, the progress bar, and the camera carry progress (recorded call — less, but better).
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | none — shadcn gate N/A (TypeScript + Vite, no UI framework; unchanged from Phase 3) |
|
||||
| Preset | not applicable |
|
||||
| Component library | `@araharah/theme` — `theme/dist/rams.css` (already imported via `shell.css`; nothing new to wire) |
|
||||
| Icon library | inline SVG — 2px uniform stroke, square linecaps, orthogonal/45° baselines (app chrome); PLUS the phase's one new committed asset: the **8-arrow maneuver glyph set** — hand-drawn 32×32 SVGs at 3px stroke (the HUD spec's weight), rendered as inline SVG in-app and rasterized once to 256×256 PNGs (maintainer `build-glyphs.mjs`, sharp) for chat turn-cards. One geometry, two renderings; the committed SVGs are the single source |
|
||||
| Font | self-hosted Geist + Space Grotesk via `theme.css` `@font-face` (unchanged) |
|
||||
| Basemap | `theme/basemap/liberty-rams.json` — Rams-constant under both Telegram themes (unchanged) |
|
||||
|
||||
**Consumed component classes** (all pre-existing; none restyled locally): `.card`, `.card-header`, `.card-ref`, `.btn-primary`, `.btn-secondary`, `.text-headline-md`, `.text-body-md`, `.text-body-sm`, `.text-label-sm`, `.text-telemetry-num` — plus the Phase 3–5 shell classes (`.strip`, `.recenter`, `.route-stack`, `.card-stack`).
|
||||
|
||||
New CSS for this phase (banner, list, guidance-card rows, progress bar) lives in `shell.css` and references `var(--color-*)` / `var(--spacing-*)` / `var(--type-*)` only. The machined-geometry literal list grows by exactly: banner arrow plate 48×48 + glyph 32×32, banner min-height 96px, compact strip min-height 24px, list row min-height 48px + row glyph 24×24, next-row lamp 8px, list panel max-height 480px, progress bar 4px, the 64px control-clearance composite (reused), guidance camera paddings {top 320, bottom 64, left/right 64} (map-space px) — all 4-multiples, 1px/2px rules, machined glyph geometry, and map-space paint arithmetic — the established exception classes.
|
||||
|
||||
---
|
||||
|
||||
## Shell Layout — Register Bands (guidance occupation, D-06)
|
||||
|
||||
The Phase 3 partition is unchanged. During guidance the registers re-populate:
|
||||
|
||||
| Band | Content during guidance | Geometry |
|
||||
|------|------------------------|----------|
|
||||
| Top register | **Maneuver banner** (new, D-06) — distance + arrow + street, matte-black chassis; the toggle for the maneuver list | Below `safe-top + space-sm`; min-height 96px; full-bleed |
|
||||
| Top register, compacted | **Location strip (compacted)** — same lamp + state word, telemetry cluster hidden, min-height 24px | Directly beneath the banner, separated by a 1px `chassis-subtle` rule; one continuous black chassis with the banner |
|
||||
| Center register | Rams basemap + position marker + Phase 5 route stack; **maneuver list panel** (only while expanded, overlays the map — no dim layer, boot-card precedent); boot/denied/pending cards unchanged when guidance is not active | Map stays the dominant surface |
|
||||
| Bottom register | **Guidance card** (the route card evolved — D-06); **abandon confirm card** (bottom placement — thumb-reach call, D-09); signal-lost card (existing, transient); recenter control bottom-right (returns on pan-unlock, Phase 3 semantics); attribution chip bottom-left | Card bottom offset `calc(var(--safe-bottom) + 64px)` — the Phase 5 composite, unchanged |
|
||||
|
||||
- **Strip compaction is layout-only.** The strip's state machine, lamp colors, and word labels are byte-identical (Phase 3 table stands; `strip.ts` logic untouched). Guidance adds a `data-guidance` attribute on the shell root: padding-block drops `space-sm` → `space-xs`, min-height 32 → 24px, and the right telemetry cluster (`±M` / `FIX Ns AGO`) hides — accuracy/fix-age are not glance data at speed. The strip never hides degradation silently: staleness and denial still announce via red lamp + word, unchanged. Compacted registration is honest registration.
|
||||
- **No new floating controls.** The recenter control keeps its plate, notch states, and hit target exactly; during guidance its `following` state means *locked-follow* (D-10) — the notch semantics read identically to the user.
|
||||
- Banner placement is the strip's own band + the space above it: the chassis grows upward, the map viewport shrinks correspondingly (camera arithmetic below compensates).
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Bound to tokens (`--spacing-*`), consumption not invention. Phase 3/5 tables stand; Phase 6 adds:
|
||||
|
||||
| Element | Number | Token source |
|
||||
|---------|--------|--------------|
|
||||
| Banner padding | `space-sm space-md` (8/16) | tokens |
|
||||
| Banner min-height | 96px (register 12 + distance 32 + street 26 + 2×`space-xs` gaps + 2×`space-sm` padding ≈ 94 → 96 = 12×8) | tokens + spec geometry |
|
||||
| Banner arrow plate | 48×48px (32px glyph + 8px breathing per side), `surface-dark` field on the black chassis (mockup nesting precedent, token `--color-surface-dark`) | machined geometry |
|
||||
| Banner arrow glyph | 32×32 viewBox, 3px stroke, square linecaps | telemetry_rams HUD spec |
|
||||
| Compact strip min-height | 24px, padding-block `space-xs` | tokens + spec geometry |
|
||||
| Banner→strip separator | 1px solid `chassis-subtle` | machined rule |
|
||||
| List panel | card geometry: `calc(100% - 2×margin)`, max-width 320px, centered; max-height 480px (scrolls); padding `space-sm` bottom | tokens + spec geometry |
|
||||
| List row | min-height 48px, padding `space-sm space-md`, glyph 24×24, 1px `matte-black` row separators (dark: `inverse-on-surface`) | tokens + machined rule |
|
||||
| Next-row lamp | 8×8px `signal-orange` square at the row's left edge (checkbox geometry precedent) | spec geometry |
|
||||
| Guidance card | bottom offset 64px composite (Phase 5, reused); telemetry row + progress bar margins `space-xs`; progress bar height 4px, width = content width (inset `space-md`) | tokens + machined geometry |
|
||||
| Confirm card | bottom register, same 64px composite (thumb-reach — see A11y) | Phase 5 composite |
|
||||
| Guidance camera padding | `{ top: 320, bottom: 64, left: 64, right: 64 }` — map-space px (MapLibre padding, not CSS; 8-multiples; `T − B = 256 ≈ 0.30 × 844` reference viewport → center at 65% down-screen, D-10) | spec geometry |
|
||||
|
||||
Exceptions: none beyond the established classes (44px hit targets via existing slop patterns; 4-multiples; map-space arithmetic).
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
The Phase 3 scale stands — zero new sizes, zero new weights. `telemetry-num` gains its second consumer (the banner distance).
|
||||
|
||||
| Role | Token | Spec | Usage in Phase 6 |
|
||||
|------|-------|------|------------------|
|
||||
| Banner distance | `--type-telemetry-num` | 28px Space Grotesk 700 / 32px, tracking −0.03em | Distance-to-maneuver number (`250` + `M`) — the glance datum |
|
||||
| Banner street | `--type-headline-md` | 20px Space Grotesk 600 / 26px, tracking −0.02em | Street/designation line, uppercase (HUD spec: "Maneuver Typography: headline-md") |
|
||||
| Banner register | `--type-label-sm` | 10px Geist 700 / 12px, tracking +0.08em, uppercase | `GUIDE // NEXT` register line; `LIST`/`CLOSE` toggle word |
|
||||
| List row street | `--type-body-md` | 14px Geist 400 / 20px | Row street names |
|
||||
| List row distance | `--type-label-sm` | 10px Geist 700 / 12px, uppercase | Per-row remaining distance (`1.2 KM`) |
|
||||
| Guidance-card time | `--type-telemetry-num` | (as above) | Minutes remaining — the route card's time slot, evolved |
|
||||
| Guidance-card units/ETA | `--type-label-sm` | (as above) | `MIN`, `{km} KM`, `ETA 18:42` |
|
||||
| Card state line | `--type-body-sm` | 12px Geist 400 / 16px | Degradation copy (no-reroute), confirm-card body |
|
||||
| Confirm headline | `--type-headline-md` | (as above) | `Abandon route?` |
|
||||
| CTA labels | `--type-label-lg` (inside `.btn-*`) | 14px Space Grotesk 700 / 18px, uppercase | `END TRIP`, `YES, ABANDON`, `CONTINUE` |
|
||||
|
||||
Unit formats are instrument constants, code-composed (Phase 3/5 precedent): distance `{n} M` under 1000 (integer), `{n.n} KM` at/over (one decimal), ETA clock 24-hour `HH:MM`, minutes `{n} MIN`. Units unchanged under `ms`. Body line-height ~1.43, heading 1.3 — token ratios verbatim.
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
60 / 30 / 10 split unchanged. **The banner's arrow joins signal-orange's reserved-for list — navigation semantics, the Phase 1 reservation's natural fulfillment.** The reserved-for list is restated and extended:
|
||||
|
||||
| Role | Value | Usage in Phase 6 |
|
||||
|------|-------|------------------|
|
||||
| Dominant (60%) | Rams basemap grayscale + shell chrome (unchanged) | The world stays grayscale; guidance ink reads against it |
|
||||
| Secondary (30%) | Instrument surfaces (unchanged) | Guidance card + list panel + confirm/trip-end cards: light `soft-beige` / dark `inverse-surface` via theme slots; banner + compact strip chassis `matte-black` (Rams-constant, strip precedent) |
|
||||
| Accent (10%) | `signal-orange` `#EB8008` | **Reserved-for list (exact, Phase 6 — supersedes the Phase 5 list where guidance surfaces are concerned):** every Phase 5 reservation (route line, heading cone, follow notch, focus brackets, lamps, primary CTA fill, press inverts); PLUS the banner maneuver arrow (32×32, 3px stroke — HUD spec); the machine-blink gantry square in REROUTING/RESUMING states (boot-card precedent); the next-maneuver row lamp + row glyph in the list; the progress-bar fill. Orange appears at most once per band, always meaning navigation |
|
||||
| Destructive | `functional-red` `#ED3F1C` | **Unchanged and NOT extended:** denied/signal-lost lamps only. Abandoning a route is a deliberate user action, not an error — the confirm card renders in standard card ink; the REROUTING and no-reroute states are deferrals, never failures — no red anywhere in guidance chrome |
|
||||
| Operational | `olive-green` `#736B1E` | LIVE lamp only (unchanged) |
|
||||
|
||||
**Banner ink hierarchy (chassis discipline — three inks + one accent):** register line `chassis-subtle` (≈9:1 on black), street `soft-beige` (≈11.6:1 — the HUD spec's ink), distance `pure-white` (≈17:1 — maximum glance contrast), arrow + state square `signal-orange`. The HUD spec pins the arrow orange and the street soft-beige; the distance reads white (mockup precedent: `250 M` in pure-white) so the accent stays singular per band.
|
||||
|
||||
**Turn-card PNG colors (D-12):** stroke `signal-orange` `#EB8008` on a `matte-black` `#1A1A1A` full-bleed plate — the banner's chat sibling; hex values copied verbatim from `tokens.css` as build constants (DSGN-01 precedent; the glyph script's source SVGs carry the literals).
|
||||
|
||||
- **Opacity 1.0 everywhere** — physical planarity law. The one sanctioned translucency remains the Phase 3 accuracy circle (data grounding).
|
||||
- **Past-maneuver rows** render in `outline` ink (`#897363`, an existing token) — muted history, not hidden (the list is honest about where you have been).
|
||||
|
||||
---
|
||||
|
||||
## Maneuver Banner (D-06 — the top register's guidance occupant)
|
||||
|
||||
Full-bleed matte-black chassis under the safe inset. The whole banner is the list toggle (`<button>`, full-width, hit height = banner height ≥ 44px).
|
||||
|
||||
**Structure (single row, top→bottom within the band):**
|
||||
|
||||
| Slot | Class / geometry | Content |
|
||||
|------|------------------|---------|
|
||||
| Register line | `.text-label-sm`, `chassis-subtle` ink, gap `space-xs` | `GUIDE // NEXT` — or the state word per the state table below (instrument vocabulary, en under both locales, composed in code from the `GUIDE` base key) |
|
||||
| Arrow plate | 48×48px `surface-dark` field | 32×32 inline SVG maneuver glyph, `signal-orange`, 3px stroke, square linecaps (the committed glyph set, in-app rendering) |
|
||||
| Distance | `.text-telemetry-num` `pure-white` + unit in `.text-label-sm` | `250 M` / `1.2 KM` — remaining distance to the maneuver, from `cumulativeDistanceM` + snapped progress (never raw fix-to-point distance, D-07); unknown during REROUTING/RESUMING = `—` (strip's unknown-value glyph) |
|
||||
| Street line | `.text-headline-md`, `soft-beige` ink, uppercase, single line, ellipsis overflow (the list carries the full name) | Street/designation verbatim from the route step `name` (data, not copy — destination-name rule); unnamed segment renders the `streetUnnamed` fallback copy |
|
||||
| Toggle word | `.text-label-sm`, `chassis-subtle` ink, right edge | `LIST` when collapsed / `CLOSE` when expanded (real-word affordance — no chevron decoration); `aria-expanded` carries the state |
|
||||
|
||||
- **Recalculating-calm state (D-04):** register → `GUIDE // REROUTING`; street slot → the reroute copy (calm, no blame, no apology); distance → `—`; the 8px orange gantry square machine-blinks beside the copy (boot-card precedent: JS-timed two-state toggle ~500ms — an indicator duty cycle, not CSS animation). The arrow plate holds the last glyph — nothing spins, nothing blames.
|
||||
- **States are instant hard swaps** (no transitions — the shell law). The banner never blanks mid-swap: every state renders all five slots.
|
||||
|
||||
---
|
||||
|
||||
## Maneuver List (D-06 — collapsible, collapsed default)
|
||||
|
||||
A `.card` panel in the center register (card geometry: max-width 320px, centered, `calc(100% - 2×margin)`), rendered **above** the map and below the banner while expanded; max-height 480px, inner scroll. **No dim layer, no modal chrome** — the boot-card precedent: the map stays visible and calm behind it.
|
||||
|
||||
- **Toggle:** the banner (above). Collapsed by default — guidance's default state is banner + card, the map dominant.
|
||||
- **Tap-out collapses:** a full-viewport transparent layer beneath the panel (no dim — `background: transparent`) catches any map tap and collapses the list. Rams-clean, no overlay glass.
|
||||
- **Row anatomy (top→bottom, trip order):** glyph 24×24 (same committed set, 2px stroke rendering at this size — the icon family's uniform-weight rule) · street `body-md` (verbatim, ellipsis, full name in the row's `title`) · distance `label-sm` right-aligned (`{n} M` / `{n.n} KM` remaining to that maneuver).
|
||||
- **Row states:** NEXT (the banner's maneuver) = orange glyph + 8px orange square lamp at the row's left edge + on-surface ink; FUTURE = `matte-black` glyph + on-surface ink (dark theme: `inverse-on-surface`); PAST = `outline` glyph + `outline` ink.
|
||||
- **Rows are real text** (readable as-is, no aria required); glyphs are `aria-hidden`; the panel is `role="region"` with `aria-label` = `ariaManeuverList`; the banner button carries `aria-expanded` + `aria-controls`.
|
||||
- **Recalculating refresh (D-04):** on `ON_ROUTE` after a reroute, the list re-renders from the new route's steps — instant swap, scroll resets to top (the NEXT row is the first visible row).
|
||||
|
||||
---
|
||||
|
||||
## Guidance Card (D-06 — the route card evolved, bottom register)
|
||||
|
||||
The Phase 5 route card's slots evolve in place — same card class, same bottom offset (64px composite), same builder discipline (createElement + textContent only; strings module only; no per-card padding overrides — the 05 addendum's `.card` child rules carry the rhythm).
|
||||
|
||||
**Structure (top→bottom):**
|
||||
|
||||
| Slot | Class | Content |
|
||||
|------|-------|---------|
|
||||
| Register | `.card-header` › `.card-ref` | `GUIDE` — the route card's `ROUTE` register, evolved (instrument vocabulary, en both locales) |
|
||||
| Headline | `.text-headline-md` | Destination name, verbatim — the continuity anchor holds |
|
||||
| Telemetry row | flex, gap `space-xs` | Minutes remaining in `.text-telemetry-num` + `MIN` in `label-sm` (the route card's time slot, now live and deadband-smoothed, D-07) · `{km} KM` in `label-sm` · `ETA 18:42` in `label-sm` (clock = now + smoothed minutes, updated on the same deadband) |
|
||||
| Progress bar | 4px, no radius | Track = `chassis-subtle` with a 1px `matte-black` border (checkbox geometry — distinguishable without color); fill = `signal-orange`, width = snapped progress %, set instantly per accepted fix (no transition). `role="progressbar"` + `aria-valuemin/max/now` + localized `aria-label` (`ariaTripProgress`) |
|
||||
| State line | `.text-body-sm` | ABSENT in the normal state (less, but better). Present only in the no-reroute degradation: the honest copy below |
|
||||
| Actions | `.card-actions` | One `.btn-secondary`: `END TRIP` → opens the abandon confirm card (the D-09 surface — the same confirm the BackButton and native-close paths reach). **No `.btn-primary` on the guidance card**: the drive's forward action is driving; orange CTA fill appears nowhere in guidance chrome except the reserved accents above |
|
||||
|
||||
**Height budget: ≤ 200px** (register + headline + telemetry + bar + one action; the state line is the only conditional row). The camera bottom padding (64) + this budget keeps the puck's corridor clear, same arithmetic family as Phase 5.
|
||||
|
||||
---
|
||||
|
||||
## Abandon-Route Confirm Card (D-09 / PX-03)
|
||||
|
||||
One confirm surface for ALL guidance exits: the guidance card's `END TRIP`, the BackButton from guidance, and the defense-in-depth native closing confirmation (the Rams card is the designed surface; the native dialog is the net behind it — research Open Q 2 recommendation, planner wires it). Standard card anatomy, **bottom register** (thumb-reach call — confirmations live where one-handed thumbs are; the banner is glance-only, never a decision surface).
|
||||
|
||||
| Element | en | ms (Bahasa Melayu) |
|
||||
|---------|----|--------------------|
|
||||
| Register | `GUIDE // CONFIRM` | `GUIDE // CONFIRM` (instrument vocabulary) |
|
||||
| Headline | `Abandon route?` | `Tinggalkan laluan?` |
|
||||
| Body | `Guidance stops and this trip's record is deleted. Nothing is stored.` | `Panduan berhenti dan rekod perjalanan ini dipadam. Tiada apa-apa disimpan.` |
|
||||
| Primary CTA (`.btn-primary`) | `YES, ABANDON` | `YA, TINGGALKAN` |
|
||||
| Secondary CTA (`.btn-secondary`) | `CONTINUE` | `TERUSKAN` |
|
||||
|
||||
- The body's deletion clause is the brand speaking (PRIV-01: the privacy promise stated once, clinically, at the exact moment it executes — D-14).
|
||||
- `YES, ABANDON` is `.btn-primary` (the working forward path — Phase 3 denied-card mapping precedent). **Not red:** abandoning is deliberate, not a failure; orange = primary action semantics; the confirm card carries no red.
|
||||
- While the confirm card is open, a BackButton click means CONTINUE (dismiss, stay in guidance — the no-dead-ends law applied to the confirm layer).
|
||||
- Both CTAs are full component-class buttons ≥ 44px effective height; `:focus-visible` brackets are built into `.btn-*` (Phase 3).
|
||||
|
||||
---
|
||||
|
||||
## Trip-Ended Card (D-14 — PRIV-01's in-app half, visible)
|
||||
|
||||
Renders in the **center register** the instant the trip ends — arrival (snapped progress ≥ `ARRIVAL_THRESHOLD_M`) or abandon (confirm path). Both paths: guidance chrome (banner, list, guidance card) clears instantly, the envelope is DELETEd server-side (the KV record gone — the TTL law's promise executed actively), the watch stops (the trip is over — privacy-minimal), and the camera holds its last pose.
|
||||
|
||||
| Element | en | ms (Bahasa Melayu) |
|
||||
|---------|----|--------------------|
|
||||
| Register | `GUIDE // END` | `GUIDE // END` (instrument vocabulary) |
|
||||
| Headline | `Trip ended` | `Perjalanan tamat` |
|
||||
| Body | `Route guidance stopped and the trip record is deleted — nothing about this drive remains.` | `Panduan laluan dihentikan dan rekod perjalanan dipadam — tiada apa-apa tentang pemanduan ini kekal.` |
|
||||
|
||||
- ONE card for arrival and abandon — both delete, one message (less, but better).
|
||||
- The card persists until the next action (no silent auto-dismiss — honesty law); the BackButton is visible and walks to the search root; a new destination resolution replaces it through the Phase 4/5 chain.
|
||||
|
||||
---
|
||||
|
||||
## Turn-Card Chat Message (D-12 — the minimized handoff's surface)
|
||||
|
||||
A Telegram chat message pushed by the Worker cron (D-11): `sendPhoto` with the maneuver glyph PNG + an HTML caption + one inline button. It is chat content, not app chrome — Telegram renders the text; the spec pins the composition. Owned strings live in the **bot** strings module (`bot/src/i18n/strings.ts` pattern; the bot copy gate asserts them), not the web module.
|
||||
|
||||
### The 8-glyph set (committed once, reused everywhere)
|
||||
|
||||
| # | Kind | Maneuver meaning | Glyph geometry (32×32 grid, 3px stroke, square caps, orthogonal/45° only) |
|
||||
|---|------|------------------|---------------------------------------------------------------|
|
||||
| 1 | `depart` | Trip start onto the first road | 4×4 filled square (origin-ring echo) + vertical shaft up |
|
||||
| 2 | `straight` | Continue ahead | Vertical shaft + orthogonal arrowhead |
|
||||
| 3 | `left` | Turn left | Up shaft + 90° left head (the mockup's turn glyph family) |
|
||||
| 4 | `right` | Turn right | Mirror of `left` |
|
||||
| 5 | `slight-left` | Keep left | 45° up-left shaft + head |
|
||||
| 6 | `slight-right` | Keep right | Mirror of `slight-left` |
|
||||
| 7 | `uturn` | U-turn (routed for real in v1 — D-01 defers turn restrictions, so the graph WILL produce them; the glyph is honest about it) | Up shaft, orthogonal 90° left, down shaft — a machined staple, no arc (orthogonal law) |
|
||||
| 8 | `arrive` | Arrival | Vertical shaft ending on the 12×12 filled square + tail — the Phase 5 destination-pin glyph, promoted |
|
||||
|
||||
Production: hand SVGs (source of truth, committed) → `build-glyphs.mjs` (sharp) → 256×256 PNGs committed beside the bot's static assets, served from the Worker's static handler (sendPhoto fetches the public URL — research-verified). Stroke `#EB8008` on a full-bleed `#1A1A1A` plate, 256×256 output (chat renders it ~48–96px; the oversize keeps edges crisp). In-app, the SAME paths render as inline SVG at 32×32 (banner) / 24×24 (list).
|
||||
|
||||
### Caption layout (HTML parse mode, ≤1024 chars — verified budget; ~200 used)
|
||||
|
||||
```
|
||||
GUIDE // T-07
|
||||
<b>250 M — TURN RIGHT ONTO JALAN SULTAN ISMAIL</b>
|
||||
ETA 18:42 · 7.8 KM LEFT
|
||||
Best-effort projection — updates while the app is minimized.
|
||||
```
|
||||
|
||||
| Line | Source | Rules |
|
||||
|------|--------|-------|
|
||||
| Register | `GUIDE // T-{nn}` — instrument constant, code-composed (nn = zero-padded maneuver index) | en both locales; plaintext |
|
||||
| Maneuver line | `{dist} — {action} onto {street}` — the 8 localized action verbs below; street verbatim from the step `name` | `<b>` wrapped, uppercase (instrument voice); `streetUnnamed` fallback |
|
||||
| Telemetry line | `ETA {time} · {km} KM LEFT` template — projected ETA/remaining from the envelope snapshot (the cron's projection, D-11) | plaintext; formats identical to app constants |
|
||||
| Honesty line (D-13) | fixed copy | plaintext, never promises live tracking; the 8 verbs, `OPEN` button label, and both templates are localized en/ms |
|
||||
|
||||
**Localized action verbs (the caption's `{action}`):** `DEPART`/`MULA` · `CONTINUE ON`/`TERUS KE` · `TURN LEFT ONTO`/`BELOK KIRI KE` · `TURN RIGHT ONTO`/`BELOK KANAN KE` · `KEEP LEFT ONTO`/`SEDAKIT KIRI KE` · `KEEP RIGHT ONTO`/`SEDAKIT KANAN KE` · `MAKE A U-TURN ONTO`/`BUAT PUSARAN KE` · `ARRIVE AT`/`TIBA DI`.
|
||||
|
||||
**Inline button:** one button, `OPEN GUIDANCE` / `BUKA PANDUAN`, carrying the Mini App direct-link with the guidance id (research Open Q 1: `startapp`/`?g=` knob — planner picks the exact name) so a killed webview resumes from the envelope. `reply_markup` rides the same `sendPhoto` call.
|
||||
|
||||
**Push cadence (UX-visible half of D-11/D-13):** a card pushes when the projected next maneuver changes (or every N minutes — discretion); cron pushes pause while the app is foregrounded (structural: the envelope is DELETEd on resume, re-POSTed at minimize). Cron lag and Telegram delivery variance are documented behaviors — the copy says "best-effort", the mechanism means it.
|
||||
|
||||
---
|
||||
|
||||
## Camera Behavior (D-10)
|
||||
|
||||
| Event | Camera action | Parameters |
|
||||
|-------|---------------|------------|
|
||||
| Guidance starts | Snap to locked-follow | `easeTo` snapped position: padding `{ top: 320, bottom: 64, left: 64, right: 64 }` (center ≈65% down-screen), `pitch: 35` (the standing pitch — never changed by guidance), `zoom: 16` (named constant `GUIDANCE_ZOOM` — urban detail; replay-tunable), duration 400ms |
|
||||
| Per accepted fix (≥10 m / ≥5 s, D-15) | Ease along the snapped line | `easeTo` the **snapped** progress point (D-05: one authority — the camera rides the same line the banner reads, never the raw fix), duration 400ms (`GUIDANCE_EASE_MS` — the `RECENTER_DURATION_MS` family, one easing discipline) |
|
||||
| User pans/zooms | Existing `movestart` `originalEvent` rule → follow unlocks; the recenter control's notch clears (Phase 3 `free` state returns) | The route line, banner, and card are untouched — unlock is camera-only |
|
||||
| Recenter tap while unlocked | Re-lock locked-follow: `easeTo` snapped point, guidance padding, notch returns | Phase 3 control semantics, guidance parameters |
|
||||
| Reroute snaps in (D-04) | No camera jump — the new route's snapped line simply continues under the puck | Instant `setData` (map motion settles naturally on the next per-fix ease) |
|
||||
| Trip ends | Camera holds its last pose | No move; the watch has stopped |
|
||||
|
||||
- **Bearing stays north-up (recorded call):** guidance never rotates the map — Phase 3/5 never touched bearing; the heading cone already communicates direction; a rotating map contradicts the calm-instrument posture. Pitch stays 35 per D-10 and the standing law.
|
||||
- Camera moves are map-viewport behavior — permitted motion; the chrome never animates (Phase 3 law stands).
|
||||
|
||||
---
|
||||
|
||||
## State Coexistence — Guidance States (D-03/D-04/D-14)
|
||||
|
||||
One guidance surface set; states swap instantly. The Phase 5 coexistence table stands beneath this one (boot/pending/destination states are unchanged when guidance is not active).
|
||||
|
||||
| State | Banner | Guidance card | List | Map/camera | Strip |
|
||||
|-------|--------|---------------|------|------------|-------|
|
||||
| Guidance active (ON_ROUTE) | `GUIDE // NEXT`, glyph + distance + street | normal (no state line) | collapsed (toggle opens) | locked-follow, route stack as Phase 5 | compacted, lamp/word unchanged |
|
||||
| OFF_ROUTE (grace window, pre-reroute) | unchanged — still `GUIDE // NEXT` (no premature signal; the machine acts, not warns) | unchanged | unchanged | locked-follow | unchanged |
|
||||
| REROUTING (D-04 calm state) | `GUIDE // REROUTING`, street slot → reroute copy, distance `—`, gantry blink | unchanged (last honest numbers hold) | unchanged (stale-but-labeled) | locked-follow on old line until the new line snaps | unchanged |
|
||||
| ON_ROUTE after reroute | `GUIDE // NEXT` with the new maneuver; list re-rendered | ETA/remaining continue from the new model (EMA state persists — no whiplash, D-07) | re-rendered, scrolled to top | same line-style paint, new geometry (`setData`) | unchanged |
|
||||
| Router-pending degradation (graph absent, D-03 — excursion while reroute impossible) | `GUIDE // NO REROUTE`, street slot → degradation copy, distance `—` | state line carries the longer honest copy; card otherwise unchanged | unchanged | route line stays drawn (fixture/engine route is real); locked-follow continues | unchanged. **No red** — a designed deferral, not an error (router-pending precedent) |
|
||||
| Resuming (re-open before first fix, D-11/D-06) | `GUIDE // RESUMING`, distance from envelope projection, gantry blink | projected numbers (labeled honest by the banner state) | collapsed | locked-follow at projected position | unchanged |
|
||||
| First fix after resume | `GUIDE // NEXT` — live numbers | live numbers | — | snapped progress corrected | unchanged |
|
||||
| Arrived / abandoned (D-14) | cleared instantly | → trip-ended card (center) | cleared | camera holds; watch stops | returns to full-height live strip |
|
||||
| Minimize (webview `deactivated`) | — | — | — | — | envelope POSTed (best-effort, D-13); cron turn-cards begin in chat |
|
||||
|
||||
Guidance never starts without a route: a router-pending route state keeps the Phase 5 pending card and binds NO PX-03 protections (nothing to abandon — swipe/close stays native, BackButton shows for the pending state per Phase 5).
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract (copy tone: clinical, honest — D-01/D-13; en + ms; web keys join `web/src/i18n/strings.ts`, bot keys join the bot strings module; never inline — the copy gates)
|
||||
|
||||
New `ShellStrings` keys (web side; registers and unit formats are instrument constants in both locales):
|
||||
|
||||
| Key | en | ms |
|
||||
|-----|----|----|
|
||||
| `guideRegister` | `GUIDE` | `GUIDE` |
|
||||
| `guideStreetUnnamed` | `Unnamed road` | `Jalan tanpa nama` |
|
||||
| `guideReroutingLine` | `Finding a new route from where you are.` | `Mencari laluan baharu dari kedudukan anda.` |
|
||||
| `guideResumingLine` | `Resuming from your last known progress.` | `Sambung semula dari kemajuan terakhir anda.` |
|
||||
| `guideNoRerouteLine` | `Rerouting unavailable on this server.` | `Pengiraan semula tiada pada pelayan ini.` |
|
||||
| `guideNoRerouteBody` | `No road graph here — follow the drawn route; recalculation is unavailable.` | `Tiada graf jalan di sini — ikut laluan yang dilukis; pengiraan semula tidak tersedia.` |
|
||||
| `ctaBannerList` | `LIST` | `SENARAI` |
|
||||
| `ctaBannerClose` | `CLOSE` | `TUTUP` |
|
||||
| `ctaEndTrip` | `END TRIP` | `TAMAT PERJALANAN` |
|
||||
| `guideConfirmHeadline` | `Abandon route?` | `Tinggalkan laluan?` |
|
||||
| `guideConfirmBody` | (confirm table above) | (confirm table above) |
|
||||
| `ctaConfirmAbandon` | `YES, ABANDON` | `YA, TINGGALKAN` |
|
||||
| `ctaConfirmContinue` | `CONTINUE` | `TERUSKAN` |
|
||||
| `guideEndHeadline` | `Trip ended` | `Perjalanan tamat` |
|
||||
| `guideEndBody` | (trip-end table above) | (trip-end table above) |
|
||||
| `ariaManeuverList` | `Full maneuver list` | `Senarai penuh manuver` |
|
||||
| `ariaBannerToggle` | `Next maneuver` | `Manuver seterusnya` |
|
||||
| `ariaTripProgress` | `Trip progress` | `Kemajuan perjalanan` |
|
||||
|
||||
Bot-side keys (turn-cards): `cardAction{Depart,Straight,Left,Right,SlightLeft,SlightRight,Uturn,Arrive}` (verbs above), `cardEtaTemplate` `ETA {time} · {km} KM LEFT` / `ETA {masa} · {km} KM LAGI`, `cardHonesty` `Best-effort projection — updates while the app is minimized.` / `Unjuran cuba terbaik — dikemas kini semasa aplikasi diminimumkan.`, `cardOpenButton` `OPEN GUIDANCE` / `BUKA PANDUAN`. Register lines (`GUIDE // NEXT`, `GUIDE // T-07`, …) are instrument constants composed in code.
|
||||
|
||||
- **Primary CTA:** `YES, ABANDON` only (the confirm card). Orange CTA fill is otherwise absent from guidance chrome.
|
||||
- **Empty state:** not applicable — guidance renders only over a live route; the pre-guidance empty states are the Phase 3/4/5 surfaces (unchanged).
|
||||
- **Error state:** the no-reroute degradation IS the honest error-adjacent state (graph absent, D-03) — standard ink, no red, never blank. A reroute request failure renders the same state (fail-honest, banner stays calm).
|
||||
- **Destructive actions:** exactly one — abandoning the trip. It is guarded by the confirm card on every path (card button, BackButton, native-close net). Both exit paths execute the D-14 deletion and show the trip-end card.
|
||||
- **Honesty law (D-13) applied:** the turn-card never says "live", "real-time", or "guaranteed" — "best-effort projection" is the strongest claim the mechanism supports; the resuming banner labels projected numbers as resuming until a live fix lands.
|
||||
|
||||
**Copy gate:** the strings-module tests assert exactly these keys in en + ms (web + bot modules); no guidance copy inline in `web/src/guidance/*`, `shell.css`, or the cron handler.
|
||||
|
||||
---
|
||||
|
||||
## Transitions & Micro-Behaviors (inside Rams rules)
|
||||
|
||||
- **No CSS `transition`/`animation` anywhere in chrome** — unchanged law. Banner states, list expand/collapse, card swaps: instant hard swaps (no height animation on the list).
|
||||
- **Machine-blink gantry** (boot-card precedent) is the only loading indicator: REROUTING and RESUMING states blink the 8px orange square at ~500ms via JS timer — two visible states, an indicator duty cycle, never an easing pulse.
|
||||
- Progress bar: width set per accepted fix, instant, no transition. A bar that glides is a chart; a bar that steps is an instrument.
|
||||
- Camera easing (400ms `easeTo` per fix) is map-viewport motion — permitted; chrome never moves.
|
||||
- Turn-card arrival in chat: Telegram's own message rendering — no app-side animation exists or is wanted.
|
||||
- No blur, no shadow, no glass, no pulse on the route line. A driven route is static geometry; only state changes it.
|
||||
|
||||
---
|
||||
|
||||
## Close Protection (PX-03 — the three layers, wired on guidance start, unwound at trip end)
|
||||
|
||||
| Layer | API (version guard) | Wiring |
|
||||
|-------|--------------------|--------|
|
||||
| Body-swipe minimize off | `disableVerticalSwipes()` (Bot API 7.7+) | On guidance start; `enableVerticalSwipes()` at trip end; capability-guarded `?.` chains per the contract style |
|
||||
| Native close confirm (defense-in-depth — the header swipe cannot be disabled, official docs) | `enableClosingConfirmation()` (6.2+) | On guidance start; disabled at trip end. The Rams confirm card remains the designed surface; the native dialog is the net (research recommendation) |
|
||||
| In-app exits | the abandon confirm card | `END TRIP`, BackButton-from-guidance, and any post-native-confirm return path land on the same card |
|
||||
|
||||
Safe areas: the banner sits at `safe-top + space-sm` (the strip's existing `--safe-top` var); the guidance/confirm cards clear `safe-bottom` via the 64px composite; insets resolve via the Phase 3 `resolveSafeInsets` union (env ∩ Telegram 8.0+, max per edge) — nothing interactive in the home-indicator zone; the basemap extends under bars.
|
||||
|
||||
---
|
||||
|
||||
## BackButton Contract (PX-01 — the full state graph, never dead-ends)
|
||||
|
||||
The Phase 5 wire (`wireBackButton(wa, hasOverlay, walkBack)` — predicate + handler as one change) extends to a context-aware `walkBack`:
|
||||
|
||||
| App state | BackButton | Click behavior |
|
||||
|-----------|------------|----------------|
|
||||
| Boot stages / destination card (root) | hidden (unchanged) | — |
|
||||
| Route state | `show()`; click → walk back | Phase 5 walk: clears route layers, card swaps to destination card, refollow, `hide()` |
|
||||
| **Guidance state** | `show()`; click → **opens the abandon confirm card** (bottom register) | Guidance is a pushed state — never torn down silently. `YES, ABANDON` → guidance teardown (chrome clears, envelope DELETE, watch stops) → **route card state** (the route still exists in memory). `CONTINUE` → card dismisses, guidance continues |
|
||||
| Route state (post-abandon) | `show()`; click → walk back | The Phase 5 route walk → destination card (search root), `hide()` |
|
||||
| **Trip-ended card** | `show()`; click → walk to search root | Card clears, `hide()` — the graph's exit ramp |
|
||||
| Confirm card open + BackButton click | (guidance still active) | Means CONTINUE — dismiss the card, stay in guidance (no stacked dialogs) |
|
||||
|
||||
The full walk — search → routes → guidance → (confirm) → routes → search — presses Back at most once per state; the button is visible exactly when a pushed state exists; its handler is never a no-op; `close()` fires only at the true root (Telegram semantics). No dead ends.
|
||||
|
||||
---
|
||||
|
||||
## Haptics (D-08 — the eyes-free channel)
|
||||
|
||||
| Event | Cue | Trigger point | Guard |
|
||||
|-------|-----|---------------|-------|
|
||||
| Maneuver approach | `HapticFeedback.selectionChanged()` | Snapped progress within 300 m of the next maneuver (named constant `APPROACH_HAPTIC_M` — replay-tunable); fires once per maneuver | Bot API 6.1+ / `?.` chain — graceful no-op |
|
||||
| Maneuver point | `HapticFeedback.impactOccurred("medium")` | Snapped progress crosses the step boundary (once, edge-triggered) | as above |
|
||||
| Arrival (discretion extension of the same mechanism) | `HapticFeedback.notificationOccurred("success")` | `ARRIVAL_THRESHOLD_M` crossed — the trip-end card's tactile twin for eyes-on-road | as above |
|
||||
|
||||
No haptic on reroute (the banner stays calm — a haptic would announce blame), none on confirm dialogs (visual + thumb is enough). Every cue is once-per-edge, replay-tested with the state machine.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility & Device Notes
|
||||
|
||||
- **Contrast (computed, WCAG):** `pure-white` on `matte-black` ≈ 17:1 (distance — the glance datum, AAA); `soft-beige` on `matte-black` ≈ 11.6:1 (street, AAA); `chassis-subtle` on `matte-black` ≈ 8.9:1 (register line, AAA); card ink `matte-black` on `soft-beige` ≈ 11.6:1 (AAA); `signal-orange` used only for graphics/fills beside ≥ AA text (arrow, lamps, bar fill — non-text UI elements ≥ 3:1 against their fields, reinforced by the bar's 1px border).
|
||||
- **Thumb-reach:** destructive confirm at the bottom register (one-handed reach); banner interactions are glance + whole-band tap (no precision targets at the top); the recenter control keeps its ≥44px hit target.
|
||||
- **Hit targets:** banner toggle = full band; list rows are non-interactive text (the list is a readout, not a menu — less, but better); card buttons are component-class.
|
||||
- **Screen readers:** banner = one toggle button (`aria-expanded`, `aria-controls`, `aria-label`); list = labelled region of real text rows; progress bar = `progressbar` with valuetext; glyphs/lamps `aria-hidden`; state is never carried by color alone (register words, gantry, card state line are text).
|
||||
- **Haptics degrade silently** (D-08); safe-area layout degrades to pure `env()` on old clients (Phase 3 law); every Telegram call is capability-guarded per the contract style (research Pitfall 5).
|
||||
|
||||
---
|
||||
|
||||
## Capture Profiles (extends `web/scripts/capture.mjs` + the Mock-Telegram harness)
|
||||
|
||||
New harness knob and legs; all existing legs unchanged:
|
||||
|
||||
| Artifact | Profile | Asserts |
|
||||
|----------|---------|---------|
|
||||
| Harness knob `?drive=<fixture>` | the mock's Telegram fix source replays a committed drive fixture (the D-16 replay JSON streams — corridor follow / off-route excursion / missed-turn excursion) through the live shell; pairs with `?dest=` + `?fixpair=` carriers | `ARAHARAH-GUIDE state=<state>` signals (state tokens only — no coordinates in console, D-15 hygiene) |
|
||||
| `shell-guidance-active.png` | `mock=android&dest=<golden-id>&fixpair=<pair>&drive=<corridor>`, light theme | banner (`GUIDE // NEXT`, arrow, distance, street) + compact strip + guidance card + progress bar + locked camera pose |
|
||||
| `shell-guidance-list.png` | same, after a scripted banner tap | list panel expanded over the map, NEXT row lamp, collapsed-by-default proof = the active leg's complement |
|
||||
| `shell-guidance-rerouting.png` | `drive=<missed-turn>` captured inside the REROUTING window | calm banner state (reroute copy, `—` distance, gantry), NO red anywhere |
|
||||
| `shell-guidance-confirm.png` | scripted `END TRIP` tap | confirm card in the bottom register, both CTAs, no red |
|
||||
| `shell-guidance-tripend.png` | scripted `YES, ABANDON` | trip-ended card (center), guidance chrome cleared |
|
||||
| `shell-guidance-dark.png` | active profile, `scheme=dark` | PX-02: chrome re-slots, banner/chassis Rams-constant |
|
||||
| `guidance-gallery-compare.png` | DSGN-02 side-by-side vs `active_route_guidance/screen.png` | fidelity artifact for the phase gallery |
|
||||
|
||||
Turn-card captions are asserted by the bot-side suite + copy gate (`bot/test/guidance-cron.test.mjs` — caption composition, verb table, ≤1024 budget), not by browser capture.
|
||||
|
||||
Bundle-budget note (planner-facing): guidance chrome is DOM builders + the inline glyph paths — inside the ≤ ~200KB gzipped BA gate. The glyph PNGs ride the bot's static assets (never the web bundle); `sharp` is a maintainer devDependency only (research-verified).
|
||||
|
||||
---
|
||||
|
||||
## Fidelity Artifact (DSGN-02 obligation)
|
||||
|
||||
Phase exit extends the gallery pattern: the seven guidance captures above beside `active_route_guidance/screen.png`, reviewed for fidelity before phase close. Where this document, the mockup markup, and `tokens.css` disagree: `tokens.css` wins, the mockup loses, this spec records the call. Recorded calls:
|
||||
|
||||
- Mockup's **lane-assignment matrix, incident telemetry band, speedometer + limit medallion, telemetry gauge deck** → deferred features (incidents v2, speed limits v1.x, REQUIREMENTS deferred table) — scrubbed, nothing placeholder-shaped.
|
||||
- Mockup's **REROUTE VIA TELEGRAM BOT / REPORT HAZARD / AUDIO** buttons → no such actions exist in v1 scope; guidance's only action is END TRIP.
|
||||
- Mockup's **backdrop-blur header + rounded puck/medallions** → scrubbed (existing law: no blur, zero radius — the Phase 3 machined marker stands).
|
||||
- Mockup's **arrow glyph at stroke 4 / 40×40 grid** → HUD spec's 32×32 / 3px wins (the committed set's geometry).
|
||||
- Mockup's **56px (`w-14`) arrow plate** → 48×48 (32 glyph + 2×8 breathing; 12×8 grid).
|
||||
- Mockup's orange **distance number** → `pure-white` wins (accent-singular-per-band; the arrow carries the orange).
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| shadcn official | none (not initialized; gate N/A for the no-framework stack) | not required |
|
||||
| third-party | none — glyph PNGs are committed artifacts built from committed hand SVGs via a maintainer sharp script (no registry, no CDN, no runtime dependency); map tiles remain an OpenFreeMap runtime data fetch, not a code dependency | not required |
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [ ] Dimension 1 Copywriting: PASS
|
||||
- [ ] Dimension 2 Visuals: PASS
|
||||
- [ ] Dimension 3 Color: PASS
|
||||
- [ ] Dimension 4 Typography: PASS
|
||||
- [ ] Dimension 5 Spacing: PASS
|
||||
- [ ] Dimension 6 Registry Safety: PASS
|
||||
|
||||
**Approval:** pending
|
||||
|
||||
---
|
||||
|
||||
*Phase: 06-turn-by-turn-guidance-live-driving*
|
||||
*Contract drafted: 2026-10-24 by gsd-ui-researcher*
|
||||
*Consumes: theme tokens (binding), liberty-rams.json layer order, 06-CONTEXT.md D-01..D-16, 05-UI-SPEC.md (binding conventions), 03-UI-SPEC.md (binding conventions), telemetry_rams/DESIGN.md §Navigation Heads-Up Display, DESIGN.md palette semantics, Phase 5 frozen GuidanceSessionEnvelope*
|
||||
Reference in New Issue
Block a user