forked from c-fu/arah2
ambek .planning
This commit is contained in:
@@ -0,0 +1,784 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user