Files

28 KiB
Raw Permalink Blame History


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.