--- 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 | `