From 83985c4a09ddd3bd430271f5f851111fecfc69c6 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Thu, 22 Oct 2026 23:51:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(03-03):=20copy=20module=20=E2=80=94=20UI-S?= =?UTF-8?q?PEC=20table=20verbatim=20(en+ms)=20+=20COPY1..6=20copy=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/i18n/strings.ts | 127 ++++++++++++++++++++++++++++++++++++ web/test/copy-gate.test.mjs | 99 ++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 web/src/i18n/strings.ts create mode 100644 web/test/copy-gate.test.mjs diff --git a/web/src/i18n/strings.ts b/web/src/i18n/strings.ts new file mode 100644 index 0000000..fb7e32a --- /dev/null +++ b/web/src/i18n/strings.ts @@ -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 = { + 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=&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"; + } +} diff --git a/web/test/copy-gate.test.mjs b/web/test/copy-gate.test.mjs new file mode 100644 index 0000000..032ff17 --- /dev/null +++ b/web/test/copy-gate.test.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +/* + * copy-gate.test.mjs — COPY1..COPY6, the UI-SPEC copy gate (mirrors bot CS1-CS3). + * -------------------------------------------------------------------------------- + * COPY1 locale key-set parity · COPY2/COPY3 verbatim-contract content checks · + * COPY4 pickLocale fallback rule · COPY5 no inline copy in shell modules · + * COPY6 localeFromInitData over the raw initData string. + */ +import { strict as assert } from "node:assert"; +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "./harness.mjs"; + +const { strings, pickLocale, localeFromInitData } = await import("../src/i18n/strings.ts"); + +const UI_DIR = path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src", "ui"); + +/* ————— COPY1 — en/ms key-set identity (Phase 2 D-05 precedent) ————— */ + +test("COPY1 en and ms carry identical key-sets (parity gate, both locales)", () => { + const enKeys = Object.keys(strings.en).sort(); + const msKeys = Object.keys(strings.ms).sort(); + assert.deepEqual(msKeys, enKeys, "ms missing/duplicated keys vs en (D-05 locale parity)"); + assert.ok(enKeys.length >= 25, `expected the full ShellStrings surface (25 keys), found ${enKeys.length}`); + assert.ok(enKeys.includes("deniedRegister"), "deniedRegister is a keys — cards.ts renders GEO //"); + assert.ok(enKeys.includes("lostRegister"), "lostRegister is a key — cards.ts renders SYS //"); +}); + +/* ————— COPY2 — denied copy names location ————— */ + +test("COPY2 the denied copy names location in both locales", () => { + assert.ok(/location/i.test(strings.en.deniedHeadline), "en denied headline names location"); + assert.ok(/location/i.test(strings.en.deniedBody), "en denied body names location"); + assert.ok(/lokasi/i.test(strings.ms.deniedHeadline), "ms denied headline names lokasi"); + assert.ok(/lokasi/i.test(strings.ms.deniedBody), "ms denied body names lokasi"); +}); + +/* ————— COPY3 — the never-persisted fact is on the boot card ————— */ + +test("COPY3 the boot-2 body states the never-persisted fact", () => { + assert.ok(/never written to storage/i.test(strings.en.bootAcquiringBody), "en boot-2 body carries the never-fact"); + assert.ok(/tidak pernah disimpan/i.test(strings.ms.bootAcquiringBody), "ms boot-2 body carries the never-fact"); +}); + +/* ————— COPY4 — pickLocale fallback rule ————— */ + +test("COPY4 pickLocale: ms and ms-MY select ms; en, en-GB, id, null, and empty string select en", () => { + assert.equal(pickLocale("ms"), "ms"); + assert.equal(pickLocale("ms-MY"), "ms"); + assert.equal(pickLocale("en"), "en"); + assert.equal(pickLocale("en-GB"), "en"); + assert.equal(pickLocale("id"), "en", "unknown/neighbor locale falls back to en"); + assert.equal(pickLocale(null), "en"); + assert.equal(pickLocale(""), "en"); +}); + +/* ————— COPY5 — no inline copy in the shell ————— */ + +test("COPY5 no shell module inlines copy — source-introspection grep over web/src/ui/*.ts and web/src/main.ts", () => { + const banned = + /Validating session|Acquiring location|TRY AGAIN|ASK VIA CHAT|Location is switched off|Signal lost|MENYEMAK|MENDAPAT|LANGSUNG|GEO \/\/ LOCATION|SYS \/\/ POSITION|BOOT-01 \/\/|BOOT-02 \/\/|CUBA LAGI|MINTA VIA CHAT|ISYARAT HILANG|LOKASI MATI|AWAITING PIN|OpenStreetMap contributors|Recenter map|Tetapkan semula peta/; + const files = existsSync(UI_DIR) + ? readdirSync(UI_DIR) + .filter((f) => f.endsWith(".ts")) + .map((f) => path.join(UI_DIR, f)) + : []; + files.push(path.join(UI_DIR, "..", "main.ts")); // the composition root — always scanned + assert.ok(files.length >= 1, `scanned a real shell tree (found ${files.length} files)`); + for (const file of files) { + const src = readFileSync(file, "utf8"); + // The gate scans code AND prose-in-comments alike? No — comments may cite + // the register labels in their own names (e.g. a header example). Strip + // block and line comments first: prose is not a call site (GEO3 pattern). + const code = src + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); + const hit = banned.exec(code); + assert.ok(!hit, `${path.relative(process.cwd(), file)} inlines copy: ${hit && hit[0]} — copy lives only in web/src/i18n/strings.ts`); + } +}); + +/* ————— COPY6 — localeFromInitData over the raw initData string ————— */ + +test("COPY6 localeFromInitData derives the locale from the raw initData string", () => { + assert.equal( + localeFromInitData('user=%7B%22language_code%22%3A%22ms%22%7D&auth_date=1&hash=x'), + "ms", + "ms language_code inside the signed user field selects ms", + ); + assert.equal( + localeFromInitData('user=%7B%22language_code%22%3A%22en-GB%22%7D&auth_date=1&hash=x'), + "en", + "en-GB falls back to en", + ); + assert.equal(localeFromInitData(""), "en", "empty initData falls back to en"); + assert.equal(localeFromInitData("not-initdata"), "en", "no user= component falls back to en"); + assert.equal(localeFromInitData("user=%7Bbad-json"), "en", "decode/JSON failure must not throw"); +});