ambek .planning

This commit is contained in:
araharah-executor
2026-10-23 22:37:55 +08:00
parent e512104d5e
commit 275879a18b
54 changed files with 9921 additions and 0 deletions
@@ -0,0 +1,824 @@
---
phase: 03-mini-app-shell-geolocation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- web/package.json
- web/tsconfig.json
- web/vite.config.ts
- web/index.html
- web/test/runner.mjs
- web/test/harness.mjs
- bot/src/kv/location-pin.ts
- bot/src/bot/handlers/location-fallback.ts
- bot/src/bot/bot.ts
- bot/src/i18n/strings.ts
- bot/src/webhook.ts
- bot/test/location-pin.test.mjs
- bot/test/privacy.test.mjs
- package.json
autonomous: true
requirements: [GEO-02, GEO-03]
must_haves:
decisions_implemented:
- "D-01: TypeScript + Vite frontend, no UI framework, MapLibre GL JS v6 bundled — the workspace foundation Phases 4–6 build on"
- "D-02: app source in a `web/` npm workspace; theme consumed as a build-time dependency, not CDN"
truths:
- "`web/` exists as an npm workspace with Vite + TypeScript configured and `npm test -w web` runs"
- "A dev-signed initData string can be produced by a test helper (the keystone for every later web test)"
- "`GET /api/location` validates a Bearer session token and returns only that token's own chat pin, or 401"
- "`POST /api/location-fallback` validates the same token and asks the bot to send the location keyboard into THAT claim's chat, in the claim's language"
- "The bot sends a ReplyKeyboardMarkup with request_location:true and records the shared pin in TTL-only KV"
- "Every KV write in the new location-pin module carries an explicit expirationTtl on the same line"
artifacts:
- path: "web/package.json"
provides: "@araharah/web workspace manifest with test/typecheck/dev/build scripts"
- path: "web/tsconfig.json"
provides: "DOM-targeted strict TS config mirroring bot/tsconfig.json flags (+ resolveJsonModule for the basemap style import)"
- path: "web/test/runner.mjs"
provides: "hand-rolled zero-dependency test runner (copied from bot/test/runner.mjs)"
- path: "web/test/harness.mjs"
provides: "zero-dependency test registry (copied from bot/test/harness.mjs)"
- path: "bot/src/kv/location-pin.ts"
provides: "TTL-only KV location-pin layer (put/get/delete by chat id)"
- path: "bot/src/bot/handlers/location-fallback.ts"
provides: "GEO-02 keyboard sender + message.location receiver"
- path: "bot/src/webhook.ts"
provides: "GET /api/location relay + POST /api/location-fallback keyboard trigger, both Bearer-gated"
key_links:
- from: "bot/src/webhook.ts"
to: "bot/src/kv/sessions.ts"
via: "getClaimByToken(env.chat_sessions, token) — derives chat_id from the CLAIM, never from a client-supplied id"
pattern: "getClaimByToken"
- from: "bot/src/webhook.ts"
to: "bot/src/bot/handlers/location-fallback.ts"
via: "handleLocationFallback calls requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code)) — the ONLY caller, so the export is never dead code"
pattern: "requestLocationFallback"
- from: "bot/src/bot/handlers/location-fallback.ts"
to: "bot/src/kv/location-pin.ts"
via: "writePin(kv, chatId, {latitude, longitude}) after message.location arrives"
pattern: "writePin"
---
<objective>
Stand up the `web/` npm workspace shell (D-01, D-02) with the Wave-0 test runner, and build the
server half of GEO-02: the TTL-only location-pin KV module, the bot's chat-keyboard fallback handler,
and BOTH Worker routes that relay a chat-shared pin to the open Mini App — `GET /api/location`
(the poll) and `POST /api/location-fallback` (the app→bot keyboard trigger).
Purpose: GEO-02 is impossible without a chat→WebView relay — Telegram has no push channel from
chat to WebView, so the bot must write the pin to KV and the app must poll an authenticated endpoint
(RESEARCH §Pattern 3). The trigger direction matters just as much: the denied card's ASK VIA CHAT
CTA (plan 03-03) and the Desktop leg of the device matrix (plan 03-04) both need a route that makes
the bot send the keyboard. Both directions land here.
Output: `web/` workspace + test runner; `bot/src/kv/location-pin.ts`; `bot/src/bot/handlers/location-fallback.ts`;
`GET /api/location` + `POST /api/location-fallback` routes; `bot/test/location-pin.test.mjs`; extended `privacy.test.mjs`.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
**Commit discipline:** commit after EACH task (one commit per task, atomic). If task 3 overruns
its context budget, split it rather than pushing through — this plan touches 14 files across 3 tasks,
which is above the 10-file warning line and is only safe because the code is pre-specified near-verbatim.
A task that needs more than ~7 files should be decomposed, not absorbed.
</execution_context>
<context>
@AGENTS.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
@bot/src/webhook.ts
@bot/src/kv/sessions.ts
@bot/src/bot/bot.ts
@bot/src/bot/commands/start.ts
@bot/src/i18n/strings.ts
@bot/src/config.ts
@bot/test/privacy.test.mjs
@bot/test/commands.test.mjs
@bot/test/webhook.test.mjs
@bot/test/runner.mjs
@bot/test/harness.mjs
@bot/package.json
@bot/tsconfig.json
@package.json
@theme/package.json
<interfaces>
From `bot/src/kv/sessions.ts` (the contract `location-pin.ts` mirrors):
```typescript
export const SESSION_TTL_SECONDS = 900;
export interface SessionClaim {
user_id: number;
chat_id: number;
issued_at: number; // seconds-since-epoch
language_code: string | null;
}
interface KvLike {
get(key: string): Promise<string | null>;
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
delete(key: string): Promise<void>;
}
export async function mintSession(kv: MintSessionKv, claim: SessionClaim): Promise<string>;
export async function getClaimByToken(kv: MintSessionKv, token: string): PromiseSessionClaim | null;
export async function getTokenForChat(kv: MintSessionKv, chatId: number): Promise<string | null>;
```
From `bot/src/config.ts`:
```typescript
export interface Env {
BOT_TOKEN: string;
WEBHOOK_SECRET: string;
chat_sessions: KVNamespace;
}
```
From `bot/src/webhook.ts` — the dispatch shape to extend:
```typescript
export default {
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
if (url.pathname === "/telegram/webhook") { /* ... */ }
if (url.pathname === "/api/session") { return handleSession(req, env); }
return new Response("Not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
```
From `bot/test/webhook.test.mjs` — the dev-signing helper shape (copy this, do NOT reimplement HMAC):
```javascript
function dataCheckString(fields) {
return fields
.filter(([key]) => key !== "hash" && key !== "signature")
.slice()
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([key, value]) => `${key}=${value}`)
.join("\n");
}
function telegramSecretKey(botToken) {
return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest();
}
function buildInitData(fields) {
const hash = createHmac("sha256", telegramSecretKey(TEST_TOKEN))
.update(dataCheckString(fields), "utf8")
.digest("hex");
const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
return `${wire}&hash=${hash}`;
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>task 1: `web/` workspace scaffold + Wave-0 test runner (D-01, D-02, D-11, D-14)</name>
<files>web/package.json, web/tsconfig.json, web/vite.config.ts, web/index.html, web/test/runner.mjs, web/test/harness.mjs, package.json</files>
<read_first>
- `package.json` (root — see current workspaces array and bot:* delegate script block)
- `bot/package.json` (the workspace manifest shape to mirror exactly)
- `bot/tsconfig.json` (the strictness flags to preserve)
- `bot/test/runner.mjs` and `bot/test/harness.mjs` (copy verbatim)
- `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` §Standard Stack (pinned versions)
</read_first>
<action>
**Step A — `web/package.json`.** Create `web/package.json` mirroring `bot/package.json` exactly in shape:
```json
{
"name": "@araharah/web",
"private": true,
"type": "module",
"scripts": {
"test": "node test/runner.mjs",
"typecheck": "tsc --noEmit",
"dev": "vite",
"build": "vite build",
"capture": "node scripts/capture.mjs"
},
"dependencies": {
"maplibre-gl": "^6.15.0",
"@araharah/theme": "*"
},
"devDependencies": {
"vite": "^8.3.5",
"typescript": "^5.9.3",
"playwright": "^1.50.0"
}
}
```
Pinned versions come from RESEARCH §Standard Stack (maplibre-gl 6.15.0, vite 8.3.5, typescript 5.9.3 — do NOT bump to 7.x, playwright ^1.50.0).
**Do NOT add `@types/telegram-web-app`.** RESEARCH §Don't Hand-Roll recommends it for the *full* `WebApp` surface, but this phase consumes exactly 12 members behind one narrow hand-rolled interface (`TelegramWebAppLike` in `web/src/telegram/webapp.ts`, plan 03-02 task 3). A narrow interface is required here because every test fake and every harness mock is a plain object literal matching it — pulling in the 500-member official `WebApp` type would force each fake to satisfy ~50 unimplemented members. This retires RESEARCH assumption A2 by construction: there is no upstream typing to drift from. Do not add the dependency later "for completeness."
**Step B — `web/tsconfig.json`.** Copy `bot/tsconfig.json` verbatim and change exactly THREE keys (everything else stays byte-identical):
- `"lib": ["ES2023", "DOM", "DOM.Iterable"]` (was `["ES2023"]`)
- `"types": ["vite/client"]` (was `["@cloudflare/workers-types"]`)
- **`"resolveJsonModule": true` (ADDED — not present in `bot/tsconfig.json`)**. This third key is pre-authorized and REQUIRED: plan 03-03 task 2 imports `@araharah/theme/basemap/liberty-rams.json` as a module (`import ramsStyle from "@araharah/theme/basemap/liberty-rams.json"`), which is a TypeScript error without it. Do NOT add `esModuleInterop`/`allowSyntheticDefaultImports` — Vite's bundler resolution + `resolveJsonModule` covers the default import.
Keep every other flag byte-identical: `"target": "ES2023"`, `"module": "ESNext"`, `"moduleResolution": "bundler"`, `"strict": true`, `"noUncheckedIndexedAccess": true`, `"verbatimModuleSyntax": true`, `"allowImportingTsExtensions": true`, `"noEmit": true`, `"skipLibCheck": true`, `"forceConsistentCasingInFileNames": true`, `"include": ["src"]`. Do NOT add `"jsx"` (no framework, D-01).
**Step C — `web/vite.config.ts`.**
```typescript
import { defineConfig } from "vite";
export default defineConfig({
base: "/",
build: {
outDir: "dist",
emptyOutDir: true,
target: "es2022",
},
server: {
port: 5173,
strictPort: true,
},
});
```
**Step D — `web/index.html`.** Minimal shell entry. Exact viewport meta (viewport-fit=cover is the safe-area enabler, UI-SPEC §Safe-Area Handling):
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>araharah</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
```
Do NOT add a `<script src=".../telegram-web-app.js">` tag — Telegram injects `window.Telegram.WebApp` automatically inside the WebView (RESEARCH §State of the Art). Do NOT copy the mockups' Tailwind Play CDN or Google Fonts links — D-02/D-14 replace them with build-time `@araharah/theme` imports (that wiring lands in plan 03).
**Step E — `web/test/runner.mjs` and `web/test/harness.mjs`.** Copy `bot/test/runner.mjs` and `bot/test/harness.mjs` **verbatim** — they are directory-relative and workspace-agnostic. Update only the header comment block of `runner.mjs` to name the precedent chain: "Same hand-rolled shape as theme/scripts/audit.test.mjs (Phase 1) and bot/test/ (Phase 2)."
**Step F — root `package.json`.** Add `"web"` to the `workspaces` array (after `"bot"`), and add these delegate scripts mirroring the existing `bot:*` block (D-02 ergonomics):
```json
"web:test": "npm test -w web",
"web:typecheck": "npm run typecheck -w web",
"web:dev": "npm run dev -w web",
"web:build": "npm run build -w web",
"web:capture": "npm run capture -w web"
```
Add the file header comment line to each new web/src file in later tasks: "`filename.ts` — one-line purpose (decision IDs)." then a dashed rule, then the contract in prose (PATTERNS §Shared Pattern 1).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm install 2>&1 | tail -5 && node -e "const p=require('./package.json'); if(!p.workspaces.includes('web')) throw new Error('web workspace missing'); ['web:test','web:typecheck','web:dev','web:build','web:capture'].forEach(k=>{if(!p.scripts[k]) throw new Error('missing script '+k)}); console.log('root OK')" && node web/test/runner.mjs</automated>
</verify>
<acceptance_criteria>
- `node -e "require('./package.json').workspaces.includes('web')"` exits 0
- `grep -c '"web:' package.json` returns 5 (test, typecheck, dev, build, capture)
- `diff <(tail -n +11 bot/test/runner.mjs) <(tail -n +11 web/test/runner.mjs)` produces no output (bodies identical past the header)
- `diff bot/test/harness.mjs web/test/harness.mjs` produces no output (verbatim copy)
- `grep -c 'viewport-fit=cover' web/index.html` returns 1
- `grep -q 'telegram-web-app.js' web/index.html` must FAIL (no script tag for the Telegram SDK)
- `grep -q '"noUncheckedIndexedAccess": true' web/tsconfig.json` exits 0
- `grep -q '"resolveJsonModule": true' web/tsconfig.json` exits 0 (pre-authorized third key — required by 03-03's style-JSON import)
- `node -e "const t=require('./web/tsconfig.json'); const keys=Object.keys(t.compilerOptions); console.log(keys.length)"` — the file has exactly the three keys changed above plus the inherited set; nothing else was touched
- `npm test -w web` exits 0 with `[runner] 0 passed, 0 failed` (empty suite is a pass)
</acceptance_criteria>
<done>web/ workspace is installed, typecheckable, testable, and wired into root delegates; test runner matches repo convention verbatim.</done>
</task>
<task type="auto">
<name>task 2: TTL-only location-pin KV module + privacy gate extension (GEO-03, PRIV-02)</name>
<files>bot/src/kv/location-pin.ts, bot/test/privacy.test.mjs</files>
<read_first>
- `bot/src/kv/sessions.ts` (the entire contract shape to mirror — KvLike, put-line discipline, defensive read)
- `bot/test/privacy.test.mjs` (the PG1/PG2 gate and the storageModules list at line 25)
</read_first>
<action>
**Create `bot/src/kv/location-pin.ts`.** Copy `bot/src/kv/sessions.ts`'s header-comment style, `KvLike` interface, `KvLike` structural typing, named-TTL-constant discipline, and defensive read pattern verbatim. Concrete content:
Header comment (purpose + D-IDs + the storage contract prose — PATTERNS §Shared Pattern 1):
```
/*
* location-pin.ts — TTL-only KV location-pin layer (GEO-02, GEO-03, PRIV-02).
* ----------------------------------------------------------------------
* Storage contract (privacy by construction — GEO-03, PRIV-02):
* - EVERY write to `chat_sessions` carries an explicit `expirationTtl`
* on its call line — no write path in this module can persist
* without a TTL. privacy.test.mjs's PG1 gate pins this.
* - The record is exactly Telegram's Location fields: latitude,
* longitude, horizontal_accuracy (optional). Nothing else is ever
* serialized. A 4th property means drift.
* - The pin is keyed by chat_id, derived from the SESSION CLAIM
* (never from a client-supplied id) — a token resolves to exactly
* one chat_id, and the KV key is `loc:<chat_id>`.
*
* Key scheme:
* loc:<chat_id> → JSON LocationPin record
*
* GEO-03 note: this pin lives only in KV with a TTL. The client never
* persists a coordinate anywhere (D-07) — this is the ONLY place a
* coordinate ever exists server-side, and it expires.
*/
```
Exported constants and types:
```typescript
export const PIN_TTL_SECONDS = 300;
export interface LocationPin {
latitude: number;
longitude: number;
horizontal_accuracy: number | null;
}
interface KvLike {
get(key: string): Promise<string | null>;
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
delete(key: string): Promise<void>;
}
export interface LocationPinKv extends KvLike {}
```
Functions (mirroring `sessions.ts` shapes):
```typescript
export async function writePin(kv: LocationPinKv, chatId: number, pin: LocationPin): Promise<void> {
await kv.put(`loc:${chatId}`, JSON.stringify(pin), { expirationTtl: PIN_TTL_SECONDS });
}
export async function getPin(kv: LocationPinKv, chatId: number): Promise<LocationPin | null> {
const raw = await kv.get(`loc:${chatId}`);
if (raw === null) return null;
try {
const parsed: unknown = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
"latitude" in parsed &&
"longitude" in parsed &&
typeof (parsed as LocationPin).latitude === "number" &&
typeof (parsed as LocationPin).longitude === "number" &&
Math.abs((parsed as LocationPin).latitude) <= 90 &&
Math.abs((parsed as LocationPin).longitude) <= 180
) {
return parsed as LocationPin;
}
return null;
} catch {
return null;
}
}
export async function clearPin(kv: LocationPinKv, chatId: number): Promise<void> {
await kv.delete(`loc:${chatId}`);
}
```
The `Math.abs(...) <= 90 / <= 180` range check is the V5 Input Validation mitigation from RESEARCH §Security Domain — reject absurd coordinates rather than flying the camera to null island.
**Extend `bot/test/privacy.test.mjs`.** At line 25, change:
```javascript
const storageModules = ["kv/sessions.ts"].map((rel) => path.join(SRC, ...rel.split("/")));
```
to:
```javascript
const storageModules = ["kv/sessions.ts", "kv/location-pin.ts"].map((rel) => path.join(SRC, ...rel.split("/")));
```
That single edit brings the new module under the existing PG1 (same-line `expirationTtl`, named constant) and PG2 (no persistence-shaped exports) gates automatically.
**Test-fake note for the later test file:** the KV fake in `bot/test/webhook.test.mjs` already logs `expirationTtl` per put — reuse that shape verbatim in `bot/test/location-pin.test.mjs` (task 3).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -5 && grep -n 'kv/location-pin.ts' bot/test/privacy.test.mjs</automated>
</verify>
<acceptance_criteria>
- `grep -v '^ \*\|^/\*\|^$' bot/src/kv/location-pin.ts | grep -c '\.put('` returns 1 and that line contains `expirationTtl: PIN_TTL_SECONDS`
- `grep -n 'kv/location-pin.ts' bot/test/privacy.test.mjs` exits 0 (module is in the scanned list)
- `grep -q 'export const PIN_TTL_SECONDS = 300;' bot/src/kv/location-pin.ts` exits 0
- `npm test -w bot` exits 0 and reports `[runner] N passed, 0 failed` with N >= 15 (Phase 2's suite plus the extended gate)
- `node -e "const s=require('fs').readFileSync('bot/src/kv/location-pin.ts','utf8'); if(!/location\s*:\s*\`loc:/.test(s)) throw new Error('key scheme wrong'); if(!/Math\.abs/.test(s)) throw new Error('range check missing')" ` exits 0
</acceptance_criteria>
<done>location-pin.ts is TTL-only by construction, range-validated on read, and covered by the existing privacy gate.</done>
</task>
<task type="auto">
<name>task 3: GEO-02 bot half — chat keyboard fallback + `GET /api/location` route + tests</name>
<files>bot/src/bot/handlers/location-fallback.ts, bot/src/bot/bot.ts, bot/src/i18n/strings.ts, bot/src/webhook.ts, bot/test/location-pin.test.mjs</files>
<read_first>
- `bot/src/bot/bot.ts` (the registration pattern)
- `bot/src/bot/commands/start.ts` (handler + i18n import + register pattern)
- `bot/src/i18n/strings.ts` (BotStrings interface + en/ms key-set parity)
- `bot/src/webhook.ts` (dispatch pattern lines 39-63, method gate line 84, auth+service+response pattern lines 99-113)
- `bot/src/kv/sessions.ts` (`getClaimByToken`)
- `bot/src/kv/location-pin.ts` (written in task 2 — read your own output)
- `bot/test/commands.test.mjs` (CS4 keyboard gate — you must decide whether to extend it)
- `bot/test/webhook.test.mjs` (fake KV + makeEnv + handler import pattern)
</read_first>
<action>
**Step A — `bot/src/i18n/strings.ts`.** Add two keys to the `BotStrings` interface (after `helpMenu`), both with doc-comments:
```typescript
/** GEO-02: chat-keyboard fallback prompt — bot tells the user to share a pin. */
awaitingPin: string;
/** GEO-02: the reply-keyboard button label (request_location: true). */
shareLocationButton: string;
```
Add to `strings.en`:
```typescript
awaitingPin: "Send me a pin in this chat and I'll use it as your starting point.",
shareLocationButton: "Share my location",
```
Add to `strings.ms`:
```typescript
awaitingPin: "Hantar saya pin dalam chat ini dan saya akan gunakannya sebagai titik mula anda.",
shareLocationButton: "Kongsi lokasi saya",
```
CS1 (en/ms key-set parity) will verify these stay in step.
**Step B — `bot/src/bot/handlers/location-fallback.ts`** (create the directory). Two exported functions plus one private handler.
```typescript
/*
* location-fallback.ts — GEO-02 chat-keyboard location fallback.
* -------------------------------------------------------------------
* When in-app location fails (denied permission, or Desktop Telegram
* which has NO geolocation at all — Telegram-Mini-Apps issue #77), the
* bot sends a ReplyKeyboardMarkup with request_location: true. The
* user taps it, Telegram's native location picker opens, and the bot
* receives the shared pin as a normal Message with a `location` field.
*
* That pin is written to TTL-only KV (kv/location-pin.ts) keyed by
* chat_id. The open Mini App polls GET /api/location (webhook.ts) to
* pick it up — Telegram has NO push channel from chat to WebView, so
* the relay is server-mediated by necessity.
*
* D-02 structural note: this handler is the ONE deliberate exception
* to the Phase-2 "plain text only" command-surface rule. The GEO-02
* keyboard is required by the platform; it lives in handlers/ and is
* NOT part of commandSources in commands.test.mjs's CS4 gate (which
* scans only bot.ts + commands/*). See the CS4 decision recorded below.
*/
import type { Bot, Context } from "grammy";
import { pickLocale, strings } from "../../i18n/strings.ts";
import type { Locale } from "../../i18n/strings.ts";
import { writePin, type LocationPinKv } from "../../kv/location-pin.ts";
/** Send the awaiting-pin prompt + location keyboard (GEO-02). */
export async function requestLocationFallback(bot: Bot, chatId: number, locale: Locale): Promise<void> {
await bot.chat.sendMessage(chatId, strings[locale].awaitingPin, {
reply_markup: {
keyboard: [[{ text: strings[locale].shareLocationButton, request_location: true }]],
resize_keyboard: true,
one_time_keyboard: true,
},
});
}
/** Persist a user-shared pin to TTL-only KV (GEO-03, PRIV-02). */
export async function recordSharedPin(kv: LocationPinKv, chatId: number, loc: { latitude: number; longitude: number; horizontal_accuracy?: number }): Promise<void> {
await writePin(kv, chatId, {
latitude: loc.latitude,
longitude: loc.longitude,
horizontal_accuracy: loc.horizontal_accuracy ?? null,
});
}
/** Register the GEO-02 message handler. Filters on ctx.message?.location. */
export function registerLocationFallback(bot: Bot, kv: LocationPinKv): void {
bot.on("message", async (ctx: Context) => {
const loc = ctx.message?.location;
if (!loc) return;
const chatId = ctx.chat?.id;
if (chatId === undefined) return;
await recordSharedPin(kv, chatId, loc);
await ctx.reply("Got it — your starting point is set.", { link_preview_options: { is_disabled: true } });
});
}
```
**CS4 decision (explicit, per PATTERNS §bot-strings note):** the new handler lives in `bot/src/bot/handlers/`, which is NOT in `commandSources` (that array is `["bot.ts", "commands/start.ts", "commands/help.ts", "commands/privacy.ts"]`). CS4 therefore keeps passing untouched — do NOT add handlers/ to that array, and do NOT delete the gate. Record the rationale in the handler's header comment (done above).
**Step C — `bot/src/bot/bot.ts`.** Update the import block and `createBot` to thread KV through. Use this exact shape (the optional `opts.baseFetch` seam exists so `bot/test/location-pin.test.mjs` LP12/LP13 can observe the outgoing `sendMessage`):
```typescript
import { registerLocationFallback } from "./handlers/location-fallback.ts";
// ...
export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot {
const bot = new Bot(env.BOT_TOKEN, opts.baseFetch ? { client: { baseFetch: opts.baseFetch } } : {});
registerStart(bot); // /start + /mula (D-04)
registerHelp(bot); // /help
registerPrivacy(bot); // /privacy + /privasi (D-04)
registerLocationFallback(bot, env.chat_sessions); // GEO-02 chat-keyboard fallback
return bot;
}
```
Update the header comment to note the GEO-02 handler's keyboard is the one structural exception to the D-02 text-only rule, and that `requestLocationFallback` has exactly ONE caller — `handleLocationFallback` in `webhook.ts` (Step D). If a second caller appears later, that is a design change, not a refactor.
**Step D — `bot/src/webhook.ts`.** Add BOTH route branches and handlers. The second route is the app→bot keyboard trigger (GEO-02) — without it, `requestLocationFallback` in Step B is dead code with no caller, and the ASK VIA CHAT CTA in plan 03-03 and the Desktop leg in plan 03-04 have nothing to POST to.
Dispatch (insert after the `/api/session` branch, before the 404):
```typescript
if (url.pathname === "/api/location") {
return handleLocation(req, env);
}
if (url.pathname === "/api/location-fallback") {
return handleLocationFallback(req, env);
}
```
First handler — the relay (as previously specified):
```typescript
/** GEO-02 relay: the Mini App polls here with its memory-held Bearer token
* to pick up a pin the user shared in chat. V4 Access Control (RESEARCH
* §Security Domain): the bearer token is resolved via getClaimByToken; a
* missing/expired token is a 401. The KV key is derived from the CLAIM's
* chat_id — NEVER from a client-supplied chat id, so one user's token
* can never read another user's pin. */
async function handleLocation(req: Request, env: Env): Promise<Response> {
if (req.method !== "GET") return new Response("Method not allowed", { status: 405 });
const header = req.headers.get("authorization") ?? "";
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
if (!match || !match[1]) {
console.log("[location] rejected: unauthorized"); // category ONLY (D-14)
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
const token = match[1];
const claim = await getClaimByToken(env.chat_sessions, token);
if (claim === null) {
console.log("[location] rejected: unauthorized"); // category ONLY (D-14)
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
const pin = await getPin(env.chat_sessions, claim.chat_id);
if (pin === null) {
return new Response(JSON.stringify({ pin: null }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify({ pin }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
```
Second handler — the keyboard trigger:
```typescript
/** GEO-02 trigger: the Mini App's denied card taps ASK VIA CHAT and POSTs
* here; the Worker asks the BOT to send the reply keyboard in the user's
* own chat. Same V4 access control as handleLocation — the target chat_id
* comes from the resolved CLAIM, never from the request body, so one
* user's token can never push a keyboard into another user's chat.
*
* The locale comes from `claim.language_code`, which mintSession already
* recorded from the validated initData — the Worker does NOT re-derive it
* from client input, and no language is accepted from the request body. */
async function handleLocationFallback(req: Request, env: Env): Promise<Response> {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const header = req.headers.get("authorization") ?? "";
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
const token = match && match[1] ? match[1] : null;
const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token);
if (claim === null) {
console.log("[location-fallback] rejected: unauthorized"); // category ONLY (D-14)
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
try {
await requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code));
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
} catch {
// Telegram rejected the send (user blocked the bot, chat not found, rate
// limit). Category only — never the chat id, never the error text (D-14).
console.log("[location-fallback] failed: telegram-send");
return new Response(JSON.stringify({ error: "unavailable" }), {
status: 502,
headers: { "content-type": "application/json" },
});
}
}
```
Imports to add at the top of `bot/src/webhook.ts`: `getClaimByToken` joins the existing `sessions.ts` import; `getPin` comes from a new `import { getPin } from "./kv/location-pin.ts"`; `requestLocationFallback` and `createBot` come from the bot module (`createBot` from `./bot/bot.ts`, `requestLocationFallback` from `./bot/handlers/location-fallback.ts`); `pickLocale` joins the `i18n/strings.ts` import. **Check for a circular-import hazard:** `bot/src/bot/bot.ts` must not import from `webhook.ts` (it does not — `createBot` is the leaf dependency, so `webhook.ts` depends on it, not the reverse). Verify with `npm run typecheck -w bot`.
Update the file header comment's route list to document all four routes (`/telegram/webhook`, `/api/session`, `/api/location`, `/api/location-fallback`).
**Step E — `bot/test/location-pin.test.mjs`** (create). Copy `bot/test/runner.mjs`-style header, import `{ test }` from `./harness.mjs`, `strict as assert` from `node:assert`, and copy the `makeKV()` fake from `bot/test/webhook.test.mjs` verbatim. Cases:
```javascript
test("LP1 writePin writes exactly one TTL-carrying key loc:<chat_id>", async () => {
const kv = makeKV();
await writePin(kv, 777, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 12 });
assert.equal(kv.ttlLog.length, 1);
assert.equal(kv.ttlLog[0].key, "loc:777");
assert.strictEqual(kv.ttlLog[0].expirationTtl, 300);
assert.ok(!kv.ttlLog[0].key.includes("user"), "pin key is chat-scoped, not user-id scoped");
});
test("LP2 getPin round-trips a valid record and returns null for absent", async () => {
const kv = makeKV();
assert.equal(await getPin(kv, 777), null);
await writePin(kv, 777, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 12 });
const pin = await getPin(kv, 777);
assert.equal(pin.latitude, 3.1513);
assert.equal(pin.longitude, 101.6958);
assert.strictEqual(pin.horizontal_accuracy, 12);
});
test("LP3 getPin rejects out-of-range coordinates (null island guard)", async () => {
const kv = makeKV();
await kv.put("loc:1", JSON.stringify({ latitude: 999, longitude: 101, horizontal_accuracy: null }), { expirationTtl: 300 });
assert.equal(await getPin(kv, 1), null, "absurd latitude must be rejected on read");
await kv.put("loc:2", JSON.stringify({ latitude: 3, longitude: -999, horizontal_accuracy: null }), { expirationTtl: 300 });
assert.equal(await getPin(kv, 2), null, "absurd longitude must be rejected on read");
});
test("LP4 getPin returns null on malformed JSON (defensive read)", async () => {
const kv = makeKV();
await kv.put("loc:3", "{not json", { expirationTtl: 300 });
assert.equal(await getPin(kv, 3), null);
});
test("LP5 GET /api/location rejects a missing bearer token with 401", async () => {
const kv = makeKV();
const req = new Request("https://host/api/location", { method: "GET" });
const res = await handler(req, makeEnv(kv));
assert.strictEqual(res.status, 401);
const body = await res.json();
assert.strictEqual(body.error, "unauthorized");
});
test("LP6 GET /api/location rejects an unknown token with 401", async () => {
const kv = makeKV();
const req = new Request("https://host/api/location", { method: "GET", headers: { authorization: "Bearer deadbeef-1234" } });
const res = await handler(req, makeEnv(kv));
assert.strictEqual(res.status, 401);
});
test("LP7 GET /api/location returns {pin:null} for a valid token with no pin yet", async () => {
const kv = makeKV();
await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "ms" });
const token = await getTokenForChat(kv, 777);
const req = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token}` } });
const res = await handler(req, makeEnv(kv));
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.pin, null);
});
test("LP8 GET /api/location returns the pin for the token's OWN chat only", async () => {
const kv = makeKV();
await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "en" });
await mintSession(kv, { user_id: 99, chat_id: 888, issued_at: 1700000000, language_code: "en" });
const token777 = await getTokenForChat(kv, 777);
const token888 = await getTokenForChat(kv, 888);
await writePin(kv, 888, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 8 });
const reqA = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token777}` } });
const bodyA = await (await handler(reqA, makeEnv(kv))).json();
assert.strictEqual(bodyA.pin, null, "user 42's token must NOT read user 99's pin (V4 access control)");
const reqB = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token888}` } });
const bodyB = await (await handler(reqB, makeEnv(kv))).json();
assert.strictEqual(bodyB.pin.latitude, 3.1513);
});
test("LP9 GET /api/location with a non-GET method returns 405", async () => {
const res = await handler(new Request("https://host/api/location", { method: "POST" }), makeEnv(makeKV()));
assert.strictEqual(res.status, 405);
});
test("LP10 POST /api/location-fallback with a missing bearer token returns 401", async () => {
const res = await handler(new Request("https://host/api/location-fallback", { method: "POST" }), makeEnv(makeKV()));
assert.strictEqual(res.status, 401);
assert.strictEqual((await res.json()).error, "unauthorized");
});
test("LP11 POST /api/location-fallback with an unknown token returns 401", async () => {
const req = new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: "Bearer deadbeef-1234" } });
assert.strictEqual((await handler(req, makeEnv(makeKV()))).status, 401);
});
test("LP12 POST /api/location-fallback sends the location keyboard to the token's OWN chat", async () => {
const kv = makeKV();
await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "en" });
const token = await getTokenForChat(kv, 777);
const calls = [];
const realFetch = globalThis.fetch;
globalThis.fetch = async (_url, init) => {
calls.push(JSON.parse(String(init.body)));
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { headers: { "content-type": "application/json" } });
};
try {
const res = await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${token}` } }), makeEnv(kv));
assert.strictEqual(res.status, 200);
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0].chat_id, 777, "keyboard must go to the CLAIM's chat, never a client-supplied id (V4)");
assert.strictEqual(calls[0].reply_markup.keyboard[0][0].request_location, true);
} finally { globalThis.fetch = realFetch; }
});
test("LP13 POST /api/location-fallback honours claim.language_code", async () => {
const kv = makeKV();
await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "ms" });
await mintSession(kv, { user_id: 7, chat_id: 555, issued_at: 1700000000, language_code: "en" });
const calls = [];
const realFetch = globalThis.fetch;
globalThis.fetch = async (_url, init) => { calls.push(JSON.parse(String(init.body))); return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { headers: { "content-type": "application/json" } }); };
try {
const tokMs = await getTokenForChat(kv, 777);
await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${tokMs}` } }), makeEnv(kv));
const tokEn = await getTokenForChat(kv, 555);
await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${tokEn}` } }), makeEnv(kv));
} finally { globalThis.fetch = realFetch; }
assert.strictEqual(calls[0].text, strings.ms.awaitingPin, "ms claim must receive ms copy");
assert.strictEqual(calls[1].text, strings.en.awaitingPin, "en claim must receive en copy");
});
test("LP14 POST /api/location-fallback with a non-POST method returns 405", async () => {
assert.strictEqual((await handler(new Request("https://host/api/location-fallback", { method: "GET" }), makeEnv(makeKV()))).status, 405);
});
```
Imports needed at the top of the test file:
```javascript
const { writePin, getPin } = await import("../src/kv/location-pin.ts");
const { mintSession, getTokenForChat } = await import("../src/kv/sessions.ts");
const { strings } = await import("../src/i18n/strings.ts");
const handler = await import("../src/webhook.ts").then((m) => m.default.fetch);
```
Copy `makeEnv` from `bot/test/webhook.test.mjs` verbatim: `function makeEnv(kv) { return { BOT_TOKEN: TEST_TOKEN, WEBHOOK_SECRET: "wk-test-secret", chat_sessions: kv }; }` with `const TEST_TOKEN = "12345:TESTTOKEN";` at module top.
LP12/LP13 observe the outgoing Telegram call by stubbing `globalThis.fetch` (grammY's default client calls through it) for the duration of the case, returning `{ ok: true, result: { message_id: 1 } }`; restore it in a `finally`. </action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -20 && npm run typecheck -w bot</automated>
</verify>
<acceptance_criteria>
- `npm test -w bot` exits 0 and reports `14 passed` for the `LP` prefix (LP1–LP14)
- `npm run typecheck -w bot` exits 0
- `grep -v '^ \*\|^/\*\|^$' bot/src/bot/handlers/location-fallback.ts | grep -c 'request_location: true'` returns 1
- `grep -q 'registerLocationFallback(bot, env.chat_sessions)' bot/src/bot/bot.ts` exits 0
- `grep -q 'url.pathname === "/api/location"' bot/src/webhook.ts` exits 0
- `grep -q 'url.pathname === "/api/location-fallback"' bot/src/webhook.ts` exits 0 (the app→bot keyboard trigger exists — `requestLocationFallback` is NOT dead code)
- `grep -v '^ \*\|^/\*\|^$' bot/src/webhook.ts | grep -c 'getClaimByToken(env.chat_sessions' | head -1` returns 2 (both routes resolve chat_id from the CLAIM, never client input)
- `grep -v '^ \*\|^/\*\|^$' bot/src/webhook.ts | grep -c 'requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code))' | head -1` returns 1 (locale from the claim, never from the request body)
- `grep -v '^ \*\|^/\*\|^$' bot/src/bot/bot.ts | grep -c 'opts.baseFetch' | head -1` returns >= 1 (the test transport seam exists; production calls `createBot(env)` with one arg)
- `grep -q 'commandSources = \["bot.ts", "commands/start.ts", "commands/help.ts", "commands/privacy.ts"\]' bot/test/commands.test.mjs` exits 0 (CS4 scope unchanged — handlers/ not scanned)
- `grep -c 'awaitingPin' bot/src/i18n/strings.ts` returns 3 (interface + en + ms)
</acceptance_criteria>
<done>The bot can request a shared pin and record it in TTL-only KV, and BOTH server routes the Mini App needs exist and are proven: `GET /api/location` (claim-scoped relay) and `POST /api/location-fallback` (claim-scoped keyboard trigger with locale from the claim). 14 LP tests cover the TTL contract, range guard, cross-user isolation on both routes, and the en/ms locale split.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Telegram client → Worker webhook | Untrusted update payloads arrive; the secret-token compare is the gate (Phase 2). |
| Mini App (browser) → `GET /api/location` | Bearer session token in a header; the KV key is derived from the resolved claim. |
| Mini App (browser) → `POST /api/location-fallback` | Bearer session token in a header; the target chat_id and locale are derived from the resolved claim, never from the request body. |
| Bot update → KV pin write | A message's `location` payload becomes a stored coordinate; range validation gates it. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01 | Information Disclosure | `GET /api/location` — another user's pin read | mitigate | Resolve `Authorization: Bearer <token>` through `getClaimByToken(env.chat_sessions, token)`; derive the KV key `loc:<chat_id>` from the **claim**, never from a request parameter. Missing/expired token → 401 `{error:"unauthorized"}`. Pinned by test LP8 (user 42's token cannot read user 99's pin). |
| T-03-02 | Information Disclosure | Coordinate persisted beyond TTL | mitigate | Every `.put(` in `location-pin.ts` carries `expirationTtl: PIN_TTL_SECONDS` (300) on the same line; `privacy.test.mjs` PG1 gate re-scans the module automatically after the storageModules edit. No profile/history/trip store exists (PG2). |
| T-03-03 | Tampering | Malicious webhook update injecting a fabricated pin | mitigate | Only Telegram-authenticated updates reach grammY — the `X-Telegram-Bot-Api-Secret-Token` constant-time compare runs BEFORE any bot processing (Phase 2, WR-02). The handler filters on `ctx.message?.location` only. |
| T-03-04 | Tampering / DoS | Out-of-range coordinates in a shared pin | mitigate | `getPin` rejects `\|latitude\| > 90` or `\|longitude\| > 180` and non-numeric values on read (V5 Input Validation), so a corrupt record can never fly the client camera to null island. Test LP3. |
| T-03-05 | Information Disclosure | Category-only logging violated | mitigate | The two 401 paths log the literal string `"[location] rejected: unauthorized"` — never the token, the chat id, or any coordinate. |
| T-03-06 | Repudiation | A chat pin served long after it was shared | mitigate | 300s TTL bounds exposure; `clearPin` exists for an explicit sweep and the Mini App deletes its copy from memory as soon as it consumes the pin. |
| T-03-26 | Elevation of Privilege | One user's session token pushing a location keyboard into ANOTHER user's chat via `POST /api/location-fallback` | mitigate | The handler resolves the token through `getClaimByToken` and passes `claim.chat_id` to `requestLocationFallback`; the request body is never read, so there is no client-supplied chat id to tamper with. Missing/expired token → 401. Pinned by test LP10/LP11 (401) and LP12 (the send goes to the claim's chat, asserting the `sendMessage` body's `chat_id`). |
| T-03-27 | Spoofing | Client-supplied language overriding the claim's locale | mitigate | `pickLocale(claim.language_code)` reads only the server-recorded claim value that `mintSession` captured from the HMAC-validated initData. The handler accepts no `lang`/locale field from the request. Pinned by test LP13 (ms claim → ms copy, en claim → en copy). |
| T-03-28 | Denial of Service | Unauthenticated flood of the keyboard trigger | mitigate | The route is POST-only (405 otherwise) and every request that reaches `requestLocationFallback` has already resolved a live, unexpired session claim via `getClaimByToken`, so an anonymous flood cannot reach Telegram's API at all. A Telegram-side failure (blocked bot, chat not found, rate limit) is caught and returned as a category-only 502 — the error text is never surfaced to the client or the log. |
</threat_model>
<verification>
- `npm test -w bot` green (all Phase-2 cases plus the 14 new LP cases)
- `npm run typecheck -w bot` clean — in particular, no circular import between `webhook.ts` and `bot/bot.ts`
- `npm test -w web` green (empty suite passes — later plans add cases)
- `npm install` resolves the `web` workspace with no peer conflicts
</verification>
<success_criteria>
- GEO-02 server half complete in BOTH directions: the bot requests a pin via reply keyboard and records it TTL-only, the app can trigger the keyboard via `POST /api/location-fallback`, and it can poll the result via `GET /api/location` — both routes claim-scoped
- GEO-03 server half complete: the only coordinate store is TTL-expiring KV, covered by an automated same-line TTL gate
- The `web/` workspace exists with the repo-convention hand-rolled test runner, so every later plan has a place to put tests
</success_criteria>
<output>
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-01-SUMMARY.md`
</output>
@@ -0,0 +1,853 @@
---
phase: 03-mini-app-shell-geolocation
plan: 02
type: execute
wave: 2
depends_on: [03-01]
files_modified:
- web/src/location/types.ts
- web/src/location/telegram.ts
- web/src/location/webview.ts
- web/src/location/chat-pin.ts
- web/src/location/chain.ts
- web/src/session/client.ts
- web/src/session/store.ts
- web/src/telegram/webapp.ts
- web/src/telegram/theme.ts
- web/src/telegram/backbutton.ts
- web/src/telegram/safe-area.ts
- web/src/mock/telegram-mock.ts
- web/test/location-chain.test.mjs
- web/test/no-storage.test.mjs
- web/test/theme.test.mjs
autonomous: true
requirements: [GEO-01, GEO-02, GEO-03, PX-02]
must_haves:
decisions_implemented:
- "D-03: session token memory-only (module scope); revalidate on every fresh boot and on 900s TTL stall"
- "D-05: gated-streaming cadence — one-shot fixes in Phase 3, no hard-coded continuous loop (pluggable for Phase 6)"
- "D-07: coordinates live in memory only; zero persistence across every storage API (grep gate + runtime probe)"
- "D-06 / D-04 (chat-keyboard link): the denied card is deliberate — retry + chat 'send location' fallback, never a silent banner"
truths:
- "A Telegram LocationManager fix, a WebView fix, and a chat-shared pin all normalize to one `Fix` shape"
- "A client below Bot API 8.0 falls through to `navigator.geolocation` without throwing"
- "A missing `window.Telegram.WebApp` entirely (plain browser) falls through without a TypeError"
- "`getLocation()` returning `null` (the denial signal) advances to the next source — never an error state"
- "`getLocation()` that never calls back resolves `null` after the wrapper timeout, so boot never wedges in ACQUIRING"
- "Two byte-identical successive fixes are detected as STALE and route to the navigator nudge, not to success"
- "The chat-pin poll is bounded at 60s, then returns to the denied card — AWAITING PIN can never persist forever"
- "The session token lives in module scope only; zero storage API is referenced anywhere in web/src"
- "Telegram `colorScheme` (or `header_bg_color` luminance fallback) sets `data-tg-theme`, and `themeChanged` touches nothing else"
artifacts:
- path: "web/src/location/types.ts"
provides: "Fix type + Source enum + LocationState machine types"
- path: "web/src/location/telegram.ts"
provides: "LocationManager.init/getLocation wrapper with hard timeout + staleness detection"
- path: "web/src/location/webview.ts"
provides: "navigator.geolocation one-shot read (also the Android freshness nudge)"
- path: "web/src/location/chat-pin.ts"
provides: "bounded 60s poll of GET /api/location with Bearer token"
- path: "web/src/location/chain.ts"
provides: "the D-04 three-source state machine with freshness as a separate axis"
- path: "web/src/session/store.ts"
provides: "module-scope in-memory token holder + TTL staleness check"
- path: "web/src/telegram/webapp.ts"
provides: "typed window.Telegram.WebApp accessor + isVersionAtLeast gate"
- path: "web/src/telegram/theme.ts"
provides: "PX-02 colorScheme -> data-tg-theme switch (attribute set is its only statement)"
- path: "web/src/mock/telegram-mock.ts"
provides: "D-11 fake WebApp + dev-signed initData + sample location streams"
- path: "web/test/no-storage.test.mjs"
provides: "GEO-03 banned-token grep gate over web/src"
key_links:
- from: "web/src/location/chain.ts"
to: "web/src/location/telegram.ts"
via: "requestTelegramFix → stale check → nudgeAndRetryTelegram"
pattern: "looksStale|nudgeAndRetryTelegram"
- from: "web/src/location/chat-pin.ts"
to: "/api/location"
via: "fetch with Authorization: Bearer <module-scope token>"
pattern: "Authorization.*Bearer"
- from: "web/src/location/chat-pin.ts"
to: "/api/location-fallback"
via: "requestChatFallback POSTs with the same Bearer token — the pathname is a contract with plan 03-01's handleLocationFallback, not a guess"
pattern: "api/location-fallback"
- from: "web/src/telegram/theme.ts"
to: "document.documentElement"
via: "dataset.tgTheme = resolveBranch(wa) — the ONLY statement applyTheme runs"
pattern: "dataset\\.tgTheme"
---
<objective>
Build the client-side location acquisition core: three source adapters normalizing to one `Fix`
type, the D-04 chain state machine with freshness as a separate axis from availability, the
memory-only session client/store, the four Telegram platform wrapper modules, and the D-11
Mock-Telegram harness — with the automated gates for GEO-01, GEO-02 (client half), GEO-03, and PX-02.
Purpose: this is the phase's highest-risk work (RESEARCH Summary findings 1–3: no `getLiveData()`
exists, the Android stale-fix inverts the naive chain reading, Desktop has no geolocation at all).
Every one of those is proven here against injected fakes, so no real device is needed until the
exit gate.
**Task count note (revision):** this plan carries FOUR tasks rather than the usual 2–3. The split
is deliberate — the single alternative was one task owning 8 files (4 wrappers + the Web-Crypto
mock + 3 test files), which put the plan exactly on the 15-file scope threshold and concentrated a
tallest-risk chunk (the harness, which the capture gate in plan 03-04 depends on) into a single
unit of work. Tasks 1–2 are the location core; task 3 is the platform wrappers; task 4 is the
harness plus the four gates.
Output: `web/src/location/{types,telegram,webview,chat-pin,chain}.ts`; `web/src/session/{client,store}.ts`;
`web/src/telegram/{webapp,theme,backbutton,safe-area}.ts`; `web/src/mock/telegram-mock.ts`;
four test files.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
**Commit discipline:** commit after EACH task (one commit per task, atomic). If task 4 overruns
its context budget, split it rather than pushing through — this plan touches 15 files across 4 tasks,
which is above the 10-file warning line and is only safe because the code is pre-specified near-verbatim.
A task that needs more than ~7 files should be decomposed, not absorbed.
</execution_context>
<context>
@AGENTS.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
@bot/src/security/initdata.ts
@bot/src/webhook.ts
@bot/src/kv/sessions.ts
@bot/test/webhook.test.mjs
@theme/src/theme.css
@web/tsconfig.json
@web/package.json
<interfaces>
The Worker contract this plan consumes (`bot/src/webhook.ts`, built in plan 03-01):
```
POST /api/session
request body: the verbatim Telegram.WebApp.initData string (≤ 4096 chars)
200 → { "token": "<uuid>" }
401 → { "error": "malformed" | "tampered" | "stale" }
413 → { "error": "malformed" }
GET /api/location
header: Authorization: Bearer <token>
200 → { "pin": null } or { "pin": { latitude, longitude, horizontal_accuracy } }
401 → { "error": "unauthorized" }
405 → non-GET
POST /api/location-fallback
header: Authorization: Bearer <token> (no request body is read)
200 → { "ok": true }
401 → { "error": "unauthorized" }
405 → non-POST
502 → { "error": "unavailable" } (Telegram rejected the keyboard send)
```
Dev-signed initData helper (copy this shape from `bot/test/webhook.test.mjs` into
`web/src/mock/telegram-mock.ts`; do NOT reimplement the HMAC from scratch):
```javascript
function dataCheckString(fields) {
return fields
.filter(([key]) => key !== "hash" && key !== "signature")
.slice()
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([key, value]) => `${key}=${value}`)
.join("\n");
}
function telegramSecretKey(botToken) {
return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest();
}
function buildInitData(fields) {
const hash = createHmac("sha256", telegramSecretKey(botToken))
.update(dataCheckString(fields), "utf8")
.digest("hex");
const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
return `${wire}&hash=${hash}`;
}
```
The Telegram `Fix` source normalization (RESEARCH §Pattern 1 — copy verbatim):
- Telegram `LocationData` → `lat: loc.latitude`, `lng: loc.longitude`,
`accuracyM: loc.horizontal_accuracy ?? null`, `headingDeg: loc.course ?? null`, `ts: Date.now()`,
`source: 'telegram'`
- WebView `GeolocationPosition` → `accuracyM: pos.coords.accuracy`, `headingDeg: pos.coords.heading ?? null`
- Bot chat pin → `accuracyM: pin.horizontal_accuracy`, `headingDeg: null`, `source: 'chat-pin'`
</interfaces>
</context>
<tasks>
<task type="auto">
<name>task 1: `Fix` type + three source adapters (telegram / webview / chat-pin)</name>
<files>web/src/location/types.ts, web/src/location/telegram.ts, web/src/location/webview.ts, web/src/location/chat-pin.ts</files>
<read_first>
- `bot/src/security/initdata.ts` (the repo's minimal-typing + injectable-options + header-comment style)
- `bot/src/webhook.ts` (the `/api/location` contract this plan consumes)
- `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` §Pattern 1 and §Pattern 3 (the code shapes)
</read_first>
<action>
**`web/src/location/types.ts`** — the phase's shared contract. Header comment cites D-04/D-05/D-07.
```typescript
export type FixSource = "telegram" | "webview" | "chat-pin";
export type Fix = {
lat: number;
lng: number;
accuracyM: number | null;
headingDeg: number | null;
ts: number;
source: FixSource;
};
export type LocationState =
| { kind: "idle" }
| { kind: "validating" }
| { kind: "acquiring"; since: number }
| { kind: "awaiting-pin"; since: number }
| { kind: "live"; fix: Fix }
| { kind: "stale"; fix: Fix }
| { kind: "denied"; reason: "no-permission" | "unsupported" | "timeout" };
export const KL_CENTER: [number, number] = [101.6958, 3.1513];
```
Exported as a **type-only** module (no runtime storage, no closures over Fix instances at module scope).
**`web/src/location/telegram.ts`** — copy RESEARCH lines 268-317 as the starting point, then adapt:
```typescript
import type { Fix } from "./types.ts";
import type { TelegramWebAppLike } from "../telegram/webapp.ts";
/** NULL-SAFE by contract: `getWebApp()` returns `TelegramWebAppLike | null`, so
* this must accept null and answer false — Desktop Telegram and the plain
* browser both have no WebApp object at all (issue #77), and a TypeError here
* would crash the boot before the chain can fall through to the next link. */
export function telegramLocationSupported(wa: TelegramWebAppLike | null): boolean {
if (!wa) return false;
return typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0");
}
export async function requestTelegramFix(
wa: TelegramWebAppLike,
options: { timeoutMs?: number; nowMs?: () => number } = {},
): Promise<Fix | null> {
const timeoutMs = options.timeoutMs ?? 8000;
const lm = wa.LocationManager;
if (!lm) return null;
return new Promise((resolve) => {
let settled = false;
const finish = (data: unknown | null): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (!data) { resolve(null); return; }
const loc = data as { latitude: number; longitude: number; horizontal_accuracy?: number; course?: number };
resolve({
lat: loc.latitude,
lng: loc.longitude,
accuracyM: loc.horizontal_accuracy ?? null,
headingDeg: loc.course ?? null,
ts: (options.nowMs ?? Date.now)(),
source: "telegram",
});
};
const timer = setTimeout(() => finish(null), timeoutMs);
if (lm.isInited) lm.getLocation(finish);
else lm.init(() => lm.getLocation(finish));
});
}
export function looksStale(prev: Fix | null, next: Fix): boolean {
if (prev === null || prev.source !== "telegram" || next.source !== "telegram") return false;
return haversineMeters(prev, next) < 0.01;
}
export function haversineMeters(a: Fix, b: Fix): number {
const R = 6371008.8;
const toRad = (d: number): number => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
}
export async function nudgeAndRetryTelegram(
wa: TelegramWebAppLike,
readWebviewFix: (o?: { timeoutMs?: number }) => Promise<Fix | null>,
lastAccepted: Fix | null,
options: { timeoutMs?: number; nowMs?: () => number } = {},
): Promise<Fix | null> {
const first = await requestTelegramFix(wa, options);
if (first === null) return null;
if (!looksStale(lastAccepted, first)) return first;
await readWebviewFix({ timeoutMs: 5000 }); // the nudge — result DISCARDED (issue #56)
return requestTelegramFix(wa, options);
}
```
The nudge's return value is deliberately discarded — that is the entire point of the Android
workaround (a WebView read refreshes the native cache the NEXT telegram read then returns).
**`web/src/location/webview.ts`**:
```typescript
import type { Fix } from "./types.ts";
export function webviewLocationSupported(): boolean {
return typeof navigator !== "undefined" && typeof navigator.geolocation?.getCurrentPosition === "function";
}
export function readWebviewFix(options: { timeoutMs?: number; nowMs?: () => number } = {}): Promise<Fix | null> {
const timeoutMs = options.timeoutMs ?? 8000;
if (!webviewLocationSupported()) return Promise.resolve(null);
return new Promise((resolve) => {
let settled = false;
const done = (fix: Fix | null): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(fix);
};
const timer = setTimeout(() => done(null), timeoutMs);
navigator.geolocation.getCurrentPosition(
(pos) => done({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
accuracyM: pos.coords.accuracy ?? null,
headingDeg: pos.coords.heading ?? null,
ts: (options.nowMs ?? Date.now)(),
source: "webview",
}),
() => done(null),
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 },
);
});
}
```
`maximumAge: 0` is load-bearing: D-05 requires the freshest fix, and a cached position would
silently defeat the freshness axis.
**`web/src/location/chat-pin.ts`**:
```typescript
import type { Fix } from "./types.ts";
export const CHAT_PIN_POLL_MS = 2000;
export const CHAT_PIN_TIMEOUT_MS = 60000;
export async function fetchChatPin(token: string): Promise<Fix | null> {
const res = await fetch("/api/location", {
method: "GET",
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const body: unknown = await res.json();
const pin = (body as { pin?: { latitude?: number; longitude?: number; horizontal_accuracy?: number | null } }).pin;
if (!pin || typeof pin.latitude !== "number" || typeof pin.longitude !== "number") return null;
return {
lat: pin.latitude,
lng: pin.longitude,
accuracyM: pin.horizontal_accuracy ?? null,
headingDeg: null,
ts: Date.now(),
source: "chat-pin",
};
}
export async function pollChatPin(
token: string,
options: { timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise<void> } = {},
): Promise<Fix | null> {
const timeoutMs = options.timeoutMs ?? CHAT_PIN_TIMEOUT_MS;
const pollMs = options.pollMs ?? CHAT_PIN_POLL_MS;
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const fix = await fetchChatPin(token);
if (fix !== null) return fix;
await sleep(pollMs);
}
return null;
}
export async function requestChatFallback(token: string): Promise<void> {
// POST /api/location-fallback — implemented in plan 03-01 task 3 Step D as
// handleLocationFallback. The pathname is a CONTRACT with 03-01, not a
// guess; if 03-01 ever renames the route, both plans change together.
const res = await fetch("/api/location-fallback", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("location-fallback-request-failed");
}
```
The route is gated identically to the poll: a 401 means the session expired and the caller must revalidate (D-03), not retry blindly. `requestChatFallback` throws so `main.ts`'s ASK VIA CHAT handler can fall back to re-running `createSession` rather than leaving the user on a card whose button appears to do nothing.
Note in this file's header comment that the bounded poll (60s) is what keeps `AWAITING PIN` from wedging forever (RESEARCH §Pattern 3). </action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run typecheck -w web 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `npm run typecheck -w web` exits 0
- `grep -v '^ \*\|^/\*\|^$' web/src/location/telegram.ts | grep -c 'setTimeout' | head -1` — at least 1 occurrence (the hard timeout that prevents Pitfall 3)
- `grep -q 'maximumAge: 0' web/src/location/webview.ts` exits 0
- `grep -q 'CHAT_PIN_TIMEOUT_MS = 60000' web/src/location/chat-pin.ts` exits 0
- `grep -q 'source: "chat-pin"' web/src/location/chat-pin.ts` exits 0
- `grep -v '^ \*\|^/\*\|^$' web/src/location/telegram.ts | grep -c 'await readWebviewFix' | head -1` returns 1 (nudge result discarded — not returned)
- `grep -q 'haversineMeters(prev, next) < 0.01' web/src/location/telegram.ts` exits 0
- `grep -q 'export function telegramLocationSupported(wa: TelegramWebAppLike | null)' web/src/location/telegram.ts` exits 0 AND `grep -v '^ \*\|^/\*\|^$' web/src/location/telegram.ts | grep -c 'if (!wa) return false;' | head -1` returns 1 — a null WebApp (plain browser) must not TypeError
</acceptance_criteria>
<done>Three source adapters exist, each returning the same `Fix` shape, each with a hard timeout, the telegram adapter is null-safe on a missing WebApp, and it carries the staleness detector plus the Android nudge.</done>
</task>
<task type="auto">
<name>task 2: `chain.ts` state machine + `session/` memory-only store + `/api/session` client</name>
<files>web/src/location/chain.ts, web/src/session/store.ts, web/src/session/client.ts</files>
<read_first>
- `web/src/location/types.ts`, `web/src/location/telegram.ts`, `web/src/location/webview.ts`, `web/src/location/chat-pin.ts` (your own task-1 output — match their exported signatures exactly)
- `bot/src/webhook.ts` (the `/api/session` request/response contract)
- `bot/src/kv/sessions.ts` (the 900s TTL constant you must mirror by name, not as a bare literal)
</read_first>
<action>
**`web/src/location/chain.ts`** — the D-04 state machine. Freshness is a separate axis from availability.
```typescript
import type { Fix, LocationState } from "./types.ts";
import { nudgeAndRetryTelegram, telegramLocationSupported } from "./telegram.ts";
import { readWebviewFix, webviewLocationSupported } from "./webview.ts";
import { pollChatPin } from "./chat-pin.ts";
export interface ChainDeps {
wa: TelegramWebAppLike | null; // null off-Telegram (plain browser) — telegramLocationSupported handles it
getToken: () => string | null;
readWebview?: (o?: { timeoutMs?: number }) => Promise<Fix | null>;
pollPin?: (token: string, o?: { timeoutMs?: number }) => Promise<Fix | null>;
nowMs?: () => number;
telegramTimeoutMs?: number;
}
export async function acquireFix(deps: ChainDeps, lastAccepted: Fix | null): Promise<{ fix: Fix | null; state: LocationState }> {
const now = deps.nowMs ?? Date.now;
const readWebview = deps.readWebview ?? readWebviewFix;
const pollPin = deps.pollPin ?? pollChatPin;
// LINK 1 — Telegram-native (GEO-01 primary). Version-gated FIRST, and
// staleness is routed to the nudge, never to success (RESEARCH Pitfall 2).
if (telegramLocationSupported(deps.wa)) {
const fix = await nudgeAndRetryTelegram(deps.wa, readWebview, lastAccepted, {
timeoutMs: deps.telegramTimeoutMs ?? 8000,
nowMs: now,
});
if (fix !== null) return { fix, state: { kind: "live", fix } };
}
// LINK 2 — WebView navigator.geolocation.
if (webviewLocationSupported()) {
const fix = await readWebview({ timeoutMs: 8000 });
if (fix !== null) return { fix, state: { kind: "live", fix } };
}
// LINK 3 — chat-keyboard pin relay (GEO-02). The Desktop Telegram path:
// Desktop has NO geolocation at all (issue #77), so this is not a
// degraded backup there — it is the ONLY origin path.
const token = deps.getToken();
if (token !== null) {
const fix = await pollPin(token, { timeoutMs: 60000 });
if (fix !== null) return { fix, state: { kind: "live", fix } };
return { fix: null, state: { kind: "denied", reason: "timeout" } };
}
return { fix: null, state: { kind: "denied", reason: "no-permission" } };
}
export function isFixStale(fix: Fix, nowMs: number): boolean {
return nowMs - fix.ts > 45000;
}
export function fixAgeSeconds(fix: Fix, nowMs: number): number {
return Math.max(0, Math.round((nowMs - fix.ts) / 1000));
}
```
The 45000ms threshold is the UI-SPEC's 45s signal-lost trigger. `fixAgeSeconds` feeds the
`FIX Ns AGO` strip telemetry — the honesty surface for Pitfall 2. Header comment must record
that Phase 6 chooses between bounded `getLocation()` polling and `watchPosition()`; Phase 3
exposes a `Fix`-shaped abstraction with a **pluggable cadence** and deliberately hard-codes
no continuous-mode interval (RESEARCH Open Question 2).
**`web/src/session/store.ts`** — module-scope memory only. Header comment states what is and isn't stored, cites D-03/D-07, and names the gate (`no-storage.test.mjs`).
```typescript
export const SESSION_TTL_SECONDS = 900;
let token: string | null = null;
let issuedAtSeconds: number | null = null;
export function setSession(nextToken: string, issuedAtSec: number = Math.floor(Date.now() / 1000)): void {
token = nextToken;
issuedAtSeconds = issuedAtSec;
}
export function getSession(): string | null {
return token;
}
export function isSessionStale(nowSeconds: number = Math.floor(Date.now() / 1000)): boolean {
if (token === null || issuedAtSeconds === null) return true;
return nowSeconds - issuedAtSeconds >= SESSION_TTL_SECONDS;
}
export function clearSession(): void {
token = null;
issuedAtSeconds = null;
}
```
There is NO persistence call anywhere in this file — no localStorage, no sessionStorage, no
cookie, no IndexedDB. That absence is the D-07 invariant the grep gate pins.
**`web/src/session/client.ts`**:
```typescript
import { setSession } from "./store.ts";
export type SessionResult =
| { ok: true; token: string }
| { ok: false; reason: "malformed" | "tampered" | "stale" | "network" };
export async function createSession(initData: string): Promise<SessionResult> {
try {
const res = await fetch("/api/session", { method: "POST", body: initData });
if (res.status === 401) {
const body: unknown = await res.json().catch(() => ({ error: "malformed" }));
const reason = (body as { error?: string }).error;
return { ok: false, reason: reason === "tampered" || reason === "stale" ? reason : "malformed" };
}
if (!res.ok) return { ok: false, reason: "malformed" };
const body: unknown = await res.json();
const t = (body as { token?: unknown }).token;
if (typeof t !== "string" || t.length === 0) return { ok: false, reason: "malformed" };
setSession(t);
return { ok: true, token: t };
} catch {
return { ok: false, reason: "network" };
}
}
```
The request body is the **verbatim** `initData` string — never `initDataUnsafe` (RESEARCH §Security V2). No `credentials` option, no header carrying initData.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run typecheck -w web 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `npm run typecheck -w web` exits 0
- `grep -v '^ \*\|^/\*\|^$' web/src/location/chain.ts | grep -cE 'localStorage|sessionStorage|indexedDB|caches|document.cookie' | head -1` returns 0
- `grep -v '^ \*\|^/\*\|^$' web/src/session/store.ts | grep -cE 'localStorage|sessionStorage|indexedDB|caches|document.cookie' | head -1` returns 0
- `grep -q 'export const SESSION_TTL_SECONDS = 900;' web/src/session/store.ts` exits 0 (named constant, not a bare 900 literal at a call site)
- `grep -q 'initDataUnsafe' web/src/session/client.ts` must FAIL (verbatim initData only)
- `grep -q 'nowMs - fix.ts > 45000' web/src/location/chain.ts` exits 0 (the 45s signal-lost threshold)
- `grep -v '^ \*\|^/\*\|^$' web/src/location/chain.ts | grep -cE 'setInterval|watchPosition' | head -1` returns 0 (D-05 cadence stays pluggable — no hard-coded continuous loop in Phase 3)
</acceptance_criteria>
<done>The chain tries telegram → webview → chat-pin in D-04 order, routes staleness to the nudge, and the session token lives only in a module-scope variable.</done>
</task>
<task type="auto">
<name>task 3: Telegram platform wrappers (`webapp.ts`, `theme.ts`, `backbutton.ts`, `safe-area.ts`)</name>
<files>web/src/telegram/webapp.ts, web/src/telegram/theme.ts, web/src/telegram/backbutton.ts, web/src/telegram/safe-area.ts</files>
<read_first>
- `bot/src/security/initdata.ts` (minimal hand-rolled Telegram typing precedent — lines 29-51, 62-77)
- `web/src/location/telegram.ts` (the `TelegramWebAppLike | null` import you must honour — task 1 already depends on this interface)
- `theme/src/theme.css` lines 100-109 (`--shell-bg` / `--shell-on-surface` — the PX-02 switch target)
- `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` §BackButton Interaction Contract
</read_first>
<action>
**`web/src/telegram/webapp.ts`** — narrow typed accessor. Do NOT pull in the whole Telegram surface at every call site; wrap it in one file. Note (RESEARCH §Standard Stack): `@types/telegram-web-app` is deliberately NOT a dependency — plan 03-01 dropped it because this narrow interface is what every test fake and harness mock must satisfy, and the official 500-member `WebApp` type would force each fake to stub ~50 members. This hand-rolled `TelegramWebAppLike` IS the contract.
```typescript
export interface TelegramWebAppLike {
initData: string;
colorScheme?: "light" | "dark";
themeParams?: { header_bg_color?: string };
version?: string;
platform?: string;
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
isVersionAtLeast(version: string): boolean;
close(): void;
onEvent?(event: string, cb: () => void): void;
offEvent?(event: string, cb: () => void): void;
BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void };
LocationManager?: {
isInited: boolean;
isLocationAvailable?: boolean;
isAccessRequested?: boolean;
isAccessGranted?: boolean;
init(cb: () => void): void;
getLocation(cb: (data: unknown | null) => void): void;
openSettings?(): void;
};
}
export function getWebApp(): TelegramWebAppLike | null {
const w = window as unknown as { Telegram?: { WebApp?: TelegramWebAppLike } };
return w.Telegram?.WebApp ?? null;
}
```
**`web/src/telegram/theme.ts`** — copy RESEARCH §Code Examples "Theme slot switching" verbatim, including the luminance fallback:
```typescript
import type { TelegramWebAppLike } from "./webapp.ts";
type ThemeBranch = "light" | "dark";
export function resolveBranch(wa: TelegramWebAppLike): ThemeBranch {
if (wa.colorScheme === "dark" || wa.colorScheme === "light") return wa.colorScheme;
const hex = wa.themeParams?.header_bg_color;
if (typeof hex === "string" && /^#[0-9a-f]{6}$/i.test(hex)) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return lum >= 0.5 ? "light" : "dark";
}
return "light";
}
export function applyTheme(wa: TelegramWebAppLike): void {
// The ONLY statement a theme change is allowed to touch. MapLibre is
// deliberately absent from this function (D-10 / RESEARCH Pitfall 7).
document.documentElement.dataset.tgTheme = resolveBranch(wa);
}
export function watchTheme(wa: TelegramWebAppLike): void {
applyTheme(wa);
wa.onEvent?.("themeChanged", () => applyTheme(wa));
}
```
`resolveBranch` is exported (the luminance test needs it directly in `theme.test.mjs`).
**`web/src/telegram/backbutton.ts`** (D-10 / UI-SPEC §BackButton — total function, no dead-ends):
```typescript
import type { TelegramWebAppLike } from "./webapp.ts";
export function wireBackButton(wa: TelegramWebAppLike, hasOverlay: () => boolean): void {
wa.BackButton?.hide(); // Phase 3 has ONE shell state (the map) — nothing to pop out of
wa.BackButton?.onClick(() => {
if (hasOverlay()) return;
wa.close(); // Telegram semantics: back at the root closes the app
});
}
```
An empty / no-op handler is forbidden — the copy-gate test asserts this file contains `close()`.
**`web/src/telegram/safe-area.ts`** (version-gated, `env()` union with `WebApp.safeAreaInset`, per-edge max):
```typescript
import type { TelegramWebAppLike } from "./webapp.ts";
export interface SafeInsets { top: number; bottom: number; left: number; right: number }
function envInsets(): SafeInsets {
const read = (v: string): number => {
const probe = document.createElement("div");
probe.style.cssText = `position:absolute;visibility:hidden;padding-${v}:env(safe-area-inset-${v},0px)`;
document.body.appendChild(probe);
const px = parseFloat(getComputedStyle(probe).getPropertyValue(`padding-${v}`)) || 0;
probe.remove();
return px;
};
return { top: read("top"), bottom: read("bottom"), left: read("left"), right: read("right") };
}
export function resolveSafeInsets(wa: TelegramWebAppLike | null): SafeInsets {
const base = envInsets();
const tg = wa && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0") ? wa.safeAreaInset : null;
if (!tg) return base; // old-client fallback = pure env()
return {
top: Math.max(base.top, tg.top ?? 0),
bottom: Math.max(base.bottom, tg.bottom ?? 0),
left: Math.max(base.left, tg.left ?? 0),
right: Math.max(base.right, tg.right ?? 0),
};
}
export function applySafeArea(insets: SafeInsets): void {
const root = document.documentElement.style;
root.setProperty("--safe-top", `${insets.top}px`);
root.setProperty("--safe-bottom", `${insets.bottom}px`);
root.setProperty("--safe-left", `${insets.left}px`);
root.setProperty("--safe-right", `${insets.right}px`);
}
```
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run typecheck -w web 2>&1 | tail -20 && node -e "const fs=require('fs'); for (const f of ['webapp','theme','backbutton','safe-area']) { if (!fs.existsSync('web/src/telegram/'+f+'.ts')) throw new Error('missing '+f); } console.log('4 wrappers present')"</automated>
</verify>
<acceptance_criteria>
- `npm run typecheck -w web` exits 0
- `grep -q 'export interface TelegramWebAppLike' web/src/telegram/webapp.ts` exits 0 AND `grep -q 'return w.Telegram?.WebApp ?? null;' web/src/telegram/webapp.ts` exits 0 (getWebApp can return null — that is the contract the chain must handle)
- `grep -q 'close()' web/src/telegram/backbutton.ts` exits 0 (total function, no dead-ends)
- `grep -v '^ \*\|^/\*\|^$' web/src/telegram/theme.ts | grep -c 'setStyle' | head -1` returns 0
- `grep -v '^ \*\|^/\*\|^$' web/src/telegram/theme.ts | grep -c 'dataset.tgTheme' | head -1` returns 1 (the single statement a theme change may touch)
- `grep -q 'isVersionAtLeast("8.0")' web/src/telegram/safe-area.ts` exits 0
- `grep -q 'WebApp.safeAreaInset\|safeAreaInset' web/src/telegram/safe-area.ts` exits 0
- `grep -rc 'node_modules' web/src/telegram/*.ts 2>/dev/null; grep -q '@types/telegram-web-app' web/package.json; test $? -ne 0` — the dropped package must NOT be reintroduced
</acceptance_criteria>
<done>Four narrow Telegram platform wrappers exist behind one hand-rolled interface; the theme switch's only statement is the data-attribute set, and getWebApp can honestly return null.</done>
</task>
<task type="auto">
<name>task 4: Mock-Telegram harness + the four RED gates (GEO-01, GEO-02 client, GEO-03, PX-02)</name>
<files>web/src/mock/telegram-mock.ts, web/test/location-chain.test.mjs, web/test/no-storage.test.mjs, web/test/theme.test.mjs</files>
<read_first>
- `web/src/telegram/webapp.ts` (task 3 output — `installMockWebApp` must return a `TelegramWebAppLike`)
- `web/src/location/types.ts` + `web/src/location/chain.ts` (task 1–2 output — the tests import these)
- `web/src/telegram/theme.ts` (task 3 output — `theme.test.mjs` imports `resolveBranch`/`applyTheme`/`watchTheme`)
- `bot/test/webhook.test.mjs` (the signing helper + fake-KV patterns to mirror)
- `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` §BackButton Interaction Contract
</read_first>
<action>
**`web/src/mock/telegram-mock.ts`** — the keystone (D-11). Dev/build-tagged only.
```typescript
/*
* telegram-mock.ts — the D-11 Mock-Telegram harness.
*
* Injects a fake `window.Telegram.WebApp` into the SAME window the app
* runs in — never an iframe. Bot API 10.2 (enforced 2026-07-20) rejects
* Mini App method calls from any origin other than the registered Mini
* App domain, so an iframe harness would silently fail (RESEARCH Pitfall 6).
*
* Dev-signed initData uses the SAME HMAC path as bot/src/security/initdata.ts
* (`HMAC(bot_token, "WebAppData")`) so `POST /api/session` accepts it
* unmodified against `bot/.dev.vars`. Import the token from the env; never
* hardcode a real bot token, never log it.
*
* This module ships nothing to production: `no-storage.test.mjs` and the
* build-artifact gate both assert it is excluded from `web/dist`.
*/
import { createHmac } from "node:crypto"; // dev/build only — Vite tree-shakes from the browser bundle path
```
**Important build constraint:** `node:crypto` cannot be bundled for the browser. Implement `buildInitData` with the **Web Crypto** API (`crypto.subtle.importKey` + `sign` with HMAC-SHA-256, plus `TextEncoder`), which is async; expose `export async function makeDevInitData(botToken, user)` and let the runner page await it. Do NOT import `node:crypto` in this file. The test suite (`web/test/*.test.mjs`) uses its own `node:crypto` helper copied from `bot/test/webhook.test.mjs` — that runs in Node, not the browser.
Fake WebApp shape:
```typescript
export interface MockOptions {
platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web";
version?: string;
colorScheme?: "light" | "dark";
telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null;
webviewFix?: { lat: number; lng: number; accuracyM?: number } | null;
grantLocation?: boolean;
}
export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike { /* returns the fake and assigns (window as any).Telegram = { WebApp } */ }
export function uninstallMockWebApp(): void { /* delete window.Telegram */ }
export function emitThemeChanged(): void { /* fire the registered themeChanged listener */ }
```
The fake's `LocationManager.getLocation(cb)` invokes `cb(options.telegramFix)` on a `setTimeout(…, 0)` when `grantLocation` is true, and `cb(null)` otherwise. `isVersionAtLeast(v)` returns true when `options.version` (default `"8.0"`) is >= v by dotted-numeric compare. `platform` defaults to `"android"`.
**`web/test/location-chain.test.mjs`** — GEO-01 + GEO-02 client half, against injected fakes (never a real MapLibre instance — ESM caveat). Cases, each named with its requirement:
```
GEO1 telegram init+getLocation yields a Fix with source 'telegram'
GEO1 a client below Bot API 8.0 falls through to navigator.geolocation without throwing
GEO1 telegramLocationSupported(null) returns false without throwing, and acquireFix with wa:null
falls through to the webview link (plain-browser leg, no window.Telegram at all)
GEO1 getLocation returning null (denied) advances to the webview source, not to an error
GEO1 getLocation that never calls back resolves null after the wrapper timeout
GEO1 two byte-identical successive telegram fixes are detected as stale and route to the nudge
GEO1 the nudge reads the webview ONCE then retries telegram (readWebview called exactly once)
GEO2 pollChatPin returns the pin when the endpoint yields one
GEO2 the chat-pin poll is bounded — a never-delivering endpoint returns null (timeoutMs 50, pollMs 5)
GEO2 a Desktop Telegram fake (no LocationManager, no navigator.geolocation) reaches denied with identical copy keys
```
Inject the fake by passing a plain object literal matching `TelegramWebAppLike` directly — do NOT construct a DOM. For `webviewLocationSupported`, the tests must set `globalThis.navigator = { geolocation: { getCurrentPosition(...) {} } }` (or delete it) per case in a `beforeEach`-style helper, and restore afterwards.
**`web/test/no-storage.test.mjs`** — GEO-03 grep gate, inverted form of `bot/test/privacy.test.mjs`:
```javascript
const BANNED = [
"localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage",
"document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage",
];
```
Scan every file under `web/src/` matching `**/*.{ts,html}` and assert no BANNED token appears. **Important:** the header comment of `web/src/mock/telegram-mock.ts` mentions those names in prose — the gate must scan for *code* occurrences only, so strip block comments and line comments before scanning (use a small regex pass removing `/* … */` and `// …`), and skip the mock module's *documentation*. Concretely: strip comments from the source text, THEN assert none of the BANNED tokens appear. Also add a case asserting `web/src/session/store.ts` contains no assignment to any storage object. Copy the path-scaffolding pattern from `bot/test/privacy.test.mjs` lines 23-27.
**`web/test/theme.test.mjs`** — PX-02:
```
PX02 colorScheme 'dark' sets data-tg-theme="dark"; 'light' sets "light"
PX02 a missing colorScheme falls back to header_bg_color luminance >= 0.5 (light) / < 0.5 (dark)
PX02 applyTheme touches only the data-tg-theme attribute — the map style object is not re-set
PX02 watchTheme re-applies on the themeChanged event
```
For the third case, grep the shipped `web/src/telegram/theme.ts` source for `setStyle` and assert it does NOT appear (source-introspection style, `bot/test/commands.test.mjs` CS4 precedent) — that makes the Pitfall-7 invariant greppable.
**DOM stub — mandatory.** Cases 1, 2 and 4 call `applyTheme`/`watchTheme`, which touch `document.documentElement.dataset`. The Node runner has no DOM. Install a minimal document stub in a `beforeEach`-style helper and remove it in the matching teardown (same discipline as the `navigator` stub in `location-chain.test.mjs`):
```javascript
const realDocument = globalThis.document;
function installDocumentStub() {
const dataset = {};
globalThis.document = { documentElement: { dataset, style: { setProperty() {} } } };
return dataset;
}
// per case:
const ds = installDocumentStub();
try { applyTheme(wa); assert.strictEqual(ds.tgTheme, "dark"); }
finally { globalThis.document = realDocument; }
```
The stub must expose `documentElement.dataset` as a plain writable object (that IS the assertion target) and `documentElement.style.setProperty` as a no-op, because `safe-area.ts`'s `applySafeArea` may be imported transitively. Save and restore `globalThis.document` in every case — a leaked stub would silently make a later test pass or fail for the wrong reason.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- `npm test -w web` exits 0 with `[runner] N passed, 0 failed` where N >= 17 (10 GEO + 2 GEO-03 + 4 PX-02 + at least 1 storage case)
- `npm run typecheck -w web` exits 0
- `grep -v '^ \*\|^/\*\|^$' web/src/mock/telegram-mock.ts | grep -c 'node:crypto' | head -1` returns 0 (Web Crypto only — node:crypto would break the browser bundle)
- `grep -q 'import { createHmac } from "node:crypto"' web/src/mock/telegram-mock.ts; test $? -ne 0` — must FAIL (no node import in a browser-bundled module)
- `grep -q 'globalThis.document' web/test/theme.test.mjs` exits 0 (the DOM stub is installed and restored)
- `grep -q 'globalThis.navigator' web/test/location-chain.test.mjs` exits 0 (the navigator stub follows the same discipline)
- `node -e "const s=require('fs').readFileSync('web/src/location/chain.ts','utf8'); if(s.includes('localStorage')) throw new Error('GEO-03 violation'); console.log('chain clean')"`
- `node -e "const s=require('fs').readFileSync('web/src/location/telegram.ts','utf8'); if(!/telegramLocationSupported\(wa: TelegramWebAppLike \| null\)/.test(s)) throw new Error('null-guard missing'); console.log('null-guard OK')"`
</acceptance_criteria>
<done>The Mock-Telegram harness exists behind a narrow interface and the four requirement gates are green against injected fakes; no real device is needed to exercise the chain, the theme switch, or the storage prohibition.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → `/api/session` and `/api/location` | Same-origin fetches carrying the memory token; the Worker validates. |
| WebView/OS → location APIs | `LocationManager` and `navigator.geolocation` both return device data treated as unverified input. |
| Telegram client → page | `themeParams`, `colorScheme`, `safeAreaInset` are read as *signals*, never as consumed values. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-07 | Information Disclosure | Coordinate or token reaching any persistent store (D-03/D-07/GEO-03) | mitigate | `web/src/session/store.ts` holds the token in a module-scope `let`; no persistence call exists in the file. `web/test/no-storage.test.mjs` scans every `web/src/**/*.{ts,html}` file (comments stripped) for `localStorage`, `sessionStorage`, `indexedDB`, `caches`, `CacheStorage`, `document.cookie`, `DeviceStorage`, `SecureStorage`, `CloudStorage` and fails the build on any hit. |
| T-03-08 | Spoofing | Client-side bypass of the session gate | mitigate | `createSession` posts the verbatim `initData` and treats a non-200 or a non-string `token` as failure — no client-side shortcut accepts an unvalidated identity. HMAC validation stays entirely server-side; `web/` contains no crypto. |
| T-03-09 | Repudiation | A stale fix displayed as `LIVE` | mitigate | `looksStale()` compares successive telegram fixes (< 0.01 m ⇒ cache never refreshed); `isFixStale(fix, nowMs)` drives the 45s signal-lost state and `fixAgeSeconds()` renders `FIX Ns AGO`. The app never claims freshness it has not measured. |
| T-03-10 | Denial of Service | `getLocation()` never invoking its callback | mitigate | `requestTelegramFix` carries an internal `setTimeout(… , 8000)` that resolves `null`; `readWebviewFix` carries `maximumAge: 0` plus its own timeout. Neither wrapper can leave the boot choreography in `ACQUIRING` indefinitely (RESEARCH Pitfall 3). |
| T-03-11 | Information Disclosure | Telegram `themeParams` values consumed as design values | accept | `resolveBranch` reads `header_bg_color` only as a 0–1 luminance signal for the light/dark branch; no Telegram hex ever reaches a CSS custom property. Residual risk is nil — worst case is a wrong branch, which the `prefers-color-scheme` default already covers. |
| T-03-12 | Elevation of Privilege | Cross-origin `Telegram.WebApp.*` calls (Bot API 10.2, enforced 2026-07-20) | mitigate | `getWebApp()` reads `window.Telegram` from the app's own document; `telegram-mock.ts` injects into the SAME window, never an iframe. No iframe, no CDN shell, no preview subdomain may host the app. |
| T-03-29 | Denial of Service | `acquireFix({ wa: null })` throwing a TypeError before the chain can fall through | mitigate | `getWebApp()` is typed `TelegramWebAppLike \| null` and `telegramLocationSupported` accepts null, returning `false` on `if (!wa)`. The plain-browser leg of the device matrix (plan 03-04) has no `window.Telegram` at all, so this guard is load-bearing, not defensive padding. Proven by a test case and by a source grep on the signature. |
</threat_model>
<verification>
- `npm test -w web` green with at least 17 cases across GEO-01, GEO-02, GEO-03, PX-02
- `npm run typecheck -w web` clean
- No storage API token appears anywhere in `web/src` (comment-stripped grep gate)
- The `themeChanged` handler's only statement is the data-attribute set (grep-gateable)
- `telegramLocationSupported(null)` is false, not a TypeError (proven by test and by a source grep)
</verification>
<success_criteria>
- GEO-01: the telegram source, the version gate, the null-WebApp guard, the null-denial branch, the timeout, and the stale-detector all proven against fakes
- GEO-02 (client half): bounded chat-pin poll proven to return null rather than wedge, and `requestChatFallback` targets the route 03-01 actually implements (`POST /api/location-fallback`)
- GEO-03: mechanical grep gate over the entire client source tree
- PX-02: colorScheme + luminance fallback + the map-untouched invariant all proven (with a document stub installed and restored)
- Phase 6 can pick poll-vs-watchPosition later without a rewrite (pluggable cadence, no hard-coded interval)
</success_criteria>
<output>
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-02-SUMMARY.md`
</output>
@@ -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>
@@ -0,0 +1,167 @@
---
phase: 03-mini-app-shell-geolocation
plan: 03
subsystem: web-shell-chrome
epoch_start: 2026-10-22
tags: [d-06, d-08, d-09, d-10, px-02, maplibre-v6, copy-gate, rams-shell]
requires:
- "03-01 web/ workspace (vite build, strict tsconfig, /api half routes)"
- "03-02 location chain (acquireFix/isFixStale/fixAgeSeconds), session store+client, telegram wrappers, D-11 mock"
- "theme/dist/rams.css tokens + component classes; theme/basemap/liberty-rams.json"
provides:
- "web/src/i18n/strings.ts — 25-key ShellStrings copy module, UI-SPEC table verbatim (en+ms), pickLocale + localeFromInitData"
- "web/src/shell.css — token-only Rams chrome (dark-slot activation, strip/card/control/marker geometry, data-map=absent fallback)"
- "web/src/map/{map,marker,follow}.ts — MapLibre v6 bootstrap (setWorkerUrl, GPUInitializationError gate, RAMS-BASEMAP-READY), DOM position marker (dot + capped accuracy circle + null-hidden cone), 96px/400ms follow controller"
- "web/src/ui/{strip,cards,recenter}.ts — honest state strip, boot/denied/lost cards, machined recenter control"
- "web/src/main.ts — boot choreography: map mounts FIRST, then session, then chain; staged validating→acquiring→live"
- "vite.config.ts define __ARAHARAH_HARNESS__ (mode-keyed) — the build constant 03-04's dual build keys on"
- "gates: COPY1-6 copy gate, SESS1-4 session gates (27-case suite total)"
affects:
- "plan 03-04 (capture harness drives main.ts's mount ordering, mock-fallback guard, ARAHARAH-LIVE race signal)"
- "Phase 5 (route snapping consumes Fix + map bootstrap); Phase 6 (tick re-runs chain rather than continuous cadence)"
tech-stack:
added:
- "maplibre-gl v6 (ESM-only, WebGL2-only) via Vite ?worker&url + setWorkerUrl"
- "build-time JSON style inlining (@araharah/theme/basemap/liberty-rams.json) with resolveJsonModule"
patterns:
- "Map-first boot order as the anti-blank-map invariant (order asserted by script)"
- "Null-guarded map consumers — WebGL2-absent shell renders deliberate absent-map state, never crashes"
- "Machine-blink duty cycle via setInterval; zero CSS transition/animation in chrome"
- "Named-CTA data slots in card markup for element-specific class-mapping gates"
key-files:
created:
- 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/test/copy-gate.test.mjs
- web/test/session.test.mjs
modified:
- web/src/main.ts (stub → composition root)
- web/src/map/map.ts (attribution bottom-left)
- web/src/map/marker.ts (comment reword only)
- web/src/shell.css (attrib chip scope, strip cluster, recenter z-index)
- web/vite.config.ts (define harness constant)
- .planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md (Open-Question-1 amendment append)
decisions:
- "localeFromInitData parses the RAW initData locally — POST /api/session returns only {token}, the claim's language_code is unreachable client-side; worst case of a forged locale is wrong-language copy (HMAC validation stays server-side)"
- "Mock install is a guarded fallback ((__ARAHARAH_HARNESS__ || DEV) && !window.__ARAHARAH_MOCK__) — plan 03-04's profile-configured runner page must never be clobbered by the default android/light fake"
- "vite define keyed on --mode harness supplies the ambient constant as a real static value — a bare declared-but-undefined global would ReferenceError in plain vite dev (Rule 3)"
- "Attribution control moved to bottom-left via addControl (UI-SPEC geometry) — bottom-right was covering the recenter control; chip restyled with #map-scoped specificity because maplibre css lands after shell.css in the bundle"
- "COPY5 scans comment-stripped sources: prose may cite banned register labels, code may not"
- "CTA buttons ship named placeholder slots ({strings.ctaTryAgain}) overwritten with strings-module text before mount — keeps the Pitfall-4 class inversion element-greppable without inline copy"
- "Signal-lost card folds bottom-register via inline style on the shared card-stack (token-referenced bottom offset), not a second stylesheet layer"
metrics:
duration: ~55 min wall (15:49 → Oct 23 ~00:05 UTC, spans a date rollover; ~45 min focused)
completed: 2026-10-23T00:05:00Z
...
# Phase 3 Plan 03: Rams shell — copy, chrome, MapLibre bootstrap, boot choreography Summary
**One-liner:** the visible Rams Mini App shell — verbatim en+ms copy module with a 6-case copy gate, token-only stylesheet, MapLibre v6 basemap with WebGL2-absent fallback, dot/cone/accuracy marker, machined recenter control, six-state honest strip, denied card with the Desktop-first CTA inversion, and the map-first staged boot that never shows a blank screen.
## Tasks
| task | Name | Commit | Files |
| ---- | ---- | ------ | ----- |
| 1 | `i18n/strings.ts` copy module (25 keys, en+ms) + COPY1-6 gate | 83985c4 | web/src/i18n/strings.ts, web/test/copy-gate.test.mjs |
| 2 | `shell.css` + map bootstrap / marker / follow | c437d31 | web/src/shell.css, web/src/map/{map,marker,follow}.ts (+ UI-SPEC amendment) |
| 3 | strip, cards, recenter, boot choreography + SESS gate | d573acc | web/src/ui/{strip,cards,recenter}.ts, web/src/main.ts, web/test/session.test.mjs, (task-3 wiring edits: map.ts, shell.css, marker.ts, vite.config.ts) |
## Verification Results
- `npm test -w web` → `[runner] 27 passed, 0 failed` (10 GEO + 3 GEO3 + 4 PX02 + 6 COPY + 4 SESS; ≥24 required)
- `npm run typecheck -w web` → clean after every task; `npm run build -w web` → clean, 3 assets (js + css + worker) + hashed woff2 fonts in dist/fonts
- Boot-order script: `bootMap(` precedes `acquireFix(` in main.ts — the D-09 anti-blank invariant is greppable
- Null-map guard script: 2× `if (map !== null)`, `follow = createFollowController(map` mapped, `follow?.follow` guarded; `ARAHARAH-LIVE` emitted OUTSIDE the guard (comment-stripped grep = 1)
- Marker wiring script: `.addTo(map)` inside the first guard and before the first `marker.update(map, fix)`; marker DOM script: explicit svg w/h/viewBox, cx/cy on both circles, cone/dot under the svg, no body-parenting
- Copy gates: 25-key en/ms parity (deniedRegister + lostRegister included), COPY1-6 all PASS`; COPY5 element-level no-inline-copy incl. the GEO/SYS register labels
- CTA class script: chat-fallback button element carries `.btn-primary`, retry carries `.btn-secondary` (Pitfall-4 inversion) — element-specific match, not file-level
- Theme audit: `npm run audit -w theme` → AUDIT CLEAN (354 declarations, 0 off-token values; also clean with web/index.html added via --pages)
- shell.css value discipline: 0 literal hex, 0 `transition:`/`animation:`, `border-radius: 0` present; ui/*.ts greps 0 for transition/animation words
- Strip telemetry uses the U+00B1 `±` glyph; `+-<accuracyM> M` absent
- Third notch state shipped: `data-fix="lost"` in shell.css + `recenter.setFixState("lost")` in main.ts + `setFixState` in recenter.ts
- UI-SPEC amendments: Pitfall-4 callout pre-present (verified, not duplicated); Open-Question-1 sentence appended exactly once under Boot Choreography
- Real-browser smoke (headless Playwright, vite dev): map tiles render, strip lamp+word clustered left, D-06 denied card visible with inverted CTA classes, recenter visible and top-most at bottom-right, attribution chip bottom-left, webfonts loaded (dist/fonts hashed copies)
## Threat model compliance
| Threat | Where implemented | Evidence |
| ------ | ----------------- | -------- |
| T-03-13 (style JSON swapped at runtime) | build-time `ramsStyle` import; no `map.setStyle` anywhere in web/src | source grep; PX02 case 3 still green |
| T-03-14 (Telegram hexes as design values) | shell.css re-points `--shell-*` to Rams tokens only; zero hex in code lines | hex grep = 0 (comment-stripped) |
| T-03-16 (stale fix rendered fresh) | tick checks `isFixStale` each second → marker `data-stale`, red lamp, SIGNAL LOST + LAST FIX | main.ts tick + criteria greps |
| T-03-17 (worker chunk) | `setWorkerUrl(workerUrl)` from Vite-emitted hashed asset; error listener logs RAMS-BASEMAP-ERROR | map.ts; worker emitted as dist asset |
| T-03-18 (WebGL2 absent → blank) | bootMap catches GPUInitializationError → null; data-map="absent"; every map consumer guarded; capture signal unguarded-handled | null-map guard script; marker wiring script |
| T-03-19 (Fix escapes closure) | Fix lives in boot()'s closure, passed by argument | D-07 lock in main.ts; GEO3 gate still green |
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] `vite.config.ts` gained `define: __ARAHARAH_HARNESS__` (mode-keyed)**
- **Found during:** task 3 (typecheck/dev wiring)
- **Issue:** the plan mandates the literal condition `(__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__`; with only an ambient `declare const`, plain `vite dev` would ReferenceError on the unreplaced global.
- **Fix:** `define: { __ARAHARAH_HARNESS__: JSON.stringify(mode === "harness") }` — true exactly in 03-04's harness builds, statically false elsewhere. Consistent with 03-04 task 2's dual-build intent, not a fork of it.
- **Commit:** d573acc
**2. [Rule 3 - Blocking] `ramsStyle` needs a typed cast + `error` listener listener-shape fix**
- **Found during:** task 2 (typecheck)
- **Issue:** the plan asserted `resolveJsonModule` alone suffices; in practice TS2732-adjacent narrowing failed: the JSON infers `version: number` vs the style spec's `version: 8` literal (TS2322), and the v6 `error` event's `ErrorEvent & Object` shape rejects `{ error?: Error }` (TS2769).
- **Fix:** `style: ramsStyle as StyleSpecification` (typed cast — resolveJsonModule import kept verbatim, no `any`, no shim, no synthetic defaults) and the inner error read as `{ error?: { message?: string } }`. Behavior unchanged; RAMS-BASEMAP-ERROR/READY prefixes verbatim.
- **Commit:** c437d31
**3. [Rule 1 - Bug] Attribution sat bottom-right and covered the recenter control; chip rendered MapLibre-white**
- **Found during:** task 3 (real-browser render inspection)
- **Issue:** default attribution position conflicts with the floating control; and because maplibre's css lands after shell.css in the bundle, the chip style (same specificity) lost the cascade — translucent white chip (banned).
- **Fix:** `attributionControl: false` + `addControl(new AttributionControl({ compact: true }), "bottom-left")` (UI-SPEC says flush bottom-left) and `#map`-scoped chip overrides.
- **Commit:** d573acc
**4. [Rule 2 - Critical] `ARAHARAH-LIVE` console signal was missing from the implementation**
- **Found during:** task 3 (acceptance script)
- **Issue:** the comment referenced it but the emit did not exist — a WebGL2-absent device would hang the 03-04 capture race.
- **Fix:** `console.log("ARAHARAH-LIVE fix=… acc=…")` in handleLive, outside the map guard.
- **Commit:** d573acc
**5. [Rule 3 - Blocking] COPY5 resilient to execution order**
- **Found during:** task 1 (first test run)
- **Issue:** COPY5 hard-requires `web/src/ui/` to exist; task 1 precedes task 3, so the suite failed ENOENT mid-plan.
- **Fix:** scan `[main.ts]` always + `ui/*.ts` when present; at plan completion the full tree (4 files) is scanned, restoring the full gate strength.
- **Commit:** 83985c4
**6. [Rule 1 - Bug] Comments tripped several greps (grep does not strip comments)**
- **Found during:** tasks 2-3 (acceptance runs)
- **Issue:** marker.ts's prose cited `document.body.appendChild(group)`, cards.ts cited the banned register-label literals and CTA words, strip/recenter comments said "transition"/"animation".
- **Fix:** reworded comments (e.g. "named-CTA data slots", "the chat-fallback CTA", "the retry CTA", "instant hard swaps"); behavioral gates unchanged (COPY5 still comment-strips correctly).
- **Commits:** c437d31, d573acc
### Plan-text variances (documented, non-fixes)
- `pickLocale` is NOT imported by main.ts (plan's import list had it): BOOT's rule "no second locale helper" holds — main.ts uses only `localeFromInitData`, whose body calls pickLocale inside strings.ts. Unused import avoided.
- The two card CTA `<button>` elements carry named-string-module placeholder slots briefly overwritten before mount — this keeps the acceptance script's element-specific class assertions truthful without inlining copy (COPY5 does not ban slot identifiers).
- `session-state` render: denied-then-waiting is a second `buildDeniedCard(locale, { awaitingPin: true })` instance — visually identical per UI-SPEC's "body swaps" semantics.
## Known Stubs
- The card checkout slot `{strings.ctaTryAgain}`/`{strings.ctaAskViaChat}` innerHTML placeholders are never rendered to users (textContent overwritten pre-mount) — not a data-flow stub.
- `installMockWebApp()` default profile (telegramFix null) — capture profiles are plan 03-04's runner's job; the fallback installs only when no profile mock is present.
- Boot stages validating/acquiring cards render code-complete but the dev-server smoke could only photograph the denied terminus (no /api/session in vite dev); full staged-state capture is 03-04's harness purpose.
## Threat Flags
None — no new network endpoints (tiles were the one pre-declared runtime fetch), no auth-path changes, no schema changes. The `/api/location-fallback` call is the pre-existing 03-01 surface consumed as specced.
## Auth Gates
None — no live Telegram, no secrets; dev-signed mocks and real runs were both untouched.
## Self-Check: PASSED
- 10 created + 5 modified files present on disk (sweep live at write time); 3 asset + 6 font artifacts in web/dist
- 3 task commits verified in `git log --oneline` (83985c4, c437d31, d573acc); zero file deletions across all three
- Suite: 27 passed / 0 failed; typecheck + build clean; theme audit clean
- STATE.md and ROADMAP.md untouched by this executor (ROADMAP.md pre-existing modification belongs to the orchestrator)
@@ -0,0 +1,603 @@
---
phase: 03-mini-app-shell-geolocation
plan: 04
type: execute
wave: 4
depends_on: [03-01, 03-03]
files_modified:
- bot/wrangler.jsonc
- bot/package.json
- package.json
- .gitignore
- web/vite.config.ts
- web/index.html
- web/src/vite-env.d.ts
- web/scripts/capture.mjs
- web/test/build-artifact.test.mjs
- .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md
autonomous: false
requirements: [GEO-01, GEO-02, GEO-03, PX-02]
must_haves:
decisions_implemented:
- "D-12: local dev topology is `wrangler dev` on 8787 serving web statics + `/api/*` + webhook with local KV (theme → web build chain)"
- "D-15: deployment stays one command (`npm run deploy` = build web → wrangler deploy with assets)"
truths:
- "One npm run deploy builds the theme, then the web app, then deploys the Worker with static assets attached"
- "The Worker owns /api/* and /telegram/* FIRST; the asset binding serves everything else"
- "web/dist contains the inlined style JSON, self-hosted fonts, and the MapLibre worker, with zero CDN references"
- "web/dist NEVER contains the Mock-Telegram installer in ANY emitted file — HTML included; the harness lives in a separate web/dist-harness build that is never deployed"
- "Playwright captures the shell through the Mock-Telegram harness off the HARNESS build: boot stages, light, dark, the Desktop denied card, the chat-pin relay, and the DSGN-02 side-by-side"
- "A runtime probe after boot-to-live asserts localStorage, sessionStorage, cookies, and IndexedDB are all empty (GEO-03 spot-check)"
- "The Android, iOS, Desktop, plain-browser, and mid-session theme-flip matrix is verified on real devices before the phase closes"
artifacts:
- path: "bot/wrangler.jsonc"
provides: "assets binding + run_worker_first glob array (D-13)"
- path: "web/vite.config.ts"
provides: "dual-build config: __ARAHARAH_HARNESS__ define + dist / dist-harness outDirs"
- path: "web/scripts/capture.mjs"
provides: "Playwright capture harness extending theme/scripts/screenshot.mjs, serving web/dist-harness (D-11)"
- path: "web/test/build-artifact.test.mjs"
provides: "D-14 build-artifact gate: no CDN refs, style JSON inlined, fonts copied, harness absent from web/dist"
- path: ".planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md"
provides: "the device-matrix exit-gate checklist and result record (Success Criterion 5)"
key_links:
- from: "bot/wrangler.jsonc"
to: "web/dist"
via: "assets.directory points at ../web/dist and run_worker_first keeps /api/* on the Worker"
pattern: "run_worker_first"
- from: "web/scripts/capture.mjs"
to: "web/dist-harness"
via: "the docroot is the --mode harness build, because the mock installer and fetch stubs are stripped from any production-mode bundle"
pattern: "dist-harness"
- from: "web/scripts/capture.mjs"
to: "web/src/main.ts"
via: "races the RAMS-BASEMAP-READY and ARAHARAH-LIVE console signals"
pattern: "ARAHARAH-LIVE"
- from: "web/scripts/capture.mjs"
to: "stitch_minimalist_route_navigator/navigate_map_overview/screen.png"
via: "the DSGN-02 side-by-side fidelity artifact"
pattern: "navigate_map_overview"
---
<objective>
Complete the one-deployable architecture (D-13/D-14/D-15): wire `web/dist` into the existing Worker via
the `[assets]` binding, add the build-artifact gate proving nothing ships from a CDN, extend the Phase-1
Playwright toolchain into a capture harness that drives the Mock-Telegram runner and produces the
DSGN-02 fidelity artifact plus the GEO-03 runtime probe — then run the blocking device-matrix exit
gate (ROADMAP Success Criterion 5).
Purpose: the phase cannot honestly close without proving the build is self-contained and the app
works on real Android, iOS, Desktop, and plain browser. Real-device availability is an open question
(RESEARCH section "Environment Availability" lists it as blocking with no fallback), so this plan
carries the blocking human-verification checkpoint rather than deferring the discovery.
Output: `bot/wrangler.jsonc` `[assets]` block; root one-command build/deploy; `web/vite.config.ts` dual-build
config; `web/index.html` harness profile; `web/src/vite-env.d.ts`; `web/scripts/capture.mjs`;
`web/test/build-artifact.test.mjs`; `03-DEVICE-MATRIX.md`.
</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-RESEARCH.md
@.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md
@.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md
@theme/scripts/screenshot.mjs
@theme/package.json
@bot/wrangler.jsonc
@bot/package.json
@package.json
@web/index.html
@web/src/main.ts
@web/src/mock/telegram-mock.ts
<wrangler_assets_shape>
Cloudflare Workers static assets binding (RESEARCH cites developers.cloudflare.com/workers/static-assets/binding):
```jsonc
{
"name": "araharah-bot",
"main": "src/webhook.ts",
"compatibility_date": "2026-10-22",
"workers_dev": true,
"assets": {
"directory": "../web/dist",
"binding": "ASSETS",
"not_found_handling": "none"
},
"run_worker_first": ["/api/*", "/telegram/*"],
"kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }]
}
```
`run_worker_first` is a GLOB ARRAY (max 100 entries), not a boolean. The Worker still owns
`/api/*` and `/telegram/*` first (D-13); the asset binding serves everything else.
</wrangler_assets_shape>
<screenshot_toolchain>
`theme/scripts/screenshot.mjs` (194 lines) is the established pattern to extend, not rewrite:
- `MIN_BYTES = 10240` blank bar
- `serveTheme()` static server with the WR-05 docroot guard (`target.startsWith(THEME + sep)`)
- READY-signal race: `page.on('console', m => m.text().startsWith('RAMS-BASEMAP-READY'))` raced
against a 30s timeout
- Report plus `process.exit(1)` on FAILED / MISSING / UNDERSIZED
For `web/`, the docroot is `web/dist` and the wait signal is `ARAHARAH-LIVE` (main.ts emits it).
Playwright is already `theme/`'s dependency at `^1.50.0` with chromium/webkit/firefox cached.
</screenshot_toolchain>
</context>
<tasks>
<task type="auto">
<name>task 1: Worker `[assets]` binding + one-command deploy + D-14 build-artifact gate</name>
<files>bot/wrangler.jsonc, bot/package.json, package.json, web/vite.config.ts, web/test/build-artifact.test.mjs, .gitignore</files>
<read_first>
- `bot/wrangler.jsonc` (the current shape — keep name, main, compatibility_date, workers_dev, kv_namespaces byte-identical)
- `bot/package.json` (the `dev` script this task pins to port 8787)
- `package.json` (root — the existing delegate-script block and where `deploy` and `dev` go)
- `web/vite.config.ts` (written in plan 03-01 task 1 — extend it, do not rewrite)
- `.gitignore` (confirm the `dist/` entry covers BOTH `theme/dist/` and `web/dist/`; REGENERATED BUILD OUTPUT MUST NEVER BE COMMITTED)
</read_first>
<action>
**`bot/wrangler.jsonc`.** Add the `assets` block and the `run_worker_first` glob array, preserving every existing key:
```jsonc
{
"name": "araharah-bot",
"main": "src/webhook.ts",
"compatibility_date": "2026-10-22",
"workers_dev": true,
// Workers static assets binding (D-13, D-14): serves ../web/dist. The
// Worker fetch still owns /api/* and /telegram/* first via
// run_worker_first (a GLOB ARRAY, not a boolean).
"assets": {
"directory": "../web/dist",
"binding": "ASSETS",
"not_found_handling": "none"
},
"run_worker_first": ["/api/*", "/telegram/*"],
"kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }]
}
```
**Root `package.json`.** Add the D-15 one-command deploy, which builds the theme first, then the web app, then runs the bot's existing deploy (`wrangler deploy && node scripts/set-webhook.mjs`):
```json
"build": "npm run build -w theme && npm run build -w web",
"deploy": "npm run build && npm run deploy -w bot"
```
**The `theme` build step is REQUIRED, not cosmetic.** `theme/dist/` is gitignored generated output (RESEARCH / `.gitignore`), and `web/src/shell.css` imports `@araharah/theme/dist/rams.css` at build time. On a fresh clone, `npm run build -w web` alone fails to resolve that specifier. Chaining `theme` → `web` makes every build script in the repo — root `build`, root `deploy`, root `dev` — correct on a fresh clone with no extra instructions. Update the existing `web:build` delegate to `npm run build -w theme && npm run build -w web` for the same reason.
Keep the existing `bot:deploy` delegate as the low-level entry point.
**`web/vite.config.ts` — the dual-build config (the config half of the capture-harness fix in task 2).** The capture harness must serve a bundle that still contains the Mock-Telegram installer, while the production bundle must not. A single `import.meta.env.DEV` guard cannot express both, because `import.meta.env.DEV` is statically `false` in ANY production-mode build — including one served to a local capture server. So the harness gets its own mode with its own `outDir` and its own injected constant:
```typescript
import { defineConfig } from "vite";
export default defineConfig(({ mode }) => {
const isHarness = mode === "harness";
return {
base: "/",
define: {
// Vite replaces this IDENTIFIER textually at build time, so the
// harness bundle compiles `if (VITE_HARNESS) installMockWebApp()`
// down to `if (true) …`. The production build never defines it,
// so the identifier is unreferenced and the branch is tree-shaken.
__ARAHARAH_HARNESS__: JSON.stringify(isHarness),
},
build: {
outDir: isHarness ? "dist-harness" : "dist",
emptyOutDir: true,
target: "es2022",
},
server: { port: 5173, strictPort: true },
};
});
```
Add `"build:harness": "vite build --mode harness"` to `web/package.json` and a root delegate `"web:build:harness": "npm run build:harness -w web"`. In `web/src/main.ts`, the mock-install guard becomes `if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) installMockWebApp();` — declare the constant once in a `web/src/vite-env.d.ts` (`declare const __ARAHARAH_HARNESS__: boolean;`) so typecheck passes in both modes. **This guard lives ONLY in `main.ts`** — the runner page uses a separate, whole-block `if (__ARAHARAH_HARNESS__) { … }` wrapper (task 2), because it must also gate the `fetch` stubs and must NOT be combined with `import.meta.env.DEV` or `!window.__ARAHARAH_MOCK__` (either would disable the harness's own profile in `vite dev`). Together the two guards are what make the capture race reachable: in the served `web/dist-harness/`, `installMockWebApp` is present; in `web/dist/`, it is absent and BA5 holds.
**`.gitignore`.** Verify the existing `dist/` entry covers `theme/dist/` AND `web/dist/`. If it is root-anchored (`/dist/`), change it to `dist/` (any depth) or add explicit `theme/dist/` and `web/dist/` lines. REGENERATED BUILD OUTPUT MUST NEVER BE COMMITTED — including `theme/dist/`, which the web build depends on but must not vendor.
**D-12 — local dev topology.** The `[assets]` binding makes `wrangler dev` serve the app, the API,
and the webhook from one origin on port 8787 with local KV simulation, which is exactly D-12's
topology. Verify it and record the command in the root delegates and in
`bot/package.json`:
- Change `bot/package.json`'s `dev` script from `"wrangler dev"` to
`"wrangler dev --port 8787 --local"` so the port is pinned (8787 is the number D-12 names) and KV
runs in local-simulation mode rather than against the remote namespace.
- Add a root delegate `"dev": "npm run build && npm run dev -w bot"` so the daily loop is:
build the theme + web app once, then one command brings up statics + `/api/*` + webhook + local
KV at `http://localhost:8787`.
- Record in `.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` (created in task 3)
that `localhost:8787` is a secure context, which is why `navigator.geolocation` works under
`wrangler dev` while a LAN-IP dev URL would not (RESEARCH assumption A3). Remote staging is used
sparingly, immediately before a real-device verification session, for exactly that reason.
**Create `web/test/build-artifact.test.mjs`.** It reads `web/dist` (produced by `npm run build -w web`,
which this task's verify runs first). If `web/dist` does not exist, every case fails with a message
telling the executor to run the build first. Cases:
```
BA1 no emitted .js or .css file references cdn.tailwindcss.com, fonts.googleapis.com,
fonts.gstatic.com, unpkg.com, or cdn.jsdelivr.net (D-14: no CDN fallbacks)
BA2 web/dist contains at least one .woff2 file (self-hosted fonts copied at build time)
CONTINGENCY — if this fails, the fonts were not carried through by Vite's `url()` resolution
of the imported `@araharah/theme/dist/rams.css`. Add an explicit copy step rather than leaving
BA2 red: either (a) copy `theme/dist/fonts/` into `web/public/fonts/` so Vite emits them verbatim,
or (b) add a `postbuild` copy script (`node scripts/copy-fonts.mjs`) that mirrors `theme/dist/fonts/*.woff2`
into `web/dist/fonts/`. Both are one-time fixes; re-run `npm run build -w theme && npm run build -w web`
and re-check before moving on. Do NOT add a CDN `<link>` fallback — BA1 would fail it.
BA3 web/dist/assets contains a hashed MapLibre worker chunk (filename matching /worker/i)
BA4 the built JS bundle carries the inlined Rams style — assert the string "liberty-rams"
OR "ne2_shaded" appears in the concatenated JS (both are unique to liberty-rams.json's
source/layer names), proving the style was inlined at build rather than fetched at runtime
BA5 the PRODUCTION bundle does not contain "installMockWebApp" or "__ARAHARAH_MOCK__" —
Vite's `define` renders `if (__ARAHARAH_HARNESS__)` as `if (false)`, so the runner page's
profile block in web/index.html is dead-code-eliminated and the main.ts DEV guard is
tree-shaken. Scan ALL of web/dist — BOTH `web/dist/assets/*.js` AND every emitted
`web/dist/**/*.html` (web/index.html is the ONE entry shared by both builds, so an
unguarded profile block would ship the mock and the fetch stubs as inline HTML that the
JS-only scan would miss entirely). Scan `web/dist/` ONLY: the harness build emits to a
SEPARATE outDir (`web/dist-harness/`, plan task 2), so this gate can never see harness
code. Do NOT point BA5 at `web/dist-harness/` — the whole point is that the shipped
bundle is clean while the harness bundle deliberately is not.
BA6 the PRODUCTION bundle does not contain "/api/session" or "/api/location-fallback"
inside any emitted HTML (the runner page's fetch stubs are stripped with the same
dead-code elimination; a hit means an unguarded profile block shipped)
```
**Two-build directory contract (load-bearing — the builds must NEVER share an outDir):**
```
web/dist/ ← production build (npm run build -w web) — the ONLY thing BA1–BA5 scan
web/dist-harness/ ← harness build (vite build --mode harness) — served ONLY by capture.mjs
```
`web/dist-harness/` is gitignored generated output like `web/dist/` and is NEVER deployed — the `[assets]` binding points at `../web/dist` only. Add `web/dist-harness/` to `.gitignore` in this task. Sharing one outDir would make the two builds clobber each other and make the BA5 gate meaningless.
Implement BA1 by walking `web/dist` recursively with `readdirSync({withFileTypes: true})`, filtering
`.js`/`.css`, and `assert.ok(!content.includes(host), ...)` for each host with the file path in the
message. For BA4 read and concatenate every `web/dist/assets/*.js`. For BA5/BA6 walk the SAME tree and
read every `.js` AND every `.html` file into two concatenated buffers (jsAssets, allHtml), then assert
`installMockWebApp` and `__ARAHARAH_MOCK__` are absent from the combined buffer, and (BA6) that
`/api/session` and `/api/location-fallback` are absent from `allHtml`. Put the offending file path in
every failure message — a JS-only scan that passes vacuously because the mock survived as inline
HTML is the exact regression BA5's extension exists to prevent. Copy the path-scaffolding
style from `bot/test/privacy.test.mjs` lines 23-27.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run build -w theme 2>&1 | tail -3 && npm run build -w web 2>&1 | tail -5 ; npm test -w web 2>&1 | grep -E '^PASS BA|runner' ; node -e "const c=require('fs').readFileSync('bot/wrangler.jsonc','utf8'); if(!c.includes('run_worker_first')) throw new Error('assets wiring missing'); if(!c.includes('../web/dist')) throw new Error('assets.directory wrong'); console.log('wrangler OK')" ; node -e "const p=require('./package.json'); if(!p.scripts.deploy) throw new Error('root deploy missing'); if(!p.scripts.build) throw new Error('root build missing'); console.log('scripts OK')"</automated>
</verify>
<acceptance_criteria>
- `npm run build -w theme && npm run build -w web` exits 0
- `npm test -w web` exits 0 and the runner output contains `PASS BA1` through `PASS BA6`
- `node -e "const c=require('fs').readFileSync('bot/wrangler.jsonc','utf8'); if(!c.includes('run_worker_first')) throw new Error(1); if(!c.includes('../web/dist')) throw new Error(2); console.log('OK')"` exits 0
- `grep -q '"deploy": "npm run build && npm run deploy -w bot"' package.json` exits 0
- `grep -q '"build": "npm run build -w theme && npm run build -w web"' package.json` exits 0 (theme must build before web — `theme/dist/` is gitignored generated output, so a fresh clone cannot resolve `@araharah/theme/dist/rams.css` without this)
- `grep -q '"dev": "npm run build && npm run dev -w bot"' package.json` exits 0 (D-12 one-command local loop)
- `grep -q '\-\-port 8787 --local' bot/package.json` exits 0 (D-12 port pinned, KV in local-simulation mode)
- `grep -q 'dist-harness' web/vite.config.ts` exits 0 AND `grep -q '__ARAHARAH_HARNESS__' web/vite.config.ts` exits 0 (the two builds have separate outDirs and an injected harness constant — the production `dist` can never contain harness code)
- `grep -q '"build:harness": "vite build --mode harness"' web/package.json` exits 0
- `grep -q 'dist-harness' .gitignore` exits 0 (harness build output is never committed)
- `cd bot && npx wrangler deploy --dry-run 2>&1 | tail -5` exits without a config-parse error — `wrangler deploy` DOES support `--dry-run` (it validates the bundle + config without uploading), which proves the `assets` and `run_worker_first` keys are accepted by wrangler 4.151.3. Note: `wrangler dev` has NO `--dry-run` flag; do not attempt it.
- `grep -v '^ \*\|^/\*\|^$' web/dist/assets/*.js | grep -c 'cdn.tailwindcss.com\|fonts.googleapis.com\|unpkg.com\|cdn.jsdelivr.net' | head -1` returns 0
- `find web/dist -name '*.woff2' | wc -l` returns >= 1 (if 0, apply BA2's contingency — a `web/public/fonts/` copy or a `postbuild` font-copy script — never a CDN link)
- **Both-direction harness isolation (T-03-21), asserted directly:** `grep -rl 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/ 2>/dev/null | wc -l` returns 0 — including `web/dist/index.html`, not just the JS chunks — while `grep -rl 'installMockWebApp' web/dist-harness/ 2>/dev/null | wc -l` returns >= 1
- `grep -c 'api/session\|api/location-fallback' web/dist/index.html 2>/dev/null` returns 0 (the runner page's fetch stubs did not survive as inline production HTML)
- `git check-ignore -q web/dist/index.html && echo IGNORED` prints IGNORED (build output never committed)
</acceptance_criteria>
<done>The Worker serves the app from its own static-assets binding, deploy is one command, and six automated gates prove the shipped bundle is fully self-contained (D-14) and free of the Mock-Telegram installer in every emitted file, HTML included.</done>
</task>
<task type="auto">
<name>task 2: Mock-Telegram runner page + Playwright capture harness (D-11, DSGN-02, GEO-03 runtime probe)</name>
<files>web/index.html, web/scripts/capture.mjs, web/src/vite-env.d.ts</files>
<read_first>
- `theme/scripts/screenshot.mjs` (the entire file — extend, do not rewrite: MIN_BYTES, serveTheme, READY-signal race, report + exit code)
- `web/vite.config.ts` (task 1 output — the `__ARAHARAH_HARNESS__` define and the `dist-harness` outDir this task serves)
- `web/src/main.ts` (the `ARAHARAH-LIVE` console signal it emits, and its install guard `(__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__`)
- `web/src/mock/telegram-mock.ts` (the `installMockWebApp(options)` signature and `MockOptions` shape — add the `languageCode` option if absent)
- `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` section "Fidelity Artifact" (what the gallery row must show)
</read_first>
<action>
**Why this task builds a SEPARATE bundle (this is the load-bearing design decision).** The obvious design — serve `web/dist` (the production build) and rely on `import.meta.env.DEV` to include the mock — CANNOT work. `import.meta.env.DEV` is statically `false` in every production-mode build, so the served `dist` contains neither `installMockWebApp` nor the harness `fetch` stubs. With no `window.Telegram`, `POST /api/session` 404s, `main.ts` renders the denied card, and the `ARAHARAH-LIVE` race in step 5 times out on every capture — the harness would silently never produce a live shot. Task 1 therefore added `vite build --mode harness` → `web/dist-harness/` with an injected `__ARAHARAH_HARNESS__` constant. **This task serves `web/dist-harness/`, not `web/dist/`.** The production bundle is what gets deployed and what BA1–BA5 gate; the harness bundle exists solely so the capture race is reachable.
**`web/index.html` — the runner page is the SOLE mock installer.** `main.ts` installs a default mock only when none is present: `if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) installMockWebApp();`. That guard is what makes the profile below authoritative. An unconditional DEV install inside `main.ts` would clobber `?mock=tdesktop&scheme=dark` with an android/light default, so the Desktop capture would silently be an Android light capture and the exit-gate artifact would prove nothing.
**THE ENTIRE PROFILE BLOCK BELOW IS WRAPPED IN `if (__ARAHARAH_HARNESS__) { … }` — this is load-bearing, not stylistic.** `web/index.html` is the ONE entry file shared by BOTH builds, so an unguarded block would put the mock installer AND the fake `fetch` (which short-circuits the HMAC session gate) into the production `web/dist/index.html`. `define` replaces `__ARAHARAH_HARNESS__` textually with `false` in the production build, so Vite/Rollup folds the branch away entirely (dead-code elimination); with `true` in the harness build it runs. `__ARAHARAH_HARNESS__` is a global identifier, so no import is needed to reference it inside `<script type="module">` — it is textually replaced before module evaluation. Do NOT wrap it in `import.meta.env.DEV` (statically false in both builds, so the harness would break) and do NOT add `!window.__ARAHARAH_MOCK__` (the runner page is the sole authority; it sets that global itself).
The profile block lives INSIDE the existing `<script type="module">`, BEFORE the `main.ts` import, and the `main.ts` import stays OUTSIDE the guard so production still boots normally:
```html
<script type="module">
/* D-11 Mock-Telegram profile: ?mock=android|ios|tdesktop|macos|web + ?scheme=light|dark + ?pin=1
Harness-only: served from web/dist-harness, never deployed.
The WHOLE block below is inside `if (__ARAHARAH_HARNESS__)` — Vite's `define`
replaces that identifier with `false` in the production build, so the branch is
dead-code-eliminated and web/dist/index.html carries neither installMockWebApp
nor the fetch stubs. BA5 scans ALL of web/dist HTML as well as its JS assets. */
if (__ARAHARAH_HARNESS__) {
const q = new URLSearchParams(location.search);
const profile = q.get("mock") || "android";
const scheme = q.get("scheme") === "dark" ? "dark" : "light";
const locale = q.get("locale") === "ms" ? "ms" : "en";
// Set BEFORE main.ts is imported — this is the marker main.ts's install guard checks.
window.__ARAHARAH_MOCK__ = { profile, scheme, locale };
// Stub the endpoints the app calls, OUTSIDE any dead-code-eliminated branch. These
// live in the runner page (not in main.ts) precisely so no import.meta.env guard can
// strip them: the harness bundle must reach LIVE without a Worker in the loop.
const realFetch = window.fetch.bind(window);
const fakeToken = "00000000-0000-4000-8000-000000000001";
window.fetch = async (input, init) => {
const url = typeof input === "string" ? input : (input && input.url) || "";
if (url.includes("/api/session") && (!init || init.method === "POST")) {
// The session gate is HMAC-validated server-side; offline the harness
// short-circuits it with a fake token the stubbed /api/location also accepts.
return new Response(JSON.stringify({ token: fakeToken }), { status: 200, headers: { "content-type": "application/json" } });
}
if (url.includes("/api/location") && (!init || init.method === "GET")) {
const body = q.get("pin") === "1"
? { pin: { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 8 } }
: { pin: null };
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
}
if (url.includes("/api/location-fallback")) {
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } });
}
return realFetch(input, init);
};
const { installMockWebApp } = await import("/src/mock/telegram-mock.ts");
installMockWebApp({
platform: profile,
colorScheme: scheme,
languageCode: locale,
grantLocation: profile !== "tdesktop",
telegramFix: { lat: 3.1513, lng: 101.6958, accuracyM: 12, headingDeg: null },
});
}
await import("/src/main.ts");
</script>
```
**Mock `initData`.** The `POST /api/session` stub never reaches the Worker, so `installMockWebApp`'s `initData` only has to be a non-empty, correctly-shaped string — the harness does NOT need a valid HMAC. Have the mock emit `user={"language_code":"<locale>","id":1,"first_name":"Harness"}&auth_date=1&query_id=h&hash=harness` (URL-encoded per Telegram's wire format) so `localeFromInitData` in `main.ts` exercises its real parsing path instead of always falling back to `"en"`. The `?locale=` parameter above overrides the language_code, so a future ms capture needs no code change.
Note in `web/index.html`'s comment that no `localStorage.setItem`/`sessionStorage.setItem` may appear here — the harness profile lives in a module-scope global (`window.__ARAHARAH_MOCK__`), never in storage (D-07).
**`web/scripts/capture.mjs`.** Copy `theme/scripts/screenshot.mjs` and adapt. Structure:
```javascript
#!/usr/bin/env node
/*
* capture.mjs — Phase-3 capture harness (D-11, DSGN-02, GEO-03 runtime probe).
* Extends theme/scripts/screenshot.mjs (Phase 1 precedent) rather than
* replacing it: same MIN_BYTES blank bar, same docroot-guarded static
* server, same READY-signal console race, same FAILED/MISSING/UNDERSIZED
* report + exit(1).
*
* Docroot is web/dist-HARNESS (the --mode harness build), NOT web/dist.
* Reason: web/index.html's entire Mock-Telegram profile block — the mock
* installer and the /api/session + /api/location + /api/location-fallback
* fetch stubs — sits inside `if (__ARAHARAH_HARNESS__)`. `vite build` replaces
* that identifier with `false`, so the branch is dead-code-eliminated and
* web/dist/index.html carries none of it (BA5 scans the dist HTML too).
* Serving web/dist would yield no window.Telegram, a real POST /api/session
* with no Worker behind it, the denied card, and a permanent
* ARAHARAH-LIVE timeout.
*
* Wait signals: RAMS-BASEMAP-READY (earlier gate), then ARAHARAH-LIVE.
*
* Outputs (web/screenshots/ — regenerated, gitignored):
* shell-boot-1.png boot stage 1 (validating session card over live map)
* shell-boot-2.png boot stage 2 (acquiring location card)
* shell-live-light.png LIVE state, Telegram light theme
* shell-live-dark.png LIVE state, Telegram dark theme (PX-02)
* shell-denied.png denied card + ASK VIA CHAT (GEO-02 entry point)
* shell-chat-pin.png LIVE via the chat-pin relay (?pin=1, stubbed /api/location)
* gallery-compare.png DSGN-02 side-by-side vs navigate_map_overview/screen.png
*
* Also runs the GEO-03 runtime probe in-page after boot-to-live and fails
* the run on any non-empty storage surface.
*/
```
Implementation specifics:
1. **Build the harness bundle first, and PROVE it is the harness bundle.** `execSync("npm run build -w theme && npm run build -w web && npm run build:harness -w web", { cwd: repoRoot })`. After the build, assert `web/dist-harness/index.html` exists AND that the harness bundle actually contains `installMockWebApp` (a cheap `grep -q` over `web/dist-harness/assets/*.js`). If either check fails, abort immediately with "harness build did not include the Mock-Telegram installer — the capture would race forever" rather than letting the READY race time out 30s later with no diagnosis. The production `web/dist` must NOT contain it (BA5, task 1) — assert both directions.
2. **Server.** Copy `serveTheme()` verbatim, changing `THEME` to `DIST = resolve(SCRIPTS, "..", "dist-harness")` and keeping the WR-05 docroot guard. Extend the `MIME` map to include `.mjs`, `.map`, `.webmanifest`.
3. **Contexts.** One chromium browser; per capture, `browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme, deviceScaleFactor: 2 })`.
4. **Geolocation grant.** `await context.grantPermissions(["geolocation"], { origin: base });` and `await context.setGeolocation({ latitude: 3.1513, longitude: 101.6958, accuracy: 12 });` before `page.goto` — for every profile EXCEPT `tdesktop`.
5. **READY race.** Attach `page.on("console", m => { if (m.text().startsWith("RAMS-BASEMAP-READY")) basemapReady = true; if (m.text().startsWith("ARAHARAH-LIVE")) liveReady = true; })` then `await Promise.race([liveReady, timeout(30000)])` per the Phase-1 pattern. **The `?mock=tdesktop` DENIED leg gets its own dedicated race against the `.card` + `[data-state="denied"]` strip lamp (see leg 6), NOT `ARAHARAH-LIVE` and NOT the 30s timeout** — `chain.ts` LINK 3 deliberately hard-codes `pollChatPin(token, { timeoutMs: 60000 })` before it returns `denied`, so the denied card first renders at roughly **60 seconds**. Race that leg against `timeout(90000)` with the reason recorded in this header comment (`DENIED_LEG_TIMEOUT_MS = 90000; // LINK 3's 60s pre-denial chat-pin poll must elapse first`). Any other leg that waits on a signal must not silently sit under 60s. **A timeout is a hard failure with a diagnostic, not a silent skip** — print which signals fired (`RAMS-BASEMAP-READY` yes/no, `ARAHARAH-LIVE` yes/no) plus the last 5 console lines, so a future regression points at the missing piece immediately.
6. **The seven captures**, in order:
- `?mock=android` immediately after goto → `shell-boot-1.png` (stage 1 card).
- `?mock=android` after 400ms → `shell-boot-2.png`.
- `?mock=android&scheme=light` after ARAHARAH-LIVE + 2000ms tile settle → `shell-live-light.png`.
- `?mock=android&scheme=dark` same → `shell-live-dark.png`.
- `?mock=tdesktop` (no `grantPermissions`, no `setGeolocation`; the mock's `grantLocation:false` plus no navigator geolocation forces the chain to link 3) → `shell-denied.png`, waiting on the `.card` + `[data-state="denied"]` strip lamp rather than ARAHARAH-LIVE, with the **dedicated 90s race** from step 5 (`DENIED_LEG_TIMEOUT_MS`). **This is the leg that proves the ASK VIA CHAT CTA renders**, which is the entire entry point to GEO-02 on Desktop (issue #77).
- `?mock=tdesktop&pin=1` → `shell-chat-pin.png`, waiting for ARAHARAH-LIVE. The `?pin=1` query makes the runner page's `GET /api/location` stub return a pin, exercising the chat-pin link of the chain end-to-end offline — the automated counterpart to the Desktop leg of the device matrix.
- `gallery-compare.png`: compose the live-light capture beside `stitch_minimalist_route_navigator/navigate_map_overview/screen.png` using a second page with a two-column grid, or by compositing the two PNGs side by side with a short script. Label each column with its source path.
7. **GEO-03 runtime probe.** After the live capture, run in-page:
```javascript
const probe = await page.evaluate(async () => ({
local: localStorage.length,
session: sessionStorage.length,
cookie: document.cookie,
dbs: (await indexedDB.databases()).length,
lsKeys: Object.keys(localStorage),
}));
```
Fail the run if `probe.local !== 0 || probe.session !== 0 || probe.cookie !== "" || probe.dbs !== 0`, with the probe values in the error message. This is GEO-03's "spot-checkable at phase exit" criterion made mechanical.
8. **Report.** Reuse the Phase-1 report block: log every capture with its byte size, then FAIL on `missing`, `undersized` (`< MIN_BYTES`), or any recorded failure; `process.exit(1)` if any, else `console.log("CAPTURE OK — 7/7 non-blank captures under web/screenshots/")` and `process.exit(0)`.
Never log the bot token or `initData`; the harness reads `BOT_TOKEN` from `bot/.dev.vars` if it needs to sign, and prints only the capture filename and byte size.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run capture -w web 2>&1 | tail -25</automated>
</verify>
<acceptance_criteria>
- `npm run capture -w web` exits 0 and prints `CAPTURE OK — 7/7` with 7 non-blank captures
- `ls web/screenshots/ | wc -l` returns >= 7
- `for f in web/screenshots/*.png; do test $(stat -c%s "$f") -ge 10240 || echo "UNDERSIZED $f"; done | wc -l` returns 0
- `grep -q 'dist-harness' web/scripts/capture.mjs` exits 0 — the harness serves the harness build, NOT the production bundle (the race is unreachable otherwise)
- `grep -q 'web/dist-harness/assets' web/scripts/capture.mjs; grep -q "resolve(SCRIPTS, \"..\", \"dist\")" web/scripts/capture.mjs; test $? -ne 0` — the docroot must not be `web/dist`
- `grep -q 'installMockWebApp' web/scripts/capture.mjs` exits 0 — the capture asserts the harness bundle really contains the installer before racing
- `grep -q 'api/session' web/index.html && grep -q 'api/location' web/index.html && grep -q 'api/location-fallback' web/index.html` exits 0 — all three endpoints are stubbed in the runner page, inside the `__ARAHARAH_HARNESS__` guard
- **The build-time guard exists (T-03-21):** `grep -c 'if (__ARAHARAH_HARNESS__) {' web/index.html | head -1` returns 1 AND `node -e "const s=require('fs').readFileSync('web/index.html','utf8'); const g=s.indexOf('if (__ARAHARAH_HARNESS__) {'); const m=s.indexOf('installMockWebApp('); if(g===-1||m===-1) throw new Error('guard or installer missing'); if(m<g) throw new Error('VIOLATION: installer is outside the guard — it would ship in web/dist/index.html'); console.log('guard OK')"` exits 0 — the ENTIRE profile block (marker, fetch stubs, installer) is inside the guard, and the `main.ts` import is outside it
- **Both directions on the built output:** `grep -rl 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/ 2>/dev/null | wc -l` returns 0 AND `grep -rl 'installMockWebApp' web/dist-harness/ 2>/dev/null | wc -l` returns >= 1
- `grep -q 'import.meta.env.DEV' web/index.html; test $? -ne 0` — must FAIL: the runner page's guard is `__ARAHARAH_HARNESS__` only (a DEV guard would be statically false in BOTH builds and would break the harness); the DEV guard lives only in `main.ts` and is combined with `!window.__ARAHARAH_MOCK__`
- `grep -q 'DENIED_LEG_TIMEOUT_MS' web/scripts/capture.mjs` exits 0 AND `grep -q '90000' web/scripts/capture.mjs` exits 0 — the `?mock=tdesktop` denied leg races LINK 3's 60s pre-denial chat-pin poll, not the 30s default (issue: a dead-end capture)
- `grep -q 'pin") === "1"' web/index.html` exits 0 (the `?pin=1` chat-pin capture is wired)
- `grep -q 'indexedDB' web/scripts/capture.mjs` exits 0 AND `grep -q 'localStorage.length' web/scripts/capture.mjs` exits 0 (the GEO-03 runtime probe exists)
- `grep -q 'sessionStorage.setItem\|localStorage.setItem' web/index.html` must FAIL — the harness profile is not persisted
- `grep -q '__ARAHARAH_MOCK__' web/index.html` exits 0
- `grep -q 'RAMS-BASEMAP-READY' web/scripts/capture.mjs` exits 0 AND `grep -q 'ARAHARAH-LIVE' web/scripts/capture.mjs` exits 0 (both READY signals raced)
- `grep -q 'navigate_map_overview' web/scripts/capture.mjs` exits 0 (DSGN-02 side-by-side source)
- `grep -q 'MIN_BYTES = 10240' web/scripts/capture.mjs` exits 0 (the Phase-1 blank bar is carried over)
- `grep -q 'startsWith(THEME + sep)\|startsWith(DIST + sep)' web/scripts/capture.mjs` exits 0 — the WR-05 docroot guard survives
- `grep -q 'declare const __ARAHARAH_HARNESS__' web/src/vite-env.d.ts` exits 0 (the injected constant is declared, so typecheck passes in both modes)
</acceptance_criteria>
<done>Seven non-blank captures exist — including the Desktop denied card, the chat-pin relay, and the DSGN-02 side-by-side — and the GEO-03 runtime probe mechanically proves every storage surface is empty after a real boot-to-live cycle.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>task 3: device-matrix exit gate — Android + iOS + Desktop + plain browser (ROADMAP Success Criterion 5)</name>
<files>.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md</files>
<read_first>
- `AGENTS.md` (free-first infra mandate; the constraints that bear on staging)
- `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` section "Environment Availability" (Android, iOS, Desktop listed as blocking with NO fallback)
- `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` section "Assumptions Log" (assumption A1, the Android stale-fix, can only be retired on a physical Android phone)
- `.planning/phases/03-mini-app-shell-geolocation/03-VALIDATION.md` section "Manual-Only Verifications" (the four manual behaviors)
</read_first>
<action>
This checkpoint is BLOCKING and cannot be automated. RESEARCH section "Environment Availability"
lists a real Android phone, a real iOS phone, and a Telegram Desktop install as required dependencies
with no fallback, and Open Question 3 (whether the executor has physical devices at all) is an open
question only the user can answer. Assumption A1 (the Android stale-fix workaround) can only be
retired on a physical Android device.
**Pre-work the executor must complete BEFORE presenting this checkpoint:**
1. Deploy to staging: `npm run deploy` (builds `theme/`, then `web/`, then `wrangler deploy`). Note the deployed URL.
Confirm it serves the app and that `POST /api/session` still answers.
2. **Verify the BotFather-registered Mini App URL matches the deployed origin.** Bot API 10.2
(auto-enabled 2026-07-20) rejects Mini App methods called from any other origin, and the failure is
silent. If BotFather points elsewhere, `Telegram.WebApp.*` calls fail with no clear error. This is a
hard prerequisite — resolve it first or every mobile leg is meaningless.
3. Write `.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` with these six legs,
each an unchecked checkbox plus an "Observed / Symptom" blank line:
- **Android (real phone, Telegram Android)** — open the Mini App; confirm BOOT-01 then BOOT-02 then
LIVE; confirm the position marker renders on the Rams basemap. Then close the app fully, reopen
it, and confirm **no second permission prompt** appears. That is GEO-01's "permission persists
across sessions": the grant lives in Telegram's per-bot store on the device, outside the WebView —
record that observation explicitly, it is the subtlest point in the phase.
- **Android stale-fix check (retires A1)** — with the app live, walk several metres, then re-check.
If the pin does not move, toggle the phone's Location setting once to force a refresh and record
whether the `navigator.geolocation` nudge resolved it. Note explicitly whether the first Android
verification needed the Location toggle; that result decides whether A1 stands or is retired.
- **iOS (real phone, Telegram iOS)** — same boot to LIVE check; marker renders; permission grant
persists across a reopen.
- **Desktop Telegram** — geolocation is absent by design (issue #77), so the expected behavior is
the **denied card with identical copy** (no client-specific variant, per D-06 and RESEARCH
Pitfall 4). Tap ASK VIA CHAT; this POSTs `/api/location-fallback`, and the Worker asks the
bot to send the location keyboard into that user's own chat (chat id comes from the session
claim — plan 03-01's `handleLocationFallback`). Confirm the keyboard appears in chat; tap it,
share a pin; confirm the app picks it up via `GET /api/location` and reaches LIVE. That is GEO-02 end-to-end.
- **Plain browser** — open the deployed URL directly in a desktop browser. The app must boot to a
deliberate state: **the denied card** (off-Telegram there is no `initData`, so the session cannot be
HMAC-validated → 401 → denied). The live branch is NOT reachable here — the denied card short-circuits
the chain before any geolocation prompt. The check is that the page renders the denied card over the
Rams shell instead of failing to load; a blank page is a FAIL. (If you separately confirm LIVE via a
browser geolocation prompt, record it as an extra observation, not as this leg's pass condition.)
- **Theme flip, live (PX-02 — VALIDATION.md "Manual-Only Verifications" row 4)** — with the app
reached LIVE on a real client, switch the Telegram theme light ↔ dark **mid-session**, without
closing or restarting the app. Confirm: (a) the shell chrome re-slots (document background,
card field, shell ink all swap); (b) the status strip stays matte-black (Rams-constant); (c) the
**map is unchanged** — same style, no flash, no camera jump (D-10 / RESEARCH Pitfall 7: a
`setStyle` on `themeChanged` would tear down and rebuild all 110 layers). Record a
before/after screenshot in each direction and note which client you flipped. This leg is the
ONLY place PX-02's mid-session behavior is verified — the Playwright captures prove only
separate cold boots per scheme. Fold it into whichever of the Android/iOS/Desktop legs you can
actually perform, and record it as its own checkbox either way; an unrecorded theme flip leaves
PX-02's "while the map keeps the Rams cartography constant" half unverified.
**Present to the user with these exact instructions:**
- Deploy first: `npm run deploy`, and note the deployed URL.
- Confirm BotFather's Mini App URL is that exact origin.
- Walk the six legs one at a time on real hardware.
- Report back which legs passed, which failed, and the exact symptom for each failure.
<resume-signal>
Type "device matrix passed" if all six legs reached LIVE or the documented denied-and-recovered state (the theme-flip leg counts as passed when the chrome re-slotted mid-session and the map was visibly unchanged).
Otherwise describe which leg failed and the symptom — the executor opens a `/gsd-debug` for it.
</resume-signal>
If the user reports that no physical Android or iOS device is available, the honest outcome is that
Success Criterion 5 is UNVERIFIED. Do NOT mark the phase complete. Record the blocker in
`.planning/STATE.md` section "Blockers/Concerns" and recommend either acquiring a device or moving the
matrix to Phase 7 hardening with an explicit requirement-level note that GEO-01 and GEO-02 remain
unverified on real hardware. Never close the phase claiming device verification that did not happen.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run build 2>&1 | tail -3 && npm test -w web 2>&1 | tail -3 && npm test -w bot 2>&1 | tail -3 && npm run capture -w web 2>&1 | tail -5 && test -f .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md && grep -c 'PASS\|FAIL\|UNVERIFIED' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md</automated>
</verify>
<acceptance_criteria>
- `npm run capture -w web` exits 0 (the automated half of the gate is green before any device is touched)
- `npm test -w web` and `npm test -w bot` both exit 0
- `test -f .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0
- `grep -c 'Android' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md | head -1` returns >= 1 AND the same for `iOS`, `Desktop`, `browser` — all four legs are recorded
- `grep -q 'PASS' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (every leg has an explicit verdict recorded)
- `grep -q 'A1\|stale-fix\|Location setting' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (the Android stale-fix result is recorded, not left implicit)
- `grep -q 'BotFather' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (the Bot API 10.2 origin prerequisite was checked)
- `grep -qi 'theme flip\|theme.*light.*dark\|LIGHT ↔ DARK' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (PX-02's mid-session theme flip has its own leg and an explicit verdict — VALIDATION.md lists it as manual-only)
- If any leg is UNVERIFIED, `.planning/STATE.md` contains the blocker under "Blockers/Concerns"
</acceptance_criteria>
<done>All six legs of the device matrix are verified on real hardware and recorded, or the phase is explicitly held open with the blocker documented — never silently closed on unverified hardware.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Built bundle → production origin | The artifact that reaches real devices; a CDN slip would leak requests to third parties. |
| Telegram client → deployed origin | Bot API 10.2 enforces same-origin Mini App calls; a wrong BotFather URL silently breaks WebApp APIs. |
| Dev harness → production bundle | The Mock-Telegram fake must never ship — it fakes location and the session gate. |
| Harness build → deployed bundle | Two builds exist; only one is deployable. Conflating them would ship a fake that bypasses the HMAC session gate. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-20 | Information Disclosure | Third-party CDN receiving user navigation requests (or app fingerprinting) | mitigate | D-14 build-artifact gate BA1: every emitted `.js`/`.css` file is scanned for `cdn.tailwindcss.com`, `fonts.googleapis.com`, `fonts.gstatic.com`, `unpkg.com`, `cdn.jsdelivr.net`; any hit fails the suite. Fonts are self-hosted woff2 (BA2), the MapLibre worker is a Vite-emitted hashed asset (BA3), and the style JSON is inlined (BA4). Tiles come only from OpenFreeMap. |
| T-03-21 | Information Disclosure | Mock-Telegram fake shipping to production | mitigate | **Dual-build isolation, enforced by a build-time constant that folds the branch away.** Two separate guards, both keyed on `__ARAHARAH_HARNESS__` (a Vite `define` that renders as `false` in `vite build` and `true` in `vite build --mode harness`). (1) `main.ts` installs its fallback mock only under `if ((__ARAHARAH_HARNESS__ \|\| import.meta.env.DEV) && !window.__ARAHARAH_MOCK__)` — both halves statically false in production, so the branch is unreferenced and tree-shaken. (2) `web/index.html` — the ONE entry shared by both builds — wraps its ENTIRE profile block (the `window.__ARAHARAH_MOCK__` marker, the `/api/session` + `/api/location` + `/api/location-fallback` fetch stubs, and the `installMockWebApp` call) in `if (__ARAHARAH_HARNESS__) { … }`; `define` substitutes `false`, Rollup dead-code-eliminates the block, and `await import("/src/main.ts")` stays OUTSIDE it so production boots normally. The outDirs are separate (`web/dist/` vs `web/dist-harness/`), and the `[assets]` binding points at `../web/dist` only. **BA5/BA6 scan every emitted `.js` AND every emitted `.html` under `web/dist/` for `installMockWebApp`, `__ARAHARAH_MOCK__`, `/api/session`, and `/api/location-fallback`** — an HTML-inclusive scan is mandatory because the runner page is inline HTML, so a JS-only gate would pass vacuously on exactly the regression this threat describes. `capture.mjs` asserts the inverse (installer PRESENT in `web/dist-harness/`) before racing, so the gate cannot pass by the harness build being empty. |
| T-03-22 | Spoofing | Bot API 10.2 origin restriction silently disabling WebApp APIs | mitigate | The device-matrix checkpoint's step 2 makes "BotFather URL == deployed origin" a hard prerequisite before any mobile leg. A mismatch is caught there, not shipped. `wireBackButton` and `LocationManager` calls all originate from the app's own document. |
| T-03-23 | Elevation of Privilege | A compromised asset binding serving arbitrary paths from the Worker origin | mitigate | `assets.directory` is a fixed build output (`../web/dist`); `not_found_handling: "none"` means a miss falls through to the Worker's 404 rather than serving a directory listing; `run_worker_first` guarantees `/api/*` and `/telegram/*` never resolve from the asset tree. |
| T-03-24 | Information Disclosure | GEO-03 runtime probe leaking probe data into logs | accept | The probe reads counts and key names only (`localStorage.length`, `Object.keys(localStorage)`, `indexedDB.databases().length`) and prints them in the failure message. No coordinate value is read or printed. |
| T-03-25 | Repudiation | Closing the phase claiming device verification that did not happen | mitigate | The checkpoint's acceptance criteria require an explicit PASS/UNVERIFIED verdict per leg in `03-DEVICE-MATRIX.md`, and the resume-signal block forbids closing on unverified hardware. Missing devices route to a documented Phase-7 deferral instead. |
| T-03-30 | Repudiation | The capture harness reporting a fake Android capture for a Desktop profile, or racing to LIVE against the wrong build | mitigate | `main.ts` installs a mock only when `window.__ARAHARAH_MOCK__` is absent, so the runner page's `?mock=` profile is the sole authority — an unconditional DEV install would silently clobber `tdesktop`/`dark` with android/light and produce a plausible-looking but wrong artifact. `capture.mjs` aborts with a named diagnostic when the READY race times out (reporting which signals fired) rather than skipping, and asserts the served docroot is `dist-harness` and that the harness bundle really contains the installer. Every capture's profile is stated in the report line. |
| T-03-31 | Tampering | A future edit serving `web/dist` from `capture.mjs`, reintroducing the unwinnable race | mitigate | The acceptance criteria grep `capture.mjs` for `dist-harness` and require that it does NOT reference `resolve(SCRIPTS, "..", "dist")`. `capture.mjs`'s header comment states the reason in full, so the failure mode is documented at the point of the code that would cause it. |
</threat_model>
<verification>
- `npm test -w web` green (includes the 6 D-14 build-artifact gates over `web/dist` only, HTML included)
- `npm test -w bot` green
- `npm run build` green from a fresh clone (theme → web chain)
- `npm run capture -w web` exits 0 with 7 non-blank captures off the HARNESS build, including a dark-theme shot, the Desktop denied card, the chat-pin relay, and the DSGN-02 side-by-side
- `grep -rl 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/` returns NOTHING (including `web/dist/index.html`) while `grep -rl 'installMockWebApp' web/dist-harness/` succeeds — the two builds are isolated in both directions, and the production scan is not JS-only
- `03-DEVICE-MATRIX.md` records an explicit verdict for all six legs, or the phase is held open with the blocker in STATE.md
</verification>
<success_criteria>
- D-13: one Worker serves static assets plus `/api/*` plus the webhook, with `run_worker_first` preserving the API routes
- D-14: the shipped bundle references zero third-party CDNs; style JSON inlined, fonts self-hosted, MapLibre worker bundled
- D-15: `npm run deploy` from the root is the single deploy command, and it builds theme before web so a fresh clone works
- T-03-21 holds in both directions: the Mock-Telegram installer and its fetch stubs are provably absent from EVERY file in the deployable bundle (`.js` and `.html`) and provably present in the harness-only build
- GEO-03: the runtime probe shows localStorage, sessionStorage, cookies, and IndexedDB all empty after a real boot-to-live cycle
- GEO-01 / GEO-02 / PX-02: verified on Android, iOS, Desktop, plain browser, and one mid-session light↔dark theme flip — or explicitly recorded as unverified with the blocker documented
</success_criteria>
<output>
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-04-SUMMARY.md`
</output>
@@ -0,0 +1,112 @@
# Phase 3: Mini App Shell + Geolocation - Context
**Gathered:** 2026-10-22
**Status:** Ready for planning
<domain>
## Phase Boundary
The Telegram Mini App boots inside Telegram, validates its session through Phase 2's /api/session gate, acquires live location reliably across Android/iOS/Desktop/browser with a full fallback chain (Telegram-native → WebView → chat keyboard), and renders the user's position on the Rams basemap under Telegram light + dark themes. Every failure surface is deliberate (never blank); no coordinate persists anywhere. BackButton + safe-area groundwork land here; the observable PX-01/PX-03 behaviors are Phase 6's. Search/routes/guidance are later phases.
</domain>
<decisions>
## Implementation Decisions
### Mini App stack + repo layout
- **D-01:** Frontend is TypeScript + Vite, **no UI framework** — MapLibre GL JS v6 bundled, interactivity via small focused modules (the mockups' pattern, productionized). This stack is the foundation Phases 4–6 build on.
- **D-02:** App source lives in a `web/` npm workspace beside `theme/` and `bot/`; root package.json gains `web:*` delegate scripts. Theme is consumed as a **build-time dependency** (`theme/dist/rams.css` imported into the Vite build, not via CDN) — completing Phase 1's self-hosted/CDN-free decision for the app path.
- **D-03:** Post-initData validation, the session token is held **memory-only** (module scope), never written to localStorage/sessionStorage/cookies. Revalidation happens on every fresh boot (Telegram revives webviews) and whenever the 900s TTL stalls mid-session. Research may stress-test this invariant across webview reload quirks, but the invariant itself is locked.
### Location acquisition strategy
- **D-04:** Primary chain: Telegram-native `WebApp.getLocation()` first (Android LocationManager with version-gated paths), fall back to `navigator.geolocation` inside the WebView, then the chat-keyboard "send location" fallback (bot-side; app consumes it). Research details the Android stale-fix and iOS WKWebView quirks — the CHAIN is locked, the per-link mechanics are research's.
- **D-05:** Cadence is **gated streaming**: one-shot fixes during browsing/search; continuous watch only during guidance (Phase 6 builds the streaming state machine). Map recenters on each fix; the position marker always shows the freshest fix.
- **D-06:** Denied/no-geolocation presents a deliberate Rams-styled denied state — explains why location matters, offers retry, points to Telegram's attach → Location path as the manual fallback (GEO-02). No silent banners.
- **D-07:** Coordinates live in **memory only** while the app runs. Zero persistence across every storage API (localStorage, sessionStorage, cookies, IndexedDB, service-worker caches). This zero-persistence assertion is part of the phase's GEO-03 spot-check, stress-tested by research against every storage surface the app touches.
### Shell UX + screens
- **D-08:** Phase 3's screen surface is map-first and single: full-bleed Rams basemap, position dot, follow/recenter control, minimal status strip reflecting session/location state. Route lines, layer toggles, search furniture belong to later phases — nothing placeholder-shaped.
- **D-09:** Boot choreography has staged deliberate states: validating session → acquiring location → live — each Rams-styled, none blank (extends the neutral-denied rule from PX-02).
- **D-10:** Map survives theme flips Rams-constant (Phase 1 D-04 slots pay off); only shell surfaces re-theme via the Telegram theme params. Safe-area handling + BackButton wiring land here as infrastructure.
### Dev/test harness
- **D-11:** A **Mock-Telegram harness** for the daily loop: a local module injecting a fake `window.Telegram.WebApp` (dev-signed initData, BackButton events, theme params with light/dark switch, sample location streams) plus a runner page booting the real app off-Telegram in a plain browser. Real-device batteries are spent at the device-matrix exit gate, not during daily dev.
- **D-12:** Local dev topology is **wrangler dev** on 8787 serving web/dist statics + `/api/*` + webhook with local KV simulation; remote staging used sparingly right before real-device verification sessions.
### Worker static-asset wiring
- **D-13:** Statics served by the **Workers `[assets]` binding** (wrangler config), from `web/dist`'s build output; Worker fetch still owns `/api/*` + webhook first. Completes the one-deployable architecture from the roadmap.
- **D-14:** Build inlines everything the app needs: CSS from theme, MapLibre, basemap style JSON (from `theme/basemap/liberty-rams.json`), self-hosted fonts copied at build time into `web/dist`. Tiles remain OpenFreeMap's endpoint at runtime; no CDN fallbacks.
- **D-15:** Deployment stays one command (`npm run deploy` = build web → wrangler deploy with assets). Same ergonomics as Phase 2. Hashed filenames from Vite play naturally with Cloudflare's asset caching.
### OpenCode's Discretion
- Exact per-link mechanics of the location chain (Telegram API calls, permission bridging) — research's domain
- Boot-state copy, exact mock initData signing mechanics, recenter-toggle visual details (within the Rams system)
- `assets` config tuning (binding name, not_found_handling, asset prefixes)
- How the reserved exit-gate Playwright matrix extends theme/scripts/screenshot.mjs
</decisions>
<specifics>
## Specific Ideas
- The app should feel like the navigate_map_overview mockup distilled: calm, map-dominant, nothing decorative.
- Allowed fallbacks compose as a chain, not a menu — user should perceive one honest app.
- Mock harness borrow: dev initData signing already exists conceptually in bot/scripts (HTTP world), and the hand-rolled test runner pattern (theme) prevails.
</specifics>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase scope + decisions
- `.planning/ROADMAP.md` §Phase 3 — goal, 5 success criteria incl. device-matrix exit, research recommendation
- `.planning/REQUIREMENTS.md` §GEO-01/02/03, PX-02 (this phase's requirements)
- `.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md` — identity floor /api/session contract details (opaque token, 900s TTL, rotation)
- `.planning/phases/01-design-tokens-rams-foundation/01-CONTEXT.md` — theme package contract (Rams-constant map, D-04 theme slots)
### Codebase
- `theme/dist/rams.css`, `theme/src/components.css`, `theme/src/tokens.css` — consumption surface
- `theme/basemap/liberty-rams.json` — the map style to load
- `bot/src/webhook.ts`, `bot/src/kv/sessions.ts` — the /api/session contract the web app calls
- `stitch_minimalist_route_navigator/navigate_map_overview/code.html` — layout reference (read-only)
- `AGENTS.md` — repo conventions
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `theme/dist/rams.css` + `components.css` — the app imports these directly (build-time)
- `theme/basemap/liberty-rams.json` + `build.mjs` — map style + derivation upstream of a loaded style object
- `theme/fonts/*` self-hosted woff2 — copied at build time into web/dist
- `theme/scripts/screenshot.mjs` — Playwright toolchain for exit-gate device-matrix extension
- Phase 2's `/api/session` — the auth endpoint, deploy scripts + .dev.vars conventions
### Established Patterns
- npm workspaces with delegate scripts (theme/, bot/)
- Hand-rolled node test runner (audit.test.mjs shape)
- TDD discipline; zero-persistence privacy invariant (KV TTL-only)
### Integration Points
- web/dist → wrangler `[assets]` binding on the existing `araharah-bot` Worker
- `WebApp.getLocation()` / navigator.geolocation → module that also feeds Phase 5's route snapping and Phase 6's guidance loop
- BackButton jumps to full state graph in Phase 6
</code_context>
<deferred>
## Deferred Ideas
- Scripted Playwright device-matrix spec (mock-based) — extend theme/scripts toolchain at the exit gate
- Placeholder-shaped search drawer / route furniture — later phases by design
- Real-device TestFlight/Play Beta channels — out of scope; device-matrix exit pass suffices for v1
</deferred>
---
*Phase: 03-mini-app-shell-geolocation*
*Context gathered: 2026-10-22*
@@ -0,0 +1,56 @@
# Phase 3: Mini App Shell + Geolocation - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions captured in CONTEXT.md — this log preserves the analysis.
**Date:** 2026-10-22
**Phase:** 03-mini-app-shell-geolocation
**Mode:** default (interactive)
## Areas Discussed
### Mini App stack + repo layout
| Question | Options presented | Selection |
|---|---|---|
| Frontend stack | TS + Vite no framework / component framework / ES modules | TS + Vite, no framework |
| App source location | web/ workspace / inside bot/ / web/ + shared/ | web/ workspace |
| Token holding | Memory-only / sessionStorage / you decide | Memory-only |
### Location acquisition strategy
| Question | Options presented | Selection |
|---|---|---|
| Primary path | Telegram → WebView → chat KB / WebView-only / research decides | Full chain, Telegram first |
| Cadence | Gated streaming / watch always / you decide | Gated streaming |
| Denied UX | Deliberate denied state / minimal banner / you decide | Deliberate denied state |
| Coord persistence | Memory-only / cache last position / you decide | Memory-only, zero persistence |
### Shell UX + screens
| Question | Options presented | Selection |
|---|---|---|
| Screen surface | Map-first single screen / map + placeholder drawer / you decide | Map-first single screen |
| Boot choreography | Staged boot states / map-first + chip / you decide | Staged boot states |
### Dev/test harness
| Question | Options presented | Selection |
|---|---|---|
| Dev harness | Mock-Telegram / real-only tunnel / mock + Playwright matrix | Mock-Telegram harness |
| Dev topology | wrangler dev local / remote staging | wrangler dev local |
### Worker static-asset wiring
| Question | Options presented | Selection |
|---|---|---|
| Static serving | Workers [assets] binding / KV/R2 / you decide | Workers [assets] binding |
| Asset inlining | All-inlined build / basemap kept remote / you decide | All-inlined build |
| Deploy ergonomics | Single command / split deploys / you decide | Single deploy command |
## Corrections Made
No corrections — user accepted recommended options throughout; chose the leaner mock option over the playwright-matrix option (matrix deferred to exit gate).
## Auto-Resolved
Not applicable.
## External Research
Not applicable (research runs inside planning per roadmap recommendation).
@@ -0,0 +1,650 @@
# Phase 3: Mini App Shell + Geolocation - Pattern Map
**Mapped:** 2026-10-22
**Files analyzed:** 40 (33 new `web/` files, 4 new `bot/` files, 3 modified root/bot config files)
**Analogs found:** 36 / 40 (4 with no codebase analog — new platform surfaces; RESEARCH.md provides the code)
> Repo state: Phases 1 (theme) + 2 (bot) are complete. There is **no `web/` directory and no frontend app code yet** — every browser-runtime analog below is either a *theme proof page*, a *mockup* (read-only), or a *bot-side* pattern. The strongest analogs are: the Worker route/KV/test patterns from `bot/`, and the MapLibre Rams-basemap proof page `theme/basemap/index.html`.
## File Classification
### `web/` workspace — new files (per RESEARCH.md §Recommended Project Structure + D-01/D-02)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `web/package.json` | config | — | `bot/package.json` | exact |
| `web/tsconfig.json` | config | — | `bot/tsconfig.json` | role-match (swap workers-types → DOM) |
| `web/vite.config.ts` | config | build | *(none — new tool)* | none |
| `web/index.html` | route/entry | — | `stitch_minimalist_route_navigator/navigate_map_overview/code.html` + `theme/basemap/index.html` | role-match |
| `web/src/main.ts` | controller (composition root) | event-driven | `theme/basemap/index.html` lines 103-173 (boot script) | role-match |
| `web/src/shell.css` | component (styles) | — | `theme/basemap/index.html` `<style>` block (lines 35-88) | exact style-rules |
| `web/src/telegram/webapp.ts` | utility (typed accessor) | — | `bot/src/security/initdata.ts` (hand-rolled minimal typings, lines 29-51) | role-match |
| `web/src/telegram/theme.ts` | utility | event-driven | `theme/src/theme.css` lines 100-109 (the switch *target*) | partial |
| `web/src/telegram/backbutton.ts` | utility | event-driven | *(none)* | none |
| `web/src/telegram/safe-area.ts` | utility | — | mockup `navigate_map_overview/code.html` `.pt-safe/.pb-safe` (line 4) | partial |
| `web/src/session/client.ts` | service | request-response | `bot/src/webhook.ts` `handleSession` (the other end of the same contract) | role-match |
| `web/src/session/store.ts` | store (memory-only) | — | `bot/src/kv/sessions.ts` (module-scope + TTL vocabulary) | role-match |
| `web/src/location/types.ts` | model | — | `bot/src/security/initdata.ts` interfaces | role-match |
| `web/src/location/telegram.ts` | service | request-response (callback→Promise) | `bot/src/security/initdata.ts` (injectable opts + timeout) | role-match |
| `web/src/location/webview.ts` | service | request-response | *(none)* | none |
| `web/src/location/chat-pin.ts` | service | bounded polling | `bot/src/kv/sessions.ts` `getClaimByToken` (read-side contract) | partial |
| `web/src/location/chain.ts` | service (state machine) | event-driven | *(none — RESEARCH Pattern 1/2 is the source)* | none |
| `web/src/map/map.ts` | component (MapLibre bootstrap) | streaming (tiles) | `theme/basemap/index.html` lines 103-125 | role-match (v4 CDN → v6 ESM delta) |
| `web/src/map/marker.ts` | component | transform | `theme/basemap/index.html` lines 146-164 (source/layer wiring) | partial |
| `web/src/map/follow.ts` | component | event-driven | *(none)* | none |
| `web/src/ui/strip.ts` | component | — | `theme/src/components.css` `.card-ref` + mockup `GPS LOCKED` chip | role-match |
| `web/src/ui/cards.ts` | component | — | `theme/src/components.css` `.card`/`.card-header`/`.btn-*` (lines 22-134) | exact (classes consumed) |
| `web/src/ui/recenter.ts` | component | event-driven | mockup `navigate_map_overview/code.html` line 137 (recenter button) | role-match |
| `web/src/i18n/strings.ts` | config (i18n) | — | `bot/src/i18n/strings.ts` | exact |
| `web/src/mock/telegram-mock.ts` | test utility (fake provider) | event-driven | `bot/test/webhook.test.mjs` signing helpers (lines 39-68) | partial |
| `web/test/runner.mjs` | test | — | `bot/test/runner.mjs` | exact (copy verbatim) |
| `web/test/harness.mjs` | test | — | `bot/test/harness.mjs` | exact (copy verbatim) |
| `web/test/location-chain.test.mjs` | test | — | `bot/test/webhook.test.mjs` (injected fakes + handler import) | exact |
| `web/test/no-storage.test.mjs` | test (grep gate) | — | `bot/test/privacy.test.mjs` | exact |
| `web/test/theme.test.mjs` | test | — | `bot/test/commands.test.mjs` (pure-function gates) | role-match |
| `web/test/copy-gate.test.mjs` | test (copy gate) | — | `bot/test/commands.test.mjs` CS1/CS3 (lines 36-74) | exact |
| `web/scripts/capture.mjs` | tooling (Playwright) | batch | `theme/scripts/screenshot.mjs` | exact |
### `bot/` workspace — new/modified files (GEO-02 server half + asset wiring)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `bot/src/kv/location-pin.ts` *(new — name per planner)* | model (KV) | CRUD (TTL-only) | `bot/src/kv/sessions.ts` | exact |
| `bot/src/webhook.ts` *(modified — add `GET /api/location`)* | route/controller | request-response | `bot/src/webhook.ts` `handleSession` (same file) | exact |
| `bot/src/bot/handlers/location-fallback.ts` *(new — GEO-02 keyboard)* | controller | event-driven (bot update) | `bot/src/bot/commands/start.ts` | role-match |
| `bot/src/bot/bot.ts` *(modified — register handler)* | controller | event-driven | `bot/src/bot/bot.ts` (same file) | exact |
| `bot/src/i18n/strings.ts` *(modified — awaiting-pin copy)* | config (i18n) | — | `bot/src/i18n/strings.ts` (same file) | exact |
| `bot/test/location-pin.test.mjs` *(new)* | test | — | `bot/test/webhook.test.mjs` + `bot/test/privacy.test.mjs` | exact |
### Root config — modified files
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `package.json` *(root — add `web` workspace + `web:*` delegates)* | config | — | existing `bot:*` delegate scripts (same file, lines 15-19) | exact |
| `bot/wrangler.jsonc` *(modified — `[assets]` binding, `run_worker_first`)* | config | — | *(none — new config surface)* | none |
| `.gitignore` *(likely NO change needed)* | config | — | line 11 `dist/` already ignores any `dist/` at any depth | n/a |
---
## Pattern Assignments
### `bot/src/webhook.ts` modification — `GET /api/location` route (route, request-response)
**Analog:** `bot/src/webhook.ts` itself — `handleSession` (lines 83-113). The new route is the third pathname branch.
**Route dispatch pattern** (lines 39-63) — add the branch beside the existing two:
```typescript
export default {
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
if (url.pathname === "/telegram/webhook") { /* ... */ }
if (url.pathname === "/api/session") {
return handleSession(req, env);
}
return new Response("Not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
```
**Auth + service + response pattern to copy** (lines 99-113) — validate first, then KV, then JSON response; category-only logging:
```typescript
const result = await validateInitData(raw, env.BOT_TOKEN);
if (!result.ok) {
console.log(`[initdata] rejected: ${result.reason}`); // D-14: category ONLY
return new Response(JSON.stringify({ error: result.reason }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
const token = await mintSession(env.chat_sessions, { /* claim */ });
return new Response(JSON.stringify({ token }), { status: 200, headers: { "content-type": "application/json" } });
```
**For `/api/location`:** swap the body-cap block (lines 84-98, not needed for GET) for a Bearer-token gate — `getClaimByToken(env.chat_sessions, token)` → null ⇒ 401 `{error:...}`; derive the KV key **from the claim's `chat_id`** (RESEARCH §Security V4: never from a client-supplied id). Method gate: `if (req.method !== "GET") return 405` mirrors line 84.
**Error-response shape** (lines 74-79): `new Response(JSON.stringify({ error: "malformed" }), { status: 413, headers: { "content-type": "application/json" } })` — keep the same `{error: <category>}` vocabulary.
---
### `bot/src/kv/location-pin.ts` (model, TTL-only KV)
**Analog:** `bot/src/kv/sessions.ts` — copy its whole contract shape.
**TTL constant + minimal record interface** (lines 42-51):
```typescript
export const SESSION_TTL_SECONDS = 900;
export interface SessionClaim {
user_id: number;
chat_id: number;
issued_at: number; // seconds-since-epoch
language_code: string | null;
}
```
The pin record is the same idea: the exact `Location` fields and **nothing else** (a 4th property means drift — same D-08 wording discipline).
**KvLike structural typing** (lines 53-59) — keeps the module testable with a plain Map fake:
```typescript
interface KvLike {
get(key: string): Promise<string | null>;
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
delete(key: string): Promise<void>;
}
```
**The non-negotiable write line** (lines 79-80) — every `put` carries `expirationTtl` on the same line, referencing a named constant (the `privacy.test.mjs` PG1 gate regex requires `/expirationTtl:\s*([A-Z_]{2,})/`):
```typescript
await kv.put(`session:${token}`, JSON.stringify(claim), { expirationTtl: SESSION_TTL_SECONDS });
await kv.put(`chat:${claim.chat_id}`, token, { expirationTtl: SESSION_TTL_SECONDS });
```
**Defensive read + null-as-expired** (lines 103-122) — `getClaimByToken`'s shape: `kv.get` → null ⇒ return null; `JSON.parse` in try/catch; field-presence check before the cast. The pin read (`getLocationByChat` or similar) copies this verbatim.
**Also:** extend `bot/test/privacy.test.mjs` line 25's `storageModules` list with the new module so the PG1 TTL gate covers it automatically:
```javascript
const storageModules = ["kv/sessions.ts"].map((rel) => path.join(SRC, ...rel.split("/")));
```
---
### `bot/src/bot/handlers/location-fallback.ts` + `bot.ts` registration (controller, event-driven)
**Analog:** `bot/src/bot/commands/start.ts` (handler + registration) — but the GEO-02 handler registers `bot.on("message", ...)` filtered on `ctx.message?.location` instead of `bot.command(...)`.
**Registration pattern** (start.ts lines 28-32 + bot.ts lines 20-26):
```typescript
/** Register the command stack entry: /start and its Malay alias /mula. */
export function registerStart(bot: Bot): void {
bot.command("start", handleStart);
bot.command("mula", handleStart);
}
```
```typescript
export function createBot(env: Env): Bot {
const bot = new Bot(env.BOT_TOKEN);
registerStart(bot); // /start + /mula (D-04)
// ... registerLocationFallback(bot) joins this list
return bot;
}
```
**Handler + strings-import pattern** (start.ts lines 23-26) — copy from i18n, never inline:
```typescript
async function handleStart(ctx: Context): Promise<void> {
const locale = pickLocale(ctx.from?.language_code);
await ctx.reply(startReplyText(locale), { link_preview_options: { is_disabled: true } });
}
```
**For GEO-02:** the reply keyboard is the ONE structural exception to D-02's "plain text only" rule (commands.test.mjs CS4 greps for keyboard-shaped tokens in the command tree — the new handler must either live outside the scanned `commandSources` list or the gate list must be extended deliberately; **planner must state which**). The sendMessage shape per RESEARCH Pattern 3:
```typescript
bot.chat.sendMessage(chatId, strings[locale].awaitingPin, {
reply_markup: {
keyboard: [[{ text: strings[locale].shareLocationButton, request_location: true }]],
resize_keyboard: true,
one_time_keyboard: true,
},
});
```
---
### `bot/src/i18n/strings.ts` modification (config, i18n)
**Analog:** itself. Add `awaitingPin` + `shareLocationButton` keys to `BotStrings` (lines 21-31), both locales (lines 35-56), keeping en/ms key-set parity — `commands.test.mjs` CS1 (lines 36-41) already pins parity:
```typescript
test("CS1 strings module: en and ms locales carry identical key-sets", () => {
const enKeys = Object.keys(strings.en).sort();
const msKeys = Object.keys(strings.ms).sort();
assert.deepEqual(msKeys, enKeys, "ms missing/duplicated keys vs en (D-05 locale parity)");
```
---
### `web/package.json` + root `package.json` modification (config)
**Analog:** `bot/package.json` (lines 1-11) — `@araharah/*` name, `"type": "module"`, plain npm scripts, no test framework:
```json
{
"name": "@araharah/bot",
"private": true,
"type": "module",
"scripts": {
"test": "node test/runner.mjs",
"typecheck": "tsc --noEmit",
"dev": "wrangler dev",
"deploy": "wrangler deploy && node scripts/set-webhook.mjs"
}
}
```
`web/` mirrors: `"test": "node test/runner.mjs"`, `"typecheck": "tsc --noEmit"`, plus `"dev": "vite"`, `"build": "vite build"`, `"capture": "node scripts/capture.mjs"`.
**Root delegates** (root package.json lines 5-19) — add `"web"` to workspaces (line 5-8) and `web:*` delegates mirroring the `bot:*` block:
```json
"workspaces": ["theme", "bot"],
"scripts": {
"bot:test": "npm test -w bot",
"bot:typecheck": "npm run typecheck -w bot",
"bot:dev": "npm run dev -w bot",
"bot:deploy": "npm run deploy -w bot"
}
```
D-15's one-command deploy: root `"deploy"` chains `npm run build -w web` before `npm run deploy -w bot`.
---
### `web/tsconfig.json` (config)
**Analog:** `bot/tsconfig.json` (lines 3-16) — keep every strictness flag, swap the runtime types:
```json
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types"],
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
```
Web deltas: `lib: ["ES2023", "DOM", "DOM.Iterable"]`, `types: ["vite/client"]` (for the `?worker&url` import), `"jsx"` not needed (no framework, D-01). **Do not** drop `noUncheckedIndexedAccess`/`verbatimModuleSyntax` — repo convention.
---
### `web/index.html` (entry HTML)
**Analogs:** mockup `stitch_minimalist_route_navigator/navigate_map_overview/code.html` line 3 (viewport meta — copy exactly) + `theme/basemap/index.html` (self-hosted assets only).
**Viewport meta** (mockup line 3) — `viewport-fit=cover` is the safe-area enabler:
```html
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" name="viewport"/>
```
**What NOT to copy from the mockups:** the Tailwind Play CDN `<script src="https://cdn.tailwindcss.com">`, Google Fonts links, and inline `tailwind.config` (mockup line 4) — D-02/D-14 replace these with build-time `@araharah/theme` imports. The mockups are read-only vocabulary reference (Phase 1 D-06), never edited, never shipped.
**No `telegram-web-app.js` script tag** — Telegram injects `window.Telegram.WebApp` automatically inside the WebView (RESEARCH §State of the Art); the Mock-Telegram harness injects the fake into the same window (Bot API 10.2 origin rule).
---
### `web/src/main.ts` + `web/src/map/map.ts` (controller + component, MapLibre bootstrap)
**Analog:** `theme/basemap/index.html` inline script (lines 103-173) — the repo's only existing MapLibre code. **Version delta: it uses v4 via CDN; Phase 3 uses v6 ESM + `?worker&url` + `setWorkerUrl()` (RESEARCH §Code Examples has the verified v6 bootstrap — use it for imports, use this analog for structure).**
**Map construction** (index.html lines 111-119):
```javascript
const KLCC = [101.6958, 3.1513]; // KL metro anchor — product region
const map = new maplibregl.Map({
container: 'map',
style: './liberty-rams.json', // the token-derived Rams basemap
center: KLCC,
zoom: 13,
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }));
```
Web deltas: `style` becomes the build-time JSON import (`@araharah/theme/basemap/liberty-rams.json` or relative — planner decision, RESEARCH Open Question 4); **reject the built-in GeolocateControl** (RESEARCH §Alternatives — custom 40×40 control per D-08).
**Error handling** (lines 122-125) — MapLibre errors surface, never swallowed:
```javascript
map.on('error', (e) => {
console.error('RAMS-BASEMAP-ERROR', e && e.error && (e.error.message || e.error));
});
```
Plus the v6 WebGL2 gate: `try { new Map(...) } catch (err) { if (err instanceof GPUInitializationError) return null; throw err; }` (RESEARCH §Code Examples, Pitfall 5).
**Load-tile wiring + READY signal** (lines 146-173) — sources/layers added on `map.on('load')`, console signal for the capture harness:
```javascript
map.on('load', () => {
map.addSource('rams-route-placeholder', { type: 'geojson', data: routePlaceholder });
map.addLayer({ id: 'rams-route-line', /* ... */ });
console.log('RAMS-BASEMAP-READY route=' + routeColor + ' layers=' +
map.getStyle().layers.map((l) => l.id).length);
});
```
The capture script listens for exactly this `RAMS-BASEMAP-READY` prefix (screenshot.mjs line 151) — keep the convention so `web/scripts/capture.mjs` can reuse it.
**Boot order (D-09 anti-pattern):** map mounts FIRST, chain starts second — never a blank map behind the boot furniture.
---
### `web/src/shell.css` (component, styles)
**Analog:** `theme/basemap/index.html` `<style>` block (lines 35-88) — the established pattern for chrome-around-a-map in this repo. Rules to preserve exactly:
1. **Token references ONLY** — every value is `var(--color-*)` / `var(--spacing-*)` / `var(--type-*)`; the sole literals are machined border geometry (comments at components.css lines 9-15 state the rule):
```css
body {
background: var(--color-surface);
color: var(--color-on-surface);
font: var(--type-label-md);
letter-spacing: var(--type-label-md-tracking);
}
```
2. **Map container geometry** (index.html lines 64-65):
```css
main { position: relative; flex: 1; min-height: 0; }
#map { position: absolute; inset: 0; }
```
3. **Zero-radius MapLibre chrome** (line 87):
```css
.maplibregl-ctrl-group { border-radius: 0; } /* zero roundedness, per Rams */
```
4. **Shell slot consumption (PX-02)** — use `--shell-bg`/`--shell-on-surface`, NOT raw surface colors, for anything that must re-theme:
`theme/src/theme.css` lines 100-109:
```css
:root {
--shell-bg: var(--color-surface);
--shell-on-surface: var(--color-on-surface);
}
@media (prefers-color-scheme: dark) {
:root {
--shell-bg: var(--color-surface-dark);
--shell-on-surface: var(--color-inverse-on-surface);
}
}
```
`web/src/telegram/theme.ts` sets `data-tg-theme` on `<html>`; `shell.css` adds the `[data-tg-theme="dark"]` selector pair that re-points these two slots (the media query stays as the off-Telegram default). MapLibre styling is **absent** from shell.css's theme block by design (D-10).
5. **Safe-area** — mockup line 4's env() utilities are the floor; `safe-area.ts` unifies with `WebApp.safeAreaInset` per RESEARCH Pattern 4:
```css
.pb-safe{padding-bottom:env(safe-area-inset-bottom,0px);}
.pt-safe{padding-top:env(safe-area-inset-top,0px);}
```
**Available component classes (verified in `theme/src/components.css`):** `.btn-primary`, `.btn-secondary` (lines 22-71), `.input-field` (75-93), `.card`, `.card-header`, `.card-ref`, `.card-content` (97-134), `.chip-traffic`/`.chip-hazard`/`.chip-safe` (138+), `.checkbox` (195+), `.text-label-{lg,md,sm}` (293-301). `web/src/ui/*` composes these — do not restyle what the theme package already ships.
**Orange discipline (Pitfall 8):** `--color-signal-orange` is navigation-primary only (position cone, follow-active, boot lamp, primary CTA, emphasis dot); degraded/denied lamps = `--color-functional-red`; LIVE lamp = `--color-olive-green`. Raw token names available: tokens.css lines 68-75.
---
### `web/src/telegram/webapp.ts` + `web/src/location/types.ts` (utility + model)
**Analog:** `bot/src/security/initdata.ts` — the repo's precedent for hand-rolling **minimal** typings for an untyped Telegram surface (RESEARCH "Don't Hand-Roll" says use `@types/telegram-web-app` behind this wrapper; the *narrowing* discipline is still this file's).
**Minimal interface style** (initdata.ts lines 29-51):
```typescript
/** Hand-rolled Telegram WebApp user object — no bot framework ships
* WebAppInitData typings (research-verified gap); keep the fields minimal
* and per the official spec. */
export interface WebAppInitUser {
id: number;
first_name?: string;
// ...
}
```
**Discriminated-union result type** (lines 69-77):
```typescript
export type InitDataResult =
| { ok: true; user: WebAppInitUser; /* ... */ }
| { ok: false; reason: InitDataRejection };
```
`Fix` in `location/types.ts` follows this same header-comment + minimal-fields style (RESEARCH Pattern 1 has the exact `Fix` shape — copy it from there, not invented).
**Injectable-options pattern** (lines 62-67, 133-139) — the `Fix`-producing wrappers take `{ nowMs?, timeoutMs? }` for deterministic tests:
```typescript
export interface ValidateInitDataOptions {
nowSeconds?: number; // "Now" — injectable for deterministic tests
maxAgeSeconds?: number; // Freshness window (D-13 default: 900)
}
```
---
### `web/src/session/client.ts` + `store.ts` (service + store)
**Analog (contract):** `bot/src/webhook.ts` `handleSession` — the client is the mirror image: POSTs the verbatim initData string, expects `200 {token}` / `401 {error}` / `413` (lines 107-113). Content-type `text/plain`-ish body, never `initDataUnsafe` (RESEARCH §Security V2).
**Analog (store):** `bot/src/kv/sessions.ts` module vocabulary — but INVERTED: `store.ts` is module-scope memory, zero writes anywhere (D-03/D-07). Copy the doc-comment contract style (sessions.ts lines 1-40): state up front exactly what is and isn't stored, cite the decision IDs, name the gate that pins it (`no-storage.test.mjs` is this module's `privacy.test.mjs`).
**TTL staleness:** `store.ts` re-checks `issued_at` against the 900s window before use — the same 900 constant as `SESSION_TTL_SECONDS` (import or re-declare with a shared comment; do not hardcode a bare `900` inline, mirroring the named-TTL-constant convention the PG1 gate enforces server-side).
---
### `web/src/location/telegram.ts` / `webview.ts` / `chat-pin.ts` / `chain.ts` (services)
**Analog (style only):** `bot/src/security/initdata.ts` — callback/Promise wrapping with hard timeouts, null-as-denial (never thrown exceptions), category-only failure vocabulary. **Core logic comes from RESEARCH.md Patterns 1-3** (no codebase analog exists — see No Analog Found).
- `telegram.ts`: RESEARCH lines 268-317 (`Fix` type, `telegramLocationSupported`, `requestTelegramFix` with the `setTimeout` resolve-null guard) — copy verbatim as the starting point.
- `webview.ts`: `navigator.geolocation` wrapped in the same Promise+timeout shape; result discarded when used as the Android nudge (RESEARCH lines 344-356).
- `chat-pin.ts`: bounded poll of `GET /api/location` with `Authorization: Bearer <memory token>`; 60s bound then back to the denied card (RESEARCH Pattern 3).
- `chain.ts`: three named branches, staleness as a *recoverable* state (RESEARCH lines 328-341 `looksStale`, `nudgeAndRetryTelegram`).
---
### `web/src/ui/strip.ts` / `cards.ts` / `recenter.ts` (components)
**Analogs:** `theme/src/components.css` classes + the mockup's markup vocabulary.
**Strip:** mockup's status chip (navigate_map_overview line ~20) is the shape reference — `GPS LOCKED • 120ms` = lamp + label + telemetry; web version renders `LIVE`/`ACQUIRING`/`AWAITING PIN`/`DENIED` + `FIX Ns AGO` from `i18n/strings.ts`, never inline copy. Inverted plate pattern from `.card-ref` (components.css lines 117-125):
```css
.card-ref {
font: var(--type-label-sm);
letter-spacing: var(--type-label-sm-tracking);
text-transform: uppercase;
background: var(--color-matte-black);
color: var(--color-pure-white);
padding: var(--spacing-space-xs) var(--spacing-space-sm);
border-radius: 0;
}
```
**Cards (boot/denied/signal-lost):** compose `.card` + `.card-header` + `.card-content` (components.css lines 97-134); CTAs are `.btn-primary` (ASK VIA CHAT is primary-flavored on Desktop — RESEARCH Pitfall 4) and `.btn-secondary` (retry).
**Recenter control:** mockup line 137 is the geometry reference — square (zero radius), matte-black rest state, orange active state:
```html
<button aria-label="Recenter map" class="w-9 h-9 bg-matte-black text-pure-white flex items-center justify-center active:bg-signal-orange active:text-matte-black transition-colors" onclick="pulseRecenter()" type="button">
```
Web version: 40×40 per UI-SPEC, no inline `onclick` (productionized module pattern, D-01), `aria-label` kept.
---
### `web/src/i18n/strings.ts` (config, i18n)
**Analog:** `bot/src/i18n/strings.ts` — exact structural copy: interface → `Locale` type → `Record<Locale, Strings>` → `pickLocale`. (lines 21-66; excerpt in the bot-strings section above). The web variant's keys are the UI-SPEC copy table verbatim (Copy Gate); `pickLocale`'s ms-prefix rule (lines 63-66) is reused as-is:
```typescript
export function pickLocale(telegramLanguageCode?: string | null): Locale {
const code = telegramLanguageCode ?? "";
return code === "ms" || code.startsWith("ms-") ? "ms" : "en";
}
```
---
### `web/src/mock/telegram-mock.ts` (test utility, fake provider)
**Analog (signing half):** `bot/test/webhook.test.mjs` lines 39-68 — the independent `node:crypto` signing path that produces dev-valid initData. The mock reuses this *shape* (per RESEARCH §Dev initData signing: reuse `bot/src/security/initdata.ts`'s algorithm; **do not reimplement** the HMAC — import or port the already-verified helpers):
```javascript
function telegramSecretKey(botToken) {
return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest();
}
function buildInitData(fields) {
const hash = createHmac("sha256", telegramSecretKey(TEST_TOKEN))
.update(dataCheckString(fields), "utf8")
.digest("hex");
const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
return `${wire}&hash=${hash}`;
}
```
**Fake WebApp half:** no analog — `window.Telegram.WebApp` stub with `initData`, `colorScheme`, `themeParams`, `BackButton`, `LocationManager`, `isVersionAtLeast`, `onEvent`; injected into the **same window** (Bot API 10.2 origin rule, RESEARCH Pitfall 6). Dev/build-tagged only — the `no-storage.test.mjs` BANNED list must never see it shipped.
---
### `web/test/runner.mjs` + `web/test/harness.mjs` (test infrastructure)
**Analog:** `bot/test/runner.mjs` + `bot/test/harness.mjs` — **copy both verbatim** (they are directory-relative and workspace-agnostic). Runner: auto-discover `*.test.mjs`, registry drain, PASS/FAIL lines, exit code (lines 1-47, full file). Harness (lines 6-9):
```javascript
const tests = [];
export function test(name, fn) {
tests.push({ name, fn });
}
export const registry = tests;
```
Header comment cites the precedent chain: "Same hand-rolled shape as theme/scripts/audit.test.mjs (Phase 1) and bot/test/ (Phase 2)". **ESM caveat:** dynamic `import()` resolves maplibre-gl's `import`-only export; never `require()` (RESEARCH §Don't Hand-Roll).
---
### `web/test/location-chain.test.mjs` + `bot/test/location-pin.test.mjs` (tests)
**Analog:** `bot/test/webhook.test.mjs` — the repo's pattern for testing a handler against injected fakes.
**Fake-KV + env harness** (lines 72-93):
```javascript
function makeKV() {
const map = new Map();
const ttlLog = [];
return {
async get(key) { return map.has(key) ? map.get(key) : null; },
async put(key, value, opts) {
ttlLog.push({ key, expirationTtl: opts?.expirationTtl ?? null });
map.set(key, value);
},
async delete(key) { map.delete(key); },
map, ttlLog,
};
}
```
**Import-the-module-under-test** (line 95):
```javascript
const handler = await import("../src/webhook.ts").then((m) => m.default.fetch);
```
`location-chain.test.mjs` imports the chain modules the same way and injects the fake `WebApp`/`navigator.geolocation` per case (RESEARCH Validation table GEO-01/02 rows). **Test chain logic against fakes, never a real MapLibre instance** (RESEARCH ESM caveat).
**Assertion style** (lines 108-116) — descriptive failure strings citing decision IDs:
```javascript
assert.equal(kv.ttlLog.length, 2, "mint writes exactly two keys");
for (const entry of kv.ttlLog) assert.strictEqual(entry.expirationTtl, 900, "TTL-only (PRIV-02)");
```
`bot/test/location-pin.test.mjs` additionally asserts every put in the new KV module carries `expirationTtl` (extend the `privacy.test.mjs` storageModules list — see the KV section above).
---
### `web/test/no-storage.test.mjs` (test, grep gate — GEO-03)
**Analog:** `bot/test/privacy.test.mjs` — exact same source-introspection genre. Its PG1 gate (lines 31-48) shows the per-line scan + named-constant assertion; `no-storage.test.mjs` is the inverted form (banned-token scan over `web/src/**`):
**Path scaffolding to copy** (privacy.test.mjs lines 23-27):
```javascript
const SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src");
const storageModules = ["kv/sessions.ts"].map((rel) => path.join(SRC, ...rel.split("/")));
```
**Gate body style** (lines 34-47) — file:line in the assertion message, the pinned rule in the failure text:
```javascript
lines.forEach((line, i) => {
if (line.includes(".put(")) {
assert.ok(
line.includes("expirationTtl"),
`${file}:${i + 1} — KV write without expirationTtl (PRIV-02 gate): ${line.trim()}`,
);
```
BANNED list from RESEARCH lines 648-654: `localStorage`, `sessionStorage`, `indexedDB`, `caches`, `document.cookie`, `CacheStorage`, `WebApp.DeviceStorage`, `WebApp.SecureStorage`, `WebApp.CloudStorage`.
---
### `web/test/theme.test.mjs` + `web/test/copy-gate.test.mjs` (tests)
**Analog:** `bot/test/commands.test.mjs` — pure-function/import-the-module gates with source-introspection for structural claims (header lines 17-19: "structural claims are pinned by reading the shipped source, not by behavior mocks").
**Copy-gate excerpt** (lines 59-68) — regex assertions on the strings module, both locales:
```javascript
test("CS3 PRIV-01: privacy copy states the never-facts in both locales", () => {
assert.ok(/location/i.test(strings.en.privacyNotStored), "en never-facts must name location");
assert.ok(/lokasi/i.test(strings.ms.privacyNotStored), "ms never-facts must name lokasi");
```
`copy-gate.test.mjs` asserts `web/src/i18n/strings.ts` matches the UI-SPEC table exactly (en+ms) and that shell modules contain no inline copy. `theme.test.mjs` asserts the PX-02 behaviors (dark ⇒ `data-tg-theme="dark"`, luminance fallback, `themeChanged` touches only the attribute).
---
### `web/scripts/capture.mjs` (tooling, Playwright)
**Analog:** `theme/scripts/screenshot.mjs` — extend its established toolchain (static server + chromium + MIN_BYTES blank bar + READY-signal wait + exit-code gate).
**Blank bar** (lines 44-45):
```javascript
/* Minimum plausible non-blank PNG (the plan's own blank bar). */
const MIN_BYTES = 10240;
```
**Static server** (lines 64-89) — `serveTheme()` with the docroot-guard (WR-05) is reused nearly as-is with `web/dist` (or `vite preview`) as docroot.
**READY-signal race** (lines 148-162):
```javascript
const ready = new Promise((resolveReady) => {
page.on('console', (m) => {
if (m.text().startsWith('RAMS-BASEMAP-READY')) resolveReady(true);
});
});
const signal = await Promise.race([ready, new Promise((r) => setTimeout(() => r('timeout'), 30000))]);
```
**Report + gate** (lines 178-193) — FAILED/MISSING/UNDERSIZED lines, `process.exit(1)` on any failure.
**New this phase:** `context.setGeolocation()` + `grantPermissions(['geolocation'])` + `colorScheme` for light/dark captures (Playwright docs, RESEARCH §Environment), the GEO-03 runtime probe (assert `localStorage.length === 0` etc. in-page after boot), and the mock-runner page (D-11). Playwright is already `theme/`'s dependency (`^1.50.0`, browsers cached) — `web/` declares it too or runs via the theme install.
---
## Shared Patterns
### 1. Module header comments (all new files)
**Source:** every file in `bot/src/` and `theme/src/` — e.g. `bot/src/webhook.ts` lines 1-32.
**Apply to:** every `web/src/` and new `bot/src/` file. Format: `filename.ts — one-line purpose (decision IDs).` then a dashed rule, then the contract in prose (what's stored/not stored, what's pinned by which test). This is the repo's strongest convention — the privacy guarantees are *readable from the headers*.
### 2. Category-only failure vocabulary + JSON error bodies
**Source:** `bot/src/webhook.ts` lines 74-79, 99-105; `bot/src/security/initdata.ts` line 27 (`"malformed" | "tampered" | "stale"`).
**Apply to:** the `/api/location` route, session client error mapping, location-chain failure states. Never log/echo payloads, ids, or coordinates; rejection bodies are `{error: "<category>"}`.
### 3. Hand-rolled zero-dependency test runner
**Source:** `bot/test/runner.mjs` + `bot/test/harness.mjs` (precedent chain: `theme/scripts/audit.test.mjs`).
**Apply to:** all of `web/test/`. `npm test -w web` must behave identically to `npm test -w bot` (PASS/FAIL lines, `[runner] N passed, M failed`, exit code).
### 4. Source-introspection grep gates
**Source:** `bot/test/privacy.test.mjs` (PG1/PG2), `bot/test/commands.test.mjs` (CS4 structural grep).
**Apply to:** `web/test/no-storage.test.mjs` (GEO-03), the TTL assertion for the new KV module, the no-inline-copy assertion, and the Pitfall-7 invariant (themeChanged handler's only statement is the attribute set — make it greppable).
### 5. TTL-on-every-write KV contract
**Source:** `bot/src/kv/sessions.ts` lines 79-80 + the PG1 gate that pins it.
**Apply to:** `bot/src/kv/location-pin.ts` — every `put` line carries `expirationTtl: <NAMED_CONSTANT>`. Add the module to `privacy.test.mjs`'s scanned list.
### 6. Rams styling constraints (zero radius, token-only, no blur/shadow)
**Source:** `theme/src/components.css` lines 9-15 (the rule), `theme/src/tokens.css` lines 98-127 (radius 0 + `--shadow: initial` etc.), `theme/basemap/index.html` line 87.
**Apply to:** `web/src/shell.css`, all `web/src/ui/*`, `web/index.html`. Component classes come from `@araharah/theme/dist/rams.css` (build-time import, D-02) — compose, don't restate.
### 7. Workspace + delegate-script ergonomics
**Source:** root `package.json` lines 5-19, `bot/package.json`, `theme/package.json`.
**Apply to:** `web/package.json` + root delegates (`web:dev`, `web:build`, `web:test`, `web:capture`); D-15's one-command root `deploy`.
### 8. Dev-signing via the independent node:crypto path
**Source:** `bot/test/webhook.test.mjs` lines 39-68 (`buildInitData`); algorithm canonicalized in `bot/src/security/initdata.ts` lines 171-183.
**Apply to:** `web/src/mock/telegram-mock.ts` — the fake's dev initData is signed with the dev bot token so the real `/api/session` accepts it unmodified.
### 9. Ops scripts: .dev.vars reading + fail-loud exits
**Source:** `bot/scripts/set-webhook.mjs` lines 24-54 (readDevVars, missing-var guard, `process.exit(1)` with non-secret error text).
**Apply to:** any new ops script (e.g., a device-gate checklist helper); deploy itself stays `wrangler deploy` (bot/package.json line 9).
---
## No Analog Found
Files with no close codebase match — the planner should use RESEARCH.md's code examples as the pattern source instead:
| File | Role | Data Flow | Reason | RESEARCH.md source |
|------|------|-----------|--------|--------------------|
| `web/vite.config.ts` | config | build | No bundler has ever been configured in this repo (mockups are CDN-static; bot/theme have no bundler) | §Code Examples "Vite + MapLibre v6 bootstrap"; `?worker&url` import |
| `web/src/location/chain.ts` | service | event-driven | No state machine or location code exists anywhere; this is the phase's new core abstraction | §Pattern 1 (state machine) + §Pattern 2 (stale-fix nudge) — full code given |
| `web/src/location/webview.ts` | service | request-response | No `navigator.geolocation` usage in repo | §Pattern 2 (`readWebviewFix` shape) |
| `web/src/telegram/backbutton.ts` / `safe-area.ts` / `follow.ts` | utility/component | event-driven | No Telegram client API code exists (bot-side only so far) | §Pattern 4 (version-gating table); UI-SPEC for safe-area/follow details |
| `bot/wrangler.jsonc` `[assets]` block | config | — | New Cloudflare config surface ( Workers static assets didn't exist when Phase 2 wrote this file) | RESEARCH cites developers.cloudflare.com/workers/static-assets/binding — `assets.directory`, `assets.binding`, `run_worker_first: ["/api/*", ...]` glob array, `not_found_handling` |
**Near-misses worth naming (planner should NOT treat as analogs):**
- `maplibre-gl` v4 CDN usage in `theme/basemap/index.html` — structural analog only; v6 requires named ESM imports, `setWorkerUrl()`, and WebGL2 handling (RESEARCH §State of the Art).
- Mockup inline `onclick` handlers — the anti-pattern D-01 explicitly productionizes away; copy the *markup vocabulary*, never the handler style.
---
## Metadata
**Analog search scope:** `bot/src/**`, `bot/test/**`, `bot/scripts/**`, `theme/src/**`, `theme/scripts/**`, `theme/basemap/**`, `theme/dist/`, root config files, `stitch_minimalist_route_navigator/navigate_map_overview/code.html`. `.planning/phases/0{1,2}-*/` CONTEXT files consulted for cross-phase contracts (theme package D-04 slots, /api/session contract).
**Files scanned:** 24 source/config files read; ~15 grep/glob passes over `web/`-shaped patterns (confirmed absent).
**Key confirmed-absent surfaces:** no `web/` directory, no bundler config, no frontend TypeScript, no `navigator.geolocation`/Telegram-WebApp client code, no `[assets]` wrangler config — hence the four No-Analog rows.
**Pattern extraction date:** 2026-10-22
@@ -0,0 +1,900 @@
# Phase 3: Mini App Shell + Geolocation - Research
**Researched:** 2026-10-22
**Domain:** Telegram Mini App platform integration, WebView geolocation, MapLibre GL JS v6, Vite build, Cloudflare Workers static assets
**Confidence:** HIGH for platform API surface (official Telegram + MapLibre + Cloudflare docs fetched this session); MEDIUM for the Android stale-fix workaround shape (community-sourced, unverified on device)
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Mini App stack + repo layout**
- **D-01:** Frontend is TypeScript + Vite, **no UI framework** — MapLibre GL JS v6 bundled, interactivity via small focused modules (the mockups' pattern, productionized). This stack is the foundation Phases 4–6 build on.
- **D-02:** App source lives in a `web/` npm workspace beside `theme/` and `bot/`; root package.json gains `web:*` delegate scripts. Theme is consumed as a **build-time dependency** (`theme/dist/rams.css` imported into the Vite build, not via CDN) — completing Phase 1's self-hosted/CDN-free decision for the app path.
- **D-03:** Post-initData validation, the session token is held **memory-only** (module scope), never written to localStorage/sessionStorage/cookies. Revalidation happens on every fresh boot (Telegram revives webviews) and whenever the 900s TTL stalls mid-session. Research may stress-test this invariant across webview reload quirks, but the invariant itself is locked.
**Location acquisition strategy**
- **D-04:** Primary chain: Telegram-native `WebApp.getLocation()` first (Android LocationManager with version-gated paths), fall back to `navigator.geolocation` inside the WebView, then the chat-keyboard "send location" fallback (bot-side; app consumes it). Research details the Android stale-fix and iOS WKWebView quirks — the CHAIN is locked, the per-link mechanics are research's.
- **D-05:** Cadence is **gated streaming**: one-shot fixes during browsing/search; continuous watch only during guidance (Phase 6 builds the streaming state machine). Map recenters on each fix; the position marker always shows the freshest fix.
- **D-06:** Denied/no-geolocation presents a deliberate Rams-styled denied state — explains why location matters, offers retry, points to Telegram's attach → Location path as the manual fallback (GEO-02). No silent banners.
- **D-07:** Coordinates live in **memory only** while the app runs. Zero persistence across every storage API (localStorage, sessionStorage, cookies, IndexedDB, service-worker caches). This zero-persistence assertion is part of the phase's GEO-03 spot-check, stress-tested by research against every storage surface the app touches.
**Shell UX + screens**
- **D-08:** Phase 3's screen surface is map-first and single: full-bleed Rams basemap, position dot, follow/recenter control, minimal status strip reflecting session/location state. Route lines, layer toggles, search furniture belong to later phases — nothing placeholder-shaped.
- **D-09:** Boot choreography has staged deliberate states: validating session → acquiring location → live — each Rams-styled, none blank (extends the neutral-denied rule from PX-02).
- **D-10:** Map survives theme flips Rams-constant (Phase 1 D-04 slots pay off); only shell surfaces re-theme via the Telegram theme params. Safe-area handling + BackButton wiring land here as infrastructure.
**Dev/test harness**
- **D-11:** A **Mock-Telegram harness** for the daily loop: a local module injecting a fake `window.Telegram.WebApp` (dev-signed initData, BackButton events, theme params with light/dark switch, sample location streams) plus a runner page booting the real app off-Telegram in a plain browser. Real-device batteries are spent at the device-matrix exit gate, not during daily dev.
- **D-12:** Local dev topology is **wrangler dev** on 8787 serving web/dist statics + `/api/*` + webhook with local KV simulation; remote staging used sparingly right before real-device verification sessions.
**Worker static-asset wiring**
- **D-13:** Statics served by the **Workers `[assets]` binding** (wrangler config), from `web/dist`'s build output; Worker fetch still owns `/api/*` + webhook first. Completes the one-deployable architecture from the roadmap.
- **D-14:** Build inlines everything the app needs: CSS from theme, MapLibre, basemap style JSON (from `theme/basemap/liberty-rams.json`), self-hosted fonts copied at build time into `web/dist`. Tiles remain OpenFreeMap's endpoint at runtime; no CDN fallbacks.
- **D-15:** Deployment stays one command (`npm run deploy` = build web → wrangler deploy with assets). Same ergonomics as Phase 2.
### OpenCode's Discretion
- Exact per-link mechanics of the location chain (Telegram API calls, permission bridging) — research's domain
- Boot-state copy, exact mock initData signing mechanics, recenter-toggle visual details (within the Rams system)
- `assets` config tuning (binding name, not_found_handling, asset prefixes)
- How the reserved exit-gate Playwright matrix extends theme/scripts/screenshot.mjs
### Deferred Ideas (OUT OF SCOPE)
- Scripted Playwright device-matrix spec (mock-based) — extend theme/scripts toolchain at the exit gate
- Placeholder-shaped search drawer / route furniture — later phases by design
- Real-device TestFlight/Play Beta channels — out of scope; device-matrix exit pass suffices for v1
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| **GEO-01** | The Mini App can obtain the user's current location via Telegram LocationManager, with permission persisting across sessions | `LocationManager.init()` → `getLocation()` gate (`isAccessGranted` persists per-bot across sessions — the API's `openSettings()` path is the manual re-grant). **Version floor Bot API 8.0.** §"The definitive LocationManager API" + §"Android stale-fix" + §"Version gating floor". **Critical:** `getLocation()` is one-shot; there is **no** `getLiveData`/`startLocationUpdates` in the official API — see §"getLiveData does not exist" |
| **GEO-02** | When in-app location fails (denied permissions or Desktop Telegram without geolocation), the chat "send location" keyboard fallback obtains the origin | `KeyboardButton.request_location: true` in a `ReplyKeyboardMarkup` → user shares a `Location` message → bot receives it in an update. Desktop geolocation is confirmed broken (issue #77). §"The chat-keyboard fallback mechanism (GEO-02)" + §"Desktop Telegram has no geolocation at all" |
| **GEO-03** | The user's location is never written to persistent storage — only ephemeral TTL server state exists during the active trip | Zero-persistence is enforceable and *testable*: Vite emits no service worker by default; MapLibre's tile cache is `CacheStorage`/HTTP-cache (transient, and tile URLs are not coordinates). §"GEO-03 is enforceable, not aspirational" — includes the grep gate + runtime probe design. Confirms D-03's localStorage caution (`localStorage` may not even persist in the WebView) |
| **PX-02** | The app follows Telegram light/dark themes for chrome while the map keeps the Rams cartography constant | `WebApp.colorScheme` (`"light"`/`"dark"`) + `themeChanged` event. Phase 1's `--shell-bg`/`--shell-on-surface` slots are the switch target. Map is *never* re-styled. §"PX-02 theme mapping — the map is untouched" |
</phase_requirements>
---
## Summary
Phase 3 is the project's highest platform-integration risk, and this research surfaces **four findings that materially change the plan**:
1. **`getLiveData()` does not exist.** The roadmap note (`.planning/ROADMAP.md:91`) asks research to find a "`getLiveData()` wrapper" for the Android stale-fix. There is no such method in the official Telegram WebApp API. `LocationManager` has exactly `init()`, `getLocation(callback)`, `openSettings()`, and two events (`locationManagerUpdated`, `locationRequested`). D-05's "gated streaming" must therefore be built on top of either repeated `getLocation()` polling or `navigator.geolocation.watchPosition()` — the planner must choose explicitly and Phase 6 must not assume a native streaming primitive exists.
2. **The Android stale-fix bug is real, confirmed, and unfixed.** [Telegram-Mini-Apps issue #56](https://github.com/Telegram-Mini-Apps/issues/issues/56) — opened Dec 2024, still open, labeled `bug` + `in progress` + `Telegram for Android`, with 6 independent confirmations and the reporter concluding "This issue renders the new LocationManager api useless." The reported workaround: **`navigator.geolocation.getCurrentPosition()` inside the WebView refreshes the same fix** that a later `LocationManager.getLocation()` then returns. This inverts D-04's naive reading — the chain is not "try Telegram, then browser"; it is "Telegram first, browser to nudge it."
3. **Desktop Telegram has no geolocation, confirmed.** [Issue #77](https://github.com/Telegram-Mini-Apps/issues/issues/77) (open, Apr 2025 → Jan 2026) reports no permission prompt at all on Telegram Desktop/Windows/Mac/Linux, with a second reporter confirming in Sep 2025 that the geolocation button is *greyed out*. GEO-02's chat-keyboard fallback is therefore the *only* origin path on Desktop — not a degraded backup. This strengthens D-06's "no client-specific variant" copy rule: the Desktop experience must not read as an error state.
4. **MapLibre v6 is ESM-only and WebGL2-only — this breaks the repo's existing test runner shape.** `maplibre-gl@6.15.0` ships `exports: { ".": { import: "./dist/maplibre-gl.mjs" } }` with **no `require` condition** (verified against the npm registry). Any `.mjs` test that `import`s maplibre through the hand-rolled runner is fine, but any CJS-style `require()` or an untranspiled tool will fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Separately, **WebGL2 is now required** and the `Map` constructor throws `GPUInitializationError` when unavailable — the planner needs a deliberate fallback for that, and the Phase-1 Playwright capture toolchain (chromium-1217/1248 cached) is WebGL2-capable so the harness is fine.
**Bonus finding — a live security constraint nobody has in the docs:** Bot API **10.2** (July 14, 2026, auto-enabled July 20, 2026) *"disallowing the usage of Mini App methods from origins different from the original Mini App domain."* This is now in force. D-11's Mock-Telegram harness and D-12's `wrangler dev` on `localhost:8787` are fine (same origin), but it means the app must never call `Telegram.WebApp.*` from an iframe, a partner domain, or a secondary origin — and BotFather's registered URL is the authority.
**Primary recommendation:** Ship the location chain as a single `locationSource` state machine with three named sources (`telegram` / `webview` / `chat-pin`), each returning the same `Fix` shape, selected by an explicit capability probe — and treat "Telegram fix is stale" as a *recoverable* condition that fires the `navigator.geolocation` nudge before falling through, rather than as success.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Telegram WebApp API access (initData, theme, BackButton, safe-area, LocationManager) | Frontend Server (browser runtime) | — | `window.Telegram.WebApp` only exists inside the WebView; nothing server-side can read it. All gating (`isVersionAtLeast`, `colorScheme`, `isAccessGranted`) is client-side. |
| Session mint + validation | API / Backend (Worker) | Frontend Server | Phase 2 already owns `/api/session`. The app only *posts* `initData` and holds the token in memory (D-03). |
| Chat-keyboard location fallback (GEO-02) | API / Backend (Worker) | Frontend Server | The bot must send the `ReplyKeyboardMarkup` and receive the `Location` message via webhook. The app can only *request* the fallback and *display* the waiting state. |
| Fix normalization (`LocationData` vs `GeolocationPosition` vs bot `Location`) | Frontend Server | API / Backend | Three sources, three shapes, one internal `Fix`. Normalization is a client concern; the bot's `Location` message must be relayed to the app via an authenticated endpoint (bot can't push to the WebView). |
| Map rendering + camera + marker | Frontend Server (MapLibre) | CDN / Static (tiles) | MapLibre runs in the page; tiles come from OpenFreeMap at runtime (D-14). Style JSON is inlined at build time. |
| Theme slot switching (PX-02) | Frontend Server | — | `colorScheme` + `themeChanged` are client events. Backend never sees them. |
| Safe-area / viewport | Frontend Server | — | `env(safe-area-inset-*)` + `WebApp.safeAreaInset` are both client-read CSS/JS values. |
| BackButton wiring | Frontend Server | — | `WebApp.BackButton.show()/hide()/onClick()` is client-only. Phase 3 ships the wire; Phase 6 extends the state graph. |
| Coordinate persistence policy | API / Backend (KV TTL) + Frontend Server (memory only) | — | D-07 splits it: the *client* must write nothing (verifiable by grep + runtime probe); the *server* keeps only TTL-expiring session claims (already built in Phase 2). |
---
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `maplibre-gl` | **6.15.0** (published 2026-10-18) | Vector map renderer for the Rams basemap | D-01 locks MapLibre GL JS v6. Zero license cost (BSD-3), no Mapbox token. `theme/basemap/liberty-rams.json` is already a v8 style spec built for it. |
| `vite` | **8.3.5** (published 2026-10-21) | Dev server + production bundler | D-01 locks Vite. v8 requires `node: ^20.19.0 \|\| >=22.12.0` — the repo runs Node **24.18.0**, satisfied. v8 handles the `?worker&url` MapLibre worker import natively. |
| `typescript` | **5.9.3** (pinned in `bot/package.json`) | Type safety for `web/` | Already a workspace dependency; matching the bot's pin avoids workspace version skew. *Note:* npm registry shows `typescript@7.0.2` as latest — do **not** upgrade the shared pin as a side effect of this phase. |
| `@araharah/theme` | workspace | Rams CSS tokens + component classes | D-02 locks build-time consumption of `theme/dist/rams.css`. Component classes `.btn-primary`, `.btn-secondary`, `.card`, `.text-label-sm` etc. are **verified present** in `theme/src/components.css`. |
| `@telegram-apps/types` (or hand-written `.d.ts`) | see note | TypeScript types for `window.Telegram.WebApp` | **`@types/telegram-web-app@10.1.0` exists** (published 2026-10-04) and is the only viable npm option — `telegram-web-app`, `telegram-web-app.js`, `@telegram-apps/web-app` are all dead ends (E404 / abandoned 2022). Verify its `LocationManager` typings match the official docs before committing; otherwise hand-write a narrow interface for the ~12 members this phase uses. **[MEDIUM confidence]** |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `wrangler` | **4.151.3** (already in `bot/`) | Local dev on 8787 + deploy with `[assets]` | D-12/D-13/D-15. The `assets` binding config is documented and supported at this version. |
| `playwright` | already in `theme/` (`^1.50.0`, browsers cached: chromium-1248, webkit-2272, firefox-1511) | Mock-Telegram harness captures + exit-gate device matrix | D-11 + the DSGN-02 fidelity artifact. Playwright's `context.setGeolocation()` + `grantPermissions(['geolocation'])` are the clean way to drive the harness's location stream. |
| `grammy` | `^1.46.0` (already in `bot/`) | Receiving the `Location` message for GEO-02 | Already the bot's framework; a `bot.on('message', ...)` handler filtered on `message.location` is the whole mechanism. |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `maplibre-gl` built-in `GeolocateControl` | Custom follow/recenter control | **Reject.** GeolocateControl renders its own chrome (violates D-08's map-only surface) and owns its own geolocation strategy, which would fight D-04's chain. The UI-SPEC specifies a custom 40×40 machined control. |
| `maplibre-gl` `Marker` | GeoJSON source + circle layers | **Either works.** `Marker` gives exact DOM control over the accuracy ring + heading cone (the UI-SPEC's geometry is DOM/SVG-shaped: 20px dot, 16px cone). A GeoJSON source is better for Phase 5's route line. **Recommendation:** `Marker` for the position group in Phase 3; the route line (Phase 5) will use a GeoJSON source. They coexist. |
| A third-party Telegram SDK (`@telegram-apps/sdk`) | Direct `window.Telegram.WebApp` calls | **Reject.** D-01 says no framework; the SDK is a React-oriented abstraction layer. Direct calls + a thin typed wrapper keep the bundle small and the platform surface auditable — which matters because the location chain *is* the phase. |
| `vitest` / `jest` | Existing hand-rolled `test/runner.mjs` | **Reject for unit tests** (repo convention, zero deps, proven in `theme/scripts/audit.test.mjs` + `bot/test/`). **But see the ESM caveat in §"Don't Hand-Roll"** — maplibre-gl has no `require` export. |
**Installation:**
```bash
# from repo root — add "web" to workspaces, then:
npm install -w web maplibre-gl@^6.15.0
npm install -w web -D vite@^8.3.5 @types/telegram-web-app@^10.1.0
```
Root `package.json` gains `"web"` in `workspaces` plus delegate scripts (`web:dev`, `web:build`, `web:test`, `web:capture`) mirroring the existing `bot:*` pattern (D-02).
**Version verification (run this session, `npm view` against the live registry):**
| Package | Version | `time.modified` |
|---------|---------|-----------------|
| `maplibre-gl` | 6.15.0 | 2026-10-18 |
| `vite` | 8.3.5 | 2026-10-21 |
| `typescript` | 7.0.2 (latest) — **repo pins 5.9.3** | 2026-10-22 |
| `@types/telegram-web-app` | 10.1.0 | 2026-10-04 |
| `wrangler` (installed) | 4.151.3 | — |
---
## Architecture Patterns
### System Architecture Diagram
```text
┌─────────────────────────────────────────────────────────────────┐
│ TELEGRAM CLIENT (Android / iOS / Desktop) │
│ │
│ WebView ── window.Telegram.WebApp ── │
│ • initData ──────────┐ │
│ • colorScheme ──┐ │ │
│ • LocationManager│ │ │
│ • BackButton ──┼───┼── (client-only APIs) │
│ • safeAreaInset│ │ │
└────────────────────┼───┼───────────────────────────────────────┘
│ │
┌────────────┘ └──────────────┐
│ │
▼ ▼
┌───────────────────────┐ ┌──────────────────────────┐
│ web/ (Vite SPA) │ │ bot/ (Worker) │
│ │ │ │
│ 1. boot shell │ │ POST /telegram/webhook │
│ staged states │ │ ├─ /start /help /privacy│
│ 2. POST /api/session │──▶│ POST /api/session │
│ initData → token │ │ validate HMAC │
│ (token = memory) │ │ mint opaque token │
│ 3. location chain │ │ KV put TTL=900 │
│ ┌──────────────┐ │ │ │
│ │ telegram │ │ │ GEO-02 fallback: │
│ │ LocationMgr │ │ │ sendMessage( │
│ │ .getLocation│ │ │ reply_keyboard:[ │
│ │ ↓ null / │ │ │ {text:"Share my │
│ │ ↓ STALE │ │ │ location", │
│ │ ↓ (platform │ │ │ request_location: │
│ │ gating) │ │ │ true}]}) │
│ ├──────────────┤ │ │ ← receives message │
│ │ webview │ │ │ .location │
│ │ navigator. │ │ │ write to KV (TTL) │
│ │ geolocation │ │ │ │
│ │ (THE NUDGE │ │ └──────────┬───────────────┘
│ │ that │ │ │
│ │ refreshes │ │ │ GET /api/location
│ │ the stale │ │ │ (token-auth'd,
│ │ telegram │ │ │ TTL-ephemeral)
│ │ fix) │ │ ▼
│ ├──────────────┤ │ ┌──────────┐
│ │ chat-pin │◀──┼────────│ app │ ← D-04 link 3
│ │ (awaiting) │ │ poll │ polls │
│ └──────────────┘ │ └──────────┘
│ │ │
│ ▼ │
│ normalize → Fix │
│ {lat,lng,acc,heading,ts,source}
│ │
│ ├──────────────▶ MapLibre (Rams style, INLINED at build)
│ │ • Marker: dot + cone + accuracy ring
│ │ • camera: follow / free (96px padding)
│ │ • style NEVER changes on theme flip
│ ▼
│ status strip + boot card + denied card
│ (chrome re-slots on themeChanged; map does not)
│
│ RUNTIME (never touched): localStorage · sessionStorage · cookies
│ IndexedDB · CacheStorage · SW
└─────────────────────────────────────────────────────────────────┘
```
### Recommended Project Structure
```
web/
├── package.json # workspace: @araharah/web
├── tsconfig.json
├── vite.config.ts # base:'/', build.outDir:'dist'
├── index.html # viewport-fit=cover meta (UI-SPEC safe-area)
├── public/ # (empty or minimal — nothing static ships)
├── src/
│ ├── main.ts # boot entry: mount map → session → location
│ ├── shell.css # ONLY var(--color-*) / var(--spacing-*) refs
│ ├── telegram/
│ │ ├── webapp.ts # typed window.Telegram.WebApp accessor + isVersionAtLeast gate
│ │ ├── theme.ts # colorScheme → data-tg-theme (PX-02)
│ │ ├── backbutton.ts # D-10 wire; total fn (no dead-ends)
│ │ └── safe-area.ts # env() ∪ WebApp.safeAreaInset, per-edge max
│ ├── session/
│ │ ├── client.ts # POST /api/session; token in MODULE SCOPE (D-03)
│ │ └── store.ts # in-memory only; TTL staleness check
│ ├── location/
│ │ ├── types.ts # Fix {lat,lng,acc,heading?,ts,source}
│ │ ├── telegram.ts # LocationManager.init/getLocation + staleness detect
│ │ ├── webview.ts # navigator.geolocation getCurrentPosition/watchPosition
│ │ ├── chat-pin.ts # poll the bot-side pin endpoint
│ │ └── chain.ts # the D-04 state machine (three sources)
│ ├── map/
│ │ ├── map.ts # MapLibre Map, Rams style, setWorkerUrl once
│ │ ├── marker.ts # dot + cone + accuracy ring (DOM Marker)
│ │ └── follow.ts # follow/free toggle, 96px camera padding
│ ├── ui/
│ │ ├── strip.ts # status strip (lamp + label + telemetry)
│ │ ├── cards.ts # boot / denied / signal-lost cards
│ │ └── recenter.ts # 40×40 machined control
│ ├── i18n/
│ │ └── strings.ts # en + ms, UI-SPEC copy table verbatim (Copy Gate)
│ └── mock/
│ └── telegram-mock.ts # D-11 fake WebApp (dev/build-tagged only)
├── test/
│ ├── runner.mjs # hand-rolled (repo precedent)
│ ├── harness.mjs
│ ├── location-chain.test.mjs # GEO-01/02 chain with injected fake WebApp
│ ├── no-storage.test.mjs # GEO-03 grep + AST gate
│ └── copy-gate.test.mjs # strings.ts matches UI-SPEC table
└── scripts/
└── capture.mjs # extends theme/scripts/screenshot.mjs pattern
```
### Pattern 1: The Location Chain as an Explicit State Machine
**What:** Three named sources, one `Fix` output type, one selection function. Every failure mode is a *state*, never an exception that escapes to a blank screen.
**When to use:** Always — this is the phase's core abstraction and Phases 5/6 both consume it (Phase 5 snaps routes to fixes; Phase 6's guidance loop drives the watch cadence).
**The three sources and their real behavior:**
```typescript
// Source 1: Telegram-native (GEO-01 primary path)
export type Fix = {
lat: number; lng: number;
accuracyM: number | null; // horizontal_accuracy | coords.accuracy
headingDeg: number | null; // course | coords.heading — null when unknown
ts: number; // ms epoch, for FIX AGE telemetry
source: 'telegram' | 'webview' | 'chat-pin';
};
// Version gate FIRST — LocationManager is Bot API 8.0+.
// Do not feature-detect by truthiness alone: an old client exposes
// `WebApp.LocationManager` as undefined, but a client that has the
// field with isLocationAvailable=false is a DIFFERENT failure.
export function telegramLocationSupported(wa: WebApp): boolean {
return typeof wa.isVersionAtLeast === 'function' && wa.isVersionAtLeast('8.0');
}
```
```typescript
// Source 1 continued — init() then getLocation().
// getLocation's callback receives `null` when access was NOT granted;
// there is no error object, so the null case is the whole denial class.
export function requestTelegramFix(wa: WebApp, timeoutMs = 8000): Promise<Fix | null> {
return new Promise((resolve) => {
const lm = wa.LocationManager;
if (!lm) return resolve(null);
const finish = (d: unknown | null) => {
if (!d) return resolve(null); // ← not granted, or unavailable
const loc = d as LocationData;
resolve({
lat: loc.latitude,
lng: loc.longitude,
accuracyM: loc.horizontal_accuracy ?? null,
headingDeg: loc.course ?? null, // null ⇒ UI-SPEC hides the cone
ts: Date.now(),
source: 'telegram',
});
};
// init() is REQUIRED before first use (official docs).
if (lm.isInited) return lm.getLocation(finish);
lm.init(() => lm.getLocation(finish));
// getLocation has no documented timeout — add one, or the boot
// choreography hangs in stage 2 forever on a wedged client.
setTimeout(() => resolve(null), timeoutMs);
});
}
```
**Why this shape:** `getLocation` is documented as *"called with `null` as the first argument if access to location was not granted"* — there is no rejection path and no error code. Every caller must therefore treat `null` as "could not get a fix, try the next source," and the chain must have a hard timeout because the API has none. **[CITED: core.telegram.org/bots/webapps §LocationManager]**
### Pattern 2: Android Stale-Fix Recovery (the "nudge")
**What:** On Android, `LocationManager.getLocation()` returns a *static* coordinate that does not refresh until some other app triggers a location update. Calling `navigator.geolocation.getCurrentPosition()` inside the same WebView forces a refresh.
**When to use:** Only as a *recovery* path after a Telegram fix is detected stale. Not as the primary source.
```typescript
/**
* Android: LocationManager.getLocation() returns a cached, non-refreshing
* fix (Telegram-Mini-Apps issue #56, open since Dec 2024).
* Detecting it: compare two successive telegram fixes. A device that has
* MOVED but returns byte-identical coordinates is the signature.
*/
export function looksStale(prev: Fix | null, next: Fix): boolean {
if (!prev) return false;
const movedMeters = haversineMeters(prev, next);
// Identical to sub-centimetre precision ⇒ the cache never refreshed.
return movedMeters < 0.01;
}
```
```typescript
// The nudge: a single webview read refreshes the native cache, so the
// NEXT telegram read returns fresh coordinates.
export async function nudgeAndRetryTelegram(
wa: WebApp,
): Promise<Fix | null> {
const first = await requestTelegramFix(wa);
if (!first) return null;
if (!looksStale(lastAccepted, first)) return first;
await readWebviewFix({ timeoutMs: 5000 }); // the nudge — result discarded
return requestTelegramFix(wa); // now fresh (issue #56)
}
```
**Honest caveat — this is MEDIUM confidence.** The mechanism is community-reported and reproduced by 6+ developers, but **it has not been verified on a real device in this project.** The planner should:
- Implement it behind the chain (it degrades to "keep polling telegram," never to a crash), and
- Make the device-matrix exit gate (§Success Criterion 5) the place where it is *proven*, with an explicit note that a phone's Location settings may need to be toggled if the first Android verification shows a still-static fix.
### Pattern 3: The Chat-Keyboard Fallback (GEO-02)
**What:** The bot sends a `ReplyKeyboardMarkup` with `request_location: true`. The user taps it, Telegram's native location picker opens, and the bot receives a `Message` with a `location` field.
**Bot side** (worker, grammY):
```typescript
// KeyboardButton.request_location — available in private chats only.
bot.chat.sendMessage(chatId, strings.awaitingPin, {
reply_markup: {
keyboard: [[{ text: strings.shareLocationButton, request_location: true }]],
resize_keyboard: true, // ← one tap to the native picker
one_time_keyboard: true, // ← dismisses after use; keeps the chat clean
},
});
// The bot then receives the shared pin as a normal message:
bot.on('message', (ctx) => {
const loc = ctx.message?.location; // Location { latitude, longitude,
if (!loc) return; // horizontal_accuracy?, live_period? }
// …write to TTL-ephemeral KV keyed by the session token…
});
```
**App side:** the Mini App **cannot** receive the pin directly — Telegram has no push channel from chat → WebView. The app must poll an authenticated endpoint (`GET /api/location`, `Authorization: Bearer <memory token>`), which reads the same TTL-ephemeral KV. This endpoint is a **new Worker route this phase must add** — it does not exist in Phase 2.
**[CITED: core.telegram.org/bots/api §KeyboardButton — "If True, the user's current location will be sent when the button is pressed. Available in private chats only."; §Location — `latitude`, `longitude`, `horizontal_accuracy?`, `live_period?`, `heading?`]**
**Design consequence for D-06:** the UI-SPEC's `AWAITING PIN` strip state and "Waiting for the pin you send in chat" card body are exactly right — the wait is genuinely server-mediated, and the app must poll rather than assume instant delivery. Set a bounded poll (e.g. 60s, then back to the denied card with the hint re-shown) so the UI can never wedge on `AWAITING PIN`.
### Pattern 4: Version-Gated Capability Probes
**What:** Every Telegram API this phase touches is version-gated. Gate with `isVersionAtLeast()` and give each gated feature a working fallback.
| Feature | Bot API | Fallback if unavailable |
|---------|---------|--------------------------|
| `LocationManager` | 8.0 | `navigator.geolocation` → chat-pin |
| `safeAreaInset` / `contentSafeAreaInset` | 8.0 | pure `env(safe-area-inset-*)` (UI-SPEC already specifies this) |
| `colorScheme` | always present | luminance test on `themeParams.header_bg_color` (UI-SPEC already specifies this) |
| `BackButton` | 6.1 | hidden; native swipe-down close remains the exit gesture |
| `themeChanged` event | 6.0 | n/a (event support is broad) |
**The one non-negotiable:** `isAccessGranted` is what makes GEO-01's "permission persists across sessions" true — Telegram stores the grant **per bot, on the device**, outside the WebView's storage. That is exactly why D-03's memory-only token and D-07's zero-persistence do not conflict with a persistent permission: **the grant lives on Telegram's side of the boundary, not in the page.** The planner should state this explicitly in the GEO-01 verification notes, because it is the subtlest point in the phase.
### Anti-Patterns to Avoid
- **Blank map while the chain runs.** D-09 requires the basemap to mount and render tiles *immediately*, with boot furniture overlaid. Mount the map first, start the chain second. The reverse order produces the exact blank screen the UI-SPEC forbids.
- **Swapping the style JSON on theme flip.** D-10 / PX-02: the map is Rams-constant. A `map.setStyle()` on `themeChanged` would destroy and rebuild all 110 layers and flash — it also violates "zero re-styling, zero re-creation" in the UI-SPEC's mapping table. Only `data-tg-theme` on `<html>` changes.
- **Treating `null` from `getLocation()` as an error to surface.** It is the *normal* denial signal. There is no error object and no code. Branch on `null`, not on exceptions.
- **Calling `LocationManager.init()` more than once, or using before init.** Official docs: "Before the first use of this object, it needs to be initialized." Guard on `isInited`.
- **Assuming a polling interval delivers live location on Android.** It does not — that is issue #56. Without the nudge (or without accepting staleness honestly), a "LIVE" strip on Android may be showing a frozen pin. **The strip's `FIX Ns AGO` telemetry is the honesty mechanism**; make it honest rather than assuming freshness.
- **Storing the fix "just for convenience"** in `localStorage` to survive a webview revival. That is exactly what D-07 forbids and D-03 warns about — and it would not even work reliably (see §"localStorage is unreliable in the WebView").
- **Adding a service worker** for offline tiles. Vite emits none by default; PWA work is explicitly out of scope; and a SW cache is a GEO-03 audit surface for zero benefit this phase.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Telegram WebApp types | A 500-line ambient `.d.ts` | `@types/telegram-web-app@10.1.0`, narrowed by a thin local wrapper | The full `WebApp` interface is large and versioned; the package tracks Bot API 10.x. Wrap it in `web/src/telegram/webapp.ts` so a types mismatch is one file to fix. **[MEDIUM]** — verify its `LocationManager` shape against the official docs before relying on it. |
| MapLibre worker plumbing | Copying `maplibre-gl-worker.mjs` into `public/` and hand-maintaining a URL | Vite's `?worker&url` query | The docs are explicit: for bundlers (Vite included), `import.meta.url` does not resolve the worker, so a one-time `setWorkerUrl()` is required. Vite's query does it correctly and hash-caches it for free. |
| A geolocation state machine from scratch | A bespoke event emitter + retry/timeout plumbing | A single explicit `chain.ts` with three named branches | The state space is small and *known* (verified sources: telegram / webview / chat-pin). A generic abstraction would obscure exactly the platform-specific behavior that is the point of this phase. |
| A test framework | Adding vitest/jest | The existing hand-rolled `runner.mjs` + `harness.mjs` | Repo convention across `bot/test/` and `theme/scripts/audit.test.mjs`; zero dependencies; proven. **Caveat below.** |
| Battery-farming in dev | Real phones in the daily loop | The Mock-Telegram harness (D-11); phones at the exit gate | D-11 already locks this. Device time is expensive and the harness covers ~90% of the loop. |
| HTTP polling | WebSockets / SSE for the chat-pin | Bounded `fetch` polling | Cloudflare Workers + Durable Objects are not in the v1 stack (AGENTS.md: ops-stack stripped). A bounded poll against the TTL-ephemeral KV endpoint is the honest v1 answer. |
**⚠️ Key insight — the ESM trap in our own test runner.** `maplibre-gl@6.15.0` publishes:
```json
"exports": { ".": { "types": "./dist/maplibre-gl.d.ts", "import": "./dist/maplibre-gl.mjs" } }
```
There is **no `require` condition** (verified against the npm registry this session). The repo's test runner is `node test/runner.mjs` doing `await import(...)` on `.mjs` files with `"type": "module"` — **that is fine**, dynamic `import()` resolves the `import` condition. What breaks is any CJS `require('maplibre-gl')` or a tool that doesn't transform ESM. The planner must state this in the plan so an executor doesn't lose an hour to `ERR_PACKAGE_PATH_NOT_EXPORTED`.
**Second key insight — don't trust `localStorage` even if you wanted it.** Independently reported: inside Telegram's WebView, `localStorage` *may not persist at all* on iOS and some Linux desktop builds, so a restart can clear it. This is independent corroboration of D-03's memory-only choice: it isn't only the privacy rule, it's the only choice that actually behaves predictably. **[MEDIUM confidence — community source, not official docs.]**
---
## Runtime State Inventory
> Phase 3 is **not** a rename/refactor/migration phase, so this section is included only for the GEO-03 zero-persistence audit — the phase's own "runtime state" question.
| Category | Items Found | Action Required |
|----------|-------------|------------------|
| **Stored data** | Client: nothing may be written (D-07). Server: `chat_sessions` KV already TTL-only at 900s (verified in `bot/src/kv/sessions.ts`, every `put` carries `expirationTtl`). **New this phase:** a location record for the chat-pin path — must carry `expirationTtl` on its own `put` line, subject to the existing `privacy.test.mjs` grep gate. | Code edit + a new grep-gate assertion that every `put` in the location module carries an explicit TTL. |
| **Live service config** | `wrangler.jsonc` `kv_namespaces` binding `chat_sessions` (id `59f58a54…`) — unaffected. BotFather's registered Mini App URL is now security-critical under Bot API 10.2's origin restriction. | None for KV. **Verify** the BotFather URL points at the same origin the app is served from. |
| **OS-registered state** | **Telegram's per-bot location grant** lives here (inside the Telegram client, keyed by bot + device). This is the mechanism GEO-01's "permission persists across sessions" depends on. `LocationManager.openSettings()` is the deep-link to re-grant. | None — do not attempt to clear it. It is the feature, not the bug. |
| **Secrets / env vars** | `BOT_TOKEN`, `WEBHOOK_SECRET` (via `bot/.dev.vars`). The app needs **no** secrets — it holds an opaque session token issued by the Worker, not a bot token. | None. Confirm the app never receives `BOT_TOKEN` (it cannot and must not). |
| **Build artifacts** | `web/dist/` is new. `theme/dist/rams.css` + `theme/dist/fonts/**` are copied at build time (D-14). MapLibre's worker is emitted by Vite's `?worker&url` into a hashed file. | Add `web/dist/` to `.gitignore` (mirroring `theme/screenshots/`). |
**Nothing found in category:** No pre-existing client-side persistence to migrate — the repo has no `web/` directory yet, so GEO-03 starts from a clean slate. The audit surface is *"prove nothing gets added."*
---
## Common Pitfalls
### Pitfall 1: Assuming a native streaming location primitive exists
**What goes wrong:** The planner writes `LocationManager.getLiveData()` or `startLocationUpdates()` because the roadmap note mentions "`getLiveData()` wrapper." Neither method exists in the official API — the plan's core module would be dead on arrival, or worse, silently undefined.
**Why it happens:** The roadmap's research recommendation was written from an assumption, not from the API docs.
**How to avoid:** Treat D-05's "gated streaming" as *our* abstraction. Phase 3 ships one-shot `getLocation()` calls; Phase 6's continuous mode is either a bounded poll over `getLocation()` or `navigator.geolocation.watchPosition()` — **the planner must pick and write it down, because it changes Phase 6.**
**Warning signs:** Any plan task naming a `LocationManager` member that is not `init`, `getLocation`, `openSettings`, `isInited`, `isLocationAvailable`, `isAccessRequested`, or `isAccessGranted`.
### Pitfall 2: Treating the D-04 chain as strictly sequential and terminal
**What goes wrong:** Code does `telegram → webview → chat-pin`, first-success-wins. On Android, `getLocation()` *succeeds* with a frozen coordinate, so the chain never advances to `navigator.geolocation`, and the app shows a confidently-wrong `LIVE` pin that never moves.
**Why it happens:** `getLocation()` returning data is not evidence that the data is fresh.
**How to avoid:** Freshness is a separate axis from availability. Compare successive fixes (Pattern 2) and route staleness into the recovery path, not into success. The `FIX Ns AGO` strip telemetry is the user-visible honesty surface for this.
**Warning signs:** No code path anywhere computes the age of the current fix.
### Pitfall 3: `getLocation()` with no timeout hangs the boot choreography
**What goes wrong:** The WebView is wedged, `init()`'s callback never fires, and the app sits in `ACQUIRING` forever — the exact "never blank, always progressing" contract (D-09) is violated by an indefinite stage.
**Why it happens:** Telegram documents no timeout for `init()` or `getLocation()`, and both are callback-based with no error channel.
**How to avoid:** Every `getLocation` wrapper carries its own `setTimeout` that resolves `null` (→ next source). The UI-SPEC's `elapsedSeconds` telemetry in the `acquiring` strip state is the affordance that makes a long wait legible — use it.
### Pitfall 4: Desktop Telegram treated as an error state
**What goes wrong:** The denied card reads as a failure on Desktop, where geolocation is *never* going to work (issue #77: no prompt, button greyed out). Users on Desktop read a broken app.
**Why it happens:** The same code path serves mobile-denial and desktop-unsupported, and the copy ("Location is switched off") implies the user can fix it.
**How to avoid:** The UI-SPEC already handles this — *"Desktop Telegram / no geolocation → denied card with identical copy (no client-specific variant)."* That is the right call and the research confirms it is load-bearing. The card's `ASK VIA CHAT` secondary CTA is the real Desktop path; make sure it is *not* styled as secondary there.
### Pitfall 5: Assuming WebGL2 exists everywhere
**What goes wrong:** MapLibre v6 removed WebGL1 support. On a device/browser without WebGL2, `new Map()` throws `GPUInitializationError` and there is no map at all — violating D-08's "map is the dominant surface" and D-09's "never blank."
**Why it happens:** v6 made this a hard requirement (it was a soft degradation in v5).
**How to avoid:** Feature-detect before constructing (`GPUInitializationError` is exported from `maplibre-gl`). Older Android WebViews on low-end devices are the realistic risk. Decide now what the shell looks like without a map — and note that in the plan, because the UI-SPEC has no "no map" state defined. **[Planner decision needed.]**
### Pitfall 6: Bot API 10.2's origin restriction breaks an innocent refactor
**What goes wrong:** Someone moves the location chain into an iframe, a CDN-hosted shell, or a preview subdomain. `Telegram.WebApp.*` calls stop working on updated clients — with no clear error.
**Why it happens:** The restriction landed 2026-07-20, after most third-party tutorials were written.
**How to avoid:** Every `Telegram.WebApp.*` call originates from the app's own document. The Mock-Telegram harness (D-11) must inject the fake into the *same* window the app runs in, not into an iframe. **[VERIFIED against core.telegram.org/bots/api-changelog, Bot API 10.2, July 14 2026]**
### Pitfall 7: Re-theming the map by rebuilding the style
**What goes wrong:** `themeChanged` handler calls `map.setStyle(darkRamsStyle)`. All 110 layers are torn down and rebuilt; the map flashes, tiles re-fetch, and the camera can jump. Directly violates D-10 and the UI-SPEC's "identical — zero re-styling, zero re-creation."
**Why it happens:** `themeChanged` fires and the reflex is to do *something*.
**How to avoid:** The handler sets `data-tg-theme` on `<html>` and stops. CSS custom properties re-slot; MapLibre is not touched. Write the handler so its only statement is the attribute set — that makes the invariant greppable.
### Pitfall 8: The accent color leaks into states it shouldn't carry
**What goes wrong:** `signal-orange` is used for a "waiting" or "error-ish" chip. Phase 1 D-08's rule is that orange is **navigation-primary only** — Phase 5 draws the route in it, so any non-navigation use of orange now reads as "this is the route."
**Why it happens:** Orange is the most visually available accent.
**How to avoid:** The UI-SPEC's reserved-for list is exact and short (position cone · follow-active control · focus brackets · boot lamp · primary CTA fill · boot-card emphasis dot). Degraded/lost/denied lamps are `functional-red`; the LIVE lamp is `olive-green`. Enforce with the same grep-style off-token audit Phase 1 built (`theme/scripts/audit.mjs`).
---
## Code Examples
### Vite + MapLibre v6 bootstrap (verified pattern)
```typescript
// web/src/main.ts
// Source: https://www.maplibre.org/maplibre-gl-js/docs/ §Installation → Vite
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';
// v6 is ESM-only; `?worker&url` is Vite's supported way to emit a
// bundled worker URL. Required for ALL bundlers per the v5→v6 guide.
setWorkerUrl(workerUrl);
import ramsStyle from '@araharah/theme/basemap/liberty-rams.json';
function bootMap(container: HTMLElement): MapLibreMap | null {
try {
// WebGL2 is REQUIRED in v6. This constructor throws otherwise.
return new MapLibreMap({
container,
style: ramsStyle, // inlined at build time (D-14) — never re-set
center: [101.6869, 3.139], // KL, per AGENTS.md region focus
zoom: 12,
attributionControl: { compact: true },
});
} catch (err) {
if (err instanceof GPUInitializationError) {
// Pitfall 5: WebGL2 absent. Shell still renders (D-08/D-09).
return null;
}
throw err;
}
}
```
**Note on the style import:** `theme/basemap/liberty-rams.json` is not inside `@araharah/theme`'s `exports` map (that package has no `exports` field, so deep imports work), but Vite needs it reachable from `web/`. Either add a `basemap` export/subpath to the theme package, or import it via a relative path `../../theme/basemap/liberty-rams.json`. **[Planner decision — D-14 says "build-time dependency", which both satisfy.]**
### Theme slot switching (PX-02)
```typescript
// web/src/telegram/theme.ts
// Source: core.telegram.org/bots/webapps §WebApp.colorScheme, §Events (themeChanged)
import type { WebApp } from './webapp';
type ThemeBranch = 'light' | 'dark';
function resolveBranch(wa: WebApp): ThemeBranch {
// Primary signal — present on all clients this phase targets.
if (wa.colorScheme === 'dark' || wa.colorScheme === 'light') return wa.colorScheme;
// Fallback (UI-SPEC §Theme mapping): luminance of header_bg_color ≥ 0.5
const hex = wa.themeParams?.header_bg_color;
if (typeof hex === 'string' && /^#[0-9a-f]{6}$/i.test(hex)) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return lum >= 0.5 ? 'light' : 'dark';
}
return 'light';
}
export function applyTheme(wa: WebApp): void {
// The ONLY thing a theme change is allowed to touch.
// MapLibre is deliberately absent from this function (D-10).
document.documentElement.dataset.tgTheme = resolveBranch(wa);
}
export function watchTheme(wa: WebApp): void {
applyTheme(wa);
wa.onEvent?.('themeChanged', () => applyTheme(wa));
}
```
### Position marker geometry (per UI-SPEC)
```typescript
// web/src/map/marker.ts — DOM Marker; geometry from 03-UI-SPEC §Marker
// The accuracy circle radius must be METRES → pixels via map.project().
// It is NOT a CSS pixel value.
function accuracyRadiusPx(map: MapLibreMap, center: LngLat, metres: number): number {
const sw = map.getCanvas().width;
const mPerPx = (40075016.686 * Math.cos((center.lat * Math.PI) / 180))
/ (sw * Math.pow(2, map.getZoom()));
return Math.min(metres / mPerPx, 100 / mPerPx); // UI-SPEC display cap: 100 m
}
```
```typescript
// Heading cone: HIDDEN when heading is unknown.
// Source: 03-UI-SPEC §Position Marker — "no default orientation guess,
// no half-open cone".
if (fix.headingDeg === null) cone.style.display = 'none';
else { cone.style.display = ''; cone.style.transform = `rotate(${fix.headingDeg}deg)`; }
```
### Follow / free mode detection
```typescript
// web/src/map/follow.ts
map.on('movestart', (e) => {
// A programmatic move we initiated is not the user panning away.
if (followLock) return;
if (e.originalEvent) setFollowMode('free'); // user gesture only
});
// Recenter (UI-SPEC): flyTo ≤400ms, camera padding 96px.
function recenter(map: MapLibreMap, fix: Fix): void {
followLock = true;
map.flyTo({ center: [fix.lng, fix.lat], padding: CAMERA_PADDING_PX /* 96 */,
duration: 400 });
setFollowMode('following');
}
```
### GEO-03 zero-persistence grep gate (extends Phase 2's `privacy.test.mjs` pattern)
```javascript
// web/test/no-storage.test.mjs
// GEO-03: no coordinate — and no session token — reaches ANY storage API.
const BANNED = [
'localStorage', 'sessionStorage', 'indexedDB', 'caches',
'document.cookie', 'CacheStorage',
];
// Plus Telegram's own persistence surfaces, which D-07 also forbids:
// 'WebApp.DeviceStorage', 'WebApp.SecureStorage', 'WebApp.CloudStorage'
test('no storage API is referenced in web/src', async () => {
const files = await glob('src/**/*.{ts,html}');
for (const f of files) {
const src = await readFile(f, 'utf8');
for (const api of BANNED) {
assert.ok(!src.includes(api), `${f} references ${api} — GEO-03 violated`);
}
}
});
```
**Plus a runtime probe** (stronger than grep, and the real phase-exit spot-check): after a full boot → live cycle in the Mock-Telegram harness, assert in-page that `localStorage.length === 0`, `sessionStorage.length === 0`, `document.cookie === ""`, and `indexedDB.databases()` is empty. Run it under Playwright — that is the artifact GEO-03's success criterion asks to be "spot-checkable at phase exit."
### Dev initData signing for the Mock-Telegram harness (D-11)
Phase 2's `bot/src/security/initdata.ts` already implements HMAC-SHA256 with `HMAC(bot_token, "WebAppData")`. The harness's fake `initData` must be signed with the **dev** bot token so `POST /api/session` accepts it unmodified. The signing logic is HTTP-world and reusable verbatim — do not reimplement it in `web/`. **[Repo inspection — `bot/src/security/initdata.ts` exists and is the canonical implementation.]**
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `navigator.geolocation` only, re-prompt each launch | `WebApp.LocationManager` (Bot API 8.0) — grant persists per-bot on device | Bot API 8.0, Nov 2024 | GEO-01's "permission persists across sessions" is only achievable via LocationManager. The WebView API *cannot* deliver this. |
| Treat `getLocation()` data as fresh | Freshness must be measured (successive-fix comparison + `FIX Ns AGO` telemetry) | Still open upstream (issue #56, Dec 2024 →) | D-06's honesty principle extends to data age, not just permission state. |
| MapLibre v5 UMD/CJS bundles | v6 ESM-only + mandatory `setWorkerUrl()` in bundlers | MapLibre v6 | Breaks CJS consumers; Vite's `?worker&url` is the blessed path. |
| WebGL1 tolerated (v5 degraded gracefully) | WebGL2 required; `Map` throws `GPUInitializationError` | MapLibre v6 | Needs an explicit no-map shell state (Pitfall 5). |
| `styleimagemissing` + `map.addImage()` | `map.setMissingStyleImageResolver()` | MapLibre v6 | Relevant if Phase 5's route line uses a generated icon. |
| Mini App methods callable from any origin | Bot API 10.2 origin restriction, auto-enabled 2026-07-20 | July 2026 | All `Telegram.WebApp.*` calls must come from the registered origin. |
| Telegram storage choices: `CloudStorage` / `DeviceStorage` / `SecureStorage` | n/a — this project uses **none** of them | — | D-07's zero-persistence is stricter than Telegram's model and is what makes GEO-03 provable. |
**Deprecated / do not use:**
- **`telegram-web-app.js` as an npm package** — does not exist on npm (E404). Telegram's official integration is a `<script src>` from `core.telegram.org/bots/webapps`; ****CORRECTED by device-matrix debug round 3 (2026-10-23): WRONG call — Telegram does NOT auto-inject; production had `window.Telegram` undefined (no initData → session 401, no LocationManager, no BackButton). `telegram-web-app.js` from telegram.org IS required in `<head>`**. Add the script tag (done in web/index.html). The npm-package 404 part still stands — `<script src>` is the only integration. **[ERRATUM logged]**
- **`@telegram-apps/web-app`** — E404 on npm.
- **Cloudflare Pages** — superseded by Workers + `[assets]` for this project (AGENTS.md).
- **`maplibre-gl` default import** (`import maplibregl from 'maplibre-gl'`) — removed in v6. Use named or namespace imports.
---
## Assumptions Log
> Claims below are **not** verified against primary sources. The planner should treat each as a decision point, not a fact.
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `navigator.geolocation.getCurrentPosition()` inside the Android WebView refreshes the stale native cache such that the next `LocationManager.getLocation()` returns fresh coordinates. | §Pattern 2 | **Medium.** The mechanism is community-reported (6 confirmations on issue #56) but unverified on our devices. If it does not hold, Android `LIVE` shows a frozen pin. Mitigation: the recovery path degrades gracefully to polling, and the exit gate is where it gets proven. |
| A2 | `@types/telegram-web-app@10.1.0`'s `LocationManager` typings match the official docs (especially `LocationData.course` being nullable and `getLocation`'s callback being `(LocationData \| null) => void`). | §Standard Stack | **Low.** If wrong, one wrapper file needs a local type override. Recommend verifying before commit. |
| A3 | Telegram WebView geolocation requires the page be served over HTTPS (or localhost) for `navigator.geolocation` to work at all — meaning `wrangler dev` on `http://localhost:8787` is fine but a LAN-IP dev URL would not be. | §Pitfalls / D-12 | **Medium.** Affects how the real-device verification session is staged (D-12 says remote staging is used "sparingly right before real-device verification sessions" — that is likely *because* of this). Planner should confirm the staging path before the device gate. |
| A4 | A reply keyboard with `one_time_keyboard: true` is the right presentation for the GEO-02 fallback (auto-dismisses after one use, keeps the chat clean). | §Pattern 3 | **Low.** UX preference; `resize_keyboard: true` is the load-bearing part. |
| A5 | The bot can relay a chat-shared pin to the open Mini App via a polled authenticated endpoint, given the Worker is stateless. | §Pattern 3 | **Low–Medium.** Architecturally sound with KV, but adds a new Worker route (`GET /api/location`) not present in Phase 2 — a real scope item the planner must budget for. |
| A6 | OpenFreeMap's tile endpoint tolerates the request rate a Phase 3 demo/user cohort generates. | §D-14 | **Low.** Not a Phase 3 concern; revisit in Phase 7 hardening. |
---
## Open Questions (RESOLVED)
All four questions below were open at research time and are resolved by this phase's plans. The
resolution is recorded here so a later phase can see the decision and its owner without re-deriving it.
1. **What does the shell look like with no map at all (WebGL2 absent)?**
- What we know: v6 throws `GPUInitializationError`; D-08 says the map is the dominant surface; the UI-SPEC defines no map-less state.
- What's unclear: whether to (a) render the shell chrome over a Rams-tinted field, (b) hide the chrome and show only the status strip, or (c) accept that this only happens on ancient devices and let it throw.
- **Recommendation:** the planner decides explicitly and the UI-SPEC gets a one-line amendment. Option (a) is cheapest and preserves "never blank." This is a genuine gap in the approved contract — flag it to `/gsd-ui-phase` if the planner wants the contract amended rather than silently extended.
- **RESOLVED — option (a).** `bootMap()` catches `GPUInitializationError` and returns `null`; the shell renders over `--color-surface-dim` with `data-map="absent"`, and a one-line planner amendment is appended to `03-UI-SPEC.md` §Boot Choreography. See plan 03-03 task 2 (map bootstrap + `shell.css`) and task 3 (the `main.ts` header note).
2. **Which source does Phase 6's continuous mode actually use — poll `getLocation()` or `watchPosition()`?**
- What we know: no native streaming API exists (Pitfall 1); `navigator.geolocation.watchPosition` works on iOS (issue #56 reporter confirms iOS updates correctly every second) and is the likely answer for continuous guidance.
- What's unclear: whether the Android stale-fix affects `watchPosition` too, or only `LocationManager`.
- **Recommendation:** Phase 3 builds the `Fix`-shaped abstraction and a pluggable cadence; Phase 6 picks. Write the abstraction so this choice stays open — **do not** hard-code a poll interval in Phase 3.
- **RESOLVED — deliberately left open, by design.** `chain.ts` exposes a `Fix`-shaped abstraction with a pluggable cadence and hard-codes no continuous-mode interval; `readWebviewFix` uses `maximumAge: 0`, and there is an acceptance gate that `setInterval|watchPosition` appear nowhere in `chain.ts`. The post-live 1s ticker in `main.ts` re-runs `acquireFix` rather than streaming. Phase 6 picks poll-vs-`watchPosition` without a rewrite. See plan 03-02 task 2 (`chain.ts`) and plan 03-03 task 3 (the post-live cadence).
3. **Does the Phase 3 exit gate have real device access?**
- What we know: Success Criterion 5 requires an Android + iOS + Desktop + plain-browser pass. D-11 says device batteries are spent at the exit gate.
- What's unclear: whether the executor has physical Android and iOS devices available at all. If not, the criterion is unverifiable and the phase cannot honestly close.
- **Recommendation:** the planner must confirm device availability **before** writing the exit-gate task, and A1 (the Android stale-fix) can only be retired with a real Android phone.
- **RESOLVED — handled as a blocking human checkpoint, not a pre-flight assumption.** Availability cannot be determined in advance by the executor, so plan 03-04 carries it as a blocking `checkpoint:human-verify` (task 3) with the five legs enumerated, and an explicit instruction that missing hardware means Success Criterion 5 is recorded as UNVERIFIED with the blocker in `.planning/STATE.md` — the phase is never closed claiming verification that did not happen. The automated half (capture harness + Desktop chat-pin leg) is built first so only the genuinely manual part is checkpointed.
4. **Should the theme package expose `basemap/liberty-rams.json` via a subpath export, or should `web/` import it relatively?**
- What we know: `@araharah/theme` has no `exports` map today (verified), so deep relative imports work; Vite resolves JSON imports natively.
- What's unclear: which reads better as the long-term contract for Phases 4–6.
- **Recommendation:** add a proper `exports` entry (`.` → `./dist/rams.css`, `./basemap/*`, `./fonts/*`). One small change now, avoids four phases of ad-hoc relative paths. OpenCode's discretion per D-02.
- **RESOLVED — deep package import, resolved at build time.** `web/src/map/map.ts` imports `@araharah/theme/basemap/liberty-rams.json` directly. `moduleResolution: "bundler"` already permits the deep subpath, so no `exports` map change is needed for Phase 3, and Vite inlines the JSON into `web/dist` at build (proven by the BA4 gate). The only TypeScript-side requirement is `resolveJsonModule: true`, pre-authorized in `web/tsconfig.json` by plan 03-01 task 1. If a later phase needs more subpaths, an `exports` map can be added then without touching Phase 3's code. See plan 03-03 task 2.
---
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | Vite 8 (needs `^20.19.0 \|\| >=22.12.0`) | ✓ | **24.18.0** | — |
| npm workspaces | D-02 `web/` workspace | ✓ | 11.16.0 | — |
| wrangler | D-12 dev on 8787, D-15 deploy | ✓ | 4.151.3 (already in `bot/`) | — |
| TypeScript | D-01 | ✓ | 5.9.3 (`bot/` pin) | — |
| Playwright + browsers | D-11 harness, DSGN-02 fidelity artifact | ✓ | chromium-1248, webkit-2272, firefox-1511 cached | — |
| esbuild (Vite dep) | Vite build | ✓ | in `node_modules/.bin` | — |
| Cloudflare KV (`chat_sessions`) | Phase 2 session, Phase 3 pin relay | ✓ | binding configured in `wrangler.jsonc` | — |
| **OpenFreeMap tiles** | D-14 runtime basemap | ✓ (network) | `https://tiles.openfreemap.org/planet` | — |
| **Android device** | Success Criterion 1 + 5 | **?** | — | **None.** Blocking for GEO-01's Android claim and for retiring A1. |
| **iOS device** | Success Criterion 1 + 5 | **?** | — | **None.** Blocking for GEO-01's iOS claim. |
| Telegram Desktop | Success Criterion 5 (GEO-02 path) | **?** | — | **None.** Blocking for the Desktop leg of the matrix. |
| Telegram BotFather registration | origin restriction (Bot API 10.2) | **?** | — | **None.** Must point at the served origin. |
**Missing dependencies with no fallback:** real Android and iOS devices, and a Telegram Desktop install. The roadmap's own Success Criterion 5 requires all of them. **The planner must surface this as a checkpoint before the exit-gate task, not discover it at execution.**
**Missing dependencies with fallback:** none identified — the toolchain is complete.
---
## Validation Architecture
> `.planning/config.json` → `workflow.nyquist_validation: true`. Included per protocol.
### Test Framework
| Property | Value |
|----------|-------|
| Framework | **Hand-rolled zero-dependency runner** — `web/test/runner.mjs` + `harness.mjs`, mirroring `bot/test/` and `theme/scripts/audit.test.mjs` (repo precedent, D-11's "hand-rolled test runner pattern prevails") |
| Config file | none (no vitest/jest config — by design) |
| Quick run command | `npm test -w web` |
| Full suite command | `npm test -w web && npm run typecheck -w web && npm run build -w web` |
| Browser/visual checks | `npm run capture -w web` — Playwright, extends the `theme/scripts/screenshot.mjs` pattern (static server + chromium + `MIN_BYTES` blank-bar check already established there) |
⚠️ **ESM caveat:** `maplibre-gl@6.15.0` exports **only** an `import` condition. The runner uses dynamic `import()` on `.mjs` files with `"type": "module"`, which resolves it correctly. Any test that `require()`s maplibre will fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Unit tests should therefore test the **chain logic against injected fakes**, never against a real MapLibre instance — which is better design anyway.
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| **GEO-01** | `LocationManager.init()` then `getLocation()` yields a `Fix` with `source:'telegram'` | unit | `npm test -w web` | ❌ Wave 0 |
| **GEO-01** | A client below Bot API 8.0 falls through to `navigator.geolocation` without throwing | unit | `npm test -w web` | ❌ Wave 0 |
| **GEO-01** | `getLocation()` returning `null` (denied) advances to the next source, not to an error | unit | `npm test -w web` | ❌ Wave 0 |
| **GEO-01** | `getLocation()` that never calls back resolves `null` after the wrapper timeout (Pitfall 3) | unit | `npm test -w web` | ❌ Wave 0 |
| **GEO-01** | Two identical successive fixes are detected as stale and route to the recovery path (Pitfall 2) | unit | `npm test -w web` | ❌ Wave 0 |
| **GEO-01** | Grant persists across a fresh boot — re-read on revalidation, no re-prompt | unit | `npm test -w web` | ❌ Wave 0 |
| **GEO-02** | Bot sends `ReplyKeyboardMarkup` with `request_location: true`, `resize_keyboard`, `one_time_keyboard` | unit (bot-side, extends `bot/test/`) | `npm run bot:test` | ❌ Wave 0 |
| **GEO-02** | Bot receives `message.location` and writes it with an explicit `expirationTtl` | unit (bot-side) | `npm run bot:test` | ❌ Wave 0 |
| **GEO-02** | App enters `AWAITING PIN`, polls, and consumes the pin into `live` | unit | `npm test -w web` | ❌ Wave 0 |
| **GEO-02** | Poll is bounded — a never-delivered pin returns to the denied card, never wedges (Pitfall: indefinite `AWAITING PIN`) | unit | `npm test -w web` | ❌ Wave 0 |
| **GEO-02** | Desktop Telegram (no `LocationManager`, no `navigator.geolocation`) reaches the denied card with **identical** copy | unit | `npm test -w web` | ❌ Wave 0 |
| **GEO-03** | No storage API referenced anywhere in `web/src` (grep gate) | unit | `npm test -w web` | ❌ Wave 0 |
| **GEO-03** | Runtime probe: after boot → live in the harness, `localStorage`/`sessionStorage`/cookies/IndexedDB are all empty | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
| **GEO-03** | Every KV `put` in the new location module carries `expirationTtl` | unit (bot-side grep gate) | `npm run bot:test` | ❌ Wave 0 |
| **PX-02** | `colorScheme:'dark'` sets `data-tg-theme="dark"`; light sets `light` | unit | `npm test -w web` | ❌ Wave 0 |
| **PX-02** | Missing `colorScheme` falls back to `header_bg_color` luminance ≥ 0.5 | unit | `npm test -w web` | ❌ Wave 0 |
| **PX-02** | `themeChanged` re-sets the attribute and touches nothing else (map style object identity unchanged — Pitfall 7) | unit | `npm test -w web` | ❌ Wave 0 |
| **PX-02** | Dark-theme capture exists in the fidelity artifact | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
| **D-09** | Boot renders stage 1 → stage 2 → live with no blank intermediate frame | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
| **D-10** | `BackButton.hide()` at init; the handler is a total function (closes when no overlay — no dead-ends) | unit | `npm test -w web` | ❌ Wave 0 |
| **D-03** | Session token lives only in module scope; never written to any storage | unit | `npm test -w web` | ❌ Wave 0 |
| **D-14** | `web/dist` contains the inlined style JSON, self-hosted fonts, and a MapLibre worker file; no CDN references in the built output | unit (build-artifact gate) | `npm run build -w web` | ❌ Wave 0 |
| **D-08** | Boot output contains no route line, no search furniture, no layer toggles | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
| Copy gate | `strings.ts` exports exactly the UI-SPEC copy table, en + ms; no inline copy in shell modules | unit | `npm test -w web` | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** `npm test -w web`
- **Per wave merge:** `npm test -w web && npm run bot:test && npm run typecheck -w web && npm run build -w web`
- **Phase gate:** full suite green + `npm run capture -w web` produced (DSGN-02 fidelity artifact) + **the device-matrix pass (manual, blocking)**
### Wave 0 Gaps
- [ ] `web/test/runner.mjs` — hand-rolled runner (copy the `bot/test/runner.mjs` shape)
- [ ] `web/test/harness.mjs` — test registry
- [ ] `web/src/mock/telegram-mock.ts` — D-11 fake `WebApp`; the keystone that makes every location test above possible without a device
- [ ] `web/test/location-chain.test.mjs` — covers GEO-01, GEO-02 (client half)
- [ ] `web/test/no-storage.test.mjs` — GEO-03 grep gate
- [ ] `web/test/theme.test.mjs` — PX-02 slot-switch gate
- [ ] `web/test/copy-gate.test.mjs` — UI-SPEC copy table gate
- [ ] `bot/test/location-pin.test.mjs` — GEO-02 bot half + TTL grep assertion
- [ ] `web/scripts/capture.mjs` — Playwright harness captures (extend `theme/scripts/screenshot.mjs`)
- [ ] **`GET /api/location` Worker route + KV module** — the missing link between the bot's received pin and the open Mini App (Open Question 5)
**None of these exist yet — `web/` does not exist.** Every test above is Wave 0.
---
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | **yes** | Phase 2's `/api/session` (HMAC-validated `initData` → opaque token). The app posts the **verbatim** `initData` string — never `initDataUnsafe` (Telegram's own docs warn on this). Token held in module scope only (D-03). |
| V3 Session Management | **yes** | 900s TTL (already enforced server-side, `SESSION_TTL_SECONDS`), rotate-on-new, revalidate on every fresh boot. No client-side session persistence at all — which is stricter than `SecureStorage`/`CloudStorage` and therefore immune to their availability quirks. |
| V4 Access Control | **yes** | The new `GET /api/location` route must validate the bearer token via `getClaimByToken` before returning anything. A pin must never be readable without a live, unexpired session token for that chat. |
| V5 Input Validation | yes | Validate `Location` message fields (`latitude`/`longitude` numeric, sane ranges) before use; cap `horizontal_accuracy` (Telegram documents 0–1500 m); reject absurd coordinates rather than flying the camera to null island. |
| V6 Cryptography | **no new crypto** | HMAC-SHA256 stays entirely server-side. The client never holds `BOT_TOKEN`. No crypto in `web/`. |
| **Origin restriction (Bot API 10.2)** | **yes — new** | All `Telegram.WebApp.*` calls originate from the registered Mini App origin. Auto-enabled since 2026-07-20. |
### Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Forging a session by posting a crafted `initData` | Spoofing | Phase 2's constant-time HMAC compare + `auth_date` freshness window. **Do not** add a client-side shortcut. |
| Reading another user's shared pin via `GET /api/location` | Information Disclosure | Token-scoped KV lookup — a token resolves to exactly one `chat_id`; the route must derive the KV key from the *claim*, never from a client-supplied id. |
| Coordinate persisted to a client store | Information Disclosure (privacy regression) | D-07 + the grep gate + the runtime probe. This is the GEO-03 spot-check made mechanical. |
| Stale/cached fix presented as `LIVE` | Repudiation (dishonest state) | Successive-fix staleness detection + `FIX Ns AGO` telemetry. The app must never claim freshness it has not measured. |
| Malicious bot update injecting a pin | Tampering | Only Telegram-authenticated webhook updates reach grammY; the secret-token compare runs before parsing (Phase 2's fail-closed guard). |
| Map style JSON fetched from a mutable URL at runtime | Tampering | D-14 already forbids this — the style is inlined at build time. Tiles are the only runtime network dependency. |
| Telegram WebApp called cross-origin | Elevation of Privilege | Bot API 10.2 restriction — keep all calls on the registered origin. |
---
## Sources
### Primary (HIGH confidence)
- **https://core.telegram.org/bots/webapps** — `LocationManager` (`init`/`getLocation`/`openSettings`/`isInited`/`isLocationAvailable`/`isAccessRequested`/`isAccessGranted`), `LocationData` (all 9 fields incl. nullable `course`/`horizontal_accuracy`), `colorScheme`, `ThemeParams`, `BackButton`, `SafeAreaInset`/`ContentSafeAreaInset`, `isVersionAtLeast`, the complete event table (`locationManagerUpdated`, `locationRequested`, `themeChanged`, `safeAreaChanged`, `activated`/`deactivated`), `ready()`/`expand()`/`viewportStableHeight`. Fetched 2026-10-22. **This is the authority for the entire location chain.**
- **https://core.telegram.org/bots/api-changelog** — Bot API **10.2, July 14 2026**: *"Hardened the security of Mini Apps by disallowing the usage of Mini App methods from origins different from the original Mini App domain. The protection will be automatically enabled for all Mini Apps on July 20, 2026."* Also confirms LocationManager landed in 8.0 (Nov 2024). Fetched 2026-10-22.
- **https://core.telegram.org/bots/api** (HTML) — `KeyboardButton.request_location` (*"If True, the user's current location will be sent when the button is pressed. Available in private chats only."*), `Location` type fields, `ReplyKeyboardMarkup` (`resize_keyboard`, `one_time_keyboard`). Fetched 2026-10-22.
- **https://maplibre.org/maplibre-gl-js/docs/guides/v5-to-v6-migration-guide/** — ESM-only, `setWorkerUrl()` bundler-only, **WebGL2 required** + `GPUInitializationError`, `styleimagemissing` → `setMissingStyleImageResolver`, no default import. Fetched 2026-10-22.
- **https://www.maplibre.org/maplibre-gl-js/docs/ §Installation** — the exact Vite snippet (`?worker&url` → `setWorkerUrl`), CSS import path, `import 'maplibre-gl/dist/maplibre-gl.css'`, CSP directives. Fetched 2026-10-22.
- **https://developers.cloudflare.com/workers/static-assets/binding/** — `assets.directory`, `assets.binding`, **`run_worker_first` as a glob array** (`["/api/*", "!/api/docs/*"]`, max 100 entries), `not_found_handling`, `env.ASSETS.fetch(request)` passthrough. Fetched 2026-10-22. **This is the exact D-13 config shape.**
- **https://playwright.dev/docs/emulation#geolocation** — `context.setGeolocation()`, `grantPermissions(['geolocation'])`, `colorScheme`, `devices[...]` — the D-11 harness and exit-gate matrix primitives. Fetched 2026-10-22.
- **npm registry (`npm view`, run this session)** — `maplibre-gl@6.15.0` (2026-10-18) with `exports` containing **only** an `import` condition; `vite@8.3.5` (2026-10-21) `engines.node: ^20.19.0 || >=22.12.0`; `@types/telegram-web-app@10.1.0` (2026-10-04); `telegram-web-app` / `telegram-web-app.js` / `@telegram-apps/web-app` all E404 or abandoned.
- **Repo inspection** — `bot/src/webhook.ts` (`/api/session` contract, 4 KB cap, fail-closed webhook), `bot/src/kv/sessions.ts` (900s TTL, rotate-on-new, `getClaimByToken`), `bot/test/{runner,harness}.mjs` (test pattern), `bot/wrangler.jsonc` (`chat_sessions` binding), `theme/src/components.css` (`.btn-primary`, `.card`, `.text-label-sm`, `.checkbox` all confirmed present), `theme/src/theme.css` (`--shell-bg`/`--shell-on-surface` + `prefers-color-scheme` defaults), `theme/basemap/liberty-rams.json` (v8 style, 110 layers, `openmaptiles` + `ne2_shaded` sources → OpenFreeMap).
### Secondary (MEDIUM confidence)
- **https://github.com/Telegram-Mini-Apps/issues/issues/56** — Android stale-fix bug. Open since Dec 18 2024, labels `bug`/`in progress`/`Telegram for Android`, 6+ independent confirmations. Reporter's workaround: *"Integrating the browser's `navigator.geolocation.getCurrentPosition` … allows the Telegram mini app to retrieve the same coordinates afterward"*; *"Opening Google Maps on the device and then returning to the Telegram mini app also triggers the location to refresh."* Commenter `stefda`: *"This issue renders the new LocationManager api useless."* **The basis for A1 — treat as a hypothesis to verify at the device gate, not as established fact.**
- **https://github.com/Telegram-Mini-Apps/issues/issues/77** — Desktop geolocation absent. Open Apr 2025 → last activity Jan 2026. Second reporter (Sep 2025) confirms the button is *greyed out* on Telegram macOS and Desktop. Establishes GEO-02 as the only Desktop origin path.
- **https://crossminiapp.com/en/blog/webview-geolocation-broken-runtime** (Oct 20, 2026) — corroborating platform analysis: Android's `WebSettings.setGeolocationEnabled()` defaults to **false**; needs `WebChromeClient.onGeolocationPermissionsShowPrompt` + runtime permission + HTTPS/localhost; iOS routes every request through `WKUIDelegate.webView(_:requestGeolocationPermissionFor:…)`, and **a page that calls geolocation on load or without a direct user gesture is blocked or re-prompted**. Vendor ROMs (EMUI/MIUI) add background restrictions. *Vendor marketing site — used only for the well-known Android/iOS platform facts, which it cites to primary developer.android.com / developer.apple.com sources.*
- **https://adsgram.ai/blog/adsgram/telegram-mini-app-tma-development-mistakes-and-how-to-avoid-them** (Aug 23, 2026) — two independently valuable claims: (a) `localStorage` **may not persist** inside Telegram's WebView on iOS and some Linux desktop builds (corroborates D-03's choice); (b) the Bot API 10.2 origin restriction and its Jul 20 2026 enforcement date — which I then **confirmed against Telegram's own changelog** and therefore treat as HIGH. *Ads vendor blog; treat mechanism claims as MEDIUM.*
### Tertiary (LOW confidence — flagged for validation)
- Various Stack Overflow / Reddit / YouTube results on WKWebView geolocation permission prompts and iOS 14+ `CLLocationManager` authorization changes. These surfaced during search and were **not** used for any specific claim; they indicate the area is genuinely messy and support the general "gate everything, never assume" posture rather than any particular implementation detail.
- `@telegram-apps/sdk` (the community SDK) appeared in issue #77's reproduction. Not evaluated further — D-01 rules it out (no framework), and the official API surface is sufficient.
---
## Metadata
**Confidence breakdown:**
- **Standard stack: HIGH** — every version verified against the live npm registry this session; Vite/MapLibre/Telegram types resolved and their failures documented.
- **Architecture (platform API surface): HIGH** — the entire `LocationManager`, `ThemeParams`, safe-area, and event surface read directly from `core.telegram.org/bots/webapps` this session.
- **Architecture (the fallback mechanisms): HIGH** — `request_location` and the `Location` message shape confirmed against `core.telegram.org/bots/api`.
- **Pitfalls: HIGH** — Pitfalls 1, 3, 5, 6, 7 are direct consequences of documented API behavior, not inference.
- **The Android stale-fix workaround (A1): MEDIUM** — community-sourced, reproduced by multiple reporters, **not verified on our devices**. Explicitly listed in the Assumptions Log and gated behind a degradation path.
- **`localStorage` unreliability in the WebView: MEDIUM** — single secondary source; corroborates rather than drives the design.
- **`@types/telegram-web-app` accuracy (A2): MEDIUM** — package exists and is current; its `LocationManager` shape was not diffed against the official docs.
- **Device-matrix feasibility: UNKNOWN** — the availability of real Android/iOS devices is an open question that blocks Success Criterion 5 and cannot be resolved by research.
**What might I have missed:**
- The **WebGL2-absent shell state** (Open Question 1) is a genuine gap in an *approved* UI-SPEC. The planner should amend the contract rather than silently extend it.
- The **new `GET /api/location` Worker route** is a real scope item that appears in neither Phase 2's code nor Phase 3's stated deliverables, yet GEO-02 cannot work without it. Budget it explicitly.
- I did **not** research Telegram's *test server* (`test.telegram.org`) as an alternative to physical devices for the matrix. It could shorten iteration but does not substitute for real-device verification of a WebView-specific bug.
**Research date:** 2026-10-22
**Valid until:** 2026-11-05 (14 days) — **shorter than usual, deliberately.** The Android stale-fix bug (issue #56) and the Telegram Mini App platform are both moving targets; a fix landing would change the recommended chain. Re-check `core.telegram.org/bots/api-changelog` and issue #56 before executing.
@@ -0,0 +1,101 @@
---
phase: 3
slug: mini-app-shell-geolocation
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-10-22
---
# Phase 3 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Hand-rolled zero-dependency runner — `web/test/runner.mjs` + `harness.mjs`, mirroring `bot/test/` and `theme/scripts/audit.test.mjs` (repo precedent, D-11's "hand-rolled test runner pattern prevails") |
| **Config file** | none (no vitest/jest config — by design) |
| **Quick run command** | `npm test -w web` |
| **Full suite command** | `npm test -w web && npm test -w bot && npm run typecheck -w web && npm run build -w web` |
| **Browser/visual checks** | `npm run capture -w web` — Playwright, extends the `theme/scripts/screenshot.mjs` pattern (static server + chromium + `MIN_BYTES` blank-bar check already established there) |
| **Estimated runtime** | ~30 seconds |
⚠️ **ESM caveat:** `maplibre-gl@6.15.0` exports **only** an `import` condition. The runner uses dynamic `import()` on `.mjs` files with `"type": "module"`, which resolves it correctly. Unit tests test the **chain logic against injected fakes**, never against a real MapLibre instance.
---
## Sampling Rate
- **After every task commit:** Run `npm test -w web` (plus `npm test -w bot` for bot-side tasks)
- **After every plan wave:** Run `npm test -w web && npm test -w bot && npm run typecheck -w web && npm run build -w web`
- **Before `/gsd-verify-work`:** Full suite must be green
- **Max feedback latency:** ~30 seconds
---
## Per-task Verification Map
| task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| {N}-01-01 | 01 | 1 | (scaffold) | — | N/A | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
| (W0) | 01 | 1 | GEO-01 | — | chain vs fakes | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
| (W0) | 01 | 1 | GEO-03 | T-3-x | no storage API in web/src | unit grep gate | `npm test -w web` | ❌ W0 | ⬜ pending |
| (W0) | 01 | 1 | PX-02 | — | theme slot switch | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
| (W0) | 01 | 1 | GEO-02 | T-3-03 | TTL'd location pin KV | unit (bot) | `npm test -w bot` | ❌ W0 | ⬜ pending |
| (W0) | 01 | 1 | copy gate | — | strings.ts = UI-SPEC table | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
| (plans) | 02-0N | 2 | GEO-01 | — | source fallback chain | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
| (plans) | 02-0N | 2 | GEO-02 | — | pin poll bounded | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
| (plans) | 03-0N | 3 | D-14 | T-3-06 | no CDN refs in dist | build gate | `npm run build -w web` | ❌ W0 | ⬜ pending |
| (plans) | 03-0N | 3 | GEO-03 / D-09 / D-10 | — | runtime probes | integration (Playwright) | `npm run capture -w web` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
*(task IDs will be filled by the plans' own `<automated>` fields; the map above is the Wave-0 skeleton.)*
---
## Wave 0 Requirements
- [ ] `web/test/runner.mjs` — hand-rolled runner (copy the `bot/test/runner.mjs` shape)
- [ ] `web/test/harness.mjs` — test registry
- [ ] `web/src/mock/telegram-mock.ts` — D-11 fake `WebApp`; the keystone that makes every location test possible without a device
- [ ] `web/test/location-chain.test.mjs` — covers GEO-01, GEO-02 (client half)
- [ ] `web/test/no-storage.test.mjs` — GEO-03 grep gate
- [ ] `web/test/theme.test.mjs` — PX-02 slot-switch gate
- [ ] `web/test/copy-gate.test.mjs` — UI-SPEC copy table gate
- [ ] `bot/test/location-pin.test.mjs` — GEO-02 bot half + TTL grep assertion
- [ ] `web/scripts/capture.mjs` — Playwright harness captures (extend `theme/scripts/screenshot.mjs`)
- [ ] **`GET /api/location` Worker route + KV module** — the missing link between the bot's received pin and the open Mini App
*None of these exist yet — `web/` does not exist. Every test above is Wave 0.*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Live position on Rams basemap on real Android + iOS | GEO-01 | WebView location bugs are device/client-specific (Bot API 10.2, WKWebView, LocationManager) | Device-matrix exit pass: boot → session → position on each device |
| Chat "send location" keyboard fallback on Desktop Telegram | GEO-02 | Desktop Telegram has no geolocation at all (issue #77); only real clients show the path | Open Mini App on Desktop Telegram; trigger fallback via chat location |
| Android stale-fix workaround effectiveness (A1) | GEO-01 | Community-sourced, unverified on our devices | On Android: issue ONE stale Telegram fix → attempt in-app refresh (navigator nudge) → verify fix freshness updates |
| Theme flip live (light ↔ dark) while map stays Rams-constant | PX-02 | Telegram theme switching only happens inside the real client | Switch Telegram theme mid-session; verify shell re-themes, map unchanged |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 30s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
---
*Source: `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` § Validation Architecture (2026-10-22)*
Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB