Files

331 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 5
slug: routing-route-display
status: approved
reviewed_at: 2026-10-24 (checker: APPROVED, 2 spacing flags + 1 wording note folded)
shadcn_initialized: false
preset: none
created: 2026-10-24
---
# Phase 5 — UI Design Contract (Routing + Route Display)
> Visual and interaction contract for the route experience. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
>
> **Source posture:** the design system already exists and is IN USE — `theme/dist/rams.css` + tokens, and the live shell (strip, card stack, recenter control, gantry, destination card) shipped in Phase 3 and passed its own UI-SPEC + device gates. This contract specifies **extension** of that shell: route line layers, the alternatives card + chips surface, the router-pending state, and camera behaviors. It invents no new token values, no new component vocabulary, no new colors. Binding upstream: 05-CONTEXT.md D-01..D-12 (fixture backend, router-pending state, bottom card + chips, orange casing, display-only terminal), the approved 03-UI-SPEC (geometry tables, theme mapping, strip/card/control components, copy discipline, a11y rules — ITS CONVENTIONS BIND), 04-CONTEXT.md (destination hand-off), Phase 1 D-08 (orange = navigation semantics only). The `navigate_map_overview` mockup is layout reference only; rounded/blur/shadow residue is scrubbed as before.
>
> **Map-law restated (05-CONTEXT canonical refs):** the MapLibre style is never re-set. Route rendering is `addLayer` + `setData` on sources added once — never re-set; additions only (`addLayer`/`addSource`/`setData`) — zero style re-creation.
---
## 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 (unchanged; the destination pin glyph joins this family) |
| 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):
| Class | Used for in Phase 5 |
|-------|---------------------|
| `.card`, `.card-header`, `.card-ref` | Route card + router-pending card (same builders' discipline as `ui/cards.ts` / `search/destination-card.ts`) |
| `.btn-secondary` | SEARCH AGAIN affordance (route card + pending card) — **deliberately not `.btn-primary`**: the route state has no forward action (D-10 forbids a start-guidance button), so no orange CTA fill appears in any Phase 5 surface |
| `.text-headline-md`, `.text-body-md`, `.text-body-sm`, `.text-label-sm`, `.text-telemetry-num` | Typography helpers (telemetry-num's sanctioned arrival — see Typography) |
New CSS for this phase (route card body, chips, marker chrome) lives in `shell.css` and references `var(--color-*)` / `var(--spacing-*)` / `var(--type-*)` only. The machined-geometry literal list grows by exactly: the chip plate (visible 28px tall, hit-slop to 44), the origin ring (24px, 2px stroke), the destination pin glyph (12px square + 8px tail), and the route line widths (map-space px, table below) — 4-multiples, 1px/2px rules, machined glyph geometry, and map-space paint arithmetic (line widths 9/5/3) — the established exception classes.
---
## Shell Layout — Register Bands (extended, D-08)
The Phase 3 partition is unchanged. Phase 5 populates the bottom register persistently for the first time:
| Band | Content in Phase 5 | Geometry |
|------|--------------------|----------|
| Top register | Status strip — **unchanged** (location register only; see Strip note below) | as Phase 3 |
| Center register | Rams basemap + position marker + **route line layers + origin ring + destination pin** (map content); boot/denied/pending/destination cards (card stack, center placement) | Map stays the dominant surface — the route is drawn ON it, never as a panel |
| Bottom register | **Route card (persistent, D-08)**; signal-lost card (existing, transient); recenter control bottom-right; attribution chip bottom-left | Route card clears the recenter control — see exact offset below |
- **Status strip: NO new states.** The strip marks the session/location state machine (Phase 3 table stands). Route calculation is a local fixture resolve — no waiting surface, no strip lamp change. The router-pending state is a *routing* condition, not a location degradation: it gets **no** red lamp, no strip entry. The route's register is its card.
- **No new controls** — no layer toggles, no start-guidance button, no progress UI (D-10). The recenter control keeps its Phase 3 behavior exactly (it recenters on the user; the route stays drawn regardless of camera).
- Route card placement: bottom register, `bottom: calc(var(--safe-bottom) + 64px)` — the **64px control-clearance composite** = `space-sm` (8, the control's own bottom offset) + 40px (machined control plate) + `space-md` (16, breathing). Card max-width 320px unchanged; on a 390×844 viewport the card never intersects the control (card bottom edge sits above the control's top edge).
- Attribution chip stays flush bottom-left, below the route card's bottom edge — never covered.
---
## Spacing Scale
Bound to tokens (`--spacing-*`), consumption not invention. Phase 3 table stands; Phase 5 adds these exact numbers:
| Element | Number | Token source |
|---------|--------|--------------|
| Route card bottom offset | 64px = `space-sm` + 40px machined + `space-md` (composite, documented) | tokens + machined control |
| Route card padding | `space-md space-md` (16/16); actions block top padding `space-sm` | tokens (unchanged pattern) |
| Chip padding | `space-xs space-sm` (4/8) → visible height 28px (label-sm 12px line + 2×8) | tokens |
| Chip hit target | ≥ 44×44px via `::after { inset: -8px }` transparent slop (28+16=44) | a11y exception (4-multiple, Phase 3 precedent) |
| Chip row gap | `space-xs` (4); chips never wrap (2 max, one row) | tokens |
| Telemetry row internal gap | `space-xs` (4) | tokens |
| Origin ring | 24px diameter, 2px `matte-black` stroke, no fill | machined geometry |
| Destination pin glyph | 12×12px square + 8px triangular tail (20px total height); name chip above it, offset `space-sm` | machined geometry |
| Camera fit-bounds padding | `{ top: 96, bottom: 320, left: 64, right: 64 }` — map-space px (MapLibre padding, not CSS; same class as Phase 3's 96px = 12×`space-sm`). Bottom 320 = 64 offset + ≤240 card height budget + 16 slack | spec geometry (8-multiples) |
Exceptions: none beyond the 44px chip hit target and the map-space camera/line arithmetic (both established exception classes).
---
## Typography
The Phase 3 scale stands. `telemetry-num` joins exactly as 03-UI-SPEC anticipated ("not used this phase — Phase 5/6"): **the route time is its first consumer.** Zero new sizes, zero new weights.
| Role | Token | Spec | Usage in Phase 5 |
|------|-------|------|------------------|
| Route time | `--type-telemetry-num` | 28px Space Grotesk 700 / 32px, tracking −0.03em | Route card time counter (`22`), the card's data point |
| Card headline | `--type-headline-md` | 20px Space Grotesk 600 / 26px, tracking −0.02em | Destination name (verbatim gazetteer name — Phase 4 display rule); pending-card headline |
| Card body | `--type-body-md` | 14px Geist 400 / 20px | Pending-card body |
| Difference / hint | `--type-body-sm` | 12px Geist 400 / 16px | One-line difference vs best; pending-card hint |
| Chips / units / register | `--type-label-sm` | 10px Geist 700 / 12px, tracking +0.08em, uppercase | Chip text, `MIN`/`KM` unit words, card-header registers, pin name chip |
| CTA label | `--type-label-lg` (inside `.btn-secondary`) | 14px Space Grotesk 700 / 18px, uppercase | SEARCH AGAIN |
Unit formats are instrument constants, code-composed like Phase 3's `±12 M` telemetry: time `22 MIN` (integer minutes), distance `8.4 KM` (one decimal), chip `{nn} // {min} MIN · {km} KM`. Units unchanged under `ms` (Phase 3 precedent). Body line-height ~1.43, heading 1.3 — token ratios verbatim.
---
## Color
60 / 30 / 10 split unchanged. **The route line becomes signal-orange's primary consumer — the Phase 1 reservation fulfilled.** The reserved-for list is restated and extended:
| Role | Value | Usage in Phase 5 |
|------|-------|------------------|
| Dominant (60%) | Rams basemap grayscale + shell chrome (unchanged) | The route is drawn over the grayscale world; nothing else competes |
| Secondary (30%) | Instrument surfaces (unchanged) | Route card + pending card: light `soft-beige` / dark `inverse-surface` via the existing theme slots; chips idle = `matte-black` plate |
| Accent (10%) | `signal-orange` `#EB8008` | **Reserved-for list (exact, Phase 5):** the route center line (selected route only — the primary consumer); position-marker heading cone; follow-active recenter notch; focus brackets (chips inherit the 3px orange focus rule); boot lamp + gantry; primary CTA fill on Phase 3 state cards (denied card — unchanged); `:active` press inverts (chips, recenter — existing law); strip boot-stage lamps. |
| Destructive | `functional-red` `#ED3F1C` | **Unchanged and NOT extended:** denied/signal-lost lamps only. The router-pending state is a documented deferral, not an error — it renders in standard card ink with no red anywhere. |
| Operational | `olive-green` `#736B1E` | LIVE lamp only (unchanged) |
Route-line paint (the D-09 contract):
| Layer (bottom→top) | Color | Width | Opacity | Geometry |
|--------------------|-------|-------|---------|----------|
| `route-casing` (selected) | `matte-black` `#1A1A1A` | 9px | 1.0 | line-cap `square`, line-join `bevel` (bevel: no miter spikes on sharp OSM angles; square caps match Rams glyph caps) |
| `route-line` (selected) | `signal-orange` `#EB8008` | 5px | 1.0 | same caps/joins; **solid — the mockup's dashed centerline is decorative residue, scrubbed** |
| `route-alt-casing` (unselected) | `matte-black` `#1A1A1A` | 5px | 1.0 | half-weight echo of the selected stack |
| `route-alt-line` (unselected) | `soft-beige` `#D9D2C6` | 3px | 1.0 | reads as the basemap's own road language (dark casing + light fill): quiet, route-shaped, zero accent competition |
- **Opacity 1.0 everywhere** — physical planarity law: surfaces are opaque; a translucent route is haze, not an instrument.
- **Unselected treatment:** the beige/black mini-stack. Selected vs unselected differ by weight AND the accent — orange appears exactly once on the map, on the chosen route. Chip tap swaps geometries between the selected and alt source pairs (`setData`, instant); layers themselves are static.
- **Layer ordering:** all four layers added inside the existing `map.on("load")` handler, AFTER the `building-3d` block, each `addLayer` with no `beforeId` → order bottom→top: `route-alt-casing`, `route-alt-line`, `route-casing`, `route-line`. Result: the route paints above every basemap layer **including building-3d** — at pitch 35 the extrusions never clip the product's one accent, and labels/shields are already beneath building-3d (existing Phase 1/3 condition, not ours to reorder). DOM markers (position marker, origin ring, destination pin) always paint above all canvas layers.
- **DSGN-01 audit note:** MapLibre paint cannot consume CSS variables — the three hex values are copied verbatim from `tokens.css` as build-time constants (the `building-3d` precedent: "zero invented colors"); the Phase 1 off-token sweep pattern covers them.
- Sources: one GeoJSON source per role — `route-alt` and `route` (or four sources if the executor prefers one per layer) — added empty at load, filled/cleared via `setData`. Sources are data, not style: this is not style mutation.
---
## Origin Ring + Destination Pin (map furniture, D-09)
| Element | Geometry | Color / ink | A11y |
|---------|----------|-------------|------|
| Origin ring | 24px diameter circle, 2px solid `matte-black` stroke, **no fill**, centered on the route geometry's first vertex — the route's actual origin, which may sit meters from the live fix (fixture world). The position marker (dot + cone + accuracy circle) renders unchanged beneath it | `matte-black`, subdued by design — no accent, no fill | decorative, `aria-hidden` |
| Destination pin glyph | 12×12px `matte-black` filled square + 3px triangular tail pointing down at the destination coordinate (square linecaps, orthogonal family — the recenter-glyph's sibling). **Never orange** — the pin is venue semantics, orange is route-only (D-09) | `matte-black` fill, 1px `pure-white` inset rule (reads on dark map tones) | glyph `aria-hidden` |
| Destination name chip | `matte-black` plate above the pin, offset `space-sm`; `pure-white` `label-sm` uppercase text = venue name verbatim; single line, max-width 160px, ellipsis overflow (the card carries the full name) | instrument-plate precedent (attribution chip, `card-ref`) | real text — readable as-is, no aria needed |
| Endpoint join | route line terminates **under** the pin; origin line starts **inside** the ring — no floating line ends | — | — |
---
## Route Card + Alternatives Chips (D-08, exact geometry)
The card is the selected route's surface; the chips are the alternates. Built via the existing card-stack `showCard(card, "bottom")` mechanism — one card visible at a time, instant swaps.
**Structure (top→bottom):**
| Slot | Class | Content |
|------|-------|---------|
| Register | `.card-header` › `.card-ref` | `ROUTE` — or `ROUTE {n}/{N}` when alternatives exist (selected index of count). Instrument vocabulary, en under both locales |
| Headline | `.text-headline-md` | Destination name, verbatim (the continuity anchor from the destination card) |
| Telemetry row | flex, gap `space-xs` | Time number in `.text-telemetry-num` + `MIN` in `label-sm`; distance `8.4 KM` in `label-sm`, baseline-aligned |
| Difference line | `.text-body-sm` | One-line difference vs best (copy table below) |
| Chips row | `role="group"`, flex, gap `space-xs` | Up to 2 alternate chips (N−1 when N routes; single-route draws render no chip row at all) |
| Actions | `.card-actions` | One `.btn-secondary`: SEARCH AGAIN (the D-10 restart affordance) |
**Height budget: ≤ 240px** (content contract enforces it: one-line register, one-line headline, one row of chips). This budget is what the 320px camera bottom padding derives from.
**Chip contract:**
| Property | Value |
|----------|-------|
| Element | `<button type="button">` — never a div-with-onclick |
| Visible plate | `matte-black` background, `pure-white` `label-sm` uppercase text, padding 4/8, visible height 28px, zero radius |
| Text | `{nn} // {min} MIN · {km} KM` (e.g. `02 // 24 MIN · 9.1 KM`) — one line, fixed format, never truncates |
| Hit target | ≥ 44×44px via `::after { inset: -8px }` transparent slop |
| Press (`:active`) | instant invert to `signal-orange` field + `matte-black` text (mockup pill precedent, tactile-depression law) — transient press feedback only; **there is no persistent "selected chip" state**: the selected route lives in the card, the tapped route becomes the card, the previously-selected becomes a chip. Chip count is invariant (N−1) |
| Focus (`:focus-visible`) | 3px `signal-orange` bracket (`.toggle-option` precedent) |
| Selection behavior | tap → geometries swap between selected/alt sources (instant `setData`) → card re-renders with the new route's time/distance/difference → camera refits the new route's bounds (400ms, below). No easing, no crossfade |
| A11y | `aria-label` = clean localized form (`ariaRouteChip` template — copy table); the group carries `aria-label` (`ariaRouteAlternatives`) |
**Map is not the a11y surface for route data:** the line layers and ring/pin are canvas/DOM decoration; the card is the semantic statement of the route (time, distance, difference, name). Chips are the only interactive route elements.
---
## Camera Behavior (D-09)
| Event | Camera action | Parameters |
|-------|---------------|------------|
| Route drawn (auto: live fix + resolved destination → fixture resolves) | `fitBounds` over the selected route geometry | padding `{ top: 96, bottom: 320, left: 64, right: 64 }`, duration 400ms, default easing family — identical discipline to `follow.ts` (`CAMERA_PADDING_PX`/`RECENTER_DURATION_MS` constants; the route adds its own padded constants beside them) |
| Chip select (alternative chosen) | `fitBounds` over the newly selected geometry — **always refits** (D-10: selection redraws + camera refits), idempotent when bounds are near-identical | same parameters |
| SEARCH AGAIN | clears layers, returns camera to the user: `flyTo` latest fix via the existing follow controller, `following` resumes | Phase 3 recenter parameters (96px, 400ms) |
| User pans/zooms during route display | existing `movestart` rule → `free` mode; the route stays drawn | no change |
| Recenter tap during route display | recenters on user; route stays drawn | no change |
| Pitch/bearing | **untouched by fitBounds** — center+zoom only; pitch stays 35, bearing stays as the user left it | — |
Camera moves are map-viewport behavior — permitted motion; the chrome never animates (Phase 3 Micro-Behaviors law stands).
---
## State Coexistence — Destination Card ↔ Route Card (04 hand-off)
The card stack renders exactly one card per state; transitions are instant hard swaps. Route calculation triggers **automatically** — no route button exists (D-10): once the location chain is live AND the destination resolved, the fixture `RoutingSource` is consulted.
| App state | Center register | Bottom register | Map layers | Camera |
|-----------|-----------------|-----------------|------------|--------|
| Live, destination resolved, route not yet consulted | destination card (Phase 4, unchanged) | — | empty | follow user |
| Live + route resolved | *(empty — the map is the surface)* | **route card** | selected stack + alt mini-stacks + ring + pin | fitBounds route |
| Live + route pending (D-03) | **router-pending card** | — | empty (nothing fake is ever drawn) | follow user (unchanged) |
| Destination unavailable (Phase 4 card) | unavailable card | — | empty | follow user — **no route request is ever made** |
| No destination (`?dest` absent) | *(Phase 3 behavior byte-identical)* | — | empty | follow user — no route request |
| Location stale during route display | — | **route card persists** | route stays drawn; position marker dims 40%, strip lamp red (Phase 3 signals) | no refit |
| Location stale during pending | pending card | signal-lost card (coexists — different registers) | empty | no change |
Staleness never evicts the route card: the drawn route is static fixture data; staleness is a *live-position* condition and keeps its Phase 3 signals (dimmed marker, red lamp). Evicting the terminal surface for a location note would break D-10.
---
## Router-Pending Card (D-03 — the denied-card class, full copy table)
A deliberate Rams surface for arbitrary origins/destinations the fixture corpus does not cover. Same anatomy as the denied card: register, headline, body, hint, CTA. Center register. Clinical, honest, calm — the app states that routing isn't wired for this trip, offers the way back, and never apologizes theatrically. **Never** a blank map, never a fake route, never red (it is a deferral, not a failure).
| Element | en | ms (Bahasa Melayu) |
|---------|----|--------------------|
| Register | `ROUTE // ROUTER` | `ROUTE // ROUTER` (instrument vocabulary) |
| Headline | `Routing not wired up yet` | `Penghalaan belum dipasang` |
| Body | `Routes currently cover known KL landmarks only. The general router ships later — your coordinates were never sent anywhere.` | `Laluan buat masa ini meliputi mercu tanda KL yang diketahui sahaja. Penghala am akan datang kemudian — koordinat anda tidak pernah dihantar ke mana-mana.` |
| Hint (body-sm) | `Send /cari in the chat and pick another landmark.` | `Hantar /cari dalam chat dan pilih mercu tanda lain.` |
| CTA (`.btn-secondary`) | `SEARCH AGAIN` | `CARI SEMULA` |
The body's privacy clause is the brand speaking (D-01: origin+destination never go to a third party — the fixture world makes this literally true, and the card says so once, without preaching).
---
## Copywriting Contract (copy tone: clinical, honest — D-01; en + ms; all keys join `web/src/i18n/strings.ts`, never inline — COPY5 gate)
New `ShellStrings` keys (compose with the Phase 3 keys; register labels and unit formats are instrument constants in both locales):
| Key | en | ms |
|-----|----|----|
| `routeRegister` | `ROUTE` | `ROUTE` |
| `routeDiffFastest` | `Fastest route` | `Laluan terpantas` |
| `routeDiffSlower` (template) | `+{min} MIN VS ROUTE {n}` | `+{min} MIN VS LALUAN {n}` |
| `routePendingRegister` | `ROUTE // ROUTER` | `ROUTE // ROUTER` |
| `routePendingHeadline` | `Routing not wired up yet` | `Penghalaan belum dipasang` |
| `routePendingBody` | (table above) | (table above) |
| `routePendingHint` | (table above) | (table above) |
| `ctaSearchAgain` | `SEARCH AGAIN` | `CARI SEMULA` |
| `ariaRouteAlternatives` | `Alternative routes` | `Laluan alternatif` |
| `ariaRouteChip` (template) | `Route {n}: {min} minutes, {km} kilometres` | `Laluan {n}: {min} minit, {km} kilometer` |
Copy formats composed in code (Phase 3 telemetry precedent, not strings): time `{n} MIN`, distance `{n.n} KM`, chip `{nn} // {min} MIN · {km} KM`, register `ROUTE {n}/{N}`.
- **Primary CTA:** none this phase — deliberate (D-10). The strongest affordance is `.btn-secondary` SEARCH AGAIN; orange CTA fill is absent from every Phase 5 surface.
- **Empty state:** the empty map with no route is *not* an empty state — it is the Phase 3/4 live state (unchanged). The only new "nothing to show" surface is the pending card, fully specified above.
- **Error state:** router-pending IS the honest error-adjacent state; fixture resolves cannot fail at runtime (committed geometry + tolerance gates). A route-source exception renders the pending card (fail-honest, never blank, never a half-drawn line).
- **Destructive actions: none.** SEARCH AGAIN discards nothing but the view (routes are in-memory; nothing persists to delete — D-12). No confirmation surfaces.
**Copy gate:** the strings-module test asserts exactly these keys in en + ms; no route copy inline in `web/src/route/*` or `shell.css`.
---
## Transitions & Micro-Behaviors (inside Rams rules)
- **No CSS `transition`/`animation` anywhere in chrome** — unchanged law. Destination card → route card, route card → chip-swap re-render, route → pending: instant hard swaps.
- Route draw appears as: layers populated (`setData`) + camera `fitBounds` in the same tick — the line arrives with the camera glide (map motion, permitted), chrome never moves.
- Chip select: instant geometry/card swap + 400ms camera refit. Press invert on the chip is instant (`:active`), no transition.
- SEARCH AGAIN: sources emptied + markers removed + card swap + `flyTo` user, all instant except the camera glide.
- No blur, no shadow, no glass, no pulse on the route line. A drawn route is static; only selection changes it.
---
## BackButton Contract (Phase 5 — first real two-state walk, PX-01 groundwork)
Phase 3's single-state wire (`overlayPresent() => false` → `close()`) extends. The route state is the app's first pushed state; the walk-back is the same total function SEARCH AGAIN invokes.
| App state | BackButton | Click behavior |
|-----------|------------|----------------|
| Boot stages | `hide()` (unchanged) | — |
| Destination card (root state) | hidden — Telegram-native swipe-down close remains the exit | — |
| **Route state** | `show()`; `onClick` → walk back | clears route layers + ring + pin, card swaps to destination card, camera refollows user, `hide()` |
| **Router-pending state** | `show()`; `onClick` → walk back | card swaps to destination card, `hide()` (no layers to clear) |
| Destination-unavailable | hidden (root-equivalent; nothing pushed) | — |
No dead ends: the button is visible exactly when a pushed state exists, and its handler always walks to the root — never a no-op, never `close()` from a pushed state. The `overlayPresent` predicate becomes "route or pending state active"; the handler and predicate ship as one change so Phase 6 extends the graph rather than reinventing the wire.
---
## 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 `?fixpair=<pair-id>` | the mock's Telegram fix delivers the fixture pair's **pinned public origin coordinate** (fixtures are build-time public data — no user coordinate involved, D-12) | pairs with the existing `?dest=<golden-id>` carrier |
| `shell-route-fixture.png` | `mock=android&dest=<golden-id>&fixpair=<pair>`, light theme: route drawn, route card, alt chip(s), ring + pin | `ARAHARAH-ROUTE state=drawn` signal (state token only — no coordinates in console, D-15 hygiene) + DOM check on rendered card text |
| `shell-route-alt.png` | same, after a scripted chip tap | selected geometry swapped: orange line follows the alt corridor; card re-rendered; `ROUTE {n}/{N}` register updated |
| `shell-route-pending.png` | `mock=android&dest=<supported-id-outside-corpus>&fixpair=<pair>` | `ARAHARAH-ROUTE state=pending` + pending card visible |
| `shell-route-dark.png` | route-fixture profile, `scheme=dark` | PX-02: chrome re-slots, route + basemap Rams-constant |
| `route-gallery-compare.png` | DSGN-02 side-by-side vs `navigate_map_overview/screen.png` (route visible in both columns) | fidelity artifact for the phase gallery |
Bundle-budget note (D-11, planner-facing): the fixture geometry + route-model code joins the web bundle under the ≤ ~200KB gzipped BA gate — the capture harness is not the enforcement point, but the route legs must run against the production-shaped bundle (harness build, same as Phase 3).
---
## Fidelity Artifact (DSGN-02 obligation)
Phase exit extends the Phase 1/3 gallery pattern: the five route captures above beside `navigate_map_overview/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 dashed route centerline → scrubbed (solid orange per D-09); mockup's 9×36px control plates → existing 40×40 machined control (unchanged); mockup's telemetry-deck/speedometer furniture → Phase 6+ scope, not this phase (D-10 display-only).
---
## 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 — fixtures ride in the web bundle; 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: 05-routing-route-display*
*Contract drafted: 2026-10-24 by gsd-ui-researcher*
*Consumes: theme tokens (binding), liberty-rams.json layer order, 05-CONTEXT.md D-01..D-12, 03-UI-SPEC.md (binding conventions), 04-CONTEXT.md (destination hand-off), Phase 1 D-08 orange reservation, telemetry_rams/DESIGN.md*
## Post-approval addendum (2026-10-24, user feedback)
Card body padding fixed in the live shell (`web/src/shell.css`, deploy `d5a224d5`):
`.card` direct text children now carry token-only inline padding (space-md) + vertical
space-sm rhythm; the header plate keeps its own. The route card + chips + pending card
inherit this — the executor MUST NOT add per-card padding overrides; compose from the
existing classes.