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

80 KiB
Raw Permalink Blame History

Phase 3: Mini App Shell + Geolocation - Research

Researched: 2026-10-22 Domain: Telegram Mini App platform integration, WebView geolocation, MapLibre GL JS v6, Vite build, Cloudflare Workers static assets Confidence: HIGH for platform API surface (official Telegram + MapLibre + Cloudflare docs fetched this session); MEDIUM for the Android stale-fix workaround shape (community-sourced, unverified on device)

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

Mini App stack + repo layout

  • D-01: Frontend is TypeScript + Vite, no UI framework — MapLibre GL JS v6 bundled, interactivity via small focused modules (the mockups' pattern, productionized). This stack is the foundation Phases 4–6 build on.
  • D-02: App source lives in a web/ npm workspace beside theme/ and bot/; root package.json gains web:* delegate scripts. Theme is consumed as a build-time dependency (theme/dist/rams.css imported into the Vite build, not via CDN) — completing Phase 1's self-hosted/CDN-free decision for the app path.
  • D-03: Post-initData validation, the session token is held memory-only (module scope), never written to localStorage/sessionStorage/cookies. Revalidation happens on every fresh boot (Telegram revives webviews) and whenever the 900s TTL stalls mid-session. Research may stress-test this invariant across webview reload quirks, but the invariant itself is locked.

Location acquisition strategy

  • D-04: Primary chain: Telegram-native WebApp.getLocation() first (Android LocationManager with version-gated paths), fall back to navigator.geolocation inside the WebView, then the chat-keyboard "send location" fallback (bot-side; app consumes it). Research details the Android stale-fix and iOS WKWebView quirks — the CHAIN is locked, the per-link mechanics are research's.
  • D-05: Cadence is gated streaming: one-shot fixes during browsing/search; continuous watch only during guidance (Phase 6 builds the streaming state machine). Map recenters on each fix; the position marker always shows the freshest fix.
  • D-06: Denied/no-geolocation presents a deliberate Rams-styled denied state — explains why location matters, offers retry, points to Telegram's attach → Location path as the manual fallback (GEO-02). No silent banners.
  • D-07: Coordinates live in memory only while the app runs. Zero persistence across every storage API (localStorage, sessionStorage, cookies, IndexedDB, service-worker caches). This zero-persistence assertion is part of the phase's GEO-03 spot-check, stress-tested by research against every storage surface the app touches.

Shell UX + screens

  • D-08: Phase 3's screen surface is map-first and single: full-bleed Rams basemap, position dot, follow/recenter control, minimal status strip reflecting session/location state. Route lines, layer toggles, search furniture belong to later phases — nothing placeholder-shaped.
  • D-09: Boot choreography has staged deliberate states: validating session → acquiring location → live — each Rams-styled, none blank (extends the neutral-denied rule from PX-02).
  • D-10: Map survives theme flips Rams-constant (Phase 1 D-04 slots pay off); only shell surfaces re-theme via the Telegram theme params. Safe-area handling + BackButton wiring land here as infrastructure.

Dev/test harness

  • D-11: A Mock-Telegram harness for the daily loop: a local module injecting a fake window.Telegram.WebApp (dev-signed initData, BackButton events, theme params with light/dark switch, sample location streams) plus a runner page booting the real app off-Telegram in a plain browser. Real-device batteries are spent at the device-matrix exit gate, not during daily dev.
  • D-12: Local dev topology is wrangler dev on 8787 serving web/dist statics + /api/* + webhook with local KV simulation; remote staging used sparingly right before real-device verification sessions.

Worker static-asset wiring

  • D-13: Statics served by the Workers [assets] binding (wrangler config), from web/dist's build output; Worker fetch still owns /api/* + webhook first. Completes the one-deployable architecture from the roadmap.
  • D-14: Build inlines everything the app needs: CSS from theme, MapLibre, basemap style JSON (from theme/basemap/liberty-rams.json), self-hosted fonts copied at build time into web/dist. Tiles remain OpenFreeMap's endpoint at runtime; no CDN fallbacks.
  • D-15: Deployment stays one command (npm run deploy = build web → wrangler deploy with assets). Same ergonomics as Phase 2.

OpenCode's Discretion

  • Exact per-link mechanics of the location chain (Telegram API calls, permission bridging) — research's domain
  • Boot-state copy, exact mock initData signing mechanics, recenter-toggle visual details (within the Rams system)
  • assets config tuning (binding name, not_found_handling, asset prefixes)
  • How the reserved exit-gate Playwright matrix extends theme/scripts/screenshot.mjs

Deferred Ideas (OUT OF SCOPE)

  • Scripted Playwright device-matrix spec (mock-based) — extend theme/scripts toolchain at the exit gate
  • Placeholder-shaped search drawer / route furniture — later phases by design
  • Real-device TestFlight/Play Beta channels — out of scope; device-matrix exit pass suffices for v1 </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
GEO-01 The Mini App can obtain the user's current location via Telegram LocationManager, with permission persisting across sessions LocationManager.init() → getLocation() gate (isAccessGranted persists per-bot across sessions — the API's openSettings() path is the manual re-grant). Version floor Bot API 8.0. §"The definitive LocationManager API" + §"Android stale-fix" + §"Version gating floor". Critical: getLocation() is one-shot; there is no getLiveData/startLocationUpdates in the official API — see §"getLiveData does not exist"
GEO-02 When in-app location fails (denied permissions or Desktop Telegram without geolocation), the chat "send location" keyboard fallback obtains the origin KeyboardButton.request_location: true in a ReplyKeyboardMarkup → user shares a Location message → bot receives it in an update. Desktop geolocation is confirmed broken (issue #77). §"The chat-keyboard fallback mechanism (GEO-02)" + §"Desktop Telegram has no geolocation at all"
GEO-03 The user's location is never written to persistent storage — only ephemeral TTL server state exists during the active trip Zero-persistence is enforceable and testable: Vite emits no service worker by default; MapLibre's tile cache is CacheStorage/HTTP-cache (transient, and tile URLs are not coordinates). §"GEO-03 is enforceable, not aspirational" — includes the grep gate + runtime probe design. Confirms D-03's localStorage caution (localStorage may not even persist in the WebView)
PX-02 The app follows Telegram light/dark themes for chrome while the map keeps the Rams cartography constant WebApp.colorScheme ("light"/"dark") + themeChanged event. Phase 1's --shell-bg/--shell-on-surface slots are the switch target. Map is never re-styled. §"PX-02 theme mapping — the map is untouched"
</phase_requirements>

Summary

Phase 3 is the project's highest platform-integration risk, and this research surfaces four findings that materially change the plan:

  1. getLiveData() does not exist. The roadmap note (.planning/ROADMAP.md:91) asks research to find a "getLiveData() wrapper" for the Android stale-fix. There is no such method in the official Telegram WebApp API. LocationManager has exactly init(), getLocation(callback), openSettings(), and two events (locationManagerUpdated, locationRequested). D-05's "gated streaming" must therefore be built on top of either repeated getLocation() polling or navigator.geolocation.watchPosition() — the planner must choose explicitly and Phase 6 must not assume a native streaming primitive exists.

  2. The Android stale-fix bug is real, confirmed, and unfixed. Telegram-Mini-Apps issue #56 — opened Dec 2024, still open, labeled bug + in progress + Telegram for Android, with 6 independent confirmations and the reporter concluding "This issue renders the new LocationManager api useless." The reported workaround: navigator.geolocation.getCurrentPosition() inside the WebView refreshes the same fix that a later LocationManager.getLocation() then returns. This inverts D-04's naive reading — the chain is not "try Telegram, then browser"; it is "Telegram first, browser to nudge it."

  3. Desktop Telegram has no geolocation, confirmed. Issue #77 (open, Apr 2025 → Jan 2026) reports no permission prompt at all on Telegram Desktop/Windows/Mac/Linux, with a second reporter confirming in Sep 2025 that the geolocation button is greyed out. GEO-02's chat-keyboard fallback is therefore the only origin path on Desktop — not a degraded backup. This strengthens D-06's "no client-specific variant" copy rule: the Desktop experience must not read as an error state.

  4. MapLibre v6 is ESM-only and WebGL2-only — this breaks the repo's existing test runner shape. maplibre-gl@6.15.0 ships exports: { ".": { import: "./dist/maplibre-gl.mjs" } } with no require condition (verified against the npm registry). Any .mjs test that imports maplibre through the hand-rolled runner is fine, but any CJS-style require() or an untranspiled tool will fail with ERR_PACKAGE_PATH_NOT_EXPORTED. Separately, WebGL2 is now required and the Map constructor throws GPUInitializationError when unavailable — the planner needs a deliberate fallback for that, and the Phase-1 Playwright capture toolchain (chromium-1217/1248 cached) is WebGL2-capable so the harness is fine.

Bonus finding — a live security constraint nobody has in the docs: Bot API 10.2 (July 14, 2026, auto-enabled July 20, 2026) "disallowing the usage of Mini App methods from origins different from the original Mini App domain." This is now in force. D-11's Mock-Telegram harness and D-12's wrangler dev on localhost:8787 are fine (same origin), but it means the app must never call Telegram.WebApp.* from an iframe, a partner domain, or a secondary origin — and BotFather's registered URL is the authority.

Primary recommendation: Ship the location chain as a single locationSource state machine with three named sources (telegram / webview / chat-pin), each returning the same Fix shape, selected by an explicit capability probe — and treat "Telegram fix is stale" as a recoverable condition that fires the navigator.geolocation nudge before falling through, rather than as success.


Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Telegram WebApp API access (initData, theme, BackButton, safe-area, LocationManager) Frontend Server (browser runtime) — window.Telegram.WebApp only exists inside the WebView; nothing server-side can read it. All gating (isVersionAtLeast, colorScheme, isAccessGranted) is client-side.
Session mint + validation API / Backend (Worker) Frontend Server Phase 2 already owns /api/session. The app only posts initData and holds the token in memory (D-03).
Chat-keyboard location fallback (GEO-02) API / Backend (Worker) Frontend Server The bot must send the ReplyKeyboardMarkup and receive the Location message via webhook. The app can only request the fallback and display the waiting state.
Fix normalization (LocationData vs GeolocationPosition vs bot Location) Frontend Server API / Backend Three sources, three shapes, one internal Fix. Normalization is a client concern; the bot's Location message must be relayed to the app via an authenticated endpoint (bot can't push to the WebView).
Map rendering + camera + marker Frontend Server (MapLibre) CDN / Static (tiles) MapLibre runs in the page; tiles come from OpenFreeMap at runtime (D-14). Style JSON is inlined at build time.
Theme slot switching (PX-02) Frontend Server — colorScheme + themeChanged are client events. Backend never sees them.
Safe-area / viewport Frontend Server — env(safe-area-inset-*) + WebApp.safeAreaInset are both client-read CSS/JS values.
BackButton wiring Frontend Server — WebApp.BackButton.show()/hide()/onClick() is client-only. Phase 3 ships the wire; Phase 6 extends the state graph.
Coordinate persistence policy API / Backend (KV TTL) + Frontend Server (memory only) — D-07 splits it: the client must write nothing (verifiable by grep + runtime probe); the server keeps only TTL-expiring session claims (already built in Phase 2).

Standard Stack

Core

Library Version Purpose Why Standard
maplibre-gl 6.15.0 (published 2026-10-18) Vector map renderer for the Rams basemap D-01 locks MapLibre GL JS v6. Zero license cost (BSD-3), no Mapbox token. theme/basemap/liberty-rams.json is already a v8 style spec built for it.
vite 8.3.5 (published 2026-10-21) Dev server + production bundler D-01 locks Vite. v8 requires node: ^20.19.0 || >=22.12.0 — the repo runs Node 24.18.0, satisfied. v8 handles the ?worker&url MapLibre worker import natively.
typescript 5.9.3 (pinned in bot/package.json) Type safety for web/ Already a workspace dependency; matching the bot's pin avoids workspace version skew. Note: npm registry shows typescript@7.0.2 as latest — do not upgrade the shared pin as a side effect of this phase.
@araharah/theme workspace Rams CSS tokens + component classes D-02 locks build-time consumption of theme/dist/rams.css. Component classes .btn-primary, .btn-secondary, .card, .text-label-sm etc. are verified present in theme/src/components.css.
@telegram-apps/types (or hand-written .d.ts) see note TypeScript types for window.Telegram.WebApp @types/telegram-web-app@10.1.0 exists (published 2026-10-04) and is the only viable npm option — telegram-web-app, telegram-web-app.js, @telegram-apps/web-app are all dead ends (E404 / abandoned 2022). Verify its LocationManager typings match the official docs before committing; otherwise hand-write a narrow interface for the ~12 members this phase uses. [MEDIUM confidence]

Supporting

Library Version Purpose When to Use
wrangler 4.151.3 (already in bot/) Local dev on 8787 + deploy with [assets] D-12/D-13/D-15. The assets binding config is documented and supported at this version.
playwright already in theme/ (^1.50.0, browsers cached: chromium-1248, webkit-2272, firefox-1511) Mock-Telegram harness captures + exit-gate device matrix D-11 + the DSGN-02 fidelity artifact. Playwright's context.setGeolocation() + grantPermissions(['geolocation']) are the clean way to drive the harness's location stream.
grammy ^1.46.0 (already in bot/) Receiving the Location message for GEO-02 Already the bot's framework; a bot.on('message', ...) handler filtered on message.location is the whole mechanism.

Alternatives Considered

Instead of Could Use Tradeoff
maplibre-gl built-in GeolocateControl Custom follow/recenter control Reject. GeolocateControl renders its own chrome (violates D-08's map-only surface) and owns its own geolocation strategy, which would fight D-04's chain. The UI-SPEC specifies a custom 40×40 machined control.
maplibre-gl Marker GeoJSON source + circle layers Either works. Marker gives exact DOM control over the accuracy ring + heading cone (the UI-SPEC's geometry is DOM/SVG-shaped: 20px dot, 16px cone). A GeoJSON source is better for Phase 5's route line. Recommendation: Marker for the position group in Phase 3; the route line (Phase 5) will use a GeoJSON source. They coexist.
A third-party Telegram SDK (@telegram-apps/sdk) Direct window.Telegram.WebApp calls Reject. D-01 says no framework; the SDK is a React-oriented abstraction layer. Direct calls + a thin typed wrapper keep the bundle small and the platform surface auditable — which matters because the location chain is the phase.
vitest / jest Existing hand-rolled test/runner.mjs Reject for unit tests (repo convention, zero deps, proven in theme/scripts/audit.test.mjs + bot/test/). But see the ESM caveat in §"Don't Hand-Roll" — maplibre-gl has no require export.

Installation:

# from repo root — add "web" to workspaces, then:
npm install -w web maplibre-gl@^6.15.0
npm install -w web -D vite@^8.3.5 @types/telegram-web-app@^10.1.0

Root package.json gains "web" in workspaces plus delegate scripts (web:dev, web:build, web:test, web:capture) mirroring the existing bot:* pattern (D-02).

Version verification (run this session, npm view against the live registry):

Package Version time.modified
maplibre-gl 6.15.0 2026-10-18
vite 8.3.5 2026-10-21
typescript 7.0.2 (latest) — repo pins 5.9.3 2026-10-22
@types/telegram-web-app 10.1.0 2026-10-04
wrangler (installed) 4.151.3 —

Architecture Patterns

System Architecture Diagram

┌─────────────────────────────────────────────────────────────────┐
│  TELEGRAM CLIENT (Android / iOS / Desktop)                      │
│                                                                 │
│  WebView  ── window.Telegram.WebApp ──                         │
│    • initData ──────────┐                                       │
│    • colorScheme ──┐   │                                       │
│    • LocationManager│   │                                       │
│    • BackButton  ──┼───┼── (client-only APIs)                   │
│    • safeAreaInset│   │                                       │
└────────────────────┼───┼───────────────────────────────────────┘
                     │   │
        ┌────────────┘   └──────────────┐
        │                               │
        ▼                               ▼
┌───────────────────────┐   ┌──────────────────────────┐
│ web/ (Vite SPA)       │   │ bot/ (Worker)            │
│                       │   │                          │
│ 1. boot shell         │   │  POST /telegram/webhook │
│    staged states      │   │    ├─ /start /help /privacy│
│ 2. POST /api/session  │──▶│  POST /api/session       │
│    initData → token   │   │    validate HMAC         │
│    (token = memory)   │   │    mint opaque token     │
│ 3. location chain     │   │    KV put TTL=900        │
│    ┌──────────────┐   │   │                          │
│    │ telegram     │   │   │  GEO-02 fallback:        │
│    │  LocationMgr │   │   │   sendMessage(           │
│    │  .getLocation│   │   │     reply_keyboard:[     │
│    │    ↓ null /  │   │   │       {text:"Share my    │
│    │    ↓ STALE   │   │   │        location",        │
│    │  ↓ (platform │   │   │        request_location: │
│    │    gating)   │   │   │        true}]})          │
│    ├──────────────┤   │   │    ← receives message    │
│    │ webview      │   │   │      .location          │
│    │  navigator.  │   │   │    write to KV (TTL)    │
│    │  geolocation │   │   │                          │
│    │  (THE NUDGE  │   │   └──────────┬───────────────┘
│    │   that       │   │              │
│    │   refreshes  │   │              │ GET /api/location
│    │   the stale  │   │              │ (token-auth'd,
│    │   telegram   │   │              │  TTL-ephemeral)
│    │   fix)       │   │              ▼
│    ├──────────────┤   │        ┌──────────┐
│    │ chat-pin     │◀──┼────────│  app     │ ← D-04 link 3
│    │  (awaiting)  │   │  poll  │  polls   │
│    └──────────────┘   │        └──────────┘
│         │             │
│         ▼             │
│  normalize → Fix      │
│  {lat,lng,acc,heading,ts,source}
│         │
│         ├──────────────▶ MapLibre (Rams style, INLINED at build)
│         │                 • Marker: dot + cone + accuracy ring
│         │                 • camera: follow / free (96px padding)
│         │                 • style NEVER changes on theme flip
│         ▼
│  status strip + boot card + denied card
│  (chrome re-slots on themeChanged; map does not)
│
│  RUNTIME (never touched): localStorage · sessionStorage · cookies
│                            IndexedDB · CacheStorage · SW
└─────────────────────────────────────────────────────────────────┘
web/
├── package.json                  # workspace: @araharah/web
├── tsconfig.json
├── vite.config.ts                # base:'/', build.outDir:'dist'
├── index.html                    # viewport-fit=cover meta (UI-SPEC safe-area)
├── public/                       # (empty or minimal — nothing static ships)
├── src/
│   ├── main.ts                   # boot entry: mount map → session → location
│   ├── shell.css                 # ONLY var(--color-*) / var(--spacing-*) refs
│   ├── telegram/
│   │   ├── webapp.ts             # typed window.Telegram.WebApp accessor + isVersionAtLeast gate
│   │   ├── theme.ts              # colorScheme → data-tg-theme (PX-02)
│   │   ├── backbutton.ts         # D-10 wire; total fn (no dead-ends)
│   │   └── safe-area.ts          # env() ∪ WebApp.safeAreaInset, per-edge max
│   ├── session/
│   │   ├── client.ts             # POST /api/session; token in MODULE SCOPE (D-03)
│   │   └── store.ts              # in-memory only; TTL staleness check
│   ├── location/
│   │   ├── types.ts              # Fix {lat,lng,acc,heading?,ts,source}
│   │   ├── telegram.ts           # LocationManager.init/getLocation + staleness detect
│   │   ├── webview.ts            # navigator.geolocation getCurrentPosition/watchPosition
│   │   ├── chat-pin.ts           # poll the bot-side pin endpoint
│   │   └── chain.ts              # the D-04 state machine (three sources)
│   ├── map/
│   │   ├── map.ts                # MapLibre Map, Rams style, setWorkerUrl once
│   │   ├── marker.ts             # dot + cone + accuracy ring (DOM Marker)
│   │   └── follow.ts             # follow/free toggle, 96px camera padding
│   ├── ui/
│   │   ├── strip.ts              # status strip (lamp + label + telemetry)
│   │   ├── cards.ts              # boot / denied / signal-lost cards
│   │   └── recenter.ts           # 40×40 machined control
│   ├── i18n/
│   │   └── strings.ts            # en + ms, UI-SPEC copy table verbatim (Copy Gate)
│   └── mock/
│       └── telegram-mock.ts      # D-11 fake WebApp (dev/build-tagged only)
├── test/
│   ├── runner.mjs                # hand-rolled (repo precedent)
│   ├── harness.mjs
│   ├── location-chain.test.mjs   # GEO-01/02 chain with injected fake WebApp
│   ├── no-storage.test.mjs       # GEO-03 grep + AST gate
│   └── copy-gate.test.mjs        # strings.ts matches UI-SPEC table
└── scripts/
    └── capture.mjs               # extends theme/scripts/screenshot.mjs pattern

Pattern 1: The Location Chain as an Explicit State Machine

What: Three named sources, one Fix output type, one selection function. Every failure mode is a state, never an exception that escapes to a blank screen.

When to use: Always — this is the phase's core abstraction and Phases 5/6 both consume it (Phase 5 snaps routes to fixes; Phase 6's guidance loop drives the watch cadence).

The three sources and their real behavior:

// Source 1: Telegram-native (GEO-01 primary path)
export type Fix = {
  lat: number; lng: number;
  accuracyM: number | null;       // horizontal_accuracy | coords.accuracy
  headingDeg: number | null;      // course | coords.heading — null when unknown
  ts: number;                     // ms epoch, for FIX AGE telemetry
  source: 'telegram' | 'webview' | 'chat-pin';
};

// Version gate FIRST — LocationManager is Bot API 8.0+.
// Do not feature-detect by truthiness alone: an old client exposes
// `WebApp.LocationManager` as undefined, but a client that has the
// field with isLocationAvailable=false is a DIFFERENT failure.
export function telegramLocationSupported(wa: WebApp): boolean {
  return typeof wa.isVersionAtLeast === 'function' && wa.isVersionAtLeast('8.0');
}
// Source 1 continued — init() then getLocation().
// getLocation's callback receives `null` when access was NOT granted;
// there is no error object, so the null case is the whole denial class.
export function requestTelegramFix(wa: WebApp, timeoutMs = 8000): Promise<Fix | null> {
  return new Promise((resolve) => {
    const lm = wa.LocationManager;
    if (!lm) return resolve(null);

    const finish = (d: unknown | null) => {
      if (!d) return resolve(null);              // ← not granted, or unavailable
      const loc = d as LocationData;
      resolve({
        lat: loc.latitude,
        lng: loc.longitude,
        accuracyM: loc.horizontal_accuracy ?? null,
        headingDeg: loc.course ?? null,           // null ⇒ UI-SPEC hides the cone
        ts: Date.now(),
        source: 'telegram',
      });
    };

    // init() is REQUIRED before first use (official docs).
    if (lm.isInited) return lm.getLocation(finish);
    lm.init(() => lm.getLocation(finish));

    // getLocation has no documented timeout — add one, or the boot
    // choreography hangs in stage 2 forever on a wedged client.
    setTimeout(() => resolve(null), timeoutMs);
  });
}

Why this shape: getLocation is documented as "called with null as the first argument if access to location was not granted" — there is no rejection path and no error code. Every caller must therefore treat null as "could not get a fix, try the next source," and the chain must have a hard timeout because the API has none. [CITED: core.telegram.org/bots/webapps §LocationManager]

Pattern 2: Android Stale-Fix Recovery (the "nudge")

What: On Android, LocationManager.getLocation() returns a static coordinate that does not refresh until some other app triggers a location update. Calling navigator.geolocation.getCurrentPosition() inside the same WebView forces a refresh.

When to use: Only as a recovery path after a Telegram fix is detected stale. Not as the primary source.

/**
 * Android: LocationManager.getLocation() returns a cached, non-refreshing
 * fix (Telegram-Mini-Apps issue #56, open since Dec 2024).
 * Detecting it: compare two successive telegram fixes. A device that has
 * MOVED but returns byte-identical coordinates is the signature.
 */
export function looksStale(prev: Fix | null, next: Fix): boolean {
  if (!prev) return false;
  const movedMeters = haversineMeters(prev, next);
  // Identical to sub-centimetre precision ⇒ the cache never refreshed.
  return movedMeters < 0.01;
}
// The nudge: a single webview read refreshes the native cache, so the
// NEXT telegram read returns fresh coordinates.
export async function nudgeAndRetryTelegram(
  wa: WebApp,
): Promise<Fix | null> {
  const first = await requestTelegramFix(wa);
  if (!first) return null;
  if (!looksStale(lastAccepted, first)) return first;

  await readWebviewFix({ timeoutMs: 5000 });   // the nudge — result discarded
  return requestTelegramFix(wa);               // now fresh (issue #56)
}

Honest caveat — this is MEDIUM confidence. The mechanism is community-reported and reproduced by 6+ developers, but it has not been verified on a real device in this project. The planner should:

  • Implement it behind the chain (it degrades to "keep polling telegram," never to a crash), and
  • Make the device-matrix exit gate (§Success Criterion 5) the place where it is proven, with an explicit note that a phone's Location settings may need to be toggled if the first Android verification shows a still-static fix.

Pattern 3: The Chat-Keyboard Fallback (GEO-02)

What: The bot sends a ReplyKeyboardMarkup with request_location: true. The user taps it, Telegram's native location picker opens, and the bot receives a Message with a location field.

Bot side (worker, grammY):

// KeyboardButton.request_location — available in private chats only.
bot.chat.sendMessage(chatId, strings.awaitingPin, {
  reply_markup: {
    keyboard: [[{ text: strings.shareLocationButton, request_location: true }]],
    resize_keyboard: true,          // ← one tap to the native picker
    one_time_keyboard: true,        // ← dismisses after use; keeps the chat clean
  },
});

// The bot then receives the shared pin as a normal message:
bot.on('message', (ctx) => {
  const loc = ctx.message?.location;   // Location { latitude, longitude,
  if (!loc) return;                     //   horizontal_accuracy?, live_period? }
  // …write to TTL-ephemeral KV keyed by the session token…
});

App side: the Mini App cannot receive the pin directly — Telegram has no push channel from chat → WebView. The app must poll an authenticated endpoint (GET /api/location, Authorization: Bearer <memory token>), which reads the same TTL-ephemeral KV. This endpoint is a new Worker route this phase must add — it does not exist in Phase 2.

[CITED: core.telegram.org/bots/api §KeyboardButton — "If True, the user's current location will be sent when the button is pressed. Available in private chats only."; §Location — latitude, longitude, horizontal_accuracy?, live_period?, heading?]

Design consequence for D-06: the UI-SPEC's AWAITING PIN strip state and "Waiting for the pin you send in chat" card body are exactly right — the wait is genuinely server-mediated, and the app must poll rather than assume instant delivery. Set a bounded poll (e.g. 60s, then back to the denied card with the hint re-shown) so the UI can never wedge on AWAITING PIN.

Pattern 4: Version-Gated Capability Probes

What: Every Telegram API this phase touches is version-gated. Gate with isVersionAtLeast() and give each gated feature a working fallback.

Feature Bot API Fallback if unavailable
LocationManager 8.0 navigator.geolocation → chat-pin
safeAreaInset / contentSafeAreaInset 8.0 pure env(safe-area-inset-*) (UI-SPEC already specifies this)
colorScheme always present luminance test on themeParams.header_bg_color (UI-SPEC already specifies this)
BackButton 6.1 hidden; native swipe-down close remains the exit gesture
themeChanged event 6.0 n/a (event support is broad)

The one non-negotiable: isAccessGranted is what makes GEO-01's "permission persists across sessions" true — Telegram stores the grant per bot, on the device, outside the WebView's storage. That is exactly why D-03's memory-only token and D-07's zero-persistence do not conflict with a persistent permission: the grant lives on Telegram's side of the boundary, not in the page. The planner should state this explicitly in the GEO-01 verification notes, because it is the subtlest point in the phase.

Anti-Patterns to Avoid

  • Blank map while the chain runs. D-09 requires the basemap to mount and render tiles immediately, with boot furniture overlaid. Mount the map first, start the chain second. The reverse order produces the exact blank screen the UI-SPEC forbids.
  • Swapping the style JSON on theme flip. D-10 / PX-02: the map is Rams-constant. A map.setStyle() on themeChanged would destroy and rebuild all 110 layers and flash — it also violates "zero re-styling, zero re-creation" in the UI-SPEC's mapping table. Only data-tg-theme on <html> changes.
  • Treating null from getLocation() as an error to surface. It is the normal denial signal. There is no error object and no code. Branch on null, not on exceptions.
  • Calling LocationManager.init() more than once, or using before init. Official docs: "Before the first use of this object, it needs to be initialized." Guard on isInited.
  • Assuming a polling interval delivers live location on Android. It does not — that is issue #56. Without the nudge (or without accepting staleness honestly), a "LIVE" strip on Android may be showing a frozen pin. The strip's FIX Ns AGO telemetry is the honesty mechanism; make it honest rather than assuming freshness.
  • Storing the fix "just for convenience" in localStorage to survive a webview revival. That is exactly what D-07 forbids and D-03 warns about — and it would not even work reliably (see §"localStorage is unreliable in the WebView").
  • Adding a service worker for offline tiles. Vite emits none by default; PWA work is explicitly out of scope; and a SW cache is a GEO-03 audit surface for zero benefit this phase.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Telegram WebApp types A 500-line ambient .d.ts @types/telegram-web-app@10.1.0, narrowed by a thin local wrapper The full WebApp interface is large and versioned; the package tracks Bot API 10.x. Wrap it in web/src/telegram/webapp.ts so a types mismatch is one file to fix. [MEDIUM] — verify its LocationManager shape against the official docs before relying on it.
MapLibre worker plumbing Copying maplibre-gl-worker.mjs into public/ and hand-maintaining a URL Vite's ?worker&url query The docs are explicit: for bundlers (Vite included), import.meta.url does not resolve the worker, so a one-time setWorkerUrl() is required. Vite's query does it correctly and hash-caches it for free.
A geolocation state machine from scratch A bespoke event emitter + retry/timeout plumbing A single explicit chain.ts with three named branches The state space is small and known (verified sources: telegram / webview / chat-pin). A generic abstraction would obscure exactly the platform-specific behavior that is the point of this phase.
A test framework Adding vitest/jest The existing hand-rolled runner.mjs + harness.mjs Repo convention across bot/test/ and theme/scripts/audit.test.mjs; zero dependencies; proven. Caveat below.
Battery-farming in dev Real phones in the daily loop The Mock-Telegram harness (D-11); phones at the exit gate D-11 already locks this. Device time is expensive and the harness covers ~90% of the loop.
HTTP polling WebSockets / SSE for the chat-pin Bounded fetch polling Cloudflare Workers + Durable Objects are not in the v1 stack (AGENTS.md: ops-stack stripped). A bounded poll against the TTL-ephemeral KV endpoint is the honest v1 answer.

⚠️ Key insight — the ESM trap in our own test runner. maplibre-gl@6.15.0 publishes:

"exports": { ".": { "types": "./dist/maplibre-gl.d.ts", "import": "./dist/maplibre-gl.mjs" } }

There is no require condition (verified against the npm registry this session). The repo's test runner is node test/runner.mjs doing await import(...) on .mjs files with "type": "module" — that is fine, dynamic import() resolves the import condition. What breaks is any CJS require('maplibre-gl') or a tool that doesn't transform ESM. The planner must state this in the plan so an executor doesn't lose an hour to ERR_PACKAGE_PATH_NOT_EXPORTED.

Second key insight — don't trust localStorage even if you wanted it. Independently reported: inside Telegram's WebView, localStorage may not persist at all on iOS and some Linux desktop builds, so a restart can clear it. This is independent corroboration of D-03's memory-only choice: it isn't only the privacy rule, it's the only choice that actually behaves predictably. [MEDIUM confidence — community source, not official docs.]


Runtime State Inventory

Phase 3 is not a rename/refactor/migration phase, so this section is included only for the GEO-03 zero-persistence audit — the phase's own "runtime state" question.

Category Items Found Action Required
Stored data Client: nothing may be written (D-07). Server: chat_sessions KV already TTL-only at 900s (verified in bot/src/kv/sessions.ts, every put carries expirationTtl). New this phase: a location record for the chat-pin path — must carry expirationTtl on its own put line, subject to the existing privacy.test.mjs grep gate. Code edit + a new grep-gate assertion that every put in the location module carries an explicit TTL.
Live service config wrangler.jsonc kv_namespaces binding chat_sessions (id 59f58a54…) — unaffected. BotFather's registered Mini App URL is now security-critical under Bot API 10.2's origin restriction. None for KV. Verify the BotFather URL points at the same origin the app is served from.
OS-registered state Telegram's per-bot location grant lives here (inside the Telegram client, keyed by bot + device). This is the mechanism GEO-01's "permission persists across sessions" depends on. LocationManager.openSettings() is the deep-link to re-grant. None — do not attempt to clear it. It is the feature, not the bug.
Secrets / env vars BOT_TOKEN, WEBHOOK_SECRET (via bot/.dev.vars). The app needs no secrets — it holds an opaque session token issued by the Worker, not a bot token. None. Confirm the app never receives BOT_TOKEN (it cannot and must not).
Build artifacts web/dist/ is new. theme/dist/rams.css + theme/dist/fonts/** are copied at build time (D-14). MapLibre's worker is emitted by Vite's ?worker&url into a hashed file. Add web/dist/ to .gitignore (mirroring theme/screenshots/).

Nothing found in category: No pre-existing client-side persistence to migrate — the repo has no web/ directory yet, so GEO-03 starts from a clean slate. The audit surface is "prove nothing gets added."


Common Pitfalls

Pitfall 1: Assuming a native streaming location primitive exists

What goes wrong: The planner writes LocationManager.getLiveData() or startLocationUpdates() because the roadmap note mentions "getLiveData() wrapper." Neither method exists in the official API — the plan's core module would be dead on arrival, or worse, silently undefined.

Why it happens: The roadmap's research recommendation was written from an assumption, not from the API docs.

How to avoid: Treat D-05's "gated streaming" as our abstraction. Phase 3 ships one-shot getLocation() calls; Phase 6's continuous mode is either a bounded poll over getLocation() or navigator.geolocation.watchPosition() — the planner must pick and write it down, because it changes Phase 6.

Warning signs: Any plan task naming a LocationManager member that is not init, getLocation, openSettings, isInited, isLocationAvailable, isAccessRequested, or isAccessGranted.

Pitfall 2: Treating the D-04 chain as strictly sequential and terminal

What goes wrong: Code does telegram → webview → chat-pin, first-success-wins. On Android, getLocation() succeeds with a frozen coordinate, so the chain never advances to navigator.geolocation, and the app shows a confidently-wrong LIVE pin that never moves.

Why it happens: getLocation() returning data is not evidence that the data is fresh.

How to avoid: Freshness is a separate axis from availability. Compare successive fixes (Pattern 2) and route staleness into the recovery path, not into success. The FIX Ns AGO strip telemetry is the user-visible honesty surface for this.

Warning signs: No code path anywhere computes the age of the current fix.

Pitfall 3: getLocation() with no timeout hangs the boot choreography

What goes wrong: The WebView is wedged, init()'s callback never fires, and the app sits in ACQUIRING forever — the exact "never blank, always progressing" contract (D-09) is violated by an indefinite stage.

Why it happens: Telegram documents no timeout for init() or getLocation(), and both are callback-based with no error channel.

How to avoid: Every getLocation wrapper carries its own setTimeout that resolves null (→ next source). The UI-SPEC's elapsedSeconds telemetry in the acquiring strip state is the affordance that makes a long wait legible — use it.

Pitfall 4: Desktop Telegram treated as an error state

What goes wrong: The denied card reads as a failure on Desktop, where geolocation is never going to work (issue #77: no prompt, button greyed out). Users on Desktop read a broken app.

Why it happens: The same code path serves mobile-denial and desktop-unsupported, and the copy ("Location is switched off") implies the user can fix it.

How to avoid: The UI-SPEC already handles this — "Desktop Telegram / no geolocation → denied card with identical copy (no client-specific variant)." That is the right call and the research confirms it is load-bearing. The card's ASK VIA CHAT secondary CTA is the real Desktop path; make sure it is not styled as secondary there.

Pitfall 5: Assuming WebGL2 exists everywhere

What goes wrong: MapLibre v6 removed WebGL1 support. On a device/browser without WebGL2, new Map() throws GPUInitializationError and there is no map at all — violating D-08's "map is the dominant surface" and D-09's "never blank."

Why it happens: v6 made this a hard requirement (it was a soft degradation in v5).

How to avoid: Feature-detect before constructing (GPUInitializationError is exported from maplibre-gl). Older Android WebViews on low-end devices are the realistic risk. Decide now what the shell looks like without a map — and note that in the plan, because the UI-SPEC has no "no map" state defined. [Planner decision needed.]

Pitfall 6: Bot API 10.2's origin restriction breaks an innocent refactor

What goes wrong: Someone moves the location chain into an iframe, a CDN-hosted shell, or a preview subdomain. Telegram.WebApp.* calls stop working on updated clients — with no clear error.

Why it happens: The restriction landed 2026-07-20, after most third-party tutorials were written.

How to avoid: Every Telegram.WebApp.* call originates from the app's own document. The Mock-Telegram harness (D-11) must inject the fake into the same window the app runs in, not into an iframe. [VERIFIED against core.telegram.org/bots/api-changelog, Bot API 10.2, July 14 2026]

Pitfall 7: Re-theming the map by rebuilding the style

What goes wrong: themeChanged handler calls map.setStyle(darkRamsStyle). All 110 layers are torn down and rebuilt; the map flashes, tiles re-fetch, and the camera can jump. Directly violates D-10 and the UI-SPEC's "identical — zero re-styling, zero re-creation."

Why it happens: themeChanged fires and the reflex is to do something.

How to avoid: The handler sets data-tg-theme on <html> and stops. CSS custom properties re-slot; MapLibre is not touched. Write the handler so its only statement is the attribute set — that makes the invariant greppable.

Pitfall 8: The accent color leaks into states it shouldn't carry

What goes wrong: signal-orange is used for a "waiting" or "error-ish" chip. Phase 1 D-08's rule is that orange is navigation-primary only — Phase 5 draws the route in it, so any non-navigation use of orange now reads as "this is the route."

Why it happens: Orange is the most visually available accent.

How to avoid: The UI-SPEC's reserved-for list is exact and short (position cone · follow-active control · focus brackets · boot lamp · primary CTA fill · boot-card emphasis dot). Degraded/lost/denied lamps are functional-red; the LIVE lamp is olive-green. Enforce with the same grep-style off-token audit Phase 1 built (theme/scripts/audit.mjs).


Code Examples

Vite + MapLibre v6 bootstrap (verified pattern)

// web/src/main.ts
// Source: https://www.maplibre.org/maplibre-gl-js/docs/ §Installation → Vite
import { Map as MapLibreMap, setWorkerUrl, GPUInitializationError }
  from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';

// v6 is ESM-only; `?worker&url` is Vite's supported way to emit a
// bundled worker URL. Required for ALL bundlers per the v5→v6 guide.
setWorkerUrl(workerUrl);

import ramsStyle from '@araharah/theme/basemap/liberty-rams.json';

function bootMap(container: HTMLElement): MapLibreMap | null {
  try {
    // WebGL2 is REQUIRED in v6. This constructor throws otherwise.
    return new MapLibreMap({
      container,
      style: ramsStyle,            // inlined at build time (D-14) — never re-set
      center: [101.6869, 3.139],    // KL, per AGENTS.md region focus
      zoom: 12,
      attributionControl: { compact: true },
    });
  } catch (err) {
    if (err instanceof GPUInitializationError) {
      // Pitfall 5: WebGL2 absent. Shell still renders (D-08/D-09).
      return null;
    }
    throw err;
  }
}

Note on the style import: theme/basemap/liberty-rams.json is not inside @araharah/theme's exports map (that package has no exports field, so deep imports work), but Vite needs it reachable from web/. Either add a basemap export/subpath to the theme package, or import it via a relative path ../../theme/basemap/liberty-rams.json. [Planner decision — D-14 says "build-time dependency", which both satisfy.]

Theme slot switching (PX-02)

// web/src/telegram/theme.ts
// Source: core.telegram.org/bots/webapps §WebApp.colorScheme, §Events (themeChanged)
import type { WebApp } from './webapp';

type ThemeBranch = 'light' | 'dark';

function resolveBranch(wa: WebApp): ThemeBranch {
  // Primary signal — present on all clients this phase targets.
  if (wa.colorScheme === 'dark' || wa.colorScheme === 'light') return wa.colorScheme;

  // Fallback (UI-SPEC §Theme mapping): luminance of header_bg_color ≥ 0.5
  const hex = wa.themeParams?.header_bg_color;
  if (typeof hex === 'string' && /^#[0-9a-f]{6}$/i.test(hex)) {
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);
    const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
    return lum >= 0.5 ? 'light' : 'dark';
  }
  return 'light';
}

export function applyTheme(wa: WebApp): void {
  // The ONLY thing a theme change is allowed to touch.
  // MapLibre is deliberately absent from this function (D-10).
  document.documentElement.dataset.tgTheme = resolveBranch(wa);
}

export function watchTheme(wa: WebApp): void {
  applyTheme(wa);
  wa.onEvent?.('themeChanged', () => applyTheme(wa));
}

Position marker geometry (per UI-SPEC)

// web/src/map/marker.ts — DOM Marker; geometry from 03-UI-SPEC §Marker
// The accuracy circle radius must be METRES → pixels via map.project().
// It is NOT a CSS pixel value.
function accuracyRadiusPx(map: MapLibreMap, center: LngLat, metres: number): number {
  const sw = map.getCanvas().width;
  const mPerPx = (40075016.686 * Math.cos((center.lat * Math.PI) / 180))
    / (sw * Math.pow(2, map.getZoom()));
  return Math.min(metres / mPerPx, 100 / mPerPx); // UI-SPEC display cap: 100 m
}
// Heading cone: HIDDEN when heading is unknown.
// Source: 03-UI-SPEC §Position Marker — "no default orientation guess,
// no half-open cone".
if (fix.headingDeg === null) cone.style.display = 'none';
else { cone.style.display = ''; cone.style.transform = `rotate(${fix.headingDeg}deg)`; }

Follow / free mode detection

// web/src/map/follow.ts
map.on('movestart', (e) => {
  // A programmatic move we initiated is not the user panning away.
  if (followLock) return;
  if (e.originalEvent) setFollowMode('free');   // user gesture only
});

// Recenter (UI-SPEC): flyTo ≤400ms, camera padding 96px.
function recenter(map: MapLibreMap, fix: Fix): void {
  followLock = true;
  map.flyTo({ center: [fix.lng, fix.lat], padding: CAMERA_PADDING_PX /* 96 */,
              duration: 400 });
  setFollowMode('following');
}

GEO-03 zero-persistence grep gate (extends Phase 2's privacy.test.mjs pattern)

// web/test/no-storage.test.mjs
// GEO-03: no coordinate — and no session token — reaches ANY storage API.
const BANNED = [
  'localStorage', 'sessionStorage', 'indexedDB', 'caches',
  'document.cookie', 'CacheStorage',
];
// Plus Telegram's own persistence surfaces, which D-07 also forbids:
//   'WebApp.DeviceStorage', 'WebApp.SecureStorage', 'WebApp.CloudStorage'
test('no storage API is referenced in web/src', async () => {
  const files = await glob('src/**/*.{ts,html}');
  for (const f of files) {
    const src = await readFile(f, 'utf8');
    for (const api of BANNED) {
      assert.ok(!src.includes(api), `${f} references ${api} — GEO-03 violated`);
    }
  }
});

Plus a runtime probe (stronger than grep, and the real phase-exit spot-check): after a full boot → live cycle in the Mock-Telegram harness, assert in-page that localStorage.length === 0, sessionStorage.length === 0, document.cookie === "", and indexedDB.databases() is empty. Run it under Playwright — that is the artifact GEO-03's success criterion asks to be "spot-checkable at phase exit."

Dev initData signing for the Mock-Telegram harness (D-11)

Phase 2's bot/src/security/initdata.ts already implements HMAC-SHA256 with HMAC(bot_token, "WebAppData"). The harness's fake initData must be signed with the dev bot token so POST /api/session accepts it unmodified. The signing logic is HTTP-world and reusable verbatim — do not reimplement it in web/. [Repo inspection — bot/src/security/initdata.ts exists and is the canonical implementation.]


State of the Art

Old Approach Current Approach When Changed Impact
navigator.geolocation only, re-prompt each launch WebApp.LocationManager (Bot API 8.0) — grant persists per-bot on device Bot API 8.0, Nov 2024 GEO-01's "permission persists across sessions" is only achievable via LocationManager. The WebView API cannot deliver this.
Treat getLocation() data as fresh Freshness must be measured (successive-fix comparison + FIX Ns AGO telemetry) Still open upstream (issue #56, Dec 2024 →) D-06's honesty principle extends to data age, not just permission state.
MapLibre v5 UMD/CJS bundles v6 ESM-only + mandatory setWorkerUrl() in bundlers MapLibre v6 Breaks CJS consumers; Vite's ?worker&url is the blessed path.
WebGL1 tolerated (v5 degraded gracefully) WebGL2 required; Map throws GPUInitializationError MapLibre v6 Needs an explicit no-map shell state (Pitfall 5).
styleimagemissing + map.addImage() map.setMissingStyleImageResolver() MapLibre v6 Relevant if Phase 5's route line uses a generated icon.
Mini App methods callable from any origin Bot API 10.2 origin restriction, auto-enabled 2026-07-20 July 2026 All Telegram.WebApp.* calls must come from the registered origin.
Telegram storage choices: CloudStorage / DeviceStorage / SecureStorage n/a — this project uses none of them — D-07's zero-persistence is stricter than Telegram's model and is what makes GEO-03 provable.

Deprecated / do not use:

  • telegram-web-app.js as an npm package — does not exist on npm (E404). Telegram's official integration is a <script src> from core.telegram.org/bots/webapps; ****CORRECTED by device-matrix debug round 3 (2026-10-23): WRONG call — Telegram does NOT auto-inject; production had window.Telegram undefined (no initData → session 401, no LocationManager, no BackButton). telegram-web-app.js from telegram.org IS required in <head>**. Add the script tag (done in web/index.html). The npm-package 404 part still stands — <script src> is the only integration. [ERRATUM logged]
  • @telegram-apps/web-app — E404 on npm.
  • Cloudflare Pages — superseded by Workers + [assets] for this project (AGENTS.md).
  • maplibre-gl default import (import maplibregl from 'maplibre-gl') — removed in v6. Use named or namespace imports.

Assumptions Log

Claims below are not verified against primary sources. The planner should treat each as a decision point, not a fact.

# Claim Section Risk if Wrong
A1 navigator.geolocation.getCurrentPosition() inside the Android WebView refreshes the stale native cache such that the next LocationManager.getLocation() returns fresh coordinates. §Pattern 2 Medium. The mechanism is community-reported (6 confirmations on issue #56) but unverified on our devices. If it does not hold, Android LIVE shows a frozen pin. Mitigation: the recovery path degrades gracefully to polling, and the exit gate is where it gets proven.
A2 @types/telegram-web-app@10.1.0's LocationManager typings match the official docs (especially LocationData.course being nullable and getLocation's callback being (LocationData | null) => void). §Standard Stack Low. If wrong, one wrapper file needs a local type override. Recommend verifying before commit.
A3 Telegram WebView geolocation requires the page be served over HTTPS (or localhost) for navigator.geolocation to work at all — meaning wrangler dev on http://localhost:8787 is fine but a LAN-IP dev URL would not be. §Pitfalls / D-12 Medium. Affects how the real-device verification session is staged (D-12 says remote staging is used "sparingly right before real-device verification sessions" — that is likely because of this). Planner should confirm the staging path before the device gate.
A4 A reply keyboard with one_time_keyboard: true is the right presentation for the GEO-02 fallback (auto-dismisses after one use, keeps the chat clean). §Pattern 3 Low. UX preference; resize_keyboard: true is the load-bearing part.
A5 The bot can relay a chat-shared pin to the open Mini App via a polled authenticated endpoint, given the Worker is stateless. §Pattern 3 Low–Medium. Architecturally sound with KV, but adds a new Worker route (GET /api/location) not present in Phase 2 — a real scope item the planner must budget for.
A6 OpenFreeMap's tile endpoint tolerates the request rate a Phase 3 demo/user cohort generates. §D-14 Low. Not a Phase 3 concern; revisit in Phase 7 hardening.

Open Questions (RESOLVED)

All four questions below were open at research time and are resolved by this phase's plans. The resolution is recorded here so a later phase can see the decision and its owner without re-deriving it.

  1. What does the shell look like with no map at all (WebGL2 absent)?

    • What we know: v6 throws GPUInitializationError; D-08 says the map is the dominant surface; the UI-SPEC defines no map-less state.
    • What's unclear: whether to (a) render the shell chrome over a Rams-tinted field, (b) hide the chrome and show only the status strip, or (c) accept that this only happens on ancient devices and let it throw.
    • Recommendation: the planner decides explicitly and the UI-SPEC gets a one-line amendment. Option (a) is cheapest and preserves "never blank." This is a genuine gap in the approved contract — flag it to /gsd-ui-phase if the planner wants the contract amended rather than silently extended.
    • RESOLVED — option (a). bootMap() catches GPUInitializationError and returns null; the shell renders over --color-surface-dim with data-map="absent", and a one-line planner amendment is appended to 03-UI-SPEC.md §Boot Choreography. See plan 03-03 task 2 (map bootstrap + shell.css) and task 3 (the main.ts header note).
  2. Which source does Phase 6's continuous mode actually use — poll getLocation() or watchPosition()?

    • What we know: no native streaming API exists (Pitfall 1); navigator.geolocation.watchPosition works on iOS (issue #56 reporter confirms iOS updates correctly every second) and is the likely answer for continuous guidance.
    • What's unclear: whether the Android stale-fix affects watchPosition too, or only LocationManager.
    • Recommendation: Phase 3 builds the Fix-shaped abstraction and a pluggable cadence; Phase 6 picks. Write the abstraction so this choice stays open — do not hard-code a poll interval in Phase 3.
    • RESOLVED — deliberately left open, by design. chain.ts exposes a Fix-shaped abstraction with a pluggable cadence and hard-codes no continuous-mode interval; readWebviewFix uses maximumAge: 0, and there is an acceptance gate that setInterval|watchPosition appear nowhere in chain.ts. The post-live 1s ticker in main.ts re-runs acquireFix rather than streaming. Phase 6 picks poll-vs-watchPosition without a rewrite. See plan 03-02 task 2 (chain.ts) and plan 03-03 task 3 (the post-live cadence).
  3. Does the Phase 3 exit gate have real device access?

    • What we know: Success Criterion 5 requires an Android + iOS + Desktop + plain-browser pass. D-11 says device batteries are spent at the exit gate.
    • What's unclear: whether the executor has physical Android and iOS devices available at all. If not, the criterion is unverifiable and the phase cannot honestly close.
    • Recommendation: the planner must confirm device availability before writing the exit-gate task, and A1 (the Android stale-fix) can only be retired with a real Android phone.
    • RESOLVED — handled as a blocking human checkpoint, not a pre-flight assumption. Availability cannot be determined in advance by the executor, so plan 03-04 carries it as a blocking checkpoint:human-verify (task 3) with the five legs enumerated, and an explicit instruction that missing hardware means Success Criterion 5 is recorded as UNVERIFIED with the blocker in .planning/STATE.md — the phase is never closed claiming verification that did not happen. The automated half (capture harness + Desktop chat-pin leg) is built first so only the genuinely manual part is checkpointed.
  4. Should the theme package expose basemap/liberty-rams.json via a subpath export, or should web/ import it relatively?

    • What we know: @araharah/theme has no exports map today (verified), so deep relative imports work; Vite resolves JSON imports natively.
    • What's unclear: which reads better as the long-term contract for Phases 4–6.
    • Recommendation: add a proper exports entry (. → ./dist/rams.css, ./basemap/*, ./fonts/*). One small change now, avoids four phases of ad-hoc relative paths. OpenCode's discretion per D-02.
    • RESOLVED — deep package import, resolved at build time. web/src/map/map.ts imports @araharah/theme/basemap/liberty-rams.json directly. moduleResolution: "bundler" already permits the deep subpath, so no exports map change is needed for Phase 3, and Vite inlines the JSON into web/dist at build (proven by the BA4 gate). The only TypeScript-side requirement is resolveJsonModule: true, pre-authorized in web/tsconfig.json by plan 03-01 task 1. If a later phase needs more subpaths, an exports map can be added then without touching Phase 3's code. See plan 03-03 task 2.

Environment Availability

Dependency Required By Available Version Fallback
Node.js Vite 8 (needs ^20.19.0 || >=22.12.0) ✓ 24.18.0 —
npm workspaces D-02 web/ workspace ✓ 11.16.0 —
wrangler D-12 dev on 8787, D-15 deploy ✓ 4.151.3 (already in bot/) —
TypeScript D-01 ✓ 5.9.3 (bot/ pin) —
Playwright + browsers D-11 harness, DSGN-02 fidelity artifact ✓ chromium-1248, webkit-2272, firefox-1511 cached —
esbuild (Vite dep) Vite build ✓ in node_modules/.bin —
Cloudflare KV (chat_sessions) Phase 2 session, Phase 3 pin relay ✓ binding configured in wrangler.jsonc —
OpenFreeMap tiles D-14 runtime basemap ✓ (network) https://tiles.openfreemap.org/planet —
Android device Success Criterion 1 + 5 ? — None. Blocking for GEO-01's Android claim and for retiring A1.
iOS device Success Criterion 1 + 5 ? — None. Blocking for GEO-01's iOS claim.
Telegram Desktop Success Criterion 5 (GEO-02 path) ? — None. Blocking for the Desktop leg of the matrix.
Telegram BotFather registration origin restriction (Bot API 10.2) ? — None. Must point at the served origin.

Missing dependencies with no fallback: real Android and iOS devices, and a Telegram Desktop install. The roadmap's own Success Criterion 5 requires all of them. The planner must surface this as a checkpoint before the exit-gate task, not discover it at execution.

Missing dependencies with fallback: none identified — the toolchain is complete.


Validation Architecture

.planning/config.json → workflow.nyquist_validation: true. Included per protocol.

Test Framework

Property Value
Framework Hand-rolled zero-dependency runner — web/test/runner.mjs + harness.mjs, mirroring bot/test/ and theme/scripts/audit.test.mjs (repo precedent, D-11's "hand-rolled test runner pattern prevails")
Config file none (no vitest/jest config — by design)
Quick run command npm test -w web
Full suite command npm test -w web && npm run typecheck -w web && npm run build -w web
Browser/visual checks npm run capture -w web — Playwright, extends the theme/scripts/screenshot.mjs pattern (static server + chromium + MIN_BYTES blank-bar check already established there)

⚠️ ESM caveat: maplibre-gl@6.15.0 exports only an import condition. The runner uses dynamic import() on .mjs files with "type": "module", which resolves it correctly. Any test that require()s maplibre will fail with ERR_PACKAGE_PATH_NOT_EXPORTED. Unit tests should therefore test the chain logic against injected fakes, never against a real MapLibre instance — which is better design anyway.

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
GEO-01 LocationManager.init() then getLocation() yields a Fix with source:'telegram' unit npm test -w web ❌ Wave 0
GEO-01 A client below Bot API 8.0 falls through to navigator.geolocation without throwing unit npm test -w web ❌ Wave 0
GEO-01 getLocation() returning null (denied) advances to the next source, not to an error unit npm test -w web ❌ Wave 0
GEO-01 getLocation() that never calls back resolves null after the wrapper timeout (Pitfall 3) unit npm test -w web ❌ Wave 0
GEO-01 Two identical successive fixes are detected as stale and route to the recovery path (Pitfall 2) unit npm test -w web ❌ Wave 0
GEO-01 Grant persists across a fresh boot — re-read on revalidation, no re-prompt unit npm test -w web ❌ Wave 0
GEO-02 Bot sends ReplyKeyboardMarkup with request_location: true, resize_keyboard, one_time_keyboard unit (bot-side, extends bot/test/) npm run bot:test ❌ Wave 0
GEO-02 Bot receives message.location and writes it with an explicit expirationTtl unit (bot-side) npm run bot:test ❌ Wave 0
GEO-02 App enters AWAITING PIN, polls, and consumes the pin into live unit npm test -w web ❌ Wave 0
GEO-02 Poll is bounded — a never-delivered pin returns to the denied card, never wedges (Pitfall: indefinite AWAITING PIN) unit npm test -w web ❌ Wave 0
GEO-02 Desktop Telegram (no LocationManager, no navigator.geolocation) reaches the denied card with identical copy unit npm test -w web ❌ Wave 0
GEO-03 No storage API referenced anywhere in web/src (grep gate) unit npm test -w web ❌ Wave 0
GEO-03 Runtime probe: after boot → live in the harness, localStorage/sessionStorage/cookies/IndexedDB are all empty integration (Playwright) npm run capture -w web ❌ Wave 0
GEO-03 Every KV put in the new location module carries expirationTtl unit (bot-side grep gate) npm run bot:test ❌ Wave 0
PX-02 colorScheme:'dark' sets data-tg-theme="dark"; light sets light unit npm test -w web ❌ Wave 0
PX-02 Missing colorScheme falls back to header_bg_color luminance ≥ 0.5 unit npm test -w web ❌ Wave 0
PX-02 themeChanged re-sets the attribute and touches nothing else (map style object identity unchanged — Pitfall 7) unit npm test -w web ❌ Wave 0
PX-02 Dark-theme capture exists in the fidelity artifact integration (Playwright) npm run capture -w web ❌ Wave 0
D-09 Boot renders stage 1 → stage 2 → live with no blank intermediate frame integration (Playwright) npm run capture -w web ❌ Wave 0
D-10 BackButton.hide() at init; the handler is a total function (closes when no overlay — no dead-ends) unit npm test -w web ❌ Wave 0
D-03 Session token lives only in module scope; never written to any storage unit npm test -w web ❌ Wave 0
D-14 web/dist contains the inlined style JSON, self-hosted fonts, and a MapLibre worker file; no CDN references in the built output unit (build-artifact gate) npm run build -w web ❌ Wave 0
D-08 Boot output contains no route line, no search furniture, no layer toggles integration (Playwright) npm run capture -w web ❌ Wave 0
Copy gate strings.ts exports exactly the UI-SPEC copy table, en + ms; no inline copy in shell modules unit npm test -w web ❌ Wave 0

Sampling Rate

  • Per task commit: npm test -w web
  • Per wave merge: npm test -w web && npm run bot:test && npm run typecheck -w web && npm run build -w web
  • Phase gate: full suite green + npm run capture -w web produced (DSGN-02 fidelity artifact) + the device-matrix pass (manual, blocking)

Wave 0 Gaps

  • web/test/runner.mjs — hand-rolled runner (copy the bot/test/runner.mjs shape)
  • web/test/harness.mjs — test registry
  • web/src/mock/telegram-mock.ts — D-11 fake WebApp; the keystone that makes every location test above possible without a device
  • web/test/location-chain.test.mjs — covers GEO-01, GEO-02 (client half)
  • web/test/no-storage.test.mjs — GEO-03 grep gate
  • web/test/theme.test.mjs — PX-02 slot-switch gate
  • web/test/copy-gate.test.mjs — UI-SPEC copy table gate
  • bot/test/location-pin.test.mjs — GEO-02 bot half + TTL grep assertion
  • web/scripts/capture.mjs — Playwright harness captures (extend theme/scripts/screenshot.mjs)
  • GET /api/location Worker route + KV module — the missing link between the bot's received pin and the open Mini App (Open Question 5)

None of these exist yet — web/ does not exist. Every test above is Wave 0.


Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication yes Phase 2's /api/session (HMAC-validated initData → opaque token). The app posts the verbatim initData string — never initDataUnsafe (Telegram's own docs warn on this). Token held in module scope only (D-03).
V3 Session Management yes 900s TTL (already enforced server-side, SESSION_TTL_SECONDS), rotate-on-new, revalidate on every fresh boot. No client-side session persistence at all — which is stricter than SecureStorage/CloudStorage and therefore immune to their availability quirks.
V4 Access Control yes The new GET /api/location route must validate the bearer token via getClaimByToken before returning anything. A pin must never be readable without a live, unexpired session token for that chat.
V5 Input Validation yes Validate Location message fields (latitude/longitude numeric, sane ranges) before use; cap horizontal_accuracy (Telegram documents 0–1500 m); reject absurd coordinates rather than flying the camera to null island.
V6 Cryptography no new crypto HMAC-SHA256 stays entirely server-side. The client never holds BOT_TOKEN. No crypto in web/.
Origin restriction (Bot API 10.2) yes — new All Telegram.WebApp.* calls originate from the registered Mini App origin. Auto-enabled since 2026-07-20.

Known Threat Patterns for this stack

Pattern STRIDE Standard Mitigation
Forging a session by posting a crafted initData Spoofing Phase 2's constant-time HMAC compare + auth_date freshness window. Do not add a client-side shortcut.
Reading another user's shared pin via GET /api/location Information Disclosure Token-scoped KV lookup — a token resolves to exactly one chat_id; the route must derive the KV key from the claim, never from a client-supplied id.
Coordinate persisted to a client store Information Disclosure (privacy regression) D-07 + the grep gate + the runtime probe. This is the GEO-03 spot-check made mechanical.
Stale/cached fix presented as LIVE Repudiation (dishonest state) Successive-fix staleness detection + FIX Ns AGO telemetry. The app must never claim freshness it has not measured.
Malicious bot update injecting a pin Tampering Only Telegram-authenticated webhook updates reach grammY; the secret-token compare runs before parsing (Phase 2's fail-closed guard).
Map style JSON fetched from a mutable URL at runtime Tampering D-14 already forbids this — the style is inlined at build time. Tiles are the only runtime network dependency.
Telegram WebApp called cross-origin Elevation of Privilege Bot API 10.2 restriction — keep all calls on the registered origin.

Sources

Primary (HIGH confidence)

  • https://core.telegram.org/bots/webapps — LocationManager (init/getLocation/openSettings/isInited/isLocationAvailable/isAccessRequested/isAccessGranted), LocationData (all 9 fields incl. nullable course/horizontal_accuracy), colorScheme, ThemeParams, BackButton, SafeAreaInset/ContentSafeAreaInset, isVersionAtLeast, the complete event table (locationManagerUpdated, locationRequested, themeChanged, safeAreaChanged, activated/deactivated), ready()/expand()/viewportStableHeight. Fetched 2026-10-22. This is the authority for the entire location chain.
  • https://core.telegram.org/bots/api-changelog — Bot API 10.2, July 14 2026: "Hardened the security of Mini Apps by disallowing the usage of Mini App methods from origins different from the original Mini App domain. The protection will be automatically enabled for all Mini Apps on July 20, 2026." Also confirms LocationManager landed in 8.0 (Nov 2024). Fetched 2026-10-22.
  • https://core.telegram.org/bots/api (HTML) — KeyboardButton.request_location ("If True, the user's current location will be sent when the button is pressed. Available in private chats only."), Location type fields, ReplyKeyboardMarkup (resize_keyboard, one_time_keyboard). Fetched 2026-10-22.
  • https://maplibre.org/maplibre-gl-js/docs/guides/v5-to-v6-migration-guide/ — ESM-only, setWorkerUrl() bundler-only, WebGL2 required + GPUInitializationError, styleimagemissing → setMissingStyleImageResolver, no default import. Fetched 2026-10-22.
  • https://www.maplibre.org/maplibre-gl-js/docs/ §Installation — the exact Vite snippet (?worker&url → setWorkerUrl), CSS import path, import 'maplibre-gl/dist/maplibre-gl.css', CSP directives. Fetched 2026-10-22.
  • https://developers.cloudflare.com/workers/static-assets/binding/ — assets.directory, assets.binding, run_worker_first as a glob array (["/api/*", "!/api/docs/*"], max 100 entries), not_found_handling, env.ASSETS.fetch(request) passthrough. Fetched 2026-10-22. This is the exact D-13 config shape.
  • https://playwright.dev/docs/emulation#geolocation — context.setGeolocation(), grantPermissions(['geolocation']), colorScheme, devices[...] — the D-11 harness and exit-gate matrix primitives. Fetched 2026-10-22.
  • npm registry (npm view, run this session) — maplibre-gl@6.15.0 (2026-10-18) with exports containing only an import condition; vite@8.3.5 (2026-10-21) engines.node: ^20.19.0 || >=22.12.0; @types/telegram-web-app@10.1.0 (2026-10-04); telegram-web-app / telegram-web-app.js / @telegram-apps/web-app all E404 or abandoned.
  • Repo inspection — bot/src/webhook.ts (/api/session contract, 4 KB cap, fail-closed webhook), bot/src/kv/sessions.ts (900s TTL, rotate-on-new, getClaimByToken), bot/test/{runner,harness}.mjs (test pattern), bot/wrangler.jsonc (chat_sessions binding), theme/src/components.css (.btn-primary, .card, .text-label-sm, .checkbox all confirmed present), theme/src/theme.css (--shell-bg/--shell-on-surface + prefers-color-scheme defaults), theme/basemap/liberty-rams.json (v8 style, 110 layers, openmaptiles + ne2_shaded sources → OpenFreeMap).

Secondary (MEDIUM confidence)

  • https://github.com/Telegram-Mini-Apps/issues/issues/56 — Android stale-fix bug. Open since Dec 18 2024, labels bug/in progress/Telegram for Android, 6+ independent confirmations. Reporter's workaround: "Integrating the browser's navigator.geolocation.getCurrentPosition … allows the Telegram mini app to retrieve the same coordinates afterward"; "Opening Google Maps on the device and then returning to the Telegram mini app also triggers the location to refresh." Commenter stefda: "This issue renders the new LocationManager api useless." The basis for A1 — treat as a hypothesis to verify at the device gate, not as established fact.
  • https://github.com/Telegram-Mini-Apps/issues/issues/77 — Desktop geolocation absent. Open Apr 2025 → last activity Jan 2026. Second reporter (Sep 2025) confirms the button is greyed out on Telegram macOS and Desktop. Establishes GEO-02 as the only Desktop origin path.
  • https://crossminiapp.com/en/blog/webview-geolocation-broken-runtime (Oct 20, 2026) — corroborating platform analysis: Android's WebSettings.setGeolocationEnabled() defaults to false; needs WebChromeClient.onGeolocationPermissionsShowPrompt + runtime permission + HTTPS/localhost; iOS routes every request through WKUIDelegate.webView(_:requestGeolocationPermissionFor:…), and a page that calls geolocation on load or without a direct user gesture is blocked or re-prompted. Vendor ROMs (EMUI/MIUI) add background restrictions. Vendor marketing site — used only for the well-known Android/iOS platform facts, which it cites to primary developer.android.com / developer.apple.com sources.
  • https://adsgram.ai/blog/adsgram/telegram-mini-app-tma-development-mistakes-and-how-to-avoid-them (Aug 23, 2026) — two independently valuable claims: (a) localStorage may not persist inside Telegram's WebView on iOS and some Linux desktop builds (corroborates D-03's choice); (b) the Bot API 10.2 origin restriction and its Jul 20 2026 enforcement date — which I then confirmed against Telegram's own changelog and therefore treat as HIGH. Ads vendor blog; treat mechanism claims as MEDIUM.

Tertiary (LOW confidence — flagged for validation)

  • Various Stack Overflow / Reddit / YouTube results on WKWebView geolocation permission prompts and iOS 14+ CLLocationManager authorization changes. These surfaced during search and were not used for any specific claim; they indicate the area is genuinely messy and support the general "gate everything, never assume" posture rather than any particular implementation detail.
  • @telegram-apps/sdk (the community SDK) appeared in issue #77's reproduction. Not evaluated further — D-01 rules it out (no framework), and the official API surface is sufficient.

Metadata

Confidence breakdown:

  • Standard stack: HIGH — every version verified against the live npm registry this session; Vite/MapLibre/Telegram types resolved and their failures documented.
  • Architecture (platform API surface): HIGH — the entire LocationManager, ThemeParams, safe-area, and event surface read directly from core.telegram.org/bots/webapps this session.
  • Architecture (the fallback mechanisms): HIGH — request_location and the Location message shape confirmed against core.telegram.org/bots/api.
  • Pitfalls: HIGH — Pitfalls 1, 3, 5, 6, 7 are direct consequences of documented API behavior, not inference.
  • The Android stale-fix workaround (A1): MEDIUM — community-sourced, reproduced by multiple reporters, not verified on our devices. Explicitly listed in the Assumptions Log and gated behind a degradation path.
  • localStorage unreliability in the WebView: MEDIUM — single secondary source; corroborates rather than drives the design.
  • @types/telegram-web-app accuracy (A2): MEDIUM — package exists and is current; its LocationManager shape was not diffed against the official docs.
  • Device-matrix feasibility: UNKNOWN — the availability of real Android/iOS devices is an open question that blocks Success Criterion 5 and cannot be resolved by research.

What might I have missed:

  • The WebGL2-absent shell state (Open Question 1) is a genuine gap in an approved UI-SPEC. The planner should amend the contract rather than silently extend it.
  • The new GET /api/location Worker route is a real scope item that appears in neither Phase 2's code nor Phase 3's stated deliverables, yet GEO-02 cannot work without it. Budget it explicitly.
  • I did not research Telegram's test server (test.telegram.org) as an alternative to physical devices for the matrix. It could shorten iteration but does not substitute for real-device verification of a WebView-specific bug.

Research date: 2026-10-22 Valid until: 2026-11-05 (14 days) — shorter than usual, deliberately. The Android stale-fix bug (issue #56) and the Telegram Mini App platform are both moving targets; a fix landing would change the recommended chain. Re-check core.telegram.org/bots/api-changelog and issue #56 before executing.