feat(03-03): copy module — UI-SPEC table verbatim (en+ms) + COPY1..6 copy gate

- web/src/i18n/strings.ts: 25-key ShellStrings, pickLocale verbatim from bot, localeFromInitData over the raw initData string (RESEARCH §Security V2)
- web/test/copy-gate.test.mjs: parity, content, fallback, no-inline-copy source-introspection (comments stripped), initData locale derivation
This commit is contained in:
araharah-executor
2026-10-22 23:51:05 +08:00
parent 4e4b75da00
commit 83985c4a09
2 changed files with 226 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
/*
* 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/*` and `web/src/main.ts` import from this module, and the copy
* gate (web/test/copy-gate.test.mjs COPY1..COPY5) keeps it verbatim against
* the approved UI-SPEC table in en + ms. Register labels (`BOOT-01 //`,
* `BOOT-02 //`, `GEO // LOCATION`, `SYS // POSITION`) 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;
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;
}
export type Locale = "en" | "ms";
export const strings: Record<Locale, ShellStrings> = {
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",
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",
},
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",
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",
},
};
/**
* 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=<url-encoded JSON>&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";
}
}