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

53 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03-mini-app-shell-geolocation 04 execute 4
03-01
03-03
bot/wrangler.jsonc
bot/package.json
package.json
.gitignore
web/vite.config.ts
web/index.html
web/src/vite-env.d.ts
web/scripts/capture.mjs
web/test/build-artifact.test.mjs
.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md
false
GEO-01
GEO-02
GEO-03
PX-02
decisions_implemented truths artifacts key_links
D-12: local dev topology is `wrangler dev` on 8787 serving web statics + `/api/*` + webhook with local KV (theme → web build chain)
D-15: deployment stays one command (`npm run deploy` = build web → wrangler deploy with assets)
One npm run deploy builds the theme, then the web app, then deploys the Worker with static assets attached
The Worker owns /api/* and /telegram/* FIRST; the asset binding serves everything else
web/dist contains the inlined style JSON, self-hosted fonts, and the MapLibre worker, with zero CDN references
web/dist NEVER contains the Mock-Telegram installer in ANY emitted file — HTML included; the harness lives in a separate web/dist-harness build that is never deployed
Playwright captures the shell through the Mock-Telegram harness off the HARNESS build: boot stages, light, dark, the Desktop denied card, the chat-pin relay, and the DSGN-02 side-by-side
A runtime probe after boot-to-live asserts localStorage, sessionStorage, cookies, and IndexedDB are all empty (GEO-03 spot-check)
The Android, iOS, Desktop, plain-browser, and mid-session theme-flip matrix is verified on real devices before the phase closes
path provides
bot/wrangler.jsonc assets binding + run_worker_first glob array (D-13)
path provides
web/vite.config.ts dual-build config: __ARAHARAH_HARNESS__ define + dist / dist-harness outDirs
path provides
web/scripts/capture.mjs Playwright capture harness extending theme/scripts/screenshot.mjs, serving web/dist-harness (D-11)
path provides
web/test/build-artifact.test.mjs D-14 build-artifact gate: no CDN refs, style JSON inlined, fonts copied, harness absent from web/dist
path provides
.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md the device-matrix exit-gate checklist and result record (Success Criterion 5)
from to via pattern
bot/wrangler.jsonc web/dist assets.directory points at ../web/dist and run_worker_first keeps /api/* on the Worker run_worker_first
from to via pattern
web/scripts/capture.mjs web/dist-harness the docroot is the --mode harness build, because the mock installer and fetch stubs are stripped from any production-mode bundle dist-harness
from to via pattern
web/scripts/capture.mjs web/src/main.ts races the RAMS-BASEMAP-READY and ARAHARAH-LIVE console signals ARAHARAH-LIVE
from to via pattern
web/scripts/capture.mjs stitch_minimalist_route_navigator/navigate_map_overview/screen.png the DSGN-02 side-by-side fidelity artifact navigate_map_overview
Complete the one-deployable architecture (D-13/D-14/D-15): wire `web/dist` into the existing Worker via the `[assets]` binding, add the build-artifact gate proving nothing ships from a CDN, extend the Phase-1 Playwright toolchain into a capture harness that drives the Mock-Telegram runner and produces the DSGN-02 fidelity artifact plus the GEO-03 runtime probe — then run the blocking device-matrix exit gate (ROADMAP Success Criterion 5).

Purpose: the phase cannot honestly close without proving the build is self-contained and the app works on real Android, iOS, Desktop, and plain browser. Real-device availability is an open question (RESEARCH section "Environment Availability" lists it as blocking with no fallback), so this plan carries the blocking human-verification checkpoint rather than deferring the discovery.

Output: bot/wrangler.jsonc [assets] block; root one-command build/deploy; web/vite.config.ts dual-build config; web/index.html harness profile; web/src/vite-env.d.ts; web/scripts/capture.mjs; web/test/build-artifact.test.mjs; 03-DEVICE-MATRIX.md.

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@AGENTS.md @.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md @.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md @.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md @theme/scripts/screenshot.mjs @theme/package.json @bot/wrangler.jsonc @bot/package.json @package.json @web/index.html @web/src/main.ts @web/src/mock/telegram-mock.ts

<wrangler_assets_shape> Cloudflare Workers static assets binding (RESEARCH cites developers.cloudflare.com/workers/static-assets/binding):

{
  "name": "araharah-bot",
  "main": "src/webhook.ts",
  "compatibility_date": "2026-10-22",
  "workers_dev": true,
  "assets": {
    "directory": "../web/dist",
    "binding": "ASSETS",
    "not_found_handling": "none"
  },
  "run_worker_first": ["/api/*", "/telegram/*"],
  "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }]
}

run_worker_first is a GLOB ARRAY (max 100 entries), not a boolean. The Worker still owns /api/* and /telegram/* first (D-13); the asset binding serves everything else. </wrangler_assets_shape>

<screenshot_toolchain> theme/scripts/screenshot.mjs (194 lines) is the established pattern to extend, not rewrite:

  • MIN_BYTES = 10240 blank bar
  • serveTheme() static server with the WR-05 docroot guard (target.startsWith(THEME + sep))
  • READY-signal race: page.on('console', m => m.text().startsWith('RAMS-BASEMAP-READY')) raced against a 30s timeout
  • Report plus process.exit(1) on FAILED / MISSING / UNDERSIZED

For web/, the docroot is web/dist and the wait signal is ARAHARAH-LIVE (main.ts emits it). Playwright is already theme/'s dependency at ^1.50.0 with chromium/webkit/firefox cached. </screenshot_toolchain>

task 1: Worker `[assets]` binding + one-command deploy + D-14 build-artifact gate bot/wrangler.jsonc, bot/package.json, package.json, web/vite.config.ts, web/test/build-artifact.test.mjs, .gitignore - `bot/wrangler.jsonc` (the current shape — keep name, main, compatibility_date, workers_dev, kv_namespaces byte-identical) - `bot/package.json` (the `dev` script this task pins to port 8787) - `package.json` (root — the existing delegate-script block and where `deploy` and `dev` go) - `web/vite.config.ts` (written in plan 03-01 task 1 — extend it, do not rewrite) - `.gitignore` (confirm the `dist/` entry covers BOTH `theme/dist/` and `web/dist/`; REGENERATED BUILD OUTPUT MUST NEVER BE COMMITTED) **`bot/wrangler.jsonc`.** Add the `assets` block and the `run_worker_first` glob array, preserving every existing key: ```jsonc { "name": "araharah-bot", "main": "src/webhook.ts", "compatibility_date": "2026-10-22", "workers_dev": true, // Workers static assets binding (D-13, D-14): serves ../web/dist. The // Worker fetch still owns /api/* and /telegram/* first via // run_worker_first (a GLOB ARRAY, not a boolean). "assets": { "directory": "../web/dist", "binding": "ASSETS", "not_found_handling": "none" }, "run_worker_first": ["/api/*", "/telegram/*"], "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] } ```

Root package.json. Add the D-15 one-command deploy, which builds the theme first, then the web app, then runs the bot's existing deploy (wrangler deploy && node scripts/set-webhook.mjs):

"build": "npm run build -w theme && npm run build -w web",
"deploy": "npm run build && npm run deploy -w bot"

The theme build step is REQUIRED, not cosmetic. theme/dist/ is gitignored generated output (RESEARCH / .gitignore), and web/src/shell.css imports @araharah/theme/dist/rams.css at build time. On a fresh clone, npm run build -w web alone fails to resolve that specifier. Chaining theme → web makes every build script in the repo — root build, root deploy, root dev — correct on a fresh clone with no extra instructions. Update the existing web:build delegate to npm run build -w theme && npm run build -w web for the same reason.

Keep the existing bot:deploy delegate as the low-level entry point.

web/vite.config.ts — the dual-build config (the config half of the capture-harness fix in task 2). The capture harness must serve a bundle that still contains the Mock-Telegram installer, while the production bundle must not. A single import.meta.env.DEV guard cannot express both, because import.meta.env.DEV is statically false in ANY production-mode build — including one served to a local capture server. So the harness gets its own mode with its own outDir and its own injected constant:

import { defineConfig } from "vite";

export default defineConfig(({ mode }) => {
  const isHarness = mode === "harness";
  return {
    base: "/",
    define: {
      // Vite replaces this IDENTIFIER textually at build time, so the
      // harness bundle compiles `if (VITE_HARNESS) installMockWebApp()`
      // down to `if (true) …`. The production build never defines it,
      // so the identifier is unreferenced and the branch is tree-shaken.
      __ARAHARAH_HARNESS__: JSON.stringify(isHarness),
    },
    build: {
      outDir: isHarness ? "dist-harness" : "dist",
      emptyOutDir: true,
      target: "es2022",
    },
    server: { port: 5173, strictPort: true },
  };
});

Add "build:harness": "vite build --mode harness" to web/package.json and a root delegate "web:build:harness": "npm run build:harness -w web". In web/src/main.ts, the mock-install guard becomes if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) installMockWebApp(); — declare the constant once in a web/src/vite-env.d.ts (declare const __ARAHARAH_HARNESS__: boolean;) so typecheck passes in both modes. This guard lives ONLY in main.ts — the runner page uses a separate, whole-block if (__ARAHARAH_HARNESS__) { … } wrapper (task 2), because it must also gate the fetch stubs and must NOT be combined with import.meta.env.DEV or !window.__ARAHARAH_MOCK__ (either would disable the harness's own profile in vite dev). Together the two guards are what make the capture race reachable: in the served web/dist-harness/, installMockWebApp is present; in web/dist/, it is absent and BA5 holds.

.gitignore. Verify the existing dist/ entry covers theme/dist/ AND web/dist/. If it is root-anchored (/dist/), change it to dist/ (any depth) or add explicit theme/dist/ and web/dist/ lines. REGENERATED BUILD OUTPUT MUST NEVER BE COMMITTED — including theme/dist/, which the web build depends on but must not vendor.

D-12 — local dev topology. The [assets] binding makes wrangler dev serve the app, the API, and the webhook from one origin on port 8787 with local KV simulation, which is exactly D-12's topology. Verify it and record the command in the root delegates and in bot/package.json:

  • Change bot/package.json's dev script from "wrangler dev" to "wrangler dev --port 8787 --local" so the port is pinned (8787 is the number D-12 names) and KV runs in local-simulation mode rather than against the remote namespace.
  • Add a root delegate "dev": "npm run build && npm run dev -w bot" so the daily loop is: build the theme + web app once, then one command brings up statics + /api/* + webhook + local KV at http://localhost:8787.
  • Record in .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md (created in task 3) that localhost:8787 is a secure context, which is why navigator.geolocation works under wrangler dev while a LAN-IP dev URL would not (RESEARCH assumption A3). Remote staging is used sparingly, immediately before a real-device verification session, for exactly that reason.

Create web/test/build-artifact.test.mjs. It reads web/dist (produced by npm run build -w web, which this task's verify runs first). If web/dist does not exist, every case fails with a message telling the executor to run the build first. Cases:

BA1 no emitted .js or .css file references cdn.tailwindcss.com, fonts.googleapis.com,
   fonts.gstatic.com, unpkg.com, or cdn.jsdelivr.net (D-14: no CDN fallbacks)
BA2 web/dist contains at least one .woff2 file (self-hosted fonts copied at build time)
   CONTINGENCY — if this fails, the fonts were not carried through by Vite's `url()` resolution
   of the imported `@araharah/theme/dist/rams.css`. Add an explicit copy step rather than leaving
   BA2 red: either (a) copy `theme/dist/fonts/` into `web/public/fonts/` so Vite emits them verbatim,
   or (b) add a `postbuild` copy script (`node scripts/copy-fonts.mjs`) that mirrors `theme/dist/fonts/*.woff2`
   into `web/dist/fonts/`. Both are one-time fixes; re-run `npm run build -w theme && npm run build -w web`
   and re-check before moving on. Do NOT add a CDN `<link>` fallback — BA1 would fail it.
BA3 web/dist/assets contains a hashed MapLibre worker chunk (filename matching /worker/i)
BA4 the built JS bundle carries the inlined Rams style — assert the string "liberty-rams"
   OR "ne2_shaded" appears in the concatenated JS (both are unique to liberty-rams.json's
   source/layer names), proving the style was inlined at build rather than fetched at runtime
BA5 the PRODUCTION bundle does not contain "installMockWebApp" or "__ARAHARAH_MOCK__" —
   Vite's `define` renders `if (__ARAHARAH_HARNESS__)` as `if (false)`, so the runner page's
   profile block in web/index.html is dead-code-eliminated and the main.ts DEV guard is
   tree-shaken. Scan ALL of web/dist — BOTH `web/dist/assets/*.js` AND every emitted
   `web/dist/**/*.html` (web/index.html is the ONE entry shared by both builds, so an
   unguarded profile block would ship the mock and the fetch stubs as inline HTML that the
   JS-only scan would miss entirely). Scan `web/dist/` ONLY: the harness build emits to a
   SEPARATE outDir (`web/dist-harness/`, plan task 2), so this gate can never see harness
   code. Do NOT point BA5 at `web/dist-harness/` — the whole point is that the shipped
   bundle is clean while the harness bundle deliberately is not.
BA6 the PRODUCTION bundle does not contain "/api/session" or "/api/location-fallback"
   inside any emitted HTML (the runner page's fetch stubs are stripped with the same
   dead-code elimination; a hit means an unguarded profile block shipped)

Two-build directory contract (load-bearing — the builds must NEVER share an outDir):

web/dist/          ← production build  (npm run build -w web)                — the ONLY thing BA1–BA5 scan
web/dist-harness/  ← harness build     (vite build --mode harness)          — served ONLY by capture.mjs

web/dist-harness/ is gitignored generated output like web/dist/ and is NEVER deployed — the [assets] binding points at ../web/dist only. Add web/dist-harness/ to .gitignore in this task. Sharing one outDir would make the two builds clobber each other and make the BA5 gate meaningless.

Implement BA1 by walking web/dist recursively with readdirSync({withFileTypes: true}), filtering .js/.css, and assert.ok(!content.includes(host), ...) for each host with the file path in the message. For BA4 read and concatenate every web/dist/assets/*.js. For BA5/BA6 walk the SAME tree and read every .js AND every .html file into two concatenated buffers (jsAssets, allHtml), then assert installMockWebApp and __ARAHARAH_MOCK__ are absent from the combined buffer, and (BA6) that /api/session and /api/location-fallback are absent from allHtml. Put the offending file path in every failure message — a JS-only scan that passes vacuously because the mock survived as inline HTML is the exact regression BA5's extension exists to prevent. Copy the path-scaffolding style from bot/test/privacy.test.mjs lines 23-27. cd /home/C-Fu/araharah && npm run build -w theme 2>&1 | tail -3 && npm run build -w web 2>&1 | tail -5 ; npm test -w web 2>&1 | grep -E '^PASS BA|runner' ; node -e "const c=require('fs').readFileSync('bot/wrangler.jsonc','utf8'); if(!c.includes('run_worker_first')) throw new Error('assets wiring missing'); if(!c.includes('../web/dist')) throw new Error('assets.directory wrong'); console.log('wrangler OK')" ; node -e "const p=require('./package.json'); if(!p.scripts.deploy) throw new Error('root deploy missing'); if(!p.scripts.build) throw new Error('root build missing'); console.log('scripts OK')" <acceptance_criteria> - npm run build -w theme && npm run build -w web exits 0 - npm test -w web exits 0 and the runner output contains PASS BA1 through PASS BA6 - node -e "const c=require('fs').readFileSync('bot/wrangler.jsonc','utf8'); if(!c.includes('run_worker_first')) throw new Error(1); if(!c.includes('../web/dist')) throw new Error(2); console.log('OK')" exits 0 - grep -q '"deploy": "npm run build && npm run deploy -w bot"' package.json exits 0 - grep -q '"build": "npm run build -w theme && npm run build -w web"' package.json exits 0 (theme must build before web — theme/dist/ is gitignored generated output, so a fresh clone cannot resolve @araharah/theme/dist/rams.css without this) - grep -q '"dev": "npm run build && npm run dev -w bot"' package.json exits 0 (D-12 one-command local loop) - grep -q '\-\-port 8787 --local' bot/package.json exits 0 (D-12 port pinned, KV in local-simulation mode) - grep -q 'dist-harness' web/vite.config.ts exits 0 AND grep -q '__ARAHARAH_HARNESS__' web/vite.config.ts exits 0 (the two builds have separate outDirs and an injected harness constant — the production dist can never contain harness code) - grep -q '"build:harness": "vite build --mode harness"' web/package.json exits 0 - grep -q 'dist-harness' .gitignore exits 0 (harness build output is never committed) - cd bot && npx wrangler deploy --dry-run 2>&1 | tail -5 exits without a config-parse error — wrangler deploy DOES support --dry-run (it validates the bundle + config without uploading), which proves the assets and run_worker_first keys are accepted by wrangler 4.151.3. Note: wrangler dev has NO --dry-run flag; do not attempt it. - grep -v '^ \*\|^/\*\|^$' web/dist/assets/*.js | grep -c 'cdn.tailwindcss.com\|fonts.googleapis.com\|unpkg.com\|cdn.jsdelivr.net' | head -1 returns 0 - find web/dist -name '*.woff2' | wc -l returns >= 1 (if 0, apply BA2's contingency — a web/public/fonts/ copy or a postbuild font-copy script — never a CDN link) - Both-direction harness isolation (T-03-21), asserted directly: grep -rl 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/ 2>/dev/null | wc -l returns 0 — including web/dist/index.html, not just the JS chunks — while grep -rl 'installMockWebApp' web/dist-harness/ 2>/dev/null | wc -l returns >= 1 - grep -c 'api/session\|api/location-fallback' web/dist/index.html 2>/dev/null returns 0 (the runner page's fetch stubs did not survive as inline production HTML) - git check-ignore -q web/dist/index.html && echo IGNORED prints IGNORED (build output never committed) </acceptance_criteria> The Worker serves the app from its own static-assets binding, deploy is one command, and six automated gates prove the shipped bundle is fully self-contained (D-14) and free of the Mock-Telegram installer in every emitted file, HTML included.

task 2: Mock-Telegram runner page + Playwright capture harness (D-11, DSGN-02, GEO-03 runtime probe) web/index.html, web/scripts/capture.mjs, web/src/vite-env.d.ts - `theme/scripts/screenshot.mjs` (the entire file — extend, do not rewrite: MIN_BYTES, serveTheme, READY-signal race, report + exit code) - `web/vite.config.ts` (task 1 output — the `__ARAHARAH_HARNESS__` define and the `dist-harness` outDir this task serves) - `web/src/main.ts` (the `ARAHARAH-LIVE` console signal it emits, and its install guard `(__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__`) - `web/src/mock/telegram-mock.ts` (the `installMockWebApp(options)` signature and `MockOptions` shape — add the `languageCode` option if absent) - `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` section "Fidelity Artifact" (what the gallery row must show) **Why this task builds a SEPARATE bundle (this is the load-bearing design decision).** The obvious design — serve `web/dist` (the production build) and rely on `import.meta.env.DEV` to include the mock — CANNOT work. `import.meta.env.DEV` is statically `false` in every production-mode build, so the served `dist` contains neither `installMockWebApp` nor the harness `fetch` stubs. With no `window.Telegram`, `POST /api/session` 404s, `main.ts` renders the denied card, and the `ARAHARAH-LIVE` race in step 5 times out on every capture — the harness would silently never produce a live shot. Task 1 therefore added `vite build --mode harness` → `web/dist-harness/` with an injected `__ARAHARAH_HARNESS__` constant. **This task serves `web/dist-harness/`, not `web/dist/`.** The production bundle is what gets deployed and what BA1–BA5 gate; the harness bundle exists solely so the capture race is reachable.

web/index.html — the runner page is the SOLE mock installer. main.ts installs a default mock only when none is present: if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) installMockWebApp();. That guard is what makes the profile below authoritative. An unconditional DEV install inside main.ts would clobber ?mock=tdesktop&scheme=dark with an android/light default, so the Desktop capture would silently be an Android light capture and the exit-gate artifact would prove nothing.

THE ENTIRE PROFILE BLOCK BELOW IS WRAPPED IN if (__ARAHARAH_HARNESS__) { … } — this is load-bearing, not stylistic. web/index.html is the ONE entry file shared by BOTH builds, so an unguarded block would put the mock installer AND the fake fetch (which short-circuits the HMAC session gate) into the production web/dist/index.html. define replaces __ARAHARAH_HARNESS__ textually with false in the production build, so Vite/Rollup folds the branch away entirely (dead-code elimination); with true in the harness build it runs. __ARAHARAH_HARNESS__ is a global identifier, so no import is needed to reference it inside <script type="module"> — it is textually replaced before module evaluation. Do NOT wrap it in import.meta.env.DEV (statically false in both builds, so the harness would break) and do NOT add !window.__ARAHARAH_MOCK__ (the runner page is the sole authority; it sets that global itself).

The profile block lives INSIDE the existing <script type="module">, BEFORE the main.ts import, and the main.ts import stays OUTSIDE the guard so production still boots normally:

<script type="module">
  /* D-11 Mock-Telegram profile: ?mock=android|ios|tdesktop|macos|web  +  ?scheme=light|dark  +  ?pin=1
     Harness-only: served from web/dist-harness, never deployed.
     The WHOLE block below is inside `if (__ARAHARAH_HARNESS__)` — Vite's `define`
     replaces that identifier with `false` in the production build, so the branch is
     dead-code-eliminated and web/dist/index.html carries neither installMockWebApp
     nor the fetch stubs. BA5 scans ALL of web/dist HTML as well as its JS assets. */
  if (__ARAHARAH_HARNESS__) {
    const q = new URLSearchParams(location.search);
    const profile = q.get("mock") || "android";
    const scheme = q.get("scheme") === "dark" ? "dark" : "light";
    const locale = q.get("locale") === "ms" ? "ms" : "en";

    // Set BEFORE main.ts is imported — this is the marker main.ts's install guard checks.
    window.__ARAHARAH_MOCK__ = { profile, scheme, locale };

    // Stub the endpoints the app calls, OUTSIDE any dead-code-eliminated branch. These
    // live in the runner page (not in main.ts) precisely so no import.meta.env guard can
    // strip them: the harness bundle must reach LIVE without a Worker in the loop.
    const realFetch = window.fetch.bind(window);
    const fakeToken = "00000000-0000-4000-8000-000000000001";
    window.fetch = async (input, init) => {
      const url = typeof input === "string" ? input : (input && input.url) || "";
      if (url.includes("/api/session") && (!init || init.method === "POST")) {
        // The session gate is HMAC-validated server-side; offline the harness
        // short-circuits it with a fake token the stubbed /api/location also accepts.
        return new Response(JSON.stringify({ token: fakeToken }), { status: 200, headers: { "content-type": "application/json" } });
      }
      if (url.includes("/api/location") && (!init || init.method === "GET")) {
        const body = q.get("pin") === "1"
          ? { pin: { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 8 } }
          : { pin: null };
        return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
      }
      if (url.includes("/api/location-fallback")) {
        return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } });
      }
      return realFetch(input, init);
    };

    const { installMockWebApp } = await import("/src/mock/telegram-mock.ts");
    installMockWebApp({
      platform: profile,
      colorScheme: scheme,
      languageCode: locale,
      grantLocation: profile !== "tdesktop",
      telegramFix: { lat: 3.1513, lng: 101.6958, accuracyM: 12, headingDeg: null },
    });
  }

  await import("/src/main.ts");
</script>

Mock initData. The POST /api/session stub never reaches the Worker, so installMockWebApp's initData only has to be a non-empty, correctly-shaped string — the harness does NOT need a valid HMAC. Have the mock emit user={"language_code":"<locale>","id":1,"first_name":"Harness"}&auth_date=1&query_id=h&hash=harness (URL-encoded per Telegram's wire format) so localeFromInitData in main.ts exercises its real parsing path instead of always falling back to "en". The ?locale= parameter above overrides the language_code, so a future ms capture needs no code change.

Note in web/index.html's comment that no localStorage.setItem/sessionStorage.setItem may appear here — the harness profile lives in a module-scope global (window.__ARAHARAH_MOCK__), never in storage (D-07).

web/scripts/capture.mjs. Copy theme/scripts/screenshot.mjs and adapt. Structure:

#!/usr/bin/env node
/*
 * capture.mjs — Phase-3 capture harness (D-11, DSGN-02, GEO-03 runtime probe).
 * Extends theme/scripts/screenshot.mjs (Phase 1 precedent) rather than
 * replacing it: same MIN_BYTES blank bar, same docroot-guarded static
 * server, same READY-signal console race, same FAILED/MISSING/UNDERSIZED
 * report + exit(1).
 *
 * Docroot is web/dist-HARNESS (the --mode harness build), NOT web/dist.
 * Reason: web/index.html's entire Mock-Telegram profile block — the mock
 * installer and the /api/session + /api/location + /api/location-fallback
 * fetch stubs — sits inside `if (__ARAHARAH_HARNESS__)`. `vite build` replaces
 * that identifier with `false`, so the branch is dead-code-eliminated and
 * web/dist/index.html carries none of it (BA5 scans the dist HTML too).
 * Serving web/dist would yield no window.Telegram, a real POST /api/session
 * with no Worker behind it, the denied card, and a permanent
 * ARAHARAH-LIVE timeout.
 *
 * Wait signals: RAMS-BASEMAP-READY (earlier gate), then ARAHARAH-LIVE.
 *
 * Outputs (web/screenshots/ — regenerated, gitignored):
 *   shell-boot-1.png       boot stage 1 (validating session card over live map)
 *   shell-boot-2.png       boot stage 2 (acquiring location card)
 *   shell-live-light.png   LIVE state, Telegram light theme
 *   shell-live-dark.png    LIVE state, Telegram dark theme (PX-02)
 *   shell-denied.png       denied card + ASK VIA CHAT (GEO-02 entry point)
 *   shell-chat-pin.png     LIVE via the chat-pin relay (?pin=1, stubbed /api/location)
 *   gallery-compare.png    DSGN-02 side-by-side vs navigate_map_overview/screen.png
 *
 * Also runs the GEO-03 runtime probe in-page after boot-to-live and fails
 * the run on any non-empty storage surface.
 */

Implementation specifics:

  1. Build the harness bundle first, and PROVE it is the harness bundle. execSync("npm run build -w theme && npm run build -w web && npm run build:harness -w web", { cwd: repoRoot }). After the build, assert web/dist-harness/index.html exists AND that the harness bundle actually contains installMockWebApp (a cheap grep -q over web/dist-harness/assets/*.js). If either check fails, abort immediately with "harness build did not include the Mock-Telegram installer — the capture would race forever" rather than letting the READY race time out 30s later with no diagnosis. The production web/dist must NOT contain it (BA5, task 1) — assert both directions.
  2. Server. Copy serveTheme() verbatim, changing THEME to DIST = resolve(SCRIPTS, "..", "dist-harness") and keeping the WR-05 docroot guard. Extend the MIME map to include .mjs, .map, .webmanifest.
  3. Contexts. One chromium browser; per capture, browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme, deviceScaleFactor: 2 }).
  4. Geolocation grant. await context.grantPermissions(["geolocation"], { origin: base }); and await context.setGeolocation({ latitude: 3.1513, longitude: 101.6958, accuracy: 12 }); before page.goto — for every profile EXCEPT tdesktop.
  5. READY race. Attach page.on("console", m => { if (m.text().startsWith("RAMS-BASEMAP-READY")) basemapReady = true; if (m.text().startsWith("ARAHARAH-LIVE")) liveReady = true; }) then await Promise.race([liveReady, timeout(30000)]) per the Phase-1 pattern. The ?mock=tdesktop DENIED leg gets its own dedicated race against the .card + [data-state="denied"] strip lamp (see leg 6), NOT ARAHARAH-LIVE and NOT the 30s timeout — chain.ts LINK 3 deliberately hard-codes pollChatPin(token, { timeoutMs: 60000 }) before it returns denied, so the denied card first renders at roughly 60 seconds. Race that leg against timeout(90000) with the reason recorded in this header comment (DENIED_LEG_TIMEOUT_MS = 90000; // LINK 3's 60s pre-denial chat-pin poll must elapse first). Any other leg that waits on a signal must not silently sit under 60s. A timeout is a hard failure with a diagnostic, not a silent skip — print which signals fired (RAMS-BASEMAP-READY yes/no, ARAHARAH-LIVE yes/no) plus the last 5 console lines, so a future regression points at the missing piece immediately.
  6. The seven captures, in order:
    • ?mock=android immediately after goto → shell-boot-1.png (stage 1 card).
    • ?mock=android after 400ms → shell-boot-2.png.
    • ?mock=android&scheme=light after ARAHARAH-LIVE + 2000ms tile settle → shell-live-light.png.
    • ?mock=android&scheme=dark same → shell-live-dark.png.
    • ?mock=tdesktop (no grantPermissions, no setGeolocation; the mock's grantLocation:false plus no navigator geolocation forces the chain to link 3) → shell-denied.png, waiting on the .card + [data-state="denied"] strip lamp rather than ARAHARAH-LIVE, with the dedicated 90s race from step 5 (DENIED_LEG_TIMEOUT_MS). This is the leg that proves the ASK VIA CHAT CTA renders, which is the entire entry point to GEO-02 on Desktop (issue #77).
    • ?mock=tdesktop&pin=1 → shell-chat-pin.png, waiting for ARAHARAH-LIVE. The ?pin=1 query makes the runner page's GET /api/location stub return a pin, exercising the chat-pin link of the chain end-to-end offline — the automated counterpart to the Desktop leg of the device matrix.
    • gallery-compare.png: compose the live-light capture beside stitch_minimalist_route_navigator/navigate_map_overview/screen.png using a second page with a two-column grid, or by compositing the two PNGs side by side with a short script. Label each column with its source path.
  7. GEO-03 runtime probe. After the live capture, run in-page:
    const probe = await page.evaluate(async () => ({
      local: localStorage.length,
      session: sessionStorage.length,
      cookie: document.cookie,
      dbs: (await indexedDB.databases()).length,
      lsKeys: Object.keys(localStorage),
    }));
    
    Fail the run if probe.local !== 0 || probe.session !== 0 || probe.cookie !== "" || probe.dbs !== 0, with the probe values in the error message. This is GEO-03's "spot-checkable at phase exit" criterion made mechanical.
  8. Report. Reuse the Phase-1 report block: log every capture with its byte size, then FAIL on missing, undersized (< MIN_BYTES), or any recorded failure; process.exit(1) if any, else console.log("CAPTURE OK — 7/7 non-blank captures under web/screenshots/") and process.exit(0).

Never log the bot token or initData; the harness reads BOT_TOKEN from bot/.dev.vars if it needs to sign, and prints only the capture filename and byte size. cd /home/C-Fu/araharah && npm run capture -w web 2>&1 | tail -25 <acceptance_criteria> - npm run capture -w web exits 0 and prints CAPTURE OK — 7/7 with 7 non-blank captures - ls web/screenshots/ | wc -l returns >= 7 - for f in web/screenshots/*.png; do test $(stat -c%s "$f") -ge 10240 || echo "UNDERSIZED $f"; done | wc -l returns 0 - grep -q 'dist-harness' web/scripts/capture.mjs exits 0 — the harness serves the harness build, NOT the production bundle (the race is unreachable otherwise) - grep -q 'web/dist-harness/assets' web/scripts/capture.mjs; grep -q "resolve(SCRIPTS, \"..\", \"dist\")" web/scripts/capture.mjs; test $? -ne 0 — the docroot must not be web/dist - grep -q 'installMockWebApp' web/scripts/capture.mjs exits 0 — the capture asserts the harness bundle really contains the installer before racing - grep -q 'api/session' web/index.html && grep -q 'api/location' web/index.html && grep -q 'api/location-fallback' web/index.html exits 0 — all three endpoints are stubbed in the runner page, inside the __ARAHARAH_HARNESS__ guard - The build-time guard exists (T-03-21): grep -c 'if (__ARAHARAH_HARNESS__) {' web/index.html | head -1 returns 1 AND node -e "const s=require('fs').readFileSync('web/index.html','utf8'); const g=s.indexOf('if (__ARAHARAH_HARNESS__) {'); const m=s.indexOf('installMockWebApp('); if(g===-1||m===-1) throw new Error('guard or installer missing'); if(m<g) throw new Error('VIOLATION: installer is outside the guard — it would ship in web/dist/index.html'); console.log('guard OK')" exits 0 — the ENTIRE profile block (marker, fetch stubs, installer) is inside the guard, and the main.ts import is outside it - Both directions on the built output: grep -rl 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/ 2>/dev/null | wc -l returns 0 AND grep -rl 'installMockWebApp' web/dist-harness/ 2>/dev/null | wc -l returns >= 1 - grep -q 'import.meta.env.DEV' web/index.html; test $? -ne 0 — must FAIL: the runner page's guard is __ARAHARAH_HARNESS__ only (a DEV guard would be statically false in BOTH builds and would break the harness); the DEV guard lives only in main.ts and is combined with !window.__ARAHARAH_MOCK__ - grep -q 'DENIED_LEG_TIMEOUT_MS' web/scripts/capture.mjs exits 0 AND grep -q '90000' web/scripts/capture.mjs exits 0 — the ?mock=tdesktop denied leg races LINK 3's 60s pre-denial chat-pin poll, not the 30s default (issue: a dead-end capture) - grep -q 'pin") === "1"' web/index.html exits 0 (the ?pin=1 chat-pin capture is wired) - grep -q 'indexedDB' web/scripts/capture.mjs exits 0 AND grep -q 'localStorage.length' web/scripts/capture.mjs exits 0 (the GEO-03 runtime probe exists) - grep -q 'sessionStorage.setItem\|localStorage.setItem' web/index.html must FAIL — the harness profile is not persisted - grep -q '__ARAHARAH_MOCK__' web/index.html exits 0 - grep -q 'RAMS-BASEMAP-READY' web/scripts/capture.mjs exits 0 AND grep -q 'ARAHARAH-LIVE' web/scripts/capture.mjs exits 0 (both READY signals raced) - grep -q 'navigate_map_overview' web/scripts/capture.mjs exits 0 (DSGN-02 side-by-side source) - grep -q 'MIN_BYTES = 10240' web/scripts/capture.mjs exits 0 (the Phase-1 blank bar is carried over) - grep -q 'startsWith(THEME + sep)\|startsWith(DIST + sep)' web/scripts/capture.mjs exits 0 — the WR-05 docroot guard survives - grep -q 'declare const __ARAHARAH_HARNESS__' web/src/vite-env.d.ts exits 0 (the injected constant is declared, so typecheck passes in both modes) </acceptance_criteria> Seven non-blank captures exist — including the Desktop denied card, the chat-pin relay, and the DSGN-02 side-by-side — and the GEO-03 runtime probe mechanically proves every storage surface is empty after a real boot-to-live cycle.

task 3: device-matrix exit gate — Android + iOS + Desktop + plain browser (ROADMAP Success Criterion 5) .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md - `AGENTS.md` (free-first infra mandate; the constraints that bear on staging) - `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` section "Environment Availability" (Android, iOS, Desktop listed as blocking with NO fallback) - `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` section "Assumptions Log" (assumption A1, the Android stale-fix, can only be retired on a physical Android phone) - `.planning/phases/03-mini-app-shell-geolocation/03-VALIDATION.md` section "Manual-Only Verifications" (the four manual behaviors) This checkpoint is BLOCKING and cannot be automated. RESEARCH section "Environment Availability" lists a real Android phone, a real iOS phone, and a Telegram Desktop install as required dependencies with no fallback, and Open Question 3 (whether the executor has physical devices at all) is an open question only the user can answer. Assumption A1 (the Android stale-fix workaround) can only be retired on a physical Android device.

Pre-work the executor must complete BEFORE presenting this checkpoint:

  1. Deploy to staging: npm run deploy (builds theme/, then web/, then wrangler deploy). Note the deployed URL. Confirm it serves the app and that POST /api/session still answers.
  2. Verify the BotFather-registered Mini App URL matches the deployed origin. Bot API 10.2 (auto-enabled 2026-07-20) rejects Mini App methods called from any other origin, and the failure is silent. If BotFather points elsewhere, Telegram.WebApp.* calls fail with no clear error. This is a hard prerequisite — resolve it first or every mobile leg is meaningless.
  3. Write .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md with these six legs, each an unchecked checkbox plus an "Observed / Symptom" blank line:
    • Android (real phone, Telegram Android) — open the Mini App; confirm BOOT-01 then BOOT-02 then LIVE; confirm the position marker renders on the Rams basemap. Then close the app fully, reopen it, and confirm no second permission prompt appears. That is GEO-01's "permission persists across sessions": the grant lives in Telegram's per-bot store on the device, outside the WebView — record that observation explicitly, it is the subtlest point in the phase.
    • Android stale-fix check (retires A1) — with the app live, walk several metres, then re-check. If the pin does not move, toggle the phone's Location setting once to force a refresh and record whether the navigator.geolocation nudge resolved it. Note explicitly whether the first Android verification needed the Location toggle; that result decides whether A1 stands or is retired.
    • iOS (real phone, Telegram iOS) — same boot to LIVE check; marker renders; permission grant persists across a reopen.
    • Desktop Telegram — geolocation is absent by design (issue #77), so the expected behavior is the denied card with identical copy (no client-specific variant, per D-06 and RESEARCH Pitfall 4). Tap ASK VIA CHAT; this POSTs /api/location-fallback, and the Worker asks the bot to send the location keyboard into that user's own chat (chat id comes from the session claim — plan 03-01's handleLocationFallback). Confirm the keyboard appears in chat; tap it, share a pin; confirm the app picks it up via GET /api/location and reaches LIVE. That is GEO-02 end-to-end.
    • Plain browser — open the deployed URL directly in a desktop browser. The app must boot to a deliberate state: the denied card (off-Telegram there is no initData, so the session cannot be HMAC-validated → 401 → denied). The live branch is NOT reachable here — the denied card short-circuits the chain before any geolocation prompt. The check is that the page renders the denied card over the Rams shell instead of failing to load; a blank page is a FAIL. (If you separately confirm LIVE via a browser geolocation prompt, record it as an extra observation, not as this leg's pass condition.)
    • Theme flip, live (PX-02 — VALIDATION.md "Manual-Only Verifications" row 4) — with the app reached LIVE on a real client, switch the Telegram theme light ↔ dark mid-session, without closing or restarting the app. Confirm: (a) the shell chrome re-slots (document background, card field, shell ink all swap); (b) the status strip stays matte-black (Rams-constant); (c) the map is unchanged — same style, no flash, no camera jump (D-10 / RESEARCH Pitfall 7: a setStyle on themeChanged would tear down and rebuild all 110 layers). Record a before/after screenshot in each direction and note which client you flipped. This leg is the ONLY place PX-02's mid-session behavior is verified — the Playwright captures prove only separate cold boots per scheme. Fold it into whichever of the Android/iOS/Desktop legs you can actually perform, and record it as its own checkbox either way; an unrecorded theme flip leaves PX-02's "while the map keeps the Rams cartography constant" half unverified.

Present to the user with these exact instructions:

  • Deploy first: npm run deploy, and note the deployed URL.
  • Confirm BotFather's Mini App URL is that exact origin.
  • Walk the six legs one at a time on real hardware.
  • Report back which legs passed, which failed, and the exact symptom for each failure.
Type "device matrix passed" if all six legs reached LIVE or the documented denied-and-recovered state (the theme-flip leg counts as passed when the chrome re-slotted mid-session and the map was visibly unchanged). Otherwise describe which leg failed and the symptom — the executor opens a `/gsd-debug` for it.

If the user reports that no physical Android or iOS device is available, the honest outcome is that Success Criterion 5 is UNVERIFIED. Do NOT mark the phase complete. Record the blocker in .planning/STATE.md section "Blockers/Concerns" and recommend either acquiring a device or moving the matrix to Phase 7 hardening with an explicit requirement-level note that GEO-01 and GEO-02 remain unverified on real hardware. Never close the phase claiming device verification that did not happen. cd /home/C-Fu/araharah && npm run build 2>&1 | tail -3 && npm test -w web 2>&1 | tail -3 && npm test -w bot 2>&1 | tail -3 && npm run capture -w web 2>&1 | tail -5 && test -f .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md && grep -c 'PASS|FAIL|UNVERIFIED' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md <acceptance_criteria> - npm run capture -w web exits 0 (the automated half of the gate is green before any device is touched) - npm test -w web and npm test -w bot both exit 0 - test -f .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md exits 0 - grep -c 'Android' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md | head -1 returns >= 1 AND the same for iOS, Desktop, browser — all four legs are recorded - grep -q 'PASS' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md exits 0 (every leg has an explicit verdict recorded) - grep -q 'A1\|stale-fix\|Location setting' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md exits 0 (the Android stale-fix result is recorded, not left implicit) - grep -q 'BotFather' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md exits 0 (the Bot API 10.2 origin prerequisite was checked) - grep -qi 'theme flip\|theme.*light.*dark\|LIGHT ↔ DARK' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md exits 0 (PX-02's mid-session theme flip has its own leg and an explicit verdict — VALIDATION.md lists it as manual-only) - If any leg is UNVERIFIED, .planning/STATE.md contains the blocker under "Blockers/Concerns" </acceptance_criteria> All six legs of the device matrix are verified on real hardware and recorded, or the phase is explicitly held open with the blocker documented — never silently closed on unverified hardware.

<threat_model>

Trust Boundaries

Boundary Description
Built bundle → production origin The artifact that reaches real devices; a CDN slip would leak requests to third parties.
Telegram client → deployed origin Bot API 10.2 enforces same-origin Mini App calls; a wrong BotFather URL silently breaks WebApp APIs.
Dev harness → production bundle The Mock-Telegram fake must never ship — it fakes location and the session gate.
Harness build → deployed bundle Two builds exist; only one is deployable. Conflating them would ship a fake that bypasses the HMAC session gate.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-03-20 Information Disclosure Third-party CDN receiving user navigation requests (or app fingerprinting) mitigate D-14 build-artifact gate BA1: every emitted .js/.css file is scanned for cdn.tailwindcss.com, fonts.googleapis.com, fonts.gstatic.com, unpkg.com, cdn.jsdelivr.net; any hit fails the suite. Fonts are self-hosted woff2 (BA2), the MapLibre worker is a Vite-emitted hashed asset (BA3), and the style JSON is inlined (BA4). Tiles come only from OpenFreeMap.
T-03-21 Information Disclosure Mock-Telegram fake shipping to production mitigate Dual-build isolation, enforced by a build-time constant that folds the branch away. Two separate guards, both keyed on __ARAHARAH_HARNESS__ (a Vite define that renders as false in vite build and true in vite build --mode harness). (1) main.ts installs its fallback mock only under if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) — both halves statically false in production, so the branch is unreferenced and tree-shaken. (2) web/index.html — the ONE entry shared by both builds — wraps its ENTIRE profile block (the window.__ARAHARAH_MOCK__ marker, the /api/session + /api/location + /api/location-fallback fetch stubs, and the installMockWebApp call) in if (__ARAHARAH_HARNESS__) { … }; define substitutes false, Rollup dead-code-eliminates the block, and await import("/src/main.ts") stays OUTSIDE it so production boots normally. The outDirs are separate (web/dist/ vs web/dist-harness/), and the [assets] binding points at ../web/dist only. BA5/BA6 scan every emitted .js AND every emitted .html under web/dist/ for installMockWebApp, __ARAHARAH_MOCK__, /api/session, and /api/location-fallback — an HTML-inclusive scan is mandatory because the runner page is inline HTML, so a JS-only gate would pass vacuously on exactly the regression this threat describes. capture.mjs asserts the inverse (installer PRESENT in web/dist-harness/) before racing, so the gate cannot pass by the harness build being empty.
T-03-22 Spoofing Bot API 10.2 origin restriction silently disabling WebApp APIs mitigate The device-matrix checkpoint's step 2 makes "BotFather URL == deployed origin" a hard prerequisite before any mobile leg. A mismatch is caught there, not shipped. wireBackButton and LocationManager calls all originate from the app's own document.
T-03-23 Elevation of Privilege A compromised asset binding serving arbitrary paths from the Worker origin mitigate assets.directory is a fixed build output (../web/dist); not_found_handling: "none" means a miss falls through to the Worker's 404 rather than serving a directory listing; run_worker_first guarantees /api/* and /telegram/* never resolve from the asset tree.
T-03-24 Information Disclosure GEO-03 runtime probe leaking probe data into logs accept The probe reads counts and key names only (localStorage.length, Object.keys(localStorage), indexedDB.databases().length) and prints them in the failure message. No coordinate value is read or printed.
T-03-25 Repudiation Closing the phase claiming device verification that did not happen mitigate The checkpoint's acceptance criteria require an explicit PASS/UNVERIFIED verdict per leg in 03-DEVICE-MATRIX.md, and the resume-signal block forbids closing on unverified hardware. Missing devices route to a documented Phase-7 deferral instead.
T-03-30 Repudiation The capture harness reporting a fake Android capture for a Desktop profile, or racing to LIVE against the wrong build mitigate main.ts installs a mock only when window.__ARAHARAH_MOCK__ is absent, so the runner page's ?mock= profile is the sole authority — an unconditional DEV install would silently clobber tdesktop/dark with android/light and produce a plausible-looking but wrong artifact. capture.mjs aborts with a named diagnostic when the READY race times out (reporting which signals fired) rather than skipping, and asserts the served docroot is dist-harness and that the harness bundle really contains the installer. Every capture's profile is stated in the report line.
T-03-31 Tampering A future edit serving web/dist from capture.mjs, reintroducing the unwinnable race mitigate The acceptance criteria grep capture.mjs for dist-harness and require that it does NOT reference resolve(SCRIPTS, "..", "dist"). capture.mjs's header comment states the reason in full, so the failure mode is documented at the point of the code that would cause it.
</threat_model>
- `npm test -w web` green (includes the 6 D-14 build-artifact gates over `web/dist` only, HTML included) - `npm test -w bot` green - `npm run build` green from a fresh clone (theme → web chain) - `npm run capture -w web` exits 0 with 7 non-blank captures off the HARNESS build, including a dark-theme shot, the Desktop denied card, the chat-pin relay, and the DSGN-02 side-by-side - `grep -rl 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/` returns NOTHING (including `web/dist/index.html`) while `grep -rl 'installMockWebApp' web/dist-harness/` succeeds — the two builds are isolated in both directions, and the production scan is not JS-only - `03-DEVICE-MATRIX.md` records an explicit verdict for all six legs, or the phase is held open with the blocker in STATE.md

<success_criteria>

  • D-13: one Worker serves static assets plus /api/* plus the webhook, with run_worker_first preserving the API routes
  • D-14: the shipped bundle references zero third-party CDNs; style JSON inlined, fonts self-hosted, MapLibre worker bundled
  • D-15: npm run deploy from the root is the single deploy command, and it builds theme before web so a fresh clone works
  • T-03-21 holds in both directions: the Mock-Telegram installer and its fetch stubs are provably absent from EVERY file in the deployable bundle (.js and .html) and provably present in the harness-only build
  • GEO-03: the runtime probe shows localStorage, sessionStorage, cookies, and IndexedDB all empty after a real boot-to-live cycle
  • GEO-01 / GEO-02 / PX-02: verified on Android, iOS, Desktop, plain browser, and one mid-session light↔dark theme flip — or explicitly recorded as unverified with the blocker documented </success_criteria>
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-04-SUMMARY.md`