Files

341 lines
27 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: 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*