# 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 (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 ## 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" | --- ## 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](https://github.com/Telegram-Mini-Apps/issues/issues/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](https://github.com/Telegram-Mini-Apps/issues/issues/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 `import`s 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:** ```bash # 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 ```text ┌─────────────────────────────────────────────────────────────────┐ │ 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 └─────────────────────────────────────────────────────────────────┘ ``` ### Recommended Project Structure ``` 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:** ```typescript // 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'); } ``` ```typescript // 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 { 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. ```typescript /** * 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; } ``` ```typescript // 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 { 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): ```typescript // 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 `), 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 `` 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: ```json "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 `` 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) ```typescript // 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) ```typescript // 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) ```typescript // 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 } ``` ```typescript // 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 ```typescript // 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) ```javascript // 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 `