--- phase: 03-mini-app-shell-geolocation plan: 03 type: execute wave: 3 depends_on: [03-02] files_modified: - web/src/i18n/strings.ts - web/src/shell.css - web/src/map/map.ts - web/src/map/marker.ts - web/src/map/follow.ts - web/src/ui/strip.ts - web/src/ui/cards.ts - web/src/ui/recenter.ts - web/src/main.ts - web/test/copy-gate.test.mjs - web/test/session.test.mjs autonomous: true requirements: [GEO-01, GEO-02, GEO-03, PX-02] must_haves: decisions_implemented: - "D-06: denied/no-geolocation shows the deliberate Rams denied card with retry + chat fallback (never a blank map or silent banner)" - "D-09: boot choreography renders staged deliberate states (validating → acquiring → live), none blank" - "D-10: Telegram theme flips re-theme shell surfaces only; the map style object stays Rams-constant" truths: - "The basemap mounts and renders tiles BEFORE the location chain starts — boot never shows a blank map" - "Every boot stage (validating → acquiring → live) renders Rams furniture over a live map; no stage is blank" - "All user-facing copy lives in web/src/i18n/strings.ts and matches the UI-SPEC table verbatim in en + ms" - "The map style is never re-set on theme flip — only data-tg-theme on changes" - "The position marker renders a dot, an accuracy circle capped at 100m, and a heading cone hidden when heading is unknown" - "Recenter flyTo is 400ms with 96px camera padding; the control is 40x40 with a >=44px hit target" - "No route line, search furniture, or layer toggle appears anywhere in the boot output (D-08)" artifacts: - path: "web/src/i18n/strings.ts" provides: "the UI-SPEC copy table verbatim, en + ms, plus pickLocale" - path: "web/src/shell.css" provides: "token-only shell layout, dark-slot activation, strip/card/control/marker geometry" - path: "web/src/map/map.ts" provides: "MapLibre v6 bootstrap with setWorkerUrl + GPUInitializationError gate + READY signal" - path: "web/src/map/marker.ts" provides: "position dot + accuracy circle + heading cone as a DOM Marker" - path: "web/src/map/follow.ts" provides: "follow/free toggle with 96px camera padding" - path: "web/src/ui/strip.ts" provides: "status strip: 8px lamp + state word + telemetry" - path: "web/src/ui/cards.ts" provides: "boot / denied / signal-lost cards composed from theme classes" - path: "web/src/main.ts" provides: "boot choreography: mount map then session then chain, staged states" key_links: - from: "web/src/main.ts" to: "web/src/map/map.ts" via: "bootMap() runs FIRST, then createSession(), then acquireFix() — the order is the anti-blank-map invariant" pattern: "bootMap" - from: "web/src/ui/cards.ts" to: "web/src/i18n/strings.ts" via: "every card body/headline imported from strings — zero inline copy" pattern: "strings" - from: "web/src/ui/cards.ts" to: "/api/location-fallback" via: "the ASK VIA CHAT button handler in main.ts calls requestChatFallback(getSession()!) — the route plan 03-01 implements as handleLocationFallback; this is the Desktop Telegram path (issue #77)" pattern: "requestChatFallback" - from: "web/src/shell.css" to: "theme/dist/rams.css" via: "shell.css references only var(--color-*)/var(--spacing-*)/var(--type-*) — never a literal hex" pattern: "var" --- Build the Rams shell: the copy module (UI-SPEC table verbatim, en + ms), the token-only stylesheet, the MapLibre v6 bootstrap with its WebGL2 gate, the position marker and follow/recenter camera controls, the status strip and state cards, and the boot choreography that mounts the map first and runs the staged validating → acquiring → live sequence. Purpose: this is where the shell becomes visible. It satisfies the phase's design contract (D-08 map-first single screen, D-09 never-blank staged boot, D-10 map-constant-under-theme-flip, PX-02 chrome re-slots) and produces the artifacts the capture harness and the device-matrix gate photograph. Output: `web/src/i18n/strings.ts`; `web/src/shell.css`; `web/src/map/{map,marker,follow}.ts`; `web/src/ui/{strip,cards,recenter}.ts`; `web/src/main.ts`; `copy-gate.test.mjs`; `session.test.mjs`. @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md @AGENTS.md @.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md @.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md @.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md @.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md @theme/src/components.css @theme/src/tokens.css @theme/src/theme.css @theme/basemap/index.html @stitch_minimalist_route_navigator/navigate_map_overview/code.html @web/src/location/types.ts @web/src/location/chain.ts @web/src/session/client.ts @web/src/session/store.ts @web/src/telegram/theme.ts @web/src/telegram/backbutton.ts @web/src/telegram/safe-area.ts Available CSS custom properties (from `theme/src/tokens.css`, consumed verbatim — never restate a value): ``` --color-surface: #fff8f0 --color-surface-dark: #121212 --color-on-surface: #1e1b14 --color-inverse-on-surface: #f7f0e3 --color-inverse-surface: #333028 --color-soft-beige: #D9D2C6 --color-chassis-subtle: #CCC4B5 --color-matte-black: #1A1A1A --color-pure-white: #FFFFFF --color-signal-orange: #EB8008 --color-functional-red: #ED3F1C --color-olive-green: #736B1E --color-on-surface-variant: #564335 --color-surface-dim: #e0d9cd --spacing-space-xs: 0.25rem --spacing-space-sm: 0.5rem --spacing-space-md: 1rem --spacing-space-lg: 1.5rem --spacing-space-xl: 2rem --spacing-margin: 1rem --type-body-md / --type-headline-md / --type-label-sm / --type-label-lg (+ -tracking companions) --shell-bg / --shell-on-surface (Phase 1 D-04 theme slots — the PX-02 switch target) ``` Consumed component classes (from `theme/src/components.css` — compose, do NOT restyle): `.btn-primary`, `.btn-secondary`, `.card`, `.card-header`, `.card-ref`, `.card-content`, `.text-headline-md`, `.text-body-md`, `.text-body-sm`, `.text-label-sm`, `.text-label-lg`. Copy verbatim from `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` section "Copywriting Contract". Register labels (`BOOT-01 //`, `GEO //`, `SYS //`) stay English under both locales. task 1: `i18n/strings.ts` copy module (UI-SPEC table verbatim, en + ms) + copy gate web/src/i18n/strings.ts, web/test/copy-gate.test.mjs - `bot/src/i18n/strings.ts` (the exact structural shape: interface, then Locale type, then Record, then pickLocale) - `bot/test/commands.test.mjs` lines 36-41 and 59-68 (the CS1 parity gate and CS3 copy-gate style) - `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` section "Copywriting Contract" (the copy table — copy it exactly) **Create `web/src/i18n/strings.ts`.** Structural copy of `bot/src/i18n/strings.ts`: interface `ShellStrings`, `export type Locale = "en" | "ms"`, `export const strings: Record`, `export function pickLocale`. Reuse `pickLocale`'s ms-prefix rule verbatim: ```typescript export function pickLocale(telegramLanguageCode?: string | null): Locale { const code = telegramLanguageCode ?? ""; return code === "ms" || code.startsWith("ms-") ? "ms" : "en"; } ``` Also export `localeFromInitData` in the same file (its full implementation is specified in task 3 step 11 — write it here so task 1 owns the module's complete public surface): ```typescript /** Derive the locale from the RAW initData string (RESEARCH §Security V2 — * the signed string, never initDataUnsafe). Telegram's wire format is * user=&auth_date=...&query_id=...&signature=...&hash=..., * so the first query component is `user`. A malformed or absent field * falls back to "en" — never throws, never blocks boot. */ export function localeFromInitData(initData: string): Locale { try { const userField = initData.split("&")[0] ?? ""; if (!userField.startsWith("user=")) return "en"; const user = JSON.parse(decodeURIComponent(userField.slice(5))) as { language_code?: string }; return pickLocale(user.language_code); } catch { return "en"; } } ``` `POST /api/session` returns only `{ token }`, so the claim's `language_code` is NOT reachable client-side — parsing `initData` locally is the only honest derivation. Parsing `initData` (not `initDataUnsafe`) does not weaken the security model: the value only chooses a locale, the HMAC validation still happens server-side, and the worst case of a forged locale is Malay copy in an English chat. The `ShellStrings` interface (25 keys): ```typescript export interface ShellStrings { bootValidatingRegister: string; bootValidatingHeadline: string; bootValidatingBody: string; bootAcquiringRegister: string; bootAcquiringHeadline: string; bootAcquiringBody: string; deniedRegister: string; ctaTryAgain: string; ctaAskViaChat: string; deniedHeadline: string; deniedBody: string; deniedHint: string; lostRegister: string; lostHeadline: string; lostBody: string; lostHint: string; chatFallbackWaiting: string; stripValidating: string; stripAcquiring: string; stripLive: string; stripSignalLost: string; stripLocationOff: string; stripAwaitingPin: string; attribution: string; ariaRecenter: string; } ``` (That is 25 keys — the copy gate asserts whatever the final count is, identical across locales. `deniedRegister` and `lostRegister` exist because `cards.ts` renders the register texts `GEO // LOCATION` and `SYS // POSITION`; without these keys they would be inline copy in `cards.ts`, violating this plan's own no-inline-copy rule AND the Copy Gate success criterion — and COPY5's banned list would not have caught them.) Exact `en` values (copy character-for-character, including em-dashes and arrows): ```typescript en: { bootValidatingRegister: "BOOT-01 // SESSION", bootValidatingHeadline: "Validating session", bootValidatingBody: "Confirming your Telegram identity. Takes a few seconds and stores nothing.", bootAcquiringRegister: "BOOT-02 // LOCATION", bootAcquiringHeadline: "Acquiring location", bootAcquiringBody: "Finding where you are. Your position is never written to storage.", deniedRegister: "GEO // LOCATION", ctaTryAgain: "TRY AGAIN", ctaAskViaChat: "ASK VIA CHAT", deniedHeadline: "Location is switched off", deniedBody: "araharah guides you from where you physically are. Without location there is no route to draw.", deniedHint: "Grant location to Telegram, or send a pin in this chat: attach → Location.", lostRegister: "SYS // POSITION", lostHeadline: "Signal lost", lostBody: "Showing your last known position — marked on the map. Reacquiring automatically.", lostHint: "You can also send a fresh pin in this chat: attach → Location.", chatFallbackWaiting: "Waiting for the pin you send in chat.", stripValidating: "VALIDATING", stripAcquiring: "ACQUIRING", stripLive: "LIVE", stripSignalLost: "SIGNAL LOST", stripLocationOff: "LOCATION OFF", stripAwaitingPin: "AWAITING PIN", attribution: "© OpenStreetMap contributors", ariaRecenter: "Recenter map", }, ``` Exact `ms` values: ```typescript ms: { bootValidatingRegister: "BOOT-01 // SESSION", bootValidatingHeadline: "Menyemak sesi", bootValidatingBody: "Mengesahkan identiti Telegram anda. Hanya beberapa saat, tiada apa-apa disimpan.", bootAcquiringRegister: "BOOT-02 // LOCATION", bootAcquiringHeadline: "Mendapatkan lokasi", bootAcquiringBody: "Mencari kedudukan anda. Lokasi anda tidak pernah disimpan.", deniedRegister: "GEO // LOCATION", ctaTryAgain: "CUBA LAGI", ctaAskViaChat: "MINTA VIA CHAT", deniedHeadline: "Lokasi dimatikan", deniedBody: "araharah memandu dari kedudukan sebenar anda. Tanpa lokasi, tiada laluan boleh dilukis.", deniedHint: "Benarkan lokasi untuk Telegram, atau hantar pin dalam chat ini: lampiran → Lokasi.", lostRegister: "SYS // POSITION", lostHeadline: "Isyarat hilang", lostBody: "Menunjukkan kedudukan terakhir anda — ditanda pada peta. Sedang mencari semula.", lostHint: "Anda juga boleh hantar pin baharu dalam chat ini: lampiran → Lokasi.", chatFallbackWaiting: "Menanti pin yang anda hantar dalam chat.", stripValidating: "MENYEMAK", stripAcquiring: "MENDAPAT", stripLive: "LANGSUNG", stripSignalLost: "ISYARAT HILANG", stripLocationOff: "LOKASI MATI", stripAwaitingPin: "AWAITING PIN", attribution: "© OpenStreetMap contributors", ariaRecenter: "Tetapkan semula peta", }, ``` The register labels (`BOOT-01 //`, `BOOT-02 //`, `GEO //`, `SYS //`) and `AWAITING PIN` are identical across locales by design (instrument vocabulary, UI-SPEC section "Copywriting Contract"). **Create `web/test/copy-gate.test.mjs`.** Cases (each named with its gate ID): ``` COPY1 en and ms carry identical key-sets (parity gate, both locales) COPY2 the denied copy names location in both locales (/location/i in en, /lokasi/i in ms) COPY3 the boot-2 body states the never-persisted fact (/never written to storage/i, /tidak pernah disimpan/) COPY4 pickLocale: ms and ms-MY select ms; en, en-GB, id, null, and empty string select en COPY5 no shell module inlines copy — source-introspection grep over web/src/ui/*.ts and web/src/main.ts for: Validating session|Acquiring location|TRY AGAIN|ASK VIA CHAT| Location is switched off|Signal lost|MENYEMAK|MENDAPAT|LANGSUNG| GEO // LOCATION|SYS // POSITION|BOOT-01 //|BOOT-02 //|CUBA LAGI|MINTA VIA CHAT| ISYARAT HILANG|LOKASI MATI|AWAITING PIN|OpenStreetMap contributors|Recenter map|Tetapkan semula peta (the four register-label patterns are load-bearing: cards.ts renders GEO // LOCATION and SYS // POSITION, and without them in the banned list those two strings could be inlined in cards.ts and no gate would notice) COPY6 localeFromInitData derives the locale from the raw initData string (the server returns only {token}, so the claim's language_code is not reachable client-side): "user=%7B%22language_code%22%3A%22ms%22%7D&auth_date=1&hash=x" -> "ms" "user=%7B%22language_code%22%3A%22en-GB%22%7D&auth_date=1&hash=x" -> "en" "" -> "en" "not-initdata" -> "en" (no user= component) "user=%7Bbad-json" -> "en" (decode/JSON failure must not throw) ``` Import the module under test with `await import("../src/i18n/strings.ts")`. For COPY5 read each `web/src/ui/*.ts` and `web/src/main.ts` with `readFileSync` and `assert.doesNotMatch`. cd /home/C-Fu/araharah && npm test -w web 2>&1 | grep -E 'COPY|runner' ; npm run typecheck -w web - `npm test -w web` exits 0 and the runner output contains `PASS COPY1` through `PASS COPY6` - `node -e "const s=require('fs').readFileSync('web/src/i18n/strings.ts','utf8'); const g=(m)=>((s.match(m)||[])[1]||''); const keys=(x)=>[...x.matchAll(/^\s{4}(\w+):/gm)].map(a=>a[1]).sort(); const a=keys(g(/en: \{([\s\S]*?)\n \},/)), b=keys(g(/ms: \{([\s\S]*?)\n \},/)); if(a.length<25) throw new Error('too few keys '+a.length); if(JSON.stringify(a)!==JSON.stringify(b)) throw new Error('parity broken'); for(const k of ['deniedRegister','lostRegister']) if(!a.includes(k)) throw new Error('missing '+k); console.log('parity OK '+a.length+' keys');"` exits 0 — 25 keys, identical sets, and BOTH register keys present (they are what stops `GEO // LOCATION` / `SYS // POSITION` from being inlined in `cards.ts`) - `grep -c 'Validating session\|Acquiring location\|TRY AGAIN\|ASK VIA CHAT\|GEO // LOCATION\|SYS // POSITION' web/src/ui/strip.ts web/src/ui/cards.ts 2>/dev/null | grep -v ':0' | wc -l` returns 0 (copy is centralized; both files must show only 0 hits — including the two register labels) Every user-facing string is centralized, matches the approved UI-SPEC table verbatim in both locales, and a copy gate pins it. task 2: `shell.css` + map (bootstrap / marker / follow) web/src/shell.css, web/src/map/map.ts, web/src/map/marker.ts, web/src/map/follow.ts - `theme/src/components.css` (the classes you compose — never restyle them) - `theme/src/theme.css` lines 100-109 (`--shell-bg` / `--shell-on-surface` plus the prefers-color-scheme default) - `theme/basemap/index.html` lines 35-88 and 103-173 (the repo's established chrome-around-a-map plus READY-signal pattern) - `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` section "Code Examples" (the verified v6 bootstrap, marker geometry, follow/recenter) - `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` sections "Position Marker + Accuracy Geometry", "Follow / Recenter Control", "Spacing Scale" **`web/src/shell.css`** — every value is a token reference; the only literals are the machined-border geometry the UI-SPEC enumerates (1px/2px rules, 8px lamp, 20px dot, 40x40 control, 44px hit target, cone points). ```css /* tokens.css wins; mockups lose. Value discipline per theme/src/components.css lines 9-15. */ @import "@araharah/theme/dist/rams.css"; * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background: var(--shell-bg); color: var(--shell-on-surface); font: var(--type-body-md); overflow: hidden; } /* PX-02 dark-slot activation (extends theme.css's media-query default) */ [data-tg-theme="light"] { --shell-bg: var(--color-surface); --shell-on-surface: var(--color-on-surface); } [data-tg-theme="dark"] { --shell-bg: var(--color-surface-dark); --shell-on-surface: var(--color-inverse-on-surface); } [data-tg-theme="dark"] .card { background: var(--color-inverse-surface); color: var(--color-inverse-on-surface); border-color: var(--color-inverse-on-surface); } [data-tg-theme="dark"] .card-header { border-bottom-color: var(--color-inverse-on-surface); color: var(--color-inverse-on-surface); } /* NO MapLibre rule here, by design (D-10 / RESEARCH Pitfall 7). */ /* Research Open Question 1 — WebGL2 absent: shell renders over a Rams-tinted field */ body[data-map="absent"] main { background: var(--color-surface-dim); } /* map register: full-bleed, the dominant surface */ main { position: fixed; inset: 0; } #map { position: absolute; inset: 0; } .maplibregl-ctrl-attrib { border-radius: 0; background: var(--color-matte-black); color: var(--color-pure-white); font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; } /* top register: status strip */ .strip { position: absolute; top: calc(var(--safe-top, 0px) + var(--spacing-space-sm)); left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; padding: var(--spacing-space-sm) var(--spacing-space-md); min-height: 32px; background: var(--color-matte-black); color: var(--color-inverse-on-surface); font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; } .strip .lamp { width: 8px; height: 8px; border: 1px solid var(--color-pure-white); } .strip .lamp[data-state="acquiring"], .strip .lamp[data-state="validating"], .strip .lamp[data-state="awaiting"] { background: var(--color-signal-orange); } .strip .lamp[data-state="live"] { background: var(--color-olive-green); } .strip .lamp[data-state="lost"], .strip .lamp[data-state="denied"] { background: var(--color-functional-red); } .strip .telemetry { font: var(--type-label-sm); } /* center register: boot / denied / lost card */ .card-stack { position: absolute; left: var(--spacing-margin); right: var(--spacing-margin); top: 50%; transform: translateY(-50%); max-width: 320px; margin-inline: auto; } .card .gantry { width: 8px; height: 8px; border: 1px solid var(--color-matte-black); } .card .gantry[data-fill="on"] { background: var(--color-signal-orange); } .card-actions { display: flex; gap: var(--spacing-space-sm); padding-top: var(--spacing-space-sm); } /* bottom register: recenter control (40x40 plate, >=44x44 hit target) */ .recenter { position: absolute; right: var(--spacing-margin); bottom: calc(var(--safe-bottom, 0px) + var(--spacing-space-sm)); width: 40px; height: 40px; padding: 0; border: 0; border-radius: 0; background: var(--color-matte-black); color: var(--color-pure-white); display: flex; align-items: center; justify-content: center; } .recenter::after { content: ""; position: absolute; inset: -2px; } .recenter .notch { width: 8px; height: 8px; background: var(--color-signal-orange); } .recenter[data-follow="free"] .notch { display: none; } /* UI-SPEC §Follow/Recenter Control, third notch state: live-but-LOST (no fix). soft-beige, NOT red — red is reserved for the strip/degraded registers; the notch reads "unlocked, not error" (RESEARCH Pitfall 8 orange discipline). */ .recenter[data-fix="lost"] .notch { background: var(--color-soft-beige); } .recenter:active { background: var(--color-signal-orange); color: var(--color-matte-black); } /* position marker (DOM Marker children) */ .pos-accuracy { fill: var(--color-matte-black); fill-opacity: 0.06; stroke: var(--color-matte-black); stroke-opacity: 0.30; stroke-width: 1; } .pos-dot { fill: var(--color-matte-black); } .pos-cone { fill: var(--color-signal-orange); } .pos-group[data-stale="true"] { opacity: 0.4; } /* No CSS transition or animation anywhere — state changes are instant hard swaps. */ ``` The only px literals present are the machined-border geometry the UI-SPEC enumerates (1px stroke widths, 8px lamp/gantry/notch, 20px dot via SVG r=10, 40px plate, -2px hit slop, 320px card max-width). **`web/src/map/map.ts`** — the verified v6 bootstrap: **`resolveJsonModule` is load-bearing for the `ramsStyle` import below.** Plan 03-01 task 1 Step B pre-authorizes exactly that as the third `web/tsconfig.json` key changed (`lib`, `types`, `resolveJsonModule`) precisely because this import is a hard TypeScript error without it. If typecheck reports `TS2732: Cannot find module '.../liberty-rams.json'`, the tsconfig was not carried over from 03-01 — fix it there. Do NOT add a `.d.ts` shim, an `any` cast, or `esModuleInterop`/`allowSyntheticDefaultImports`. `resolveJsonModule` alone is sufficient: `moduleResolution: "bundler"` already permits the deep subpath `@araharah/theme/basemap/*`, so RESEARCH Open Question 4 resolves at build time with no `exports` map change. ```typescript import { Map as MapLibreMap, setWorkerUrl, GPUInitializationError } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url"; import ramsStyle from "@araharah/theme/basemap/liberty-rams.json"; import { KL_CENTER } from "../location/types.ts"; setWorkerUrl(workerUrl); // required for ALL bundlers in v6 export function bootMap(container: HTMLElement): MapLibreMap | null { try { const map = new MapLibreMap({ container, style: ramsStyle, // inlined at build time (D-14) — NEVER re-set center: KL_CENTER, zoom: 13, attributionControl: { compact: true }, }); map.on("error", (e) => { console.error("RAMS-BASEMAP-ERROR", e && e.error && (e.error.message || e.error)); }); map.on("load", () => { console.log("RAMS-BASEMAP-READY layers=" + map.getStyle().layers.map((l) => l.id).length); }); return map; } catch (err) { if (err instanceof GPUInitializationError) return null; // Pitfall 5: shell still renders throw err; } } ``` **Research Open Question 1 — deliberate answer:** when `bootMap` returns `null`, the shell renders over a Rams-tinted field (`body[data-map="absent"] main { background: var(--color-surface-dim); }`), preserving D-08/D-09 "never blank" instead of crashing. This is RESEARCH section "Open Questions" item 1's recommendation. Record it as a one-line planner amendment in `main.ts`'s header AND append the same sentence to `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` under section "Boot Choreography": `> 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.` The `RAMS-BASEMAP-READY` console prefix is load-bearing — `theme/scripts/screenshot.mjs` (line 151) and plan 03-04's `capture.mjs` both wait on exactly that prefix. **`web/src/map/marker.ts`** — DOM Marker, geometry from UI-SPEC section "Position Marker": ```typescript import { Marker, type Map as MapLibreMap } from "maplibre-gl"; import type { Fix } from "../location/types.ts"; const DISPLAY_ACCURACY_CAP_M = 100; export function accuracyRadiusPx(map: MapLibreMap, lat: number, metres: number): number { const sw = map.getCanvas().width; const mPerPx = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (sw * Math.pow(2, map.getZoom())); return Math.min(metres, DISPLAY_ACCURACY_CAP_M) / mPerPx; } export function createPositionMarker(): { marker: Marker; update(map: MapLibreMap, fix: Fix): void; setStale(v: boolean): void; } { const group = document.createElement("div"); group.className = "pos-group"; const svgNs = "http://www.w3.org/2000/svg"; // The SVG carries EXPLICIT width/height/viewBox and every child carries explicit // cx/cy. An with no width/height renders at 300x150 (or collapses) and a // with no cx/cy lands at (0,0) — both make the marker invisible. const svg = document.createElementNS(svgNs, "svg"); svg.setAttribute("width", "240"); svg.setAttribute("height", "240"); svg.setAttribute("viewBox", "-120 -120 240 240"); const circle = document.createElementNS(svgNs, "circle"); circle.setAttribute("class", "pos-accuracy"); circle.setAttribute("cx", "0"); circle.setAttribute("cy", "0"); const cone = document.createElementNS(svgNs, "polygon"); cone.setAttribute("class", "pos-cone"); cone.setAttribute("points", "0,-8 6,5 0,2 -6,5"); const dot = document.createElementNS(svgNs, "circle"); dot.setAttribute("class", "pos-dot"); dot.setAttribute("cx", "0"); dot.setAttribute("cy", "0"); dot.setAttribute("r", "10"); // cone + dot are SVG-NAMESPACE nodes: they MUST be children of the , never // of the HTML
— an SVG element directly under a
is not rendered. svg.append(circle, cone, dot); // MapLibre owns this group's DOM once addTo(map) runs — it parents it into the // map canvas wrapper. DO NOT document.body.appendChild(group): an un-parented // body child has no positioning CSS and paints UNDER the `inset: 0` map, so the // marker would be invisible even though it exists. group.appendChild(svg); const marker = new Marker({ element: group }).setLngLat([0, 0]); return { marker, update(map, fix) { marker.setLngLat([fix.lng, fix.lat]); circle.setAttribute("r", String(accuracyRadiusPx(map, fix.lat, fix.accuracyM ?? 0))); if (fix.headingDeg === null) cone.style.display = "none"; // hidden when unknown — never a half-open guess else { cone.style.display = ""; cone.style.transform = `rotate(${fix.headingDeg}deg)`; } }, setStale(v) { group.dataset.stale = String(v); }, }; } ``` **`web/src/map/follow.ts`**: ```typescript import type { Map as MapLibreMap } from "maplibre-gl"; import type { Fix } from "../location/types.ts"; export const CAMERA_PADDING_PX = 96; // 12 x space-sm — marker never hides under strip or controls export const RECENTER_DURATION_MS = 400; export type FollowMode = "following" | "free"; export function createFollowController(map: MapLibreMap, onModeChange: (m: FollowMode) => void) { let mode: FollowMode = "following"; let lock = false; const set = (m: FollowMode): void => { if (m !== mode) { mode = m; onModeChange(m); } }; map.on("movestart", (e: { originalEvent?: unknown }) => { if (lock) return; if (e.originalEvent) set("free"); // a user gesture only, never a programmatic move }); return { get mode(): FollowMode { return mode; }, follow(fix: Fix): void { lock = true; map.flyTo({ center: [fix.lng, fix.lat], padding: CAMERA_PADDING_PX, duration: RECENTER_DURATION_MS }); set("following"); lock = false; }, }; } ``` cd /home/C-Fu/araharah && npm run build -w web 2>&1 | tail -20 - `npm run build -w web` exits 0 (maplibre-gl v6 ESM resolves; the style JSON import resolves) - `grep -q 'setWorkerUrl(workerUrl)' web/src/map/map.ts` exits 0 - `grep -q 'worker&url' web/src/map/map.ts` exits 0 - `grep -v '^ \*\|^/\*\|^$' web/src/shell.css | grep -c 'transition:' | head -1` returns 0 AND the same for `animation:` — no chrome motion - `grep -v '^ \*\|^/\*\|^$' web/src/shell.css | grep -cE '#[0-9a-fA-F]{3,6}' | head -1` returns 0 — no literal hex, tokens only - `grep -c 'border-radius: 0' web/src/shell.css | head -1` returns >= 1 - `grep -q 'CAMERA_PADDING_PX = 96' web/src/map/follow.ts` exits 0 AND `grep -q 'RECENTER_DURATION_MS = 400' web/src/map/follow.ts` exits 0 - `grep -q 'GPUInitializationError' web/src/map/map.ts` exits 0 - `grep -q 'DISPLAY_ACCURACY_CAP_M = 100' web/src/map/marker.ts` exits 0 - `grep -q 'headingDeg === null' web/src/map/marker.ts` exits 0 - `grep -q 'data-map="absent"' web/src/shell.css` exits 0 (the WebGL2-absent shell state is deliberate, not a crash) - `ls web/dist/assets/ | grep -cE '\.(css|js)$' | head -1` returns >= 2 The Rams basemap boots under MapLibre v6 with a WebGL2 fallback, the marker geometry matches the UI-SPEC, and follow/recenter use the specified 96px padding and 400ms flyTo. task 3: status strip, state cards, recenter control, and the boot choreography (`main.ts`) web/src/ui/strip.ts, web/src/ui/cards.ts, web/src/ui/recenter.ts, web/src/main.ts, web/test/session.test.mjs - `theme/src/components.css` lines 22-134 (`.btn-primary`, `.btn-secondary`, `.card`, `.card-header`, `.card-ref`, `.card-content`) — compose these, never restyle - `web/src/location/types.ts` and `web/src/location/chain.ts` (the `LocationState` union you render) - `web/src/session/client.ts`, `web/src/session/store.ts`, `web/src/telegram/*.ts` (the wiring you compose) - `web/src/i18n/strings.ts` (your own task-1 output — every string comes from here) - `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` sections "Boot Choreography", "Status Strip", "Micro-Behaviors", "BackButton Interaction Contract" **`web/src/ui/strip.ts`** — renders the six states from the UI-SPEC table: ```typescript import type { LocationState } from "../location/types.ts"; import { strings, type Locale } from "../i18n/strings.ts"; export function createStatusStrip(host: HTMLElement): { setLocale(locale: Locale): void; render(state: LocationState, fixAgeSec: number | null, accuracyM: number | null): void; } { /* ... */ } ``` Render rules (UI-SPEC section "Status Strip", verbatim semantics): - Lamp is 8x8px, `data-state` in `{validating, acquiring, live, lost, denied, awaiting}`; colors come from `.strip .lamp[data-state=...]` in `shell.css`. - Left label from `strings[locale].strip*`. Right telemetry is HIDDEN until live; on live it renders `± M` and `FIX S AGO`; on lost it renders `LAST FIX S AGO`; on acquiring it renders elapsed seconds since the state was entered. **The live accuracy uses the U+00B1 `±` glyph, not an ASCII `+-`** — the UI-SPEC copy contract (Status Strip table and Micro-Behaviors) pins `±12 M` and requires "same format, units unchanged" in Malay; `+-` is a visible one-glyph deviation from the approved contract. - The strip is Rams-constant matte-black under both themes — no theme selector touches it. - The lamp element carries `aria-hidden="true"`; state is carried by the word label. **`web/src/ui/cards.ts`** — three card builders, all strings imported: ```typescript export function buildBootCard(locale: Locale, stage: 1 | 2): HTMLElement; export function buildDeniedCard(locale: Locale, opts: { awaitingPin?: boolean }): HTMLElement; export function buildSignalLostCard(locale: Locale): HTMLElement; export function startGantry(el: HTMLElement): void; export function stopGantry(el: HTMLElement): void; ``` - Boot card: `.card` containing `.card-header` (register from `strings.bootValidatingRegister` / `bootAcquiringRegister` inside a `.card-ref`), `.text-headline-md` headline, `.text-body-md` body, plus the 8px `.gantry` square carrying `data-fill`. - The gantry blink is a JS `setInterval(..., 500)` toggling `data-fill` between `"on"` and `"off"` — a machine-blink duty cycle, NOT a CSS animation. `startGantry`/`stopGantry` let `main.ts` stop it when live. - Denied card (D-06): `.card` + `.card-header` (register from `strings.deniedRegister` = `GEO // LOCATION`, English under both locales as instrument vocabulary) + `.text-headline-md` + `.text-body-md` + `.text-body-sm` hint + `.card-actions` holding a `.btn-secondary` TRY AGAIN and a `.btn-primary` ASK VIA CHAT. When `opts.awaitingPin` is true the body swaps to `strings.chatFallbackWaiting`. **D-06 is load-bearing here:** the denied state is a deliberate Rams-styled surface that explains why location matters, offers retry, and points at Telegram's attach then Location path as the manual fallback. It is NEVER a silent banner, and there is **no client-specific variant** — Desktop Telegram (which has no geolocation at all, issue #77) gets byte-identical copy, because a Desktop user cannot fix a permission they were never asked for. The `ASK VIA CHAT` CTA is the real Desktop path, so it carries `.btn-primary` (TRY AGAIN carries `.btn-secondary`), per RESEARCH Pitfall 4 ("make sure it is NOT styled as secondary there") and `03-PATTERNS.md` §`web/src/ui/*`. **This inverts the class mapping in `03-UI-SPEC.md`'s "Consumed component classes" table — that table already carries the inversion as a `Planner amendment (RESEARCH Pitfall 4)` callout directly beneath it, so **verify it is present and leave it as-is; append only if missing** (see the amendment-verification block at the end of this task):** `> Planner amendment (RESEARCH Pitfall 4): the class mapping is inverted for the denied card — \`ASK VIA CHAT\` is \`.btn-primary\` (it is the only working origin path on Desktop Telegram) and \`TRY AGAIN\` is \`.btn-secondary\`. The Desktop leg of the device matrix is what proves the CTA renders, so this must not read as a diminished secondary action.` - Signal-lost card: register from `strings.lostRegister` (`SYS // POSITION`), `lostHeadline`, `lostBody`, `lostHint`. - No inline copy; every text node comes from `strings[locale]` — INCLUDING the two card-register labels. `"GEO // LOCATION"` and `"SYS // POSITION"` appear as string literals in `web/src/i18n/strings.ts` and NOWHERE else in `web/src/`; COPY5 (task 1) fails if either appears in a shell module. **`web/src/ui/recenter.ts`**: ```typescript export function createRecenterControl(host: HTMLElement, locale: Locale, onRecenter: () => void): { setMode(mode: "following" | "free"): void; setFixState(state: "fresh" | "lost"): void; setLocale(locale: Locale): void; } { /* ... */ } ``` - A `