/* * strings.ts — the Mini App shell's copy tokens.css (UI-SPEC §Copywriting Contract). * ------------------------------------------------------------------------------- * Every user-facing string the shell renders lives here and NOWHERE else — * `web/src/ui/*`, `web/src/route/*` and `web/src/main.ts` import from this * module, and the copy gate (web/test/copy-gate.test.mjs COPY1..COPY7) keeps * it verbatim against the approved UI-SPEC table in en + ms. Register labels * (`BOOT-01 //`, `BOOT-02 //`, `GEO // LOCATION`, `SYS // POSITION`, `ROUTE`, * `ROUTE // ROUTER`, `GUIDE`) are instrument vocabulary: identical in both * locales by * design. Tone is clinical + honest (D-01): plain statements of what is * stored and what is not. * * Locales: English default ("en") + Bahasa Melayu ("ms") — Phase 2 D-05 * precedent, same pickLocale ms-prefix rule as bot/src/i18n/strings.ts. */ export interface ShellStrings { bootValidatingRegister: string; bootValidatingHeadline: string; bootValidatingBody: string; bootAcquiringRegister: string; bootAcquiringHeadline: string; bootAcquiringBody: string; deniedRegister: string; ctaTryAgain: string; ctaAskViaChat: string; ctaLocationSettings: string; deniedHeadline: string; deniedBody: string; deniedHint: string; lostRegister: string; lostHeadline: string; lostBody: string; lostHint: string; chatFallbackWaiting: string; stripValidating: string; stripAcquiring: string; stripLive: string; stripSignalLost: string; stripLocationOff: string; stripAwaitingPin: string; attribution: string; ariaRecenter: string; destinationHeadline: string; destinationUnavailableHeadline: string; destinationUnavailableBody: string; addToHomeScreen: string; homeScreenAlreadyAdded: string; routeRegister: string; routeDiffFastest: string; /** Template — {min}/{n} placeholders are replaced in code (instrument formats, not locale strings). */ routeDiffSlower: string; routePendingRegister: string; routePendingHeadline: string; routePendingBody: string; routePendingHint: string; ctaSearchAgain: string; ariaRouteAlternatives: string; /** Template — {n}/{min}/{km} placeholders are replaced in code. */ ariaRouteChip: string; /* — Phase 6 guidance chrome (06-UI-SPEC §Copywriting Contract; D-06/D-09/D-14). Every guidance word lives here in en + ms — never inline (the copy gates). Register labels (GUIDE, GUIDE // NEXT/REROUTING/…, GUIDE // T-nn) are instrument vocabulary composed in code from guideRegister. Tone: clinical, honest — the privacy promise stated once, at the exact moment it executes. */ /** The banner/card register base word — composed with the state word in code. */ guideRegister: string; /** Fallback street line for an unnamed OSM segment (D-06 street slot). */ guideStreetUnnamed: string; /** REROUTING calm-state line — no blame, no apology (D-04). */ guideReroutingLine: string; /** RESUMING projected-state line until the first live fix (D-06/D-11). */ guideResumingLine: string; /** NO REROUTE banner line — router-pending degradation (D-03). */ guideNoRerouteLine: string; /** NO REROUTE card state line — the longer honest copy (D-03). */ guideNoRerouteBody: string; /** Banner toggle word, collapsed state (real-word affordance, no chevron). */ ctaBannerList: string; /** Banner toggle word, expanded state. */ ctaBannerClose: string; /** The guidance card's ONLY action — opens the abandon confirm card (D-09). */ ctaEndTrip: string; /** The abandon-route confirm card (D-09/PX-03) — the one destructive guard. */ guideConfirmHeadline: string; /** The privacy clause stated at the exact moment it executes (D-14/PRIV-01). */ guideConfirmBody: string; /** Confirm primary CTA — deliberately NOT red (deliberate, not an error). */ ctaConfirmAbandon: string; /** Confirm dismiss — guidance continues. */ ctaConfirmContinue: string; /** Trip-ended card (D-14) — ONE card for arrival and abandon. */ guideEndHeadline: string; /** Trip-end deletion copy — the TTL law's promise, visible. */ guideEndBody: string; /** The maneuver list region label (list = labelled region of real text rows). */ ariaManeuverList: string; /** The banner button label (one toggle: aria-expanded + aria-controls). */ ariaBannerToggle: string; /** The guidance card's progress bar label. */ ariaTripProgress: string; /** The destination card's forward action: re-trigger routing (device-gate fix: * SEARCH AGAIN's destination state was terminal-with-no-forward). */ ctaRoute: string; /** The route button's aria label. */ ariaRouteButton: string; /** The document title while a destination is resolved — Telegram's header * mirrors it (device-gate feedback: "arah² → Routing to "). */ titleRoute: string; } export type Locale = "en" | "ms"; export const strings: Record = { en: { bootValidatingRegister: "BOOT-01 // SESSION", bootValidatingHeadline: "Validating session", bootValidatingBody: "Confirming your Telegram identity. Takes a few seconds and stores nothing.", bootAcquiringRegister: "BOOT-02 // LOCATION", bootAcquiringHeadline: "Acquiring location", bootAcquiringBody: "Finding where you are. Your position is never written to storage.", deniedRegister: "GEO // LOCATION", ctaTryAgain: "TRY AGAIN", ctaAskViaChat: "ASK VIA CHAT", ctaLocationSettings: "LOCATION SETTINGS", deniedHeadline: "Location is switched off", deniedBody: "araharah guides you from where you physically are. Without location there is no route to draw.", deniedHint: "Grant location to Telegram, or send a pin in this chat: attach → Location.", lostRegister: "SYS // POSITION", lostHeadline: "Signal lost", lostBody: "Showing your last known position — marked on the map. Reacquiring automatically.", lostHint: "You can also send a fresh pin in this chat: attach → Location.", chatFallbackWaiting: "Waiting for the pin you send in chat.", stripValidating: "VALIDATING", stripAcquiring: "ACQUIRING", stripLive: "LIVE", stripSignalLost: "SIGNAL LOST", stripLocationOff: "LOCATION OFF", stripAwaitingPin: "AWAITING PIN", attribution: "© OpenStreetMap contributors", ariaRecenter: "Recenter map", destinationHeadline: "Destination", destinationUnavailableHeadline: "Destination unavailable", destinationUnavailableBody: "That place is not in this build. Search again from the bot.", addToHomeScreen: "ADD TO HOME SCREEN", homeScreenAlreadyAdded: "Already on your home screen", routeRegister: "ROUTE", routeDiffFastest: "Fastest route", routeDiffSlower: "+{min} MIN VS ROUTE {n}", routePendingRegister: "ROUTE // ROUTER", routePendingHeadline: "Routing not wired up yet", routePendingBody: "Routes currently cover known KL landmarks only. The general router ships later — your coordinates were never sent anywhere.", routePendingHint: "Send /cari in the chat and pick another landmark.", ctaSearchAgain: "SEARCH AGAIN", ariaRouteAlternatives: "Alternative routes", ariaRouteChip: "Route {n}: {min} minutes, {km} kilometres", guideRegister: "GUIDE", guideStreetUnnamed: "Unnamed road", guideReroutingLine: "Finding a new route from where you are.", guideResumingLine: "Resuming from your last known progress.", guideNoRerouteLine: "Rerouting unavailable on this server.", guideNoRerouteBody: "No road graph here — follow the drawn route; recalculation is unavailable.", ctaBannerList: "LIST", ctaBannerClose: "CLOSE", ctaEndTrip: "END TRIP", guideConfirmHeadline: "Abandon route?", guideConfirmBody: "Guidance stops and this trip's record is deleted. Nothing is stored.", ctaConfirmAbandon: "YES, ABANDON", ctaConfirmContinue: "CONTINUE", guideEndHeadline: "Trip ended", guideEndBody: "Route guidance stopped and the trip record is deleted — nothing about this drive remains.", ariaManeuverList: "Full maneuver list", ariaBannerToggle: "Next maneuver", ariaTripProgress: "Trip progress", ctaRoute: "ROUTE", ariaRouteButton: "Route to this destination", titleRoute: "arah² → Routing to {name}", }, ms: { bootValidatingRegister: "BOOT-01 // SESSION", bootValidatingHeadline: "Menyemak sesi", bootValidatingBody: "Mengesahkan identiti Telegram anda. Hanya beberapa saat, tiada apa-apa disimpan.", bootAcquiringRegister: "BOOT-02 // LOCATION", bootAcquiringHeadline: "Mendapatkan lokasi", bootAcquiringBody: "Mencari kedudukan anda. Lokasi anda tidak pernah disimpan.", deniedRegister: "GEO // LOCATION", ctaTryAgain: "CUBA LAGI", ctaAskViaChat: "MINTA VIA CHAT", ctaLocationSettings: "TETAPAN LOKASI", deniedHeadline: "Lokasi dimatikan", deniedBody: "araharah memandu dari kedudukan sebenar anda. Tanpa lokasi, tiada laluan boleh dilukis.", deniedHint: "Benarkan lokasi untuk Telegram, atau hantar pin dalam chat ini: lampiran → Lokasi.", lostRegister: "SYS // POSITION", lostHeadline: "Isyarat hilang", lostBody: "Menunjukkan kedudukan terakhir anda — ditanda pada peta. Sedang mencari semula.", lostHint: "Anda juga boleh hantar pin baharu dalam chat ini: lampiran → Lokasi.", chatFallbackWaiting: "Menanti pin yang anda hantar dalam chat.", stripValidating: "MENYEMAK", stripAcquiring: "MENDAPAT", stripLive: "LANGSUNG", stripSignalLost: "ISYARAT HILANG", stripLocationOff: "LOKASI MATI", stripAwaitingPin: "AWAITING PIN", attribution: "© OpenStreetMap contributors", ariaRecenter: "Tetapkan semula peta", destinationHeadline: "Destinasi", destinationUnavailableHeadline: "Destinasi tidak tersedia", destinationUnavailableBody: "Tempat itu tiada dalam binaan ini. Cari semula dari bot.", addToHomeScreen: "TAMBAH KE SKRIN UTAMA", homeScreenAlreadyAdded: "Sudah di skrin utama anda", routeRegister: "ROUTE", routeDiffFastest: "Laluan terpantas", routeDiffSlower: "+{min} MIN VS LALUAN {n}", routePendingRegister: "ROUTE // ROUTER", routePendingHeadline: "Penghalaan belum dipasang", routePendingBody: "Laluan buat masa ini meliputi mercu tanda KL yang diketahui sahaja. Penghala am akan datang kemudian — koordinat anda tidak pernah dihantar ke mana-mana.", routePendingHint: "Hantar /cari dalam chat dan pilih mercu tanda lain.", ctaSearchAgain: "CARI SEMULA", ariaRouteAlternatives: "Laluan alternatif", ariaRouteChip: "Laluan {n}: {min} minit, {km} kilometer", guideRegister: "GUIDE", guideStreetUnnamed: "Jalan tanpa nama", guideReroutingLine: "Mencari laluan baharu dari kedudukan anda.", guideResumingLine: "Sambung semula dari kemajuan terakhir anda.", guideNoRerouteLine: "Pengiraan semula tiada pada pelayan ini.", guideNoRerouteBody: "Tiada graf jalan di sini — ikut laluan yang dilukis; pengiraan semula tidak tersedia.", ctaBannerList: "SENARAI", ctaBannerClose: "TUTUP", ctaEndTrip: "TAMAT PERJALANAN", guideConfirmHeadline: "Tinggalkan laluan?", guideConfirmBody: "Panduan berhenti dan rekod perjalanan ini dipadam. Tiada apa-apa disimpan.", ctaConfirmAbandon: "YA, TINGGALKAN", ctaConfirmContinue: "TERUSKAN", guideEndHeadline: "Perjalanan tamat", guideEndBody: "Panduan laluan dihentikan dan rekod perjalanan dipadam — tiada apa-apa tentang pemanduan ini kekal.", ariaManeuverList: "Senarai penuh manuver", ariaBannerToggle: "Manuver seterusnya", ariaTripProgress: "Kemajuan perjalanan", ctaRoute: "LALUI", ariaRouteButton: "Lalui ke destinasi ini", titleRoute: "arah² → Perjalanan ke {name}", }, }; /** * D-05 fallback rule (verbatim from bot/src/i18n/strings.ts): a Telegram * `language_code` that starts with the Malay tag selects "ms"; every other * value (including null/undefined and unknown languages) falls back to en. */ export function pickLocale(telegramLanguageCode?: string | null): Locale { const code = telegramLanguageCode ?? ""; return code === "ms" || code.startsWith("ms-") ? "ms" : "en"; } /** Derive the locale from the RAW initData string (RESEARCH §Security V2 — * the signed string, never initDataUnsafe). Telegram's wire format is * user=&auth_date=...&query_id=...&signature=...&hash=..., * so the first query component is `user`. A malformed or absent field * falls back to "en" — never throws, never blocks boot. */ export function localeFromInitData(initData: string): Locale { try { const userField = initData.split("&")[0] ?? ""; if (!userField.startsWith("user=")) return "en"; const user = JSON.parse(decodeURIComponent(userField.slice(5))) as { language_code?: string }; return pickLocale(user.language_code); } catch { return "en"; } }