--- phase: 04-destination-search-kl-gazetteer plan: 04 type: execute wave: 3 depends_on: [04-01, 04-03] files_modified: - web/src/search/resolve-destination.ts - web/src/search/destination-card.ts - web/src/telegram/webapp.ts - web/src/telegram/homescreen.ts - web/src/mock/telegram-mock.ts - web/src/i18n/strings.ts - web/src/main.ts - web/index.html - web/package.json - web/test/destination-preload.test.mjs - web/test/homescreen.test.mjs - web/test/no-storage.test.mjs - web/test/build-artifact.test.mjs - web/scripts/capture.mjs autonomous: false requirements: [ONBD-02, ONBD-03] must_haves: decisions_implemented: - "D-11 + RESEARCH Pitfall 1 (MECHANISM CORRECTED): the webview reads the destination id from `location.search`'s `dest` param FIRST, and from `initData.start_param` SECOND. Both readers apply the SAME validation (`/^[A-Za-z0-9_-]{1,64}$/`). The id is a plain public gazetteer id — NO crypto, NO signature, NO token" - "D-12 (ONBD-03 scope): minimal PWA hooks only — `addToHomeScreen` / `checkHomeScreenStatus` behind a capability guard, plus a startapp-less boot landing in the search-ready map. NO manifest, NO service worker, NO `beforeinstallprompt`" - "D-08/D-11: the webview resolves the id against its OWN build-time copy of the shared gazetteer module — the id mapping stays in sync by construction" - "GEO-03/D-07: the resolved destination lives in MEMORY ONLY — no storage surface, no persistence call" - "D-12: a startapp-less boot is UNCHANGED — the Phase 3 staged boot still lands in the search-ready map" truths: - "Opening the Mini App with '?dest=suria-klcc' resolves the destination to a valid lat/lng from the webview's own gazetteer copy, with no network call" - "Opening the Mini App with '?dest=suria-klcc' as a start_param (the direct-link path) resolves the SAME destination" - "An unknown or malformed id produces an honest 'destination unavailable' state — never a blank map and never a fabricated coordinate" - "The webview and the bot resolve the SAME id set — a build gate proves the shipped web bundle carries the whole gazetteer" - "The addToHomeScreen affordance is a no-op on a client older than Bot API 8.0 or one without the method" - "A checkHomeScreenStatus of 'unknown' (always the case on iOS) is treated as 'supported but indeterminate' — never as an error and never as 'not added'" - "A startapp-less boot lands in the same search-ready map as before — the Phase 3 boot is unchanged" - "The destination never reaches any storage surface — the runtime probe still finds empty storage after boot" - "A Playwright capture shows the webview booted WITH a preloaded destination, and the production bundle carries the gazetteer" artifacts: - path: "web/src/search/resolve-destination.ts" provides: "the memory-only id → destination resolution + the two-source payload reader (D-11, mechanism corrected)" contains: "readDestinationId" - path: "web/src/search/destination-card.ts" provides: "the destination surface built from the resolved entry (the webview's visible confirmation)" - path: "web/src/telegram/homescreen.ts" provides: "the ONBD-03 addToHomeScreen / checkHomeScreenStatus wiring behind the capability guard (D-12)" contains: "wireHomeScreen" - path: "web/src/telegram/webapp.ts" provides: "the narrow TelegramWebAppLike contract gaining startParam, addToHomeScreen?, checkHomeScreenStatus?" - path: "web/src/mock/telegram-mock.ts" provides: "the harness mock gaining the startParam option, the homeScreenStatus option, and the home-screen stubs" - path: "web/test/destination-preload.test.mjs" provides: "the ONBD-02 gates: both readers, the unknown-id honest state, the id validation, the memory-only source gate" - path: "web/test/homescreen.test.mjs" provides: "the ONBD-03 gates: the capability guard, the 'unknown' status handling, the 'added' branch" - path: "web/scripts/capture.mjs" provides: "the destination-preload capture leg + the ARAHARAH-DEST signal race" key_links: - from: "web/src/search/resolve-destination.ts" to: "@araharah/search" via: "the id resolves against the webview's own build-time copy of the shared gazetteer — the Worker never re-derives it" pattern: "getEntryById" - from: "web/src/search/resolve-destination.ts" to: "web/src/main.ts" via: "boot reads the destination once and seeds the in-memory destination holder" pattern: "resolveDestination" - from: "web/src/telegram/homescreen.ts" to: "web/src/telegram/webapp.ts" via: "the capability guard calls the OPTIONAL members on the narrow contract" pattern: "addToHomeScreen" - from: "web/src/main.ts" to: "web/src/telegram/webapp.ts" via: "boot wires the home screen affordance only when the client supports it" pattern: "wireHomeScreen" - from: "web/scripts/capture.mjs" to: "web/index.html" via: "the '?dest=' harness profile drives the capture leg through the real reader path" pattern: "dest=" - from: "web/test/build-artifact.test.mjs" to: "search/src/data/kl-gazetteer.json" via: "the shipped web bundle must carry the gazetteer's id set (the bot/web drift gate)" pattern: "suria-klcc" --- Complete the webview half of the deep-link hand-off: read the destination id from the corrected carrier (`location.search`'s `dest` first, `initData.start_param` second), resolve it against the webview's own build-time copy of the shared gazetteer, render an honest destination surface (never a blank map, never a fabricated coordinate), and land ONBD-03's minimal home-screen hooks — then produce the capture evidence and run the blocking device gate for the deep-link carrier. Purpose: ONBD-02 is the requirement whose success depends on undocumented Telegram client behaviour, and the research's highest-value finding is that a `web_app` inline button CANNOT populate `start_param` — so the reader must accept BOTH carriers with the same validation, and the phase must not claim the requirement is met until a real device has demonstrated it. ONBD-03 is deliberately minimal (D-12): the Telegram-native `addToHomeScreen()` / `checkHomeScreenStatus()` behind a capability guard, with the iOS `"unknown"` status treated as "possibly added", never as a failure — and no manifest, no service worker, no `beforeinstallprompt` (the browser API does not exist in the Telegram webview). Output: `web/src/search/resolve-destination.ts`, `web/src/search/destination-card.ts`, `web/src/telegram/homescreen.ts`, the extended `web/src/telegram/webapp.ts` + `web/src/mock/telegram-mock.ts` + `web/src/i18n/strings.ts`, the extended `web/src/main.ts` + `web/index.html`, three test suites, the extended capture harness, and the blocking device gate. @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md @AGENTS.md @.planning/phases/04-destination-search-kl-gazetteer/04-CONTEXT.md @.planning/phases/04-destination-search-kl-gazetteer/04-RESEARCH.md @.planning/phases/04-destination-search-kl-gazetteer/04-PATTERNS.md @web/src/main.ts @web/src/telegram/webapp.ts @web/src/telegram/backbutton.ts @web/src/session/store.ts @web/src/mock/telegram-mock.ts @web/src/i18n/strings.ts @web/src/ui/cards.ts @web/src/ui/strip.ts @web/index.html @web/scripts/capture.mjs @web/test/no-storage.test.mjs @web/test/build-artifact.test.mjs @search/src/index.ts @search/src/types.ts The bot's OPEN button (plan 04-03) is a `web_app` inline button whose URL carries `?dest=`. `WebAppInfo` has exactly ONE field — `url` — so `start_param` CANNOT be the carrier for an inline button. The webview therefore reads TWO sources, in this order, with the SAME validation: 1. **PRIMARY — the URL's own query string:** `new URLSearchParams(window.location.search).get("dest")`. This is the inline-button path. 2. **SECONDARY — `initData.start_param`:** the officially documented direct-link path (`https://t.me/?startapp=`) and the home-screen shortcut's path. Telegram populates `start_param` from a `startattach` attachment-menu link or a `?startapp=` direct link — NOT from an inline `web_app` button. Both are validated against `/^[A-Za-z0-9_-]{1,64}$/`. The value may carry a `dest_` prefix (the D-11 wording's literal `dest_` form) — the reader strips a leading `dest_` before validation, so BOTH `?dest=suria-klcc` and `?startapp=dest_suria-klcc` resolve to the same entry. `initData` is the SIGNED string (`Telegram.WebApp.initData`), NOT `initDataUnsafe` — the signed string is what the server validates, and the `start_param` field is present in it verbatim. Parsing it is a two-line split on `&` + `decodeURIComponent`; the repo's `bot/src/security/initdata.ts` `parseFields` shows the pattern (and its warning: `+` stays literal, never `URLSearchParams`). NO CRYPTO, NO SIGNATURE, NO TOKEN (D-11): the id is a public place id. An unknown id yields an honest "destination unavailable" state — never a fabricated coordinate, never a blank map. `web/src/search/resolve-destination.ts` — the memory-only resolver. Copy `web/src/session/store.ts`'s header law verbatim-in-spirit (lines 3-10: "WHAT is stored … WHAT is never stored … no persistence call anywhere in this file"). **Exports:** ```typescript export type DestinationSource = "url" | "start-param" | "none"; export interface ResolvedDestination { entry: GazetteerEntry; source: DestinationSource; } /** Read the destination id from BOTH carriers (mechanism corrected per RESEARCH Pitfall 1). * Primary: the URL's own query string (`?dest=`) — the inline-button path. * Secondary: initData's `start_param` — the documented direct-link / home-screen path. * Both are validated against the same charset; a `dest_` prefix is stripped. */ export function readDestinationId( search: string, initData: string | null | undefined, ): { id: string | null; source: DestinationSource }; /** Resolve an id against the webview's OWN build-time copy of the shared gazetteer. * Returns null for an unknown id — the honest null (never a fabricated coordinate). */ export function resolveDestination(id: string | null): GazetteerEntry | null; /** The memory-only holder: the resolved destination lives in module scope, nowhere else. */ export function setDestination(entry: GazetteerEntry | null): void; export function getDestination(): GazetteerEntry | null; ``` **The id validation.** `/^[A-Za-z0-9_-]{1,64}$/` AFTER stripping a leading `dest_`. An id failing the regex is treated as absent (never partially trusted). **The unknown-id path.** `resolveDestination` returns `null` — the caller renders the honest "destination unavailable" surface and the boot continues into the normal staged boot. A blank map is FORBIDDEN (the Phase 3 D-09 anti-blank invariant is binding here too). **The memory-only law.** The module contains NO storage call. `web/test/no-storage.test.mjs` already walks EVERY `web/src` file, so the new module is covered automatically — but the plan adds an explicit case too. **`web/src/search/destination-card.ts`** — the visible confirmation. Copy `web/src/ui/cards.ts`'s construction style (the `.card` / `.card-header` / `.card-ref` / `.btn-primary` composed classes, never restyled locally) and `web/src/ui/strip.ts`'s "every string comes from the strings module" law. It renders the destination's NAME and its curated ADDRESS (the address verbatim, never the normalized form — RESEARCH Pitfall 3), and it uses the existing theme classes so no off-token styling appears. The card is NOT a new screen — it is the Phase 3 card stack's existing surface with a different card. The roadmap's Phase 4 `UI hint: no` means no UI-SPEC round: the destination card composes ONLY classes that already exist in the Phase 3 shell (`web/src/shell.css` + the theme's component sheet). **The strings.** `web/src/i18n/strings.ts` GAINS: - `destinationHeadline` — "Destination" / "Destinasi" - `destinationUnavailableHeadline` — "Destination unavailable" / "Destinasi tidak tersedia" - `destinationUnavailableBody` — "That place is not in this build. Search again from the bot." / ms equivalent - `addToHomeScreen` — "ADD TO HOME SCREEN" / "TAMBAH KE SKRIN UTAMA" - `homeScreenAlreadyAdded` — "Already on your home screen" / "Sudah di skrin utama anda" The copy-gate suite (`web/test/copy-gate.test.mjs`) asserts the en/ms key-sets match — the new fields are covered automatically by CS1-style key-set identity. **`web/src/main.ts` — the boot wiring.** The destination read slots adjacent to the locale derivation (stage 12, lines 92-95), because both are "derive from the environment, memory only" steps: ```typescript // 13 — the destination (ONBD-02). TWO carriers, ONE validation (mechanism // corrected per RESEARCH Pitfall 1): the URL's own query string first // (the inline-button path — a web_app button's WebAppInfo carries only // a `url`), then initData's start_param (the documented direct-link / // home-screen path). An unknown id yields the honest unavailable state // and the boot continues into the normal staged boot — never a blank map. const destPayload = readDestinationId(window.location.search, wa?.initData ?? null); const destination = resolveDestination(destPayload.id); setDestination(destination); ``` When `destination !== null`, the destination card is mounted into the existing card stack. When the id was present but unknown, the unavailable card is mounted. When there was no id at all, NOTHING changes — the Phase 3 boot is untouched (D-12's "startapp-less boot landing in the search-ready map"). The boot emits ONE capture signal, UNCONDITIONALLY (outside any map guard, the same shape as the existing `ARAHARAH-LIVE` signal — a WebGL2-absent device still resolves the race): ```typescript console.log("ARAHARAH-DEST state=" + (destination ? "resolved" : "absent")); ``` The signal is a STATE TOKEN, never the id (D-15 hygiene: a console token carries no place data). Because a token alone cannot prove WHICH destination resolved, the capture leg pairs it with a DOM check on the rendered card's text (task 3's waitMode block asserts `Suria KLCC`). **`web/src/telegram/webapp.ts` — the contract grows.** The `TelegramWebAppLike` interface gains: ```typescript /** The documented direct-link start parameter — NOT populated by an inline * web_app button (its WebAppInfo carries only a `url`). */ startParam?: string; /** Bot API 8.0+ only. Optional — absent on older clients. */ addToHomeScreen?(): void; /** Bot API 8.0+ only. On iOS this ALWAYS reports "unknown". */ checkHomeScreenStatus?(cb: (status: HomeScreenStatus) => void): void; ``` plus: ```typescript export type HomeScreenStatus = "unsupported" | "unknown" | "added" | "missed"; ``` Do NOT add `@types/telegram-web-app` (Phase 3 dropped it deliberately — the hand-rolled contract IS the contract; the official type would force each fake to stub ~50 members). The interface's header comment already states this law and must be extended to name the three new members. **`web/src/mock/telegram-mock.ts` — the mock grows in the SAME change.** The interface's header says "every test fake, the D-11 harness mock, and the production clients satisfy exactly these members" — so the mock MUST gain the three members in this change: - `MockOptions.startParam?: string | null` — appended to the mock initData as `&start_param=` in `buildMockInitData` (the mock's initData is HMAC-unvalidated by design — the runner page stubs `/api/session`). - `MockOptions.homeScreenStatus?: HomeScreenStatus` — the status the mock's `checkHomeScreenStatus` callback receives. - The mock's `addToHomeScreen` and `checkHomeScreenStatus` stubs, plus a `__homeScreenAdded` counter the tests can read (the mock already exposes `__events` and `__backButtonClicks` for exactly this purpose). **`web/src/telegram/homescreen.ts` — the ONBD-03 wiring.** Copy `web/src/telegram/backbutton.ts` (the whole 20-line file is the template: guard on the optional member, a total-function handler, a header that documents the phase-scoped contract): ```typescript export function wireHomeScreen(wa: TelegramWebAppLike, locale: Locale): void ``` Behaviour: - The capability guard is BOTH halves: the method may be absent on older clients, AND the platform documents the API as Bot API 8.0+. `if (typeof wa.addToHomeScreen !== "function" || !wa.isVersionAtLeast("8.0")) return;` — a no-op on an unsupported client. - `checkHomeScreenStatus` — when it reports `"added"`, the affordance is HIDDEN (the icon is already there; do not nag). When it reports `"unknown"` (always on iOS), the affordance is SHOWN with the neutral prompt — "unknown" means "supported but indeterminate", NEVER an error and NEVER "not added". When it reports `"missed"`, the affordance is shown with the prompt. When it reports `"unsupported"`, the affordance is not shown. - `addToHomeScreen()` is called when the user taps the affordance. - `wa.onEvent?.("homeScreenAdded", ...)` — Telegram fires this after the icon is added; the affordance hides then. - The affordance lives in the EXISTING Phase 3 shell — the status strip's right cluster or the card stack. **No new UI furniture is invented** (the roadmap's Phase 4 `UI hint: no`): the affordance is composed from classes that already exist (`web/src/shell.css` + the theme component sheet). If a genuinely new surface were needed, that would require a UI-SPEC round — it does not. - **NO `beforeinstallprompt`, NO manifest, NO service worker.** `beforeinstallprompt` does not exist inside the Telegram webview and a second icon the Telegram client does not know about is worse than none. `web/test/no-storage.test.mjs`'s GEO3-2 case already bans `serviceWorker.register` / `navigator.serviceWorker` / `workbox` outright. **`web/index.html` — the `?dest=` harness profile.** The profile block (lines 20-35) reads `?mock=` / `?scheme=` / `?locale=` / `?pin=` / `?sessiondelay=` / `?fixdelay=`. It gains `?dest=`: ```javascript const dest = q.get("dest") || null; ... window.__ARAHARAH_MOCK__ = { profile, scheme, locale, dest }; ``` The value must reach the app's reader. Because `page.goto` preserves the query string, `window.location.search` ALREADY carries `?dest=` in the webview — the reader path needs no stub. But the mock's `startParam` option needs the value threaded through `installMockWebApp({ ... dest })` for the SECONDARY carrier's harness leg. The whole block stays inside `if (__ARAHARAH_HARNESS__)` — Vite's `define` dead-code-eliminates it from production, and `BA5`/`BA6` scan the emitted HTML for `installMockWebApp` / `__ARAHARAH_MOCK__` / `/api/session` / `/api/location-fallback`. The `?dest=` value must NOT add a new endpoint or a new inline fetch stub to the production build. **`web/package.json`** — add `"@araharah/search": "*"` to `dependencies` (the `web/package.json` line 15 `"@araharah/theme": "*"` precedent). **`web/scripts/capture.mjs` — the ONBD-02 capture leg.** Copy the chat-pin leg (lines 267-269) and the `expected` array (lines 361-369): ```javascript await captureLeg({ name: 'shell-dest-preload.png', query: 'mock=android&dest=suria-klcc', colorScheme: 'light', grantGeo: true, waitMode: 'dest' }); ``` Three bookkeeping edits: the header's output list (lines 22-29), the leg itself, and the `expected` array. The wait mechanism is the console-signal race — add `ARAHARAH-DEST` to the `SIGNALS` list (line 165) and the `seen` map (line 189). `web/src/main.ts` emits the STATE-TOKEN signal shown earlier in this contract (next to the boot-wiring paragraph) — `ARAHARAH-DEST state=resolved|absent`, never the id — UNCONDITIONALLY, outside any map guard, so a WebGL2-absent device still resolves the race. `web/test/build-artifact.test.mjs` — the drift gate. RESEARCH §Runtime State Inventory: "the `web/test/build-artifact.test.mjs` gate may need a new assertion (e.g. the gazetteer JSON *must* be in `web/dist` for the webview copy to work)". **BA7 (new)** — the shipped production bundle carries the gazetteer: read every `web/dist/assets/*.js`, concatenate, and assert that EVERY gazetteer id is present (the ids come from the shared dataset — the suite imports `search/src/data/kl-gazetteer.json`). This is the WEB half of the bot/web id-set parity gate: if the web bundle ships without the dataset, the webview's `resolveDestination` can never resolve an id. **BA8 (new)** — the production bundle carries NO deep-link crypto: assert the concatenated bundle contains NONE of `createHmac`, `crypto.subtle`, `HMAC(`, `sha256` — the deep-link path is a plain id (D-11). (The Telegram SDK script is loaded from `https://telegram.org/js/telegram-web-app.js` in the HTML — the OFFICIAL script may contain crypto internals. The gate therefore scans the JS ASSETS only, NOT the HTML, and the official script is external (not bundled).) **The bot/web id-set parity gate** belongs in the BOT suite (the bot test workspace can read both files); plan 04-03's `cari.test.mjs` is the natural home, but the bot suite's `files_modified` in plan 04-03 does not list it — so this plan adds the parity assertion to the WEB suite's BA7 by comparing the web bundle's ids against the committed dataset (the same committed JSON both sides read, so parity holds by construction). The REMAINING risk — a stale web bundle — is what BA7 catches. **The build-artifact suite's existing behaviour is preserved:** BA1-BA6 must keep passing, and the new cases scan `web/dist` ONLY (never `web/dist-harness`). The blocking human checkpoint at the end of this plan (the phase's exit gate for ONBD-02 and ONBD-03). **A real device is REQUIRED** — RESEARCH §Environment Availability lists it as unavailable in this environment with no fallback, exactly like Phase 3's device matrix. The phase MUST NOT be closed claiming device verification that did not happen. **What the device must demonstrate:** 1. **ONBD-02, Android:** send `/cari KLCC` in the bot's chat → the venue card appears with the OPEN button → tap OPEN → the Mini App opens WITH the destination preloaded (the destination card is visible, showing Suria KLCC). 2. **ONBD-02, iOS:** the same flow on an iPhone. 3. **ONBD-02, Telegram Desktop:** the same flow (Desktop has no geolocation, so the destination card is what is being observed — the location half is Phase 3's separate matrix). 4. **ONBD-03:** add araharah to the phone's home screen (via the app's affordance or Telegram's own menu) → launch from the home-screen icon → the app opens into the SEARCH-READY state (no destination, no crash, no blank map). On iOS, `checkHomeScreenStatus` will report `"unknown"` — that is expected platform behaviour and MUST NOT be reported as a failure. 5. **The unknown-id honesty:** open the Mini App with a deliberately wrong id (e.g. `?dest=not-a-real-place`) → the app shows the honest "destination unavailable" surface, NOT a blank map and NOT a fabricated pin. **The record.** Create `.planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md` with a row per leg and an explicit `PASS` / `FAIL` / `UNVERIFIED` verdict each. If no device is available, the honest outcome is `UNVERIFIED` — record the blocker in `.planning/STATE.md` under "Blockers/Concerns" and recommend acquiring a device or moving ONBD-02's device verification to Phase 7 with an explicit requirement-level note. NEVER close the phase claiming device verification that did not happen. task 1: the destination resolver (two carriers, one validation, memory only) web/src/search/resolve-destination.ts, web/src/search/destination-card.ts, web/src/telegram/webapp.ts, web/src/mock/telegram-mock.ts, web/src/i18n/strings.ts, web/package.json, web/test/destination-preload.test.mjs - `web/src/session/store.ts` (the whole file — the memory-only module law the resolver copies) - `web/src/telegram/webapp.ts` (the whole file — the narrow contract that grows) - `web/src/mock/telegram-mock.ts` (the whole file — `MockOptions`, `buildMockInitData`, the `__events`/`__backButtonClicks` exposure pattern, and the header law that the mock satisfies the same members as production) - `web/src/ui/cards.ts` (the card construction style the destination card copies — composed theme classes, strings from the strings module) - `web/src/i18n/strings.ts` (the interface + en/ms records + `localeFromInitData`) - `web/test/no-storage.test.mjs` (the comment-strip helper and the banned-token list the resolver must satisfy) - `web/test/session.test.mjs` (the pure-module + source-gate suite style the new suite copies) - `search/src/types.ts` (plan 04-01's output — the `GazetteerEntry` shape the resolver returns) **`web/package.json`** — add `"@araharah/search": "*"` to `dependencies` (the `"@araharah/theme": "*"` precedent at line 15). **`web/src/telegram/webapp.ts`** — extend the `TelegramWebAppLike` interface with the three members per the `` block, plus the `HomeScreenStatus` type export. Update the header comment to name them and restate the "no `@types/telegram-web-app`" law. **`web/src/mock/telegram-mock.ts`** — extend `MockOptions` with `startParam?: string | null` and `homeScreenStatus?: HomeScreenStatus`; append `&start_param=` to `buildMockInitData` when `startParam` is set; add the `addToHomeScreen` / `checkHomeScreenStatus` stubs and a `__homeScreenAdded` counter; add `startParam` to the fake object. Update the header comment to note the new options. **`web/src/search/resolve-destination.ts`** — NEW, exactly per the `` block. Its header comment states the mechanism correction, the two carriers, the shared validation, the memory-only law, and the honest-null law. The reader: ```typescript export function readDestinationId( search: string, initData: string | null | undefined, ): { id: string | null; source: DestinationSource } { const fromUrl = new URLSearchParams(search).get("dest"); const cleaned = fromUrl === null ? null : cleanId(fromUrl); if (cleaned !== null) return { id: cleaned, source: "url" }; const fromStart = startParamFromInitData(initData); const cleanedStart = fromStart === null ? null : cleanId(fromStart); if (cleanedStart !== null) return { id: cleanedStart, source: "start-param" }; return { id: null, source: "none" }; } ``` `cleanId` strips a leading `dest_` and validates against `/^[A-Za-z0-9_-]{1,64}$/`, returning `null` on failure. `startParamFromInitData` parses the SIGNED initData string the way `bot/src/security/initdata.ts`'s `parseFields` does — split on `&`, take the `start_param` field, `decodeURIComponent` it, and treat a decode failure as absent. **Never** use `URLSearchParams` on the initData string (`+` stays literal there) and **never** read `initDataUnsafe`. `resolveDestination(id)` calls `getEntryById` from `@araharah/search/src/index.ts` (the EXPLICIT subpath-with-extension form — the package has no exports map, so a bare `@araharah/search` specifier resolves to nothing; the `@araharah/theme` subpath precedent) and returns the entry or `null`. **`web/src/search/destination-card.ts`** — NEW. Build the destination surface from the EXISTING Phase 3 theme classes (`.card`, `.card-header`, `.card-ref`, `.text-headline-md`, `.text-body-sm` — the same classes `web/src/ui/cards.ts` uses) and the strings module. Two builders: `buildDestinationCard(locale, entry)` and `buildDestinationUnavailableCard(locale)`. No new CSS; no off-token styling. **`web/src/i18n/strings.ts`** — add the five new fields per the `` block to the `ShellStrings` interface and to BOTH the `en` and `ms` records. The attribution string stays as-is. **`web/test/destination-preload.test.mjs`** — NEW. Cases: - `DP1 the URL carrier resolves` — `readDestinationId("?dest=suria-klcc", "")` → `{ id: "suria-klcc", source: "url" }`. - `DP2 the start_param carrier resolves` — `readDestinationId("", "user=%7B%7D&auth_date=1&start_param=suria-klcc&hash=h")` → `{ id: "suria-klcc", source: "start-param" }`. - `DP3 the URL carrier wins over start_param` — with both present, the URL wins (the inline-button path is primary). - `DP4 the dest_ prefix is stripped` — `?dest=dest_suria-klcc` and `start_param=dest_suria-klcc` both → `suria-klcc` (the D-11 wording's literal form still resolves). - `DP5 no id yields the honest none` — both absent → `{ id: null, source: "none" }`. - `DP6 a malformed id is rejected` — `?dest=../../etc/passwd`, `?dest=