784 lines
59 KiB
Markdown
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> |