Spec: F18 — Bi-lingual UI (Vietnamese / English)
Status: shipped · Audited: 2026-07-14 · Contract: partial; see F18 i18n audit
1. Problem & CEO Job
Vietnamese-first, not translated (docs/AGENTS.md). Primary users think in Vietnamese; English serves foreign stakeholders, investors, and international expansion. Prevent not just missing keys but English-shaped Vietnamese that makes Lumi feel foreign at home.
2. User Stories & Acceptance Criteria
- As a Vietnamese user, I want every string native, so that Lumi reads like it was written in Saigon, not translated from San Francisco.
- No hardcoded user-visible strings anywhere (merge-blocking rule 3 in CLAUDE.md); all via
useTranslation('<namespace>'). - All shipped locale namespaces have identical key sets — enforced by the i18n unit test, with approved English placeholders retained until F69 translation expansion.
- Dates/numbers/currency format per locale; VND amounts never render as
$.
- No hardcoded user-visible strings anywhere (merge-blocking rule 3 in CLAUDE.md); all via
- As a user, I want my language choice to persist (F01
me/language/) and apply instantly. - Authenticated cold start preloads route namespaces only from an
authoritative persisted or refresh-provided account language; fresh browsers
never guess from
navigator.languageand download a second locale. Shared shell and Today operations namespaces are grouped per locale to reduce request overhead without loading unrelated route copy. - New page ⇒ new namespace (page-named), both locales in the same PR.
Namespace inventory (must mirror frontend/src/i18n/locales/en/)
accept-invite, brief, chat, commitments, common, decisions, error-boundary, invite, landing, layout, login, projects, pulse, recurring, register, risks, settings, sidebar, tasks, teams, today
3. Scope Boundaries
- In: react-i18next setup, per-page namespaces, language switch, key-parity test.
- Out: additional languages beyond en/vi (architecture is ready — 62 locale dirs exist; expansion to ar/ms/id/fil/lv is F69), server-side content translation (pulse text stays in the language written — Lumi processes it, never translates user data), pulse question localisation (questions are hardcoded Vietnamese strings, not i18n keys — see F07 §9 gap).
4. Data Model
None frontend-side; user preference stored on User (F01).
5. API Contract
PATCH /api/auth/me/language/ (F01). API data is language-neutral; only UI chrome translates.
6. UI / UX
- Key naming:
<namespace>.<action>_<noun>(tasks.error_load). - Vietnamese register: direct, short, the way a chief of staff texts the boss — not formal-document Vietnamese. When en/vi phrasing conflicts, write the Vietnamese first and let English follow it.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | en/vi key parity per namespace | unit | frontend i18n test (pnpm test) |
| 2 | No raw strings in new components | review | CLAUDE.md rule 3 (merge-blocking) |
| 3 | Both languages render | e2e | screenshot flow (extend to vi pass — see §11 row 4) |
8. Current Behavior
Shipped. The product uses namespace-based locale files with English and Vietnamese kept in lockstep for active surfaces. language_detected is captured from Accept-Language on registration and persisted to User.language; PATCH me/language/ lets users switch at any time. The i18n unit test (i18n.test.ts) enforces key parity and detects namespaces registered in code but missing from disk (fixed E4 — 18 dead duplicate keys removed from en-AU/CA/GB/… variants). Every shipped locale is key-complete and therefore loads no cross-locale fallback catalog; only an absent or unsupported detector value resolves to Vietnamese. Authenticated startup reuses one promise per exact (locale, namespace) and Vite groups only same-locale shell/Today critical namespaces, so refresh-provided account language can start route copy immediately without cross-locale speculation.
9. Error States
| # | Error | Response | Notes |
|---|---|---|---|
| ES1 | Invalid language code in PATCH | 400 {"detail": "Invalid language"} | Backend SUPPORTED_LOCALES check |
| ES2 | Confirm detected language when none detected | 409 {"detail": "No detected language to confirm."} | confirm_detected_language view |
| ES3 | Backend language save failure | setLanguage catches exception + rolls back frontend language | No dedicated backend test for save failure |
| ES4 | Missing namespace or key in a shipped locale | i18next logs a warning and renders the key name (e.g., "common:save") rather than silently mixing languages | CI parity checks prevent this for committed locale catalogs; no runtime cross-locale fallback is loaded |
| ES5 | Language detector blocked (localStorage unavailable) | Falls through to navigator.language; en-US → en via locale matching | No test for localStorage unavailability |
10. Diagrams & Docs Touched
frontend/AGENTS.md → i18n.
11. Implementation Audit (2026-06-12)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Namespace-based locale files; en/vi parity per namespace enforced by CI test | ✅ match | i18n.test.ts; pnpm test fails on key-parity violation |
| 2 | Language persists via User.language_preference (PATCH /api/auth/me/language/) across sessions | ✅ match | F01 §5; test_language_preference_persists |
| 3 | Key parity test detects registered-in-code namespaces missing from disk (E4 fixed) | ✅ match | Registered namespaces test added; 18 dead duplicate keys removed |
| 4 | Key-complete shipped locales do not load cross-locale fallback catalogs; absent or unsupported detector values fall back to Vietnamese | ✅ match | i18n.test.ts proves fallbackLanguagesFor for shipped, regional, absent, and unsupported values; production trace loads locale-en-* only |
| 5 | Rapid language switch race rolls back to the last confirmed language, not a stale optimistic prevLang | ✅ match | 2026-07-01 authStore.test.ts adds overlapping setLanguage proof: en succeeds, ms/ar fail, final store state returns to en |
| 6 | Shipped locale dirs cannot drift from the frontend selector inventory | ✅ match | 2026-07-01 i18n.test.ts now compares locale dirs on disk against languageCodes, collapsing only the six intentional en-* variants into en |
| 7 | Backend SUPPORTED_LOCALES stays aligned with the shipped locale dirs on disk | ✅ match | 2026-07-01 i18n.test.ts parses backend/apps/accounts/models.py and asserts SUPPORTED_LOCALES equals the raw locale-dir inventory exactly |
| 8 | New page ⇒ new namespace (page-named), both locales in same PR | ✅ match | D-r18 verified via diff <(ls en/) <(ls vi/) — 35 namespace files in both dirs |
Summary: 9/9 match; locale-aware formatter coverage and shipped-locale key parity are now attached to the F18 contract. F69 still owns translation quality for expanded markets.
Edge Cases Catalog (formerly §11, 2026-06-12; E1–E9): Key items integrated above. Full catalog: language switch failure rollback (E1 — tested); rapid switch race (E2→row 5); unauthenticated switch via localStorage (E3 — by design); partial key parity for new namespaces (E4 — fixed); 62 dirs / 8 registered (E5→row 6); empty value vs missing key handling (E6 — parity-enforced; runtime renders the key rather than mixing locales); 30-day dismissal via localStorage (E7 — by design); unsupported detector fallback to Vietnamese without shipped-locale fallback downloads (E8→row 4); SUPPORTED_LOCALES vs LANGUAGES drift (E9→row 7). Full catalog in git history (2026-06-12 changelog entry).
12. Open Questions
- None.
13. Bug / Change Log
2026-07-16 — Keep shipped locales off cross-locale fallback downloads
Type: Performance + correctness
Summary:
- Replaced the global Vietnamese fallback catalog with a locale-aware fallback: shipped and supported regional locales are self-contained, while absent or unsupported detector values still resolve to Vietnamese.
- Added a focused F18 unit contract for shipped, regional, absent, and unsupported language values.
- A production browser trace for an English Today session requested no
locale-vi-*chunks. Retained three-run medians reduced empirical JS transfer to 290.4 kB for Today, 291.4 kB for Chat, 292.8 kB for Tasks, and 284.3 kB for Deals; all four routes stayed at 0 kB telemetry transfer.
2026-07-16 — Authoritative startup language and bounded locale chunks
Type: Performance + correctness
Summary:
- Returning sessions start matched-route namespaces from the persisted account language when available, or from F01’s refresh-provided user language on a fresh browser; browser locale is never guessed across that trust boundary.
- Locale-module promises are deduplicated inside the i18n backend.
- Shared shell and Today operations copy emit as locale-local chunks, reducing authenticated request overhead without widening unrelated routes.
- Focused production evidence moved Today empirical JS from 300.8 to 299.0 kB and kept floor LCP non-regressive at 4020 ms versus the 4080 ms control.
2026-07-14 — Contract Graph ownership and honest web-language boundary
Type: Verification
Summary:
- Added the F18 CUE contract for the web i18n runtime, catalog parity, language selector handoff, client rollback, and cross-tab propagation.
- Kept F01 as the sole account-language API owner and made browser, hardcoded-copy, and locale-formatting claims explicit blocking gaps.
Impact:
- F18 is no longer represented as an unqualified shipped row while its runtime and quality evidence remain incomplete.
2026-07-10 — Cross-tab language propagation (W-SYS-10 Check 2)
Type: Bug Fix (frontend)
Summary:
docs/tests/evidence/web/W-SYS-10/walk.mdfound that switching language in one tab never reached an already-open second tab (same browser profile, sharedlocalStorage) —authStore.languagewas plain Zustand state, populated once at mount, with nostorage-event listener anywhere tying a second tab’s copy back to the first.- Added
frontend/src/i18n/crossTabLanguage.ts— awindow.addEventListener ('storage', …)listener on thei18nextLngkeyi18next-browser-languagedetectoralready caches tolocalStorage(detection.caches) but only ever reads once at init. Wired intofrontend/src/i18n/index.ts. A change in another tab now appliesi18n.changeLanguage()the moment the browser’s nativestorageevent arrives — no polling, no dependency on the tab regaining focus. frontend/src/stores/authStore.tsnow also mirrors i18next’s ownlanguageChangedevent into the store’slanguagefield, so the store stays truthful regardless of which tab (or trigger) actually switched.- Tests:
frontend/src/test/_shared/crossTabLanguage.test.ts(5, injected deps — no real window needed) + 3 new integration cases inauthStore.test.tsdispatching a realStorageEventonwindow. - Re-run of the committed walk (
node frontend/scripts/audit-walks/w-sys-10.mjs) confirms Check 2 now passes — see the walk.md’s 2026-07-10 “After fix” section.
2026-07-02 — Correct spec drift: check 1 shipped AC confirmed by §11
Type: Documentation Change
Summary:
- §2 AC brought in line with §11 audit (8/8 ✅). Checked 1 previously unchecked AC:
en/andvi/namespaces have identical key sets enforced by the i18n unit test (§11 row 1).
- Remaining unchecked ACs:
- “No hardcoded user-visible strings anywhere” — ongoing merge-blocking process rule (CLAUDE.md rule 3), not a point-in-time audit claim; no §11 row confirms a codebase-wide scan.
- “Dates/numbers/currency format per locale; VND amounts never render as $” — no §11 row covers this; genuine gap.
- No code changes; documentation only.
2026-07-01 — Close F18 locale-inventory guardrail gaps
Type: Frontend test coverage + documentation truth fix
Summary:
- Closed the stale F18 rows that claimed shipped locale dirs could drift silently from the frontend selector inventory and from backend
SUPPORTED_LOCALES. i18n.test.tsnow proves both contracts directly: the frontend selector covers every shipped locale dir after the intentional six-wayen-* -> encollapse, and backendSUPPORTED_LOCALESmatches the raw locale-dir inventory exactly.
Tests:
cd frontend && pnpm test -- src/test/i18n.test.ts
2026-07-01 — Close rapid language-switch rollback race
Type: Frontend state fix + regression proof
Summary:
setLanguagenow rolls back only the latest in-flight mutation and uses the last confirmed persisted language instead of a closure-captured optimistic value.- Added a focused overlapping-request test that proves the store recovers to
enafterensucceeds and laterms/armutations fail out of order.
Tests:
cd frontend && pnpm test -- src/test/authStore.test.ts
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Numbered “Current Behavior” as §8.
- §8 Diagrams → §10; §9 Implementation Audit (verdict) → §11 (8-row table); §10 Open Questions → §12; §11 Edge Cases absorbed into §11 note; §12 Error States → §9; §13 Bug Log stays.
- §11 audit: 8 rows, 5 ✅, 3 gaps (rapid-switch race, unregistered dirs, LANGUAGES drift).
2026-06-14 — Scope boundary updated; F69 is the multi-language expansion path
Type: Documentation Change
2026-06-12 — Verified locale namespace parity criterion (D-r18)
Type: Doc Fix / Verified
2026-06-12 — Add Edge Cases (E1–E9) and Error States (ES1–ES5) to F18 spec
Type: Documentation Change