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

38 KiB
Raw Blame History

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

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:

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:

  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):

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:

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,})/):

  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:

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):

/** 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);
}
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:

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:

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:

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:

{
  "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:

  "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:

{
  "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:

<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):

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:

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:

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):
body {
  background: var(--color-surface);
  color: var(--color-on-surface);
  font: var(--type-label-md);
  letter-spacing: var(--type-label-md-tracking);
}
  1. Map container geometry (index.html lines 64-65):
main { position: relative; flex: 1; min-height: 0; }
#map { position: absolute; inset: 0; }
  1. Zero-radius MapLibre chrome (line 87):
.maplibregl-ctrl-group { border-radius: 0; } /* zero roundedness, per Rams */
  1. 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:
: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).

  1. Safe-area — mockup line 4's env() utilities are the floor; safe-area.ts unifies with WebApp.safeAreaInset per RESEARCH Pattern 4:
.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):

/** 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):

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:

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):

.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:

<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:

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):

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):

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):

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):

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:

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):

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:

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:

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):

/* 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):

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