shipped

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 $.
  • 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.language and 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

#CaseTypeWhere
1en/vi key parity per namespaceunitfrontend i18n test (pnpm test)
2No raw strings in new componentsreviewCLAUDE.md rule 3 (merge-blocking)
3Both languages rendere2escreenshot 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

#ErrorResponseNotes
ES1Invalid language code in PATCH400 {"detail": "Invalid language"}Backend SUPPORTED_LOCALES check
ES2Confirm detected language when none detected409 {"detail": "No detected language to confirm."}confirm_detected_language view
ES3Backend language save failuresetLanguage catches exception + rolls back frontend languageNo dedicated backend test for save failure
ES4Missing namespace or key in a shipped localei18next logs a warning and renders the key name (e.g., "common:save") rather than silently mixing languagesCI parity checks prevent this for committed locale catalogs; no runtime cross-locale fallback is loaded
ES5Language detector blocked (localStorage unavailable)Falls through to navigator.language; en-US → en via locale matchingNo test for localStorage unavailability

10. Diagrams & Docs Touched

frontend/AGENTS.md → i18n.

11. Implementation Audit (2026-06-12)

#ClaimStatusEvidence
1Namespace-based locale files; en/vi parity per namespace enforced by CI test✅ matchi18n.test.ts; pnpm test fails on key-parity violation
2Language persists via User.language_preference (PATCH /api/auth/me/language/) across sessions✅ matchF01 §5; test_language_preference_persists
3Key parity test detects registered-in-code namespaces missing from disk (E4 fixed)✅ matchRegistered namespaces test added; 18 dead duplicate keys removed
4Key-complete shipped locales do not load cross-locale fallback catalogs; absent or unsupported detector values fall back to Vietnamese✅ matchi18n.test.ts proves fallbackLanguagesFor for shipped, regional, absent, and unsupported values; production trace loads locale-en-* only
5Rapid language switch race rolls back to the last confirmed language, not a stale optimistic prevLang✅ match2026-07-01 authStore.test.ts adds overlapping setLanguage proof: en succeeds, ms/ar fail, final store state returns to en
6Shipped locale dirs cannot drift from the frontend selector inventory✅ match2026-07-01 i18n.test.ts now compares locale dirs on disk against languageCodes, collapsing only the six intentional en-* variants into en
7Backend SUPPORTED_LOCALES stays aligned with the shipped locale dirs on disk✅ match2026-07-01 i18n.test.ts parses backend/apps/accounts/models.py and asserts SUPPORTED_LOCALES equals the raw locale-dir inventory exactly
8New page ⇒ new namespace (page-named), both locales in same PR✅ matchD-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.md found that switching language in one tab never reached an already-open second tab (same browser profile, shared localStorage) — authStore.language was plain Zustand state, populated once at mount, with no storage-event listener anywhere tying a second tab’s copy back to the first.
  • Added frontend/src/i18n/crossTabLanguage.ts — a window.addEventListener ('storage', …) listener on the i18nextLng key i18next-browser-languagedetector already caches to localStorage (detection.caches) but only ever reads once at init. Wired into frontend/src/i18n/index.ts. A change in another tab now applies i18n.changeLanguage() the moment the browser’s native storage event arrives — no polling, no dependency on the tab regaining focus.
  • frontend/src/stores/authStore.ts now also mirrors i18next’s own languageChanged event into the store’s language field, 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 in authStore.test.ts dispatching a real StorageEvent on window.
  • 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/ and vi/ 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.ts now proves both contracts directly: the frontend selector covers every shipped locale dir after the intentional six-way en-* -> en collapse, and backend SUPPORTED_LOCALES matches 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:

  • setLanguage now 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 en after en succeeds and later ms / ar mutations 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