ambek .planning
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user