27 KiB
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.csstokens 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) andtokens.cssare 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 CHATis.btn-primary(it is the only working origin path on Telegram Desktop, which has no geolocation at all — issue #77) andTRY AGAINis.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
AttributionControlcompact, styled to Rams:label-smtype on a matte-black flat chip, flush bottom-left, no translucency/blur (mockup'sbackdrop-blurchrome 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 thelocationManagerUpdatedevent 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-blackbackground,pure-whiteinline-SVG crosshair/target icon (2px stroke, square caps,my_locationsilhouette hand-drawn as orthogonal glyph). - States (instant swaps):
following(default while live): plate stays matte-black; a solid 8×8pxsignal-orangesquare 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.liveLOST (no fix): icon inverts tofunctional-red? No — red is reserved for the strip/degraded registers; instead the center notch fills withsoft-beige(unlocked, not error).
- Tap behavior: in
free, recenters camera on the latest fix and resumesfollowing(90→96px camera padding); infollowing, re-nudges to the freshest fix (idempotent). Hit target ≥ 44×44px. - Zero hover/press transitions — press inverts instantly to
signal-orangefield +matte-blackglyph (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 = pureenv(). - Top register sits at
safe-top + space-sm; floating bottom controls atsafe-bottom + space-sm; card in bottom register addsspace-mdclearance. - 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/animationanywhere 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
flyTofor 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