Files
Arah2/.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md
T

27 KiB
Raw Blame History


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