docs(03-04): UI-SPEC amend — third denied CTA (LOCATION SETTINGS) as debug-round-2 amendment

This commit is contained in:
araharah-executor
2026-10-23 01:07:50 +08:00
parent 89268074a6
commit ad4d18b180
@@ -0,0 +1,340 @@
---
phase: 3
slug: mini-app-shell-geolocation
status: approved
reviewed_at: 2026-10-22 (checker: APPROVED, 4 amendments folded)
shadcn_initialized: false
preset: none
created: 2026-10-22
---
# Phase 3 — UI Design Contract (Mini App Shell + Geolocation)
> Visual and interaction contract for the Telegram Mini App shell. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
>
> **Source posture:** the design system already exists (Phase 1). This contract specifies **consumption** of `theme/dist/rams.css` tokens and component classes — it invents no new values. Binding upstream: 03-CONTEXT.md D-01..D-15, Phase 1 D-04 (theme slots), Phase 2 D-01/D-05 (copy tone + en/ms precedent). Design-doc prose (`telemetry_rams/DESIGN.md`) and `tokens.css` are binding where this spec and the mockup markup disagree — mockups are layout reference only (`stitch_minimalist_route_navigator/navigate_map_overview/code.html`), with rounded/blur/shadow/backdrop utilities scrubbed.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none — shadcn gate N/A (stack is TypeScript + Vite with **no UI framework**, 03-CONTEXT D-01) |
| Preset | not applicable |
| Component library | `@araharah/theme` — `theme/dist/rams.css` imported at build time into the Vite build (D-02, D-14; never via CDN) |
| Icon library | inline SVG — 2px uniform stroke, square linecaps, orthogonal/45° baselines, no Material Symbols (self-host/no-CDN rule, D-14; Phase 1 sheet precedent) |
| Font | self-hosted Geist (body) + Space Grotesk (headings/telemetry) via `@font-face` in `theme.css`; `font-display: block`, no fallback flash |
| Basemap | `theme/basemap/liberty-rams.json` loaded as the MapLibre style — Rams-constant under both Telegram themes |
**Consumed component classes** (from `components.css` — use these; do not restyle locally):
| Class | Used for in Phase 3 |
|-------|---------------------|
| `.btn-primary` | Retry CTA on denied / signal-lost cards |
| `.btn-secondary` | Ask-via-chat CTA on denied card |
| `.card`, `.card-header`, `.card-ref` | Boot-state card, denied card, signal-lost card |
| `.text-headline-md`, `.text-body-md`, `.text-body-sm`, `.text-label-sm`, `.text-label-lg` | Typography helpers |
| `.checkbox` geometry rules | Pattern basis for the 8px state lamp (square, matte-black border, geometric center fill — no glyph, no roundness) |
> Planner amendment (RESEARCH Pitfall 4): the class mapping above is **inverted for the denied card** — `ASK VIA CHAT` is `.btn-primary` (it is the only working origin path on Telegram Desktop, which has no geolocation at all — issue #77) and `TRY AGAIN` is `.btn-secondary`. The Desktop leg of the device matrix is what proves the CTA renders, so it must not read as a diminished secondary action. The rows above are retained as the general mapping; this callout overrides them **only** for the denied card's `.card-actions`.
New CSS for this phase (shell layout, strip, marker chrome, dark-slot activation) must reference `var(--color-*)`, `var(--spacing-*)`, `var(--type-*)` only — zero literal hex/px outside the machined border geometry (1px/2px rules, 8px lamp, 20px position dot, mockup-marker cone geometry documented below).
---
## Shell Layout & Register Bands (map-first, D-08)
Single screen, mobile-first, full-bleed. Viewport partitions into instrument register bands (design doc "Grid & Breakpoints"):
| Band | Content | Geometry |
|------|---------|----------|
| Top register | Status strip (full-width matte-black chassis band) + safe-area inset above it | `padding-block: env()/inset-top + space-sm`; strip height ≥ 32px, min 44px hit |
| Center register | Rams basemap, full-bleed (`position: fixed; inset: 0`), position marker + accuracy circle, nothing else on the map | Map is the dominant surface |
| Bottom register | Floating recenter/follow control, bottom-right; optional signal-lost card; map attribution chip, bottom-left | Controls sit above `safe-area-inset-bottom + space-sm` |
- **No bottom navigation bar** — the mockup's 4-tab nav belongs to the whole app, not this phase; nothing placeholder-shaped (D-08).
- **No search furniture, no layer toggles, no route line** — later phases. The map shows only: basemap, position marker, accuracy circle.
- Map attribution (OpenFreeMap/OSM, legally required) renders via MapLibre `AttributionControl` compact, styled to Rams: `label-sm` type on a matte-black flat chip, flush bottom-left, no translucency/blur (mockup's `backdrop-blur` chrome is scrubbed).
- Map viewport padding for follow mode: camera padding `96px` (12 × space-sm) so the position marker never hides under strip or controls.
---
## Spacing Scale
Bound to tokens (`--spacing-*`, 8px baseline — multiples of 4 only). Consumption, not invention:
| Token | Value | Usage in Phase 3 |
|-------|-------|------------------|
| `space-xs` | 4px | Strip internal gaps, chip inset edges |
| `space-sm` | 8px | Compact element spacing, strip/edge breathing between safe inset and content |
| `space-md` | 16px | Default element spacing, card padding, `margin`/`gutter` edge margin of floating elements |
| `space-lg` | 24px | Card content to action-group gap |
| `space-xl` | 32px | Control-dock offset denominators, camera-radius arithmetic |
| `space-2xl` | 48px | Reserved (unused this phase) |
Exact numeric contract for Phase 3 chrome:
| Element | Number | Token source |
|---------|--------|--------------|
| Status strip padding | `space-sm space-md` (8/16) | tokens |
| Strip state lamp | 8×8px square (= mockup `w-2 h-2`; identical to `.checkbox:checked` center square) | spec geometry |
| Floating control visible size | 40×40px | machined geometry (recorded deviation: mockup plate is 36×36 — tokens win per D-07; 40×40 is a 4-multiple and part of the machined-border exception list) |
| Floating control hit target | ≥ 44×44px (4-multiple exception HIT-SLOP; enlarge hit box with transparent padding — every interactive element ≥ 44px) | a11y exception |
| Card padding | `space-md space-md` (16/16), actions block top padding `space-sm` | tokens |
| Floating elements edge offset | `margin` (1rem) from viewport edges | tokens |
| Basemap camera follow padding | 96px (12×`space-sm`, map-space arithmetic — MapLibre padding, not CSS) | spec geometry |
Exceptions: none beyond the 44px hit target (a mechanical 4-multiple).
---
## Typography
Bound to the `--type-*` scale verbatim. Phase 3 uses 4 sizes across 3 weights, all pre-existing token values — 400 (body-md), 600 (headline-md), 700 (label-sm/label-lg via `.btn-*` component classes) — no new weights:
| Role | Token | Spec | Usage |
|------|-------|------|-------|
| Body | `--type-body-md` | 14px Geist 400 / 20px, tracking 0 | Card body copy, fix hint lines |
| Screen heading (state cards) | `--type-headline-md` | 20px Space Grotesk 600 / 26px, tracking −0.02em | Denied / signal-lost card headlines |
| Label / register | `--type-label-sm` | 10px Geist 700 / 12px, tracking +0.08em, uppercase | Status strip text, card-header register categories, attribution |
| Control label | `--type-label-lg` (inside `.btn-*`) | 14px Space Grotesk 700 / 18px, uppercase | CTA button labels |
Secondary hint token (do not introduce others): `body-sm` (12px Geist 400 / 16px) for attribution/secondary hint lines. `telemetry-num` is not used this phase (no speed/ETA data yet — Phase 5/6).
Body line-height ~1.43, heading 1.3 — the tokens' own ratios, consumed verbatim.
---
## Color
60 / 30 / 10 split over the full-bleed map:
| Role | Value | Usage |
|------|-------|-------|
| Dominant (60%) | Rams basemap grayscale groundwork (`liberty-rams.json`, `#F5F4F4`/`#ECEBE9`/`#C6C2BB` family) + shell chrome `--shell-bg` (light `#fff8f0` surface / dark `#121212` surface-dark, resolved by Telegram theme) | Map is dominant; chrome backgrounds are the only other large field |
| Secondary (30%) | Instrument surfaces — light: `soft-beige` `#D9D2C6` cards, `chassis-subtle` `#CCC4B5` nested plates; dark: `inverse-surface` `#333028` cards | Boot/denied/lost cards, strip chassis (`matte-black` `#1A1A1A` is a constant instrument plate in both themes, mockup precedent) |
| Accent (10%) | `signal-orange` `#EB8008` | **Reserved-for list (exact):** position-marker heading cone; follow-active state on recenter control; focus brackets (built into `.btn-primary`/`.btn-secondary`/`.input-field`/`.checkbox`/`.toggle-option` — cards are not focusable); boot-stage state lamp on the strip; primary CTA fill on state cards; loading emphasis in boot card |
| Destructive | `functional-red` `#ED3F1C` | Signal-lost / denied state lamp and register dot only (hazard/error semantics — "position failing" is the only degradation in this phase) |
| Operational | `olive-green` `#736B1E` | LIVE state lamp (operational/safe chip semantics), with a 1px `pure-white` inset rule for contrast on the black strip |
| Never | `signal-orange` outside the reserved list; olive-green outside lamp; no new colors; `--color-error` M3 token reserved for future form/component errors — not used this phase | Route stays orange in Phase 5 — orange must never carry non-navigation meaning (Phase 1 D-08) |
Accent reserved for: position cone · follow-active control · focus brackets · boot lamp · primary CTA fill · boot-card emphasis dot. Never "all interactive elements," never error/degraded states (those are `functional-red`), never idle chrome.
---
## Telegram Theme-Params → CSS Mapping (PX-02 + Phase 1 D-04)
**Principle: light/dark is a *slot switch*, not a color import.** Telegram's theme selection (D-04 slots) selects between two **Rams token pairs**; Telegram's own hex values are read as the *signal* (which theme is active), never as consumed values. This keeps DSGN-01 ("everything derives from tokens") and PX-02 ("follows Telegram light/dark") simultaneously true.
| Telegram signal | Light branch | Dark branch | CSS action |
|-----------------|--------------|-------------|------------|
| `WebApp.colorScheme` (`"light"` / `"dark"`, present on all clients this phase targets) | `"light"` | `"dark"` | Set `data-tg-theme="light|dark"` on `<html>` |
| Fallback signal (client without `colorScheme`): luminance of `themeParams.header_bg_color` ≥ 0.5 | light | dark | Same data-attribute |
| `themeChanged` event | re-evaluate | re-evaluate | Re-set attribute — the map is untouched (constant), chrome re-slots |
Resulting slot activation (extends the `prefers-color-scheme` defaults already in `theme.css` with the data-attribute scope):
| CSS variable | `data-tg-theme="light"` | `data-tg-theme="dark"` |
|--------------|------------------------|------------------------|
| `--shell-bg` | `var(--color-surface)` `#fff8f0` | `var(--color-surface-dark)` `#121212` |
| `--shell-on-surface` | `var(--color-on-surface)` `#1e1b14` | `var(--color-inverse-on-surface)` `#f7f0e3` |
| Card field (`.card` under shell root) | `var(--color-soft-beige)` `#D9D2C6`, ink `matte-black` | `var(--color-inverse-surface)` `#333028`, ink `--color-inverse-on-surface` `#f7f0e3` |
| Card border | `1px solid var(--color-matte-black)` | `1px solid var(--color-inverse-on-surface)` (machined 1px rule, adapted ink) |
| Map style | `liberty-rams.json` | **identical** — zero re-styling, zero re-creation |
**Rams-constant despite theme (deliberate):** the status strip chassis (matte-black/inverse-on-surface band with soft-beige ink, mockup "Top Command Register" precedent), the position marker, accuracy circle, and basemap. These are *instruments on the chassis*, not chrome. All invariants above survive re-theming instantly — the only theme-following surfaces are document background, card fields, and shell ink.
MapLibre map colors themselves are set only by the JSON style; the app never swaps style JSON on theme change.
---
## Boot Choreography — Staged Deliberate States (D-09, never blank)
The basemap mounts and starts rendering tiles immediately; every boot stage overlays the **live map** with Rams furniture — no blank screens, no spinner glyphs, no blur/dim masks. Stage transitions are **instant hard swaps** (no CSS transitions/animations exist in the component layer — "switches instantly" per design doc).
| Stage | Center register | Status strip (left lamp + label) |
|-------|-----------------|----------------------------------|
| 1. Validating session | Boot card (`.card`): header register `BOOT-01 // SESSION`, headline `Validating session`, one body line | lamp `signal-orange` + `validating` label |
| 2. Acquiring location | Boot card re-swaps: register `BOOT-02 // LOCATION`, headline `Acquiring location`, body line; the card's loading gantry lamp toggles between two fields (see Micro-Behaviors) — never an easing pulse | lamp `signal-orange` + `acquiring` label |
| 3. Live | Boot card **removes instantly**; position marker + accuracy circle render; strip telemetry fills | lamp `olive-green` + `live` label; right side shows `±Nm` accuracy + fix age |
| Denied (D-06) | Denied card replaces boot card — see copy contract | lamp `functional-red` + denied label; strip is persistent (not silently a banner) |
| Signal lost (post-live) | Map stays live with last-known marker dimmed to 40% opacity; compact signal-lost card folds into bottom register after 45s staleness | lamp `functional-red` + lost label |
The boot card lives in the center register and does not block map visibility (map is a calm, mapping-most surface behind it — no overlay dim, no glass). Card widths: `calc(100% - 2 × margin)`, max-width 320px (40×8 geometric max, mobile register).
Loading emphasis inside the boot card: a single 8px `signal-orange` square beside the headline (`.checkbox:checked` geometry as the loading gantry reference) toggling between filled and hollow square at a steady instants-only duty cycle (2 visible states, ~500ms period via JS timer — machine-blink, not CSS animation).
> Planner amendment (RESEARCH Open Question 1): when WebGL2 is absent, bootMap returns null and the shell renders over --color-surface-dim with data-map="absent" — no blank screen, no crash.
---
## Copywriting Contract (copy tone: clinical, honest, D-01; en + ms per Phase 2 D-05 precedent, module pattern `web/src/i18n/strings.ts`)
All Phase 3 user-facing strings live in one strings module — never inline in the shell. Register labels (`BOOT-01 //`, `GEO //`, `SYS //`) stay in English under both locales (instrument vocabulary, mockup precedent).
| Element | en | ms (Bahasa Melayu) |
|---------|----|--------------------|
| Boot stage 1 headline | `Validating session` | `Menyemak sesi` |
| Boot stage 1 body | `Confirming your Telegram identity. Takes a few seconds and stores nothing.` | `Mengesahkan identiti Telegram anda. Hanya beberapa saat, tiada apa-apa disimpan.` |
| Boot stage 2 headline | `Acquiring location` | `Mendapatkan lokasi` |
| Boot stage 2 body | `Finding where you are. Your position is never written to storage.` | `Mencari kedudukan anda. Lokasi anda tidak pernah disimpan.` |
| **Primary CTA (all retry contexts)** | `TRY AGAIN` | `CUBA LAGI` |
| Secondary CTA (denied card only) | `ASK VIA CHAT` | `MINTA VIA CHAT` |
| Settings CTA (denied card, **Telegram clients only** — `wa.LocationManager` present) | `LOCATION SETTINGS` | `TETAPAN LOKASI` |
> Debug-round 2 amendment (2026-10-23, recorded in 03-DEVICE-MATRIX.md): the Telegram
> LocationManager deep-link — `openSettings()` — is the only realistic recovery when the
> denial is settings-resolvable (access previously denied / services off; the chain re-run
> instant-fails without prompting). Third denied-card action renders conditionally on
> Telegram clients; clicking it opens Telegram's permission pane and the
> `locationManagerUpdated` event re-runs the chain on grant.
| Denied headline | `Location is switched off` | `Lokasi dimatikan` |
| Denied body | `araharah guides you from where you physically are. Without location there is no route to draw.` | `araharah memandu dari kedudukan sebenar anda. Tanpa lokasi, tiada laluan boleh dilukis.` |
| Denied fix hint (body-sm) | `Grant location to Telegram, or send a pin in this chat: attach → Location.` | `Benarkan lokasi untuk Telegram, atau hantar pin dalam chat ini: lampiran → Lokasi.` |
| Look-lost headline | `Signal lost` | `Isyarat hilang` |
| Signal-lost body | `Showing your last known position — marked on the map. Reacquiring automatically.` | `Menunjukkan kedudukan terakhir anda — ditanda pada peta. Sedang mencari semula.` |
| Signal-lost hint (body-sm, reasserts manual path) | `You can also send a fresh pin in this chat: attach → Location.` | `Anda juga boleh hantar pin baharu dalam chat ini: lampiran → Lokasi.` |
| Chat-fallback instruction (Ask-via-chat follow-up, strip + card copy) | `Waiting for the pin you send in chat.` | `Menanti pin yang anda hantar dalam chat.` |
| Strip labels (uppercase, per state) | `VALIDATING` / `ACQUIRING` / `LIVE` / `SIGNAL LOST` / `LOCATION OFF` | `MENYEMAK` / `MENDAPAT` / `LANGSUNG` / `ISYARAT HILANG` / `LOKASI MATI` |
| Accuracy telemetry (LIVE, right side, `label-sm`, Space Grotesk) | `±12 M` + `FIX 2 S AGO` (numbers live-refresh) | same format, units unchanged |
| Attribution (rendered, not spoken) | `© OpenStreetMap contributors` | unchanged |
Empty-state copy: not applicable (desktop clients without geolocation are covered by the denied chain + ASK VIA CHAT fallback, GEO-02). **Destructive actions: none in this phase** — no confirmation surfaces.
---
## Status Strip — Contents & States (architecture for D-08/D-09)
Flat matte-black band, full width, top of viewport (below safe inset), content on the mandated layout above. Left cluster: 8px state lamp + `.text-label-sm` state word. Right cluster: `.text-label-sm` Space Grotesk telemetry (accuracy / fix age), hidden until LIVE. Marks the exact session/location state machine:
| State | Lamp | Label | Right telemetry | Card in center/bottom register |
|-------|------|-------|-----------------|-------------------------------|
| validating | `signal-orange` | `VALIDATING` | — | boot card stage 1 |
| acquiring | `signal-orange` | `ACQUIRING` | elapsedSeconds | boot card stage 2 |
| live | `olive-green` + 1px `pure-white` inset rule | `LIVE` | | `±Nm` + `FIX Ns AGO` | none |
| stale / signal-lost | `functional-red` | `SIGNAL LOST` | `LAST FIX Ns AGO` | signal-lost card after 45s |
| denied | `functional-red` | `LOCATION OFF` | — | denied card (persistent) |
| chat-fallback | `signal-orange` | `AWAITING PIN` | — | denied card body swaps to waiting copy |
The strip is *Rams-constant* (matte-black chassis under both Telegram themes). It is the app's honest register — it never hides a degradation silently.
---
## Position Marker + Accuracy Geometry (map reference: navigate_map_overview mockup lines 94–104, scrubbed of pulse animation)
| Element | Geometry | Color / ink |
|---------|----------|-------------|
| Accuracy circle | Circle radius = reported GPS accuracy (projected meters, never CSS px); display cap at 100m if raw accuracy exceeds it (truth preserved in strip telemetry) | fill `matte-black` at 6% opacity, outline `1px solid` `matte-black` at 30% opacity — data grounding, not accent |
| Position dot | 20px diameter filled circle (mockup core `r=10`) | `matte-black` `#1A1A1A` solid — reads on every grayscale map tone |
| Heading cone | Sharp 16px-tall triangle centered on the dot, apex at the dot, base away (mockup polygon `0,-8 6,5 0,2 -6,5` geometry family, orange field); rotated to `heading` only when a heading is available; **hidden entirely when heading is unknown** — no default orientation guess, no half-open cone | `signal-orange` fill |
| Last-known-dim (signal-lost) | Same geometry, whole marker group at 40% opacity | marker itself unmoved — honesty of stale data |
| Fix flash | none — no pulses, no ping animations (mockup `animate-ping` is scrubbed); each accepted fix visibly re-renders the accuracy ring radius, which is the proof of freshness | — |
Marker colors are Rams-constant under both Telegram themes (map-constant rule, D-10).
---
## Follow / Recenter Control (D-08 + visual discretion: machined microswitch)
One floating square control, bottom-right (above safe inset + `margin`):
- **Visible plate & a11y:** 40×40px icon-only control carries an accessible name localized via the strings module (`aria-label="Recenter map"` / ms equivalent — mockup precedent at navigate_map_overview/code.html:137); the 8px state lamp on the strip is decorative (`aria-hidden`), state relied on the word label. `matte-black` background, `pure-white` inline-SVG crosshair/target icon (2px stroke, square caps, `my_location` silhouette hand-drawn as orthogonal glyph).
- **States (instant swaps):**
- `following` (default while live): plate stays matte-black; a solid 8×8px `signal-orange` square sits in the control's center notch (checkbox geometry — orange center = locked-on register, mirroring `.checkbox:checked`).
- `free` (user panned/zoomed away): center square is removed — matte housing with hollow white icon.
- `live` LOST (no fix): icon inverts to `functional-red`? No — red is reserved for the strip/degraded registers; instead the center notch fills with `soft-beige` (unlocked, not error).
- **Tap behavior:** in `free`, recenters camera on the latest fix and resumes `following` (90→96px camera padding); in `following`, re-nudges to the freshest fix (idempotent). Hit target ≥ 44×44px.
- Zero hover/press transitions — press inverts instantly to `signal-orange` field + `matte-black` glyph (design doc "Tactile Active Depressions").
---
## Safe-Area Handling (PX-03 groundwork, built now)
- `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">` — the map is always full-bleed under every bar.
- Safe insets resolved as: `const(top: env(safe-area-inset-top), bottom: env(safe-area-inset-bottom))` **and**, when available (version-gated WebApp API), the Telegram-reported content/viewport safe rects — whichever is larger per edge. Old-client fallback = pure `env()`.
- Top register sits at `safe-top + space-sm`; floating bottom controls at `safe-bottom + space-sm`; card in bottom register adds `space-md` clearance.
- Nothing interactive ever sits inside the home-indicator or notch zone; the basemap itself may extend under bars.
- Desktop Telegram / plain browser: both `env()` values resolve to 0 — layout is unchanged (D-11 harness asserts this).
---## BackButton Interaction Contract (shell states: map only)
The app has exactly one shell state in Phase 3 — the map. No dead-ends contract:
| Situation | BackButton | Click behavior |
|-----------|-----------|----------------|
| Boot stages 1–2 | `hide()` immediately at init (nothing to pop out of yet) | — |
| Map live /following | hidden (Telegram-native swipe-down close remains the exit gesture — allowed in this phase; PX-03's constrained close is Phase 6) | — |
| Denied / signal-lost card (ephemeral-looking surface) | hidden — these cards are *registers of the single map state*, not sub-states; retry CTAs lead forward, never backward | — |
| If any future/overlay surface ever shows the BackButton in this phase | `on('backButtonClicked')` wired to a total function: **if no overlay exists → `WebApp.close()`** (Telegram semantics: back at the root closes the app). An empty / no-op handler is forbidden — no dead-ends | direct close |
| BackButton events (Phase 6 groundwork) | the event listener module ships in this phase with the single-state wire above, so Phase 6 extends the state graph rather than inventing wiring | — |
---
## Micro-Behaviors (loading / transitions, inside Rams rules)
- **No CSS `transition`/`animation` anywhere in app chrome** (component layer precedent, "switches instantly"). State changes are instant hard swaps.
- Machine-blink loading gantry (boot card lamp): JS-timed toggle between filled/hollow square, ~500ms period, two visible states — an indicator duty cycle, not an easing animation.
- Camera moves: MapLibre `flyTo` for recenter is a *map viewport* behavior, permitted; it is not chrome motion. Keep recenter duration ≤ 400ms, easing default linear family — the map glides; the chrome never does.
- Fix flash: none (see marker section).
- No blur, no shadow, no frosted glass, no `animate-ping`-style mockup residue anywhere in the shell.
---
## Interaction Inventory (planner-facing, closes the visual contract)
| Interaction | Trigger | Visual response |
|-------------|---------|-----------------|
| App boot → validating → acquiring → live | automatic | boot card stages, strip state swaps (staged table above) |
| Location permission denied (D-06) | in-app chain exhausts | denied card + red strip lamp + TRY AGAIN / ASK VIA CHAT |
| Desktop Telegram / no geolocation | in-app chain reports unsupported | denied card with identical copy (no client-specific variant) |
| `ASK VIA CHAT` tapped | user | chat fallback request issued from bot side (GEO-02); card body swaps to `Waiting for the pin…`, strip → `AWAITING PIN`; consumed fix enters `live` |
| Fresh pin consumed from chat | bot-side event | boot/denied furniture clears instantly, marker renders, strip → `LIVE` |
| Pan/zoom away from user | user gesture | control center square disappears (free) |
| Recenter tap | user | camera flyTo latest fix, center square returns (following) |
| Signal stale ≥ 45s | timer | marker dims 40%, strip lamp red, signal-lost card appears in bottom register |
| Recovery from lost | fresh accepted fix | card clears, marker full opacity, strip → `LIVE` instantly |
| Telegram theme flips | `themeChanged` | chrome re-slots (mapping table); map untouched |
| BackButton click (if ever visible) | Telegram | `WebApp.close()` — no dead-ends |
---
## Fidelity Artifact (DSGN-02 obligation)
Phase exit extends the Phase 1 gallery pattern: a Playwright-captured side-by-side of the implemented shell (via the Mock-Telegram harness runner, D-11) against `navigate_map_overview/screen.png` — map, marker, strip, control, plus one capture per boot stage and one dark-theme capture. Rams tokens govern everything except scrubbed (rounded/blur) mockup residue. Where this document, the mockup markup, and `tokens.css` disagree in values: `tokens.css` wins, mockup loses, this spec records the call.
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none (shadcn not initialized; gate N/A for no-framework stack) | not required |
| third-party | none | not required |
Map tiles render from OpenFreeMap's runtime endpoint (D-14) — a data fetch, not a code dependency.
---
## Copy Gate (ties to code, mirrors Phase 2 precedent)
The strings module is the copy's `tokens.css`: every string in the table above lives in `web/src/i18n/strings.ts`-pattern module with `en` default + `ms` locale picked from Telegram `language_code`; a copy-gate test asserts the module exports exactly the contract table — no inline copy in shell modules.
---
## 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: 03-mini-app-shell-geolocation*
*Contract drafted: 2026-10-22 by gsd-ui-researcher*
*Consumes: theme tokens (binding), liberty-rams.json, telemetry_rams/DESIGN.md, 03-CONTEXT.md D-01..D-15, Phase 1 D-04, Phase 2 D-01/D-05*