Files
2026-10-23 22:37:55 +08:00

784 lines
59 KiB
Markdown

---
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 <html> 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"
---
<objective>
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`.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@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
<design_tokens>
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`.
</design_tokens>
<copy_table>
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.
</copy_table>
</context>
<tasks>
<task type="auto">
<name>task 1: `i18n/strings.ts` copy module (UI-SPEC table verbatim, en + ms) + copy gate</name>
<files>web/src/i18n/strings.ts, web/test/copy-gate.test.mjs</files>
<read_first>
- `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)
</read_first>
<action>
**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<Locale, ShellStrings>`, `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=<url-encoded JSON>&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`.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w web 2>&1 | grep -E 'COPY|runner' ; npm run typecheck -w web</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>Every user-facing string is centralized, matches the approved UI-SPEC table verbatim in both locales, and a copy gate pins it.</done>
</task>
<task type="auto">
<name>task 2: `shell.css` + map (bootstrap / marker / follow)</name>
<files>web/src/shell.css, web/src/map/map.ts, web/src/map/marker.ts, web/src/map/follow.ts</files>
<read_first>
- `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"
</read_first>
<action>
**`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 <svg> with no width/height renders at 300x150 (or collapses) and a
// <circle> 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 <svg>, never
// of the HTML <div> — an SVG element directly under a <div> 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;
},
};
}
```
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run build -w web 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>task 3: status strip, state cards, recenter control, and the boot choreography (`main.ts`)</name>
<files>web/src/ui/strip.ts, web/src/ui/cards.ts, web/src/ui/recenter.ts, web/src/main.ts, web/test/session.test.mjs</files>
<read_first>
- `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"
</read_first>
<action>
**`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 `±<accuracyM> M` and `FIX <age> S AGO`; on lost it renders `LAST FIX <age> 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 `<button type="button" class="recenter" aria-label={strings[locale].ariaRecenter} data-follow="following" data-fix="fresh">` containing an inline SVG crosshair (2px uniform stroke, `stroke-linecap="square"`, orthogonal geometry — NOT Material Symbols, self-host/no-CDN per UI-SPEC) plus an 8x8 `<span class="notch">`.
- `setMode` toggles `data-follow`; `setFixState` toggles `data-fix` between `fresh` and `lost`. This carries **all three** UI-SPEC notch states: `following` + fresh → orange notch (locked-on); `free` → notch removed; `following` + `lost` → soft-beige notch (unlocked, NOT red — red belongs to the strip/degraded registers).
- Press feedback comes from `.recenter:active` in `shell.css` — instant, no transition.
- The click handler calls `onRecenter()`; there is NO inline `onclick` attribute (D-01 productionizes the mockups' inline handlers away).
**`web/src/main.ts`** — the composition root. Header comment:
```typescript
/*
* main.ts — boot choreography (D-08, D-09, D-10).
* ORDER IS LOAD-BEARING: the basemap mounts and starts rendering tiles
* FIRST; the location chain starts SECOND. The reverse order produces the
* blank map D-09 forbids.
*
* 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.
*/
```
Imports:
```typescript
import "./shell.css";
import { bootMap } from "./map/map.ts";
import { createPositionMarker } from "./map/marker.ts";
import { createFollowController } from "./map/follow.ts";
import { createStatusStrip } from "./ui/strip.ts";
import { buildBootCard, buildDeniedCard, buildSignalLostCard, startGantry, stopGantry } from "./ui/cards.ts";
import { createRecenterControl } from "./ui/recenter.ts";
import { getWebApp } from "./telegram/webapp.ts";
import { watchTheme } from "./telegram/theme.ts";
import { wireBackButton } from "./telegram/backbutton.ts";
import { resolveSafeInsets, applySafeArea } from "./telegram/safe-area.ts";
import { createSession } from "./session/client.ts";
import { getSession, isSessionStale } from "./session/store.ts";
import { acquireFix, isFixStale, fixAgeSeconds } from "./location/chain.ts";
import type { Fix } from "./location/types.ts";
import { pickLocale, type Locale } from "./i18n/strings.ts";
import { installMockWebApp } from "./mock/telegram-mock.ts";
```
Boot sequence, in this exact order:
1. `applySafeArea(resolveSafeInsets(getWebApp()))` — safe-area vars are set before any layout reads them.
2. `const wa = getWebApp(); if (wa) { watchTheme(wa); wireBackButton(wa, () => overlayPresent); }` — the BackButton hides at init (single shell state). `overlayPresent` is the single-shell predicate declared at the top of step 3 (all of steps 1-3 are sequential statements of one boot function, so step 3's declaration must be emitted before this closure is invoked).
3. `const overlayPresent = (): boolean => false; // Phase 3: single shell state (UI-SPEC BackButton contract)`, then `const map = bootMap(document.getElementById("map")!); if (!map) document.body.dataset.map = "absent";` — `overlayPresent` is declared **before** step 2's `wireBackButton(wa, () => overlayPresent)` runs, in the same top-level block (all of steps 1-3 are sequential statements of one boot function, so a later `const` is still in scope for the earlier closure only if it is evaluated first — declare it here, ahead of its first use).
4. Create the marker, the `strip`, the card host, and the recenter control; mount them. Default state is validating with the stage-1 boot card; `startGantry` on the gantry element.
```typescript
const marker = createPositionMarker(); // created here, attached in step 8
```
**The marker MUST be attached to the map.** `createPositionMarker()` only builds detached DOM — until `marker.marker.addTo(map)` runs, MapLibre has not parented the group into the map canvas and nothing is visible on screen. Step 8 performs that attachment inside the null guard; a `Marker` that is constructed and never `addTo`-ed is an invisible marker, which is why the acceptance criteria grep for `.addTo(map)`.
5. **Mock install is a fallback, never an override.**
```typescript
if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) installMockWebApp();
```
Both halves of that guard are load-bearing:
- `!window.__ARAHARAH_MOCK__` — plan 03-04's runner page installs a *profile-configured* mock (`?mock=tdesktop&scheme=dark`) BEFORE this module runs. An unconditional install would clobber that profile with a default android/light fake, so the Desktop capture would silently be an Android light capture and the exit-gate artifact would prove nothing. In a plain `vite dev` session nothing sets the global, so the default install still fires.
- `__ARAHARAH_HARNESS__` — a build-time constant that plan 03-04's dual-build config defines only for `vite build --mode harness`. Without it the capture harness could not inject a mock into its own served bundle, because `import.meta.env.DEV` is statically false in any production-mode build. Declare it once in `web/src/vite-env.d.ts` as `declare const __ARAHARAH_HARNESS__: boolean;` (created in plan 03-04 task 2) — until that file exists, write the ambient declaration in `main.ts` and move it later.
Type `window.__ARAHARAH_MOCK__` via a cast (`(window as unknown as { __ARAHARAH_MOCK__?: unknown }).__ARAHARAH_MOCK__`) — it is set by the runner page, never in storage (D-07).
6. `const session = await createSession(wa?.initData ?? "")` — on failure render the denied card and stop. Never a blank map.
7. `render({ kind: "acquiring", since: Date.now() })`, swap to the stage-2 boot card, then `const { fix, state } = await acquireFix({ wa, getToken: getSession }, lastFix)` — pass `wa` as-is (it may be `null` off-Telegram; `acquireFix`/`telegramLocationSupported` accept null, plan 03-02 task 1).
8. **Guard every map-consuming call.** `bootMap` returns `null` when WebGL2 is absent, so `createFollowController(map, …)` (which calls `map.on(...)`), `marker.update(map, fix)` (which calls `map.getCanvas()` / `getZoom()`), and `follow.follow(fix)` (which calls `map.flyTo`) would all throw an NPE and take down the boot choreography — contradicting Open Question 1's "no blank screen, no crash". Wrap each in the null check:
```typescript
if (map !== null) {
follow = createFollowController(map, (m) => recenter.setMode(m));
marker.marker.addTo(map); // FIRST attachment — map is the marker's only parent
}
// …on live, and in every re-run of the chain:
if (map !== null) {
marker.update(map, fix);
follow?.follow(fix);
}
```
Declare `let follow: ReturnType<typeof createFollowController> | null = null;` alongside the map. **The `addTo(map)` call is INSIDE the guard, before the first `marker.update(map, fix)`** — guard-aware, so no attachment is attempted when WebGL2 is absent (which would throw), and ordered before `update` so the first positioned update paints onto a marker the map already owns. **The strip still reaches LIVE and the boot/denied cards still render** — the shell is chrome over `--color-surface-dim`, the map is simply absent (Pitfall 5 / Open Question 1). Steps 4-7 are already map-independent; step 9's `marker.setStale(true)` is a DOM-only call on the marker group and needs no guard, but `follow?.follow(fix)` inside the re-acquire must stay guarded.
9. On live: `if (map !== null) { marker.update(map, fix); follow?.follow(fix); }`; strip renders live; `stopGantry`; remove the boot card; `console.log("ARAHARAH-LIVE fix=" + fix.source + " acc=" + String(fix.accuracyM))`. The `ARAHARAH-LIVE` signal is emitted **regardless** of whether a map exists — otherwise a WebGL2-absent device would hang the capture race and read as a failed boot.
10. A `setInterval(tick, 1000)` started ONLY after live updates strip telemetry each second and, when `isFixStale(fix, Date.now())` first crosses 45s, calls `marker.setStale(true)`, `recenter.setFixState("lost")`, renders the signal-lost card, and re-runs `acquireFix`. On each fresh accepted fix, call `recenter.setFixState("fresh")`. This re-runs the chain rather than polling continuously — Phase 6 owns the continuous cadence (D-05).
11. The denied card's ASK VIA CHAT button (D-06: this is the Desktop Telegram path, not a diminished
secondary action) calls `requestChatFallback(getSession()!)`, sets state `awaiting-pin`, and
re-runs the chain; TRY AGAIN re-runs the chain from link 1. If `requestChatFallback` throws (a
401 means the session expired), re-run `createSession(wa?.initData)` first, then retry once
(D-03 revalidation). The denied state is a persistent card, never a transient banner, and its
copy is identical on every client.
12. **Locale derivation — concrete rule.** The server returns only `{ token }` from
`POST /api/session`; the `language_code` in the session claim is NOT available client-side, so
it must be derived from `initData` locally. `localeFromInitData` is **already implemented and
exported by task 1** (see task 1's `i18n/strings.ts` spec — task 1 owns the module's complete
public surface, and its `COPY6` case already pins the helper's behavior across all five inputs).
Import it here and use it: `const locale = wa ? localeFromInitData(wa.initData) : "en";` followed
by `strip.setLocale(locale)` and re-rendering the cards with it. **Do NOT re-specify the helper's
body and do NOT re-add a COPY6 case here** — the implementation and its gate are task 1's; if
they are missing, task 1 was incomplete, fix it there rather than forking a second copy.
No `Fix` is written to any storage API; it lives in `main.ts`'s closure and is passed by argument to `marker.update` / `follow.follow` (D-07).
**`web/test/session.test.mjs`** — pure-function gates against `web/src/session/store.ts`:
```
SESS1 setSession/getSession round-trips and isSessionStale is false inside the 900s window
SESS2 isSessionStale is true at or beyond 900s, and true before any token is set
SESS3 clearSession nulls the token
SESS4 store.ts references no storage API (source-introspection, comments stripped)
```
**Verify** (do NOT blindly append) that BOTH `Planner amendment (…)` lines are present in `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md`, and append a line ONLY if it is missing. Re-running this task (or running task 2 first) must never duplicate an amendment in the binding design contract — check with `grep -c 'Planner amendment (RESEARCH Pitfall 4)'` and `grep -c 'Planner amendment (RESEARCH Open Question 1)'`, appending only where the count is 0.
1. Under section "Boot Choreography" — the WebGL2-absent sentence, exactly as specified in task 2. (Task 2 owns the append; this is a presence check, not a second append.)
2. Under section "Design System" → "Consumed component classes" (directly after that table) — the RESEARCH Pitfall 4 class inversion, exactly as specified in the `cards.ts` bullet above. **This callout is already present in the spec** — a prior revision applied it (with equivalent wording) immediately after the table. Verify presence; do not add a second copy.
Both amendments must be greppable by their exact `Planner amendment (…)` prefixes so the acceptance criteria can pin them.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25 ; npm run typecheck -w web ; npm run build -w web 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `npm test -w web` exits 0 with `[runner] N passed, 0 failed` (N >= 26: 17 from plan 03-02 plus 6 COPY plus 4 SESS, minus overlap)
- `npm run typecheck -w web` exits 0
- `npm run build -w web` exits 0
- `node -e "const s=require('fs').readFileSync('web/src/main.ts','utf8'); const m=s.indexOf('bootMap('), c=s.indexOf('acquireFix('); if(m===-1||c===-1) throw new Error('missing calls'); if(m>c) throw new Error('VIOLATION: chain starts before map mounts (D-09)'); console.log('boot order OK');"` exits 0
- `grep -v '^ \*\|^/\*\|^$' web/src/main.ts | grep -c '(__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__' | head -1` returns 1 — the mock install is a fallback, never an override of a profile-configured harness mock, and is reachable in the harness build (03-04) as well as `vite dev`
- `grep -v '^ \*\|^/\*\|^$' web/src/main.ts | grep -c 'localeFromInitData(wa.initData)' | head -1` returns 1 AND `grep -q 'pickLocale(wa?.initData ? undefined : undefined)' web/src/main.ts; test $? -ne 0` — must FAIL (no garbled no-op locale derivation)
- `grep -c 'setInterval' web/src/main.ts | head -1` returns >= 1 AND `grep -c 'isFixStale' web/src/main.ts | head -1` returns >= 1
- `grep -c 'transition\|animation' web/src/ui/cards.ts web/src/ui/strip.ts web/src/ui/recenter.ts | grep -v ':0' | wc -l` returns 0
- `grep -qE 'Validating session|Acquiring location|TRY AGAIN|ASK VIA CHAT|Location is switched off|Signal lost|GEO // LOCATION|SYS // POSITION' web/src/ui/cards.ts 2>/dev/null; test $? -ne 0` — must FAIL (no inline copy, register labels included)
- `grep -q 'onclick=' web/src/ui/cards.ts web/src/ui/strip.ts web/src/ui/recenter.ts 2>/dev/null; test $? -ne 0` — must FAIL (D-01: no inline handlers)
- `grep -q 'data-map="absent"' web/src/main.ts` exits 0
- **Null-map guard (WebGL2 absent, Open Question 1):** `node -e "const s=require('fs').readFileSync('web/src/main.ts','utf8'); const guards=(s.match(/if \(map !== null\)/g)||[]).length; if(guards<2) throw new Error('expected the follow-construction guard AND the live-path guard; found '+guards); if(!s.includes('follow?.follow')) throw new Error('follow.follow is unguarded'); if(!/follow = createFollowController\(map/.test(s)) throw new Error('follow controller creation not mapped'); console.log('null-map guards OK ('+guards+')')"` exits 0 — with `bootMap()` returning `null`, `createFollowController(map, …)`, `marker.update(map, …)`, and `follow.follow(…)` must all be skipped, or the boot choreography throws at step 8 and the WebGL2-absent device shows a blank page instead of the shell over `--color-surface-dim`
- `grep -v '^ \*\|^/\*\|^$' web/src/main.ts | grep -c 'ARAHARAH-LIVE' | head -1` returns >= 1 AND that emit is NOT nested inside the `if (map !== null)` block — the capture race must still resolve on a map-less device. Assert by reading the surrounding lines, not by a bare count.
- **Marker is wired to the map (the marker must actually be visible):** `grep -q '\.addTo(map)' web/src/main.ts` exits 0 AND `grep -q 'createPositionMarker()' web/src/main.ts` exits 0 — `createPositionMarker()` alone only builds detached DOM; without `.addTo(map)` the group is never parented into the map canvas and no marker renders.
- **Marker attachment is inside the null guard and precedes the first update:** `node -e "const s=require('fs').readFileSync('web/src/main.ts','utf8'); const g=s.indexOf('if (map !== null) {'); if(g===-1) throw new Error('no null-map guard'); const add=s.indexOf('.addTo(map)',g); if(add===-1) throw new Error('addTo(map) is not inside the guard'); const up=s.indexOf('marker.update(map, fix)',g); if(up===-1) throw new Error('no marker.update in the guarded block'); if(add>up) throw new Error('VIOLATION: addTo(map) must precede the first marker.update'); console.log('marker wiring OK');"` exits 0 — attaching after the first update, or outside the guard, means either an NPE on a WebGL2-absent device or an invisible marker.
- **Marker DOM structure (SVG namespace + explicit geometry + no stray body parent):** `node -e "const s=require('fs').readFileSync('web/src/map/marker.ts','utf8'); const svg=s.match(/<[a-zA-Z]*svg[\s\S]*?<\/svg>/)||s; if(!/svg\.append\(circle, cone, dot\)|svg\.appendChild\(circle\);[\s\S]*?svg\.appendChild\(cone\);[\s\S]*?svg\.appendChild\(dot\)/.test(s)) throw new Error('cone/dot are not children of the svg'); if(/group\.append\(cone|group\.appendChild\(cone|group\.append\(cone, dot/.test(s)) throw new Error('SVG nodes appended to the HTML div — they will not render'); if(/document\.body\.appendChild\(group\)/.test(s)) throw new Error('group is appended to document.body; MapLibre owns it once addTo(map) runs'); if(!/viewBox/.test(s)) throw new Error('svg has no viewBox'); if(!/width/.test(s)||!/height/.test(s)) throw new Error('svg has no explicit width/height'); const dots=(s.match(/setAttribute\(\"r\", \"10\"\)/g)||[]).length; if(dots<1) throw new Error('dot has no r'); if((s.match(/setAttribute\(\"cx\", \"0\"\)/g)||[]).length<2) throw new Error('dot/circle lack explicit cx/cy'); console.log('marker DOM OK');"` exits 0 — the dot and cone are SVG-namespace nodes under an `<svg>` with explicit `width`/`height`/`viewBox`, both circles have explicit `cx`/`cy`, and nothing parents the group to `document.body`. An SVG element directly under an HTML `<div>` is not rendered, a `<circle>` without `cx`/`cy` lands at (0,0), and an `<svg>` without `width`/`height` has no size — any one of the three makes the position marker invisible while the code still "looks correct".
- **Strip telemetry uses the canonical `±` glyph (UI-SPEC copy contract):** `grep -q '±' web/src/ui/strip.ts` exits 0 AND `grep -v '^ \*\|^/\*\|^$' web/src/ui/strip.ts | grep -c '+-<accuracyM> M' | head -1` returns 0 — `+-` is a one-glyph deviation from the UI-SPEC's canonical `±{accuracy} M`, and shipping it would fail the copy contract's "same format, units unchanged" clause.
- **Third notch state (UI-SPEC §Follow/Recenter Control):** `grep -q 'data-fix="lost"' web/src/shell.css` exits 0 AND `grep -q 'setFixState' web/src/ui/recenter.ts` exits 0 AND `grep -q 'recenter.setFixState("lost")' web/src/main.ts` exits 0 — the live-LOST soft-beige notch ships, not just following/free.
- `grep -q 'D-06' web/src/ui/cards.ts web/src/main.ts` exits at least once — the denied-state decision is cited in the code that implements it
- `node -e "const s=require('fs').readFileSync('web/src/ui/cards.ts','utf8'); const btn=s.match(/<button[^>]*class=\"([^\"]*)\"[^>]*>\s*(?:\{?strings(?:\[[^\]]*\]|\w+)?\.ctaAskViaChat|ASK VIA CHAT|ctaAskViaChat)/); if(!btn) throw new Error('no ASK VIA CHAT button element found'); if(!btn[1].includes('btn-primary')) throw new Error('ASK VIA CHAT is not .btn-primary: '+btn[1]); const retry=s.match(/<button[^>]*class=\"([^\"]*)\"[^>]*>\s*(?:\{?strings(?:\[[^\]]*\]|\w+)?\.ctaTryAgain|ctaTryAgain)/); if(!retry) throw new Error('no TRY AGAIN button element found'); if(!retry[1].includes('btn-secondary')) throw new Error('TRY AGAIN is not .btn-secondary: '+retry[1]); console.log('CTA classes OK');"` exits 0 — the assertion is ELEMENT-SPECIFIC (it matches the button whose label is `ctaAskViaChat`), because both classes appear in the same card and a file-level grep cannot distinguish them. If the markup builds the class string in a variable rather than a literal, assert on that variable's value instead and name it in the test's failure message.
- `grep -q 'Planner amendment (RESEARCH Pitfall 4)' .planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` exits 0 (the class-mapping inversion was recorded in the approved contract, not silently applied to the code)
- `grep -q 'Planner amendment (RESEARCH Open Question 1)' web/src/main.ts` exits 0
- `grep -q 'Planner amendment (RESEARCH Open Question 1)' .planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` exits 0 (the contract was amended, not silently extended)
</acceptance_criteria>
<done>The shell boots map-first through validating then acquiring then live with Rams furniture at every stage, the strip is the honest state register, and the copy gate plus storage gate both hold.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Telegram theme params to CSS custom properties | Client-supplied values crossing into rendering. |
| Map tile endpoint (OpenFreeMap) to renderer | External runtime network dependency, the only one, per D-14. |
| Session result / location fix to DOM | Server responses rendered without validation. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-13 | Tampering | Map style JSON replaced at runtime | mitigate | `ramsStyle` is a build-time JSON import inlined into `web/dist` (D-14). `map.setStyle(` appears nowhere in `web/src` — greppable, and asserted by the PX02 case 3 test. Tiles are the only runtime fetch. |
| T-03-14 | Tampering | Telegram theme hexes used as design values | mitigate | `resolveBranch` reduces `header_bg_color` to a luminance scalar; `shell.css` only re-points `--shell-bg` / `--shell-on-surface` to Rams tokens. Gate: `grep -v '^ \*\|^/\*\|^$' web/src/shell.css \| grep -cE '#[0-9a-fA-F]{3,6}'` must return 0. |
| T-03-15 | Information Disclosure | Telegram `themeParams` values retained | accept | Values are read, reduced to a branch, and discarded; nothing is stored. Residual risk is nil. |
| T-03-16 | Spoofing | Stale fix rendered as a fresh LIVE pin | mitigate | The marker dims to 40% via `data-stale="true"` and the strip switches to SIGNAL LOST with `LAST FIX Ns AGO` once `isFixStale` crosses 45s. The accuracy ring radius re-renders on every accepted fix, so a frozen coordinate shows as an unchanging ring. |
| T-03-17 | Denial of Service | MapLibre worker chunk fails to load | mitigate | `setWorkerUrl(workerUrl)` points at a Vite-emitted hashed asset under our control, no CDN. `map.on("error", ...)` surfaces tile and resource failures to the console for the capture gate; the shell chrome stays functional. |
| T-03-18 | Denial of Service | WebGL2 absent, unhandled `GPUInitializationError` | mitigate | `bootMap` catches `GPUInitializationError` and returns `null`; `main.ts` sets `data-map="absent"` so the shell renders over `--color-surface-dim`. **Every map-consuming call site is additionally null-guarded**: `createFollowController(map, …)`, `marker.update(map, fix)`, and `follow.follow(fix)` all sit behind `if (map !== null)` / `follow?.follow(...)`, because an unguarded `map.on()` / `getCanvas()` / `flyTo()` would throw an NPE at boot step 8 and convert the graceful fallback back into the blank screen D-09 forbids. `ARAHARAH-LIVE` is emitted OUTSIDE the guard so the capture race still resolves on a map-less device. Gate: the null-map acceptance script in task 3 (RESEARCH Pitfall 5 / Open Question 1). |
| T-03-19 | Elevation of Privilege | Fix held in a module-scope global readable by a later module | accept | The `Fix` lives in `main.ts`'s closure and is passed by argument to `marker.update` / `follow.follow`. Any widening is caught by `no-storage.test.mjs` plus the Phase-7 architecture audit. |
</threat_model>
<verification>
- `npm test -w web` green with at least 24 cases
- `npm run typecheck -w web` and `npm run build -w web` both clean
- `shell.css` has zero `transition:`, zero `animation:`, and zero literal hex outside the machined-border geometry
- Boot order verified by script: `bootMap(` appears before `acquireFix(` in `main.ts`
- Null-map guards verified by script: every `createFollowController` / `marker.update` / `follow.follow` call site sits inside `if (map !== null)` (or uses `follow?.`), and `ARAHARAH-LIVE` is emitted outside that guard
- UI-SPEC amended with BOTH planner amendments: the WebGL2-absent note (under "Boot Choreography") and the RESEARCH Pitfall 4 CTA class inversion (under "Design System" → "Consumed component classes")
</verification>
<success_criteria>
- D-08: single map-first screen, no route line, no search furniture, no layer toggles anywhere in the shell
- D-09: validating then acquiring then live, each Rams-styled over a live map, never blank
- D-10 / PX-02: `data-tg-theme` is the only thing a theme change touches; the map style is never re-set
- The copy gate holds the UI-SPEC table verbatim in en and ms across all 25 keys — including the two card-register labels (`deniedRegister` / `lostRegister`) — with no inline copy in any shell module
- Marker geometry, control geometry, and camera arithmetic match the approved UI-SPEC numbers
</success_criteria>
<output>
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-03-SUMMARY.md`
</output>