Files
arah2/.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md
T
2026-10-23 22:37:55 +08:00

651 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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