| 1 | # Localization Matrix |
| 2 | |
| 3 | Canonical tracking document for every locale Codewhale ships, is actively |
| 4 | building, is planning, or has explicitly deferred. |
| 5 | |
| 6 | > **Scope note (2026-07-12):** this matrix covers three surfaces — the TUI |
| 7 | > locale packs (`crates/tui/locales/`), the translated READMEs (repo root), |
| 8 | > and the website (`web/`). The three ship on different cadences, so a |
| 9 | > locale can be **shipped** on one surface and **planned** on another; the |
| 10 | > per-surface tables below are the per-surface truth. The website registry |
| 11 | > is `web/lib/i18n/config.ts` (`ALL_LOCALES`): the locale switcher and route |
| 12 | > generation both derive from it. |
| 13 | |
| 14 | Customer-visible copy also follows the [Codewhale voice and terminal |
| 15 | charter](VOICE.md); commands, key names, and glyphs remain code-owned around |
| 16 | localized prose. |
| 17 | |
| 18 | Last updated: 2026-08-03 (v0.9.4 website dictionary spine, #4934). |
| 19 | Source-of-truth README: `README.md` (English, post-#3087). |
| 20 | |
| 21 | ## Status legend |
| 22 | |
| 23 | | Status | Meaning | |
| 24 | |--------|---------| |
| 25 | | **shipped** | Live on codewhale.net and/or published as a standalone README, or a TUI pack at exact `en.json` parity | |
| 26 | | **partial** | Shipped but intentionally incomplete; missing scope falls back to English and the partial status is visible | |
| 27 | | **planned** | Explicitly prioritized for the next wave | |
| 28 | | **deferred** | Acknowledged as wanted but not yet scheduled; needs layout QA, bridge support, or community champion | |
| 29 | |
| 30 | --- |
| 31 | |
| 32 | ## TUI locale packs |
| 33 | |
| 34 | The TUI packs under `crates/tui/locales/` are the largest translation |
| 35 | surface in the repo. `en.json` is the reference; a pack is **complete** |
| 36 | only at exact raw key parity with it, enforced by |
| 37 | `scripts/check-tui-locale-parity.py` (CI) and the parity tests in |
| 38 | `crates/tui/src/localization.rs`. See `crates/tui/locales/AGENTS.md` for the |
| 39 | authoring contract. |
| 40 | |
| 41 | | Locale | File | Keys vs `en.json` (1299) | Status | Notes | |
| 42 | |--------|------|--------------------------|--------|-------| |
| 43 | | English | `en.json` | 1299/1299 | **shipped** | Reference pack. | |
| 44 | | Japanese | `ja.json` | 1299/1299 | **shipped** | Complete. | |
| 45 | | Simplified Chinese | `zh-Hans.json` | 1299/1299 | **shipped** | Complete. | |
| 46 | | Traditional Chinese | `zh-Hant.json` | 1299/1299 | **partial** | Key-complete — nothing falls back at runtime. Still declared partial in `PARTIAL_PACKS` per #4057; promoting it out is an open decision. | |
| 47 | | Brazilian Portuguese | `pt-BR.json` | 1299/1299 | **shipped** | Complete. | |
| 48 | | Latin American Spanish | `es-419.json` | 1299/1299 | **shipped** | Complete. Note the website tracks `es` — the shipped TUI pack is Latin American Spanish, not `es-ES`. | |
| 49 | | Vietnamese | `vi.json` | 1299/1299 | **shipped** | Complete. | |
| 50 | | Korean | `ko.json` | 1299/1299 | **shipped** | Complete. | |
| 51 | | Catalan | `ca.json` | 1299/1299 | **shipped** | Complete (#4749/#4788). Awaiting native-speaker review. | |
| 52 | | German | `de.json` | 1299/1299 | **shipped** | Complete (#4788). Awaiting native-speaker review. | |
| 53 | | French | `fr.json` | 1299/1299 | **shipped** | Complete (#4788). Awaiting native-speaker review. | |
| 54 | | Indonesian | `id.json` | 1299/1299 | **shipped** | Complete (#4789). Awaiting native-speaker review. | |
| 55 | | Hindi | `hi.json` | 1299/1299 | **shipped** | Complete (#4790). Devanagari shaping spike: `docs/evidence/v092-devanagari-terminal-shaping.md` — code-level guarantees only; terminal visual QA and native review still open. | |
| 56 | | Russian | `ru.json` | 1299/1299 | **shipped** | Complete (#3092). Cyrillic script fixtures guard against mixed-language copy. Awaiting native-speaker review. | |
| 57 | | Ukrainian | `uk.json` | 1299/1299 | **shipped** | Complete (#4791). Cyrillic script fixtures keep it distinct from Russian (no ы/э/ъ; і/ї/є/ґ present). Awaiting native-speaker review. | |
| 58 | |
| 59 | ## Website locales |
| 60 | |
| 61 | The website derives routing, the switcher, sitemap, and hreflang from |
| 62 | `ALL_LOCALES` in `web/lib/i18n/config.ts` — one canonical registry, no |
| 63 | second taxonomy. **partial** locales route and are selectable with a |
| 64 | visible `(partial)` badge in the switcher; their dictionaries |
| 65 | (`web/lib/i18n/dictionaries/<code>/`) cover shared chrome (masthead, nav, |
| 66 | mobile menu, theme toggle, live ticker, footer, switcher) and the home page, |
| 67 | held to exact key parity with the English reference by |
| 68 | `npm run check:locales` and `web/lib/i18n/dictionaries.test.ts`. |
| 69 | Everything outside that scope renders the English page copy — a deliberate |
| 70 | fallback, never a dictionary key on screen. |
| 71 | |
| 72 | **As of #4934 (v0.9.4) there is one dictionary path for every routed |
| 73 | locale, Chinese included.** `web/app/[locale]/page.tsx`, |
| 74 | `web/components/nav.tsx`, and `web/components/footer.tsx` no longer carry an |
| 75 | `isZh` / `foreign` copy branch: they read `getHome(locale)` and |
| 76 | `getChrome(locale)`. `web/lib/i18n/dictionaries/zh/` now exists (it used to |
| 77 | be inline TSX), and nav/footer link sets are generated once in |
| 78 | `web/lib/i18n/links.ts` so every locale gets the identical route shape. |
| 79 | Reference shape: **`ChromeDict` 52 keys, `HomeDict` 62 keys.** Bilingual |
| 80 | secondary nav labels, the masthead seal and issue line, the ticker live |
| 81 | label, and the per-locale `Intl` date tag are dictionary values — no locale |
| 82 | renders another language's script by accident. |
| 83 | |
| 84 | | Locale | Code | Status | Notes | |
| 85 | |--------|------|--------|-------| |
| 86 | | English | `en` | **shipped** | Source text and the reference dictionary shape. Every page has an EN route. | |
| 87 | | Simplified Chinese | `zh` | **shipped** | Full parity with EN on all first-class pages. Chrome + home are dictionary-backed (`dictionaries/zh/`) as of #4934; the remaining page bodies are still inline `{ en, zh }` content modules. | |
| 88 | | Japanese | `ja` | **partial** | #3091. Chrome + home page localized via dictionary; other page bodies/metadata fall back to English. | |
| 89 | | Vietnamese | `vi` | **partial** | #3091. Same scope as Japanese. | |
| 90 | | Korean | `ko` | **partial** | #3093. Same scope as Japanese. | |
| 91 | | Russian | `ru` | **partial** | #3092. Same scope as Japanese. | |
| 92 | | Ukrainian | `uk` | **partial** | #4791 — shipped alongside Russian, same scope. | |
| 93 | | Spanish | `es` | **partial** | #3093. Same scope as Japanese. | |
| 94 | | Brazilian Portuguese | `pt-BR` | **partial** | #3093. Same scope as Japanese. | |
| 95 | | French | `fr` | **planned** | #4788 — TUI pack shipped in v0.9.2; website next wave. | |
| 96 | | German | `de` | **planned** | #4788 — TUI pack shipped in v0.9.2; website next wave. | |
| 97 | | Catalan | `ca` | **planned** | #4749/#4788 — TUI pack shipped in v0.9.2; website next wave. | |
| 98 | | Indonesian | `id` | **partial** | #4789. Same scope as Japanese. | |
| 99 | | Hindi | `hi` | **planned** | #4790 — TUI pack shipped in v0.9.2; website next wave. | |
| 100 | | Arabic | `ar` | **deferred** | RTL candidate. Deferred until layout/typography QA exists (bidirectional text, mirrored chrome, number formatting). | |
| 101 | |
| 102 | Every partial locale carries the full 52/62 key set (see |
| 103 | `npm run check:locales`); the chrome and home page are genuinely translated, |
| 104 | not English pass-through — `dictionaries.test.ts` fails on an English |
| 105 | prose value in a non-English pack. The new v0.9.4 strings are |
| 106 | machine-translated to the same standard as the rest of each pack and are |
| 107 | **awaiting native-speaker review**, consistent with the TUI packs above. |
| 108 | |
| 109 | Remaining website scope for the partial locales (next wave): per-page body |
| 110 | copy and `generateMetadata` titles/descriptions beyond the home page, the |
| 111 | `{ en, zh }` shared-content modules under `web/lib/content/`, the |
| 112 | TerminalPlayer scene excerpts in `web/components/thinking-trace.tsx`, and |
| 113 | the `KIND_LABEL` pairs in `web/components/feed-card.tsx`. The dictionary |
| 114 | layer, routing, hreflang, and switcher already cover them, so filling in a |
| 115 | page is a dictionary edit, not plumbing. That remaining English is exactly |
| 116 | what the `(partial)` badge is honest about. |
| 117 | |
| 118 | ## README locales |
| 119 | |
| 120 | | Locale | File | Status | Parity check | |
| 121 | |--------|------|--------|-------------| |
| 122 | | English | `README.md` | **shipped** | Canonical source | |
| 123 | | Simplified Chinese | `README.zh-CN.md` | **shipped** | `scripts/check-readme-translations.py` (stamp + fences + URLs + sections) | |
| 124 | | Japanese | `README.ja-JP.md` | **shipped** | Same | |
| 125 | | Vietnamese | `README.vi.md` | **shipped** | Same | |
| 126 | | Korean | `README.ko-KR.md` | **shipped** | Same | |
| 127 | | Latin American Spanish | `README.es-419.md` | **shipped** | Same | |
| 128 | | Brazilian Portuguese | `README.pt-BR.md` | **shipped** | Same | |
| 129 | | Russian | `README.ru.md` | **shipped** | Same (#3092). Awaiting native-speaker review. | |
| 130 | | Ukrainian | `README.uk.md` | **shipped** | Same (#4791). Awaiting native-speaker review. | |
| 131 | | Indonesian | `README.id.md` | **shipped** | Same (#4789). Awaiting native-speaker review. | |
| 132 | |
| 133 | ## Drift checks |
| 134 | |
| 135 | | Check | Tool | Status | |
| 136 | |-------|------|--------| |
| 137 | | TUI pack key parity with `en.json` (complete packs) | `scripts/check-tui-locale-parity.py` + parity tests in `crates/tui/src/localization.rs` | **Shipped** (CI Lint job) | |
| 138 | | README translations stay in sync with `README.md` | `scripts/check-readme-translations.py` | **Shipped** (CI Lint job) | |
| 139 | | README locale links symmetric | `scripts/check-readme-locales.sh` | **Shipped** (CI Lint job) | |
| 140 | | Website dictionaries cover every routed locale except the `en` reference | `npm run check:locales` + `web/lib/i18n/dictionaries.test.ts` | **Shipped** (#3091, extended to `zh` in #4934) | |
| 141 | | No unmarked English prose survives in a non-English website dictionary | `leaves no unmarked English prose in any non-English dictionary` in `web/lib/i18n/dictionaries.test.ts` | **Shipped** (#4934) | |
| 142 | | Nav/footer routes stay in locale-swap parity for every routed locale | `web/lib/docs-ia.test.ts` over `web/lib/i18n/links.ts` | **Shipped** (#4934) | |
| 143 | | Accept-Language routes deterministically to all routed locales | `web/lib/i18n/detect.test.ts` (middleware delegates to `lib/i18n/detect.ts`) | **Shipped** (#3091) | |
| 144 | | Locale selector lists all routed locales with partial badges | `web/lib/i18n/config.test.ts` (switcher + router derive from one registry) | **Shipped** (#3091) | |
| 145 | | hreflang alternates cover every routed locale | `web/lib/page-meta.test.ts` | **Shipped** (#3091) | |
| 146 | | Cyrillic packs stay script-pure (no mixed-language copy, ru≠uk) | `cyrillic_packs_have_script_purity_and_no_mixed_language_fixtures` in `crates/tui/src/localization.rs` + `dictionaries.test.ts` | **Shipped** (#3092/#4791) | |
| 147 | | Devanagari grapheme-safe clip/wrap at 40/60/80 columns | `truncate_to_width_never_splits_devanagari_clusters` + width fixtures in `crates/tui/src/localization.rs` | **Shipped** (#4790) | |
| 148 | | Adding a UI locale never changes model-visible prompt bytes | `v092_locales_add_no_prompt_bookends_so_prompt_bytes_stay_stable` in `crates/tui/src/prompts.rs` | **Shipped** (cache-stability contract) | |
| 149 | | No shipped locale renders a missing-message marker | `no_shipped_locale_renders_a_missing_message_marker` in `crates/tui/src/localization.rs` | **Shipped** | |
| 150 | |
| 151 | ## How to add a locale |
| 152 | |
| 153 | A locale is not "added" until all three surfaces below either ship it or |
| 154 | carry an explicit `planned`/`partial`/`deferred` row in this matrix. |
| 155 | |
| 156 | ### 1. TUI pack |
| 157 | |
| 158 | 1. Create `crates/tui/locales/<tag>.json` with every key in `en.json`, |
| 159 | following `crates/tui/locales/AGENTS.md` (placeholders stay literal; |
| 160 | product terms stay English per pack convention; preserve intentional |
| 161 | leading/trailing spaces). |
| 162 | 2. Add the `Locale` variant plus its `tag`/`translation_target_name`/ |
| 163 | `parse_locale`/`shipped`/`shipped_complete` arms in |
| 164 | `crates/tui/src/localization.rs`, and the `include_str!` arm in the |
| 165 | test module. |
| 166 | 3. Wire the typed settings schema (`UiLocale` in |
| 167 | `crates/tui/src/config_ui.rs`) plus the pickers and displays that enumerate |
| 168 | locales: onboarding language picker |
| 169 | (`crates/tui/src/tui/onboarding/language.rs` — a test forces every shipped |
| 170 | locale to be offered), setup-wizard match arms, and the locale display arms |
| 171 | in the `/config` and changelog commands. Keep the schema/round-trip invariant |
| 172 | tied to `Locale::shipped()` so these surfaces cannot silently drift. |
| 173 | 4. Run `python3 scripts/check-tui-locale-parity.py` and |
| 174 | `cargo test -p codewhale-tui localization`. |
| 175 | 5. If the pack must ship incomplete, declare it partial (see `zh-Hant` / |
| 176 | #4057): keep it out of `shipped_complete()`, mark it in |
| 177 | `is_partial_pack()`, and add it to `PARTIAL_PACKS` in |
| 178 | `scripts/check-tui-locale-parity.py` with a tracking issue. |
| 179 | |
| 180 | ### 2. README |
| 181 | |
| 182 | 1. Translate `README.md` into `README.<tag>.md`, preserving structure, |
| 183 | commands, and the #3087 factual history. |
| 184 | 2. Cross-link it from the language line in `README.md` and from the other |
| 185 | translated READMEs. |
| 186 | 3. Restamp per `scripts/check-readme-translations.py`, then run |
| 187 | `python3 scripts/check-readme-translations.py` and |
| 188 | `bash scripts/check-readme-locales.sh`. |
| 189 | |
| 190 | ### 3. Website |
| 191 | |
| 192 | 1. Add/flip the locale entry in `ALL_LOCALES` in `web/lib/i18n/config.ts` — |
| 193 | the switcher, routes, middleware, sitemap, and hreflang derive from it, |
| 194 | so no per-locale switcher edit is needed. Use the `partial` status for |
| 195 | locales that ship the chrome+home dictionary scope before full page |
| 196 | parity. |
| 197 | 2. Create `web/lib/i18n/dictionaries/<code>/chrome.ts` and `home.ts` |
| 198 | following the English reference shape (`dictionaries/en/`). |
| 199 | 3. Middleware detection needs no change for base tags; region variants and |
| 200 | base→variant mappings live in `web/lib/i18n/detect.ts`. |
| 201 | 4. Run `cd web && npm run check:locales && npm test && npm run build`. |
| 202 | |
| 203 | ### 4. Matrix |
| 204 | |
| 205 | Update the TUI, README, and Website tables above — one row per surface, |
| 206 | with per-surface status. |
| 207 | |
| 208 | ## Assessments |
| 209 | |
| 210 | ### Galician (`gl`) and Basque (`eu`) — 2026-07-25, per #4749 |
| 211 | |
| 212 | Assessed alongside the Catalan pack (#4749 / #4788), which asked whether |
| 213 | Galician and Basque are "similar-value European additions" worth shipping |
| 214 | in the same wave. |
| 215 | |
| 216 | **Decision: defer both.** Rationale: |
| 217 | |
| 218 | - The case #4788 makes for Catalan is specifically that it "has an |
| 219 | unusually strong software-localization tradition and an active volunteer |
| 220 | community" — a review-capacity argument, not a market-size one. That |
| 221 | argument does not transfer: Galician and Basque have materially smaller |
| 222 | localization communities, so a pack for either would ship with no |
| 223 | realistic path to native-speaker review. |
| 224 | - Galician speakers have a workable fallback already: the shipped |
| 225 | `es-419` pack (and `pt-BR` is lexically close). Basque is a language |
| 226 | isolate with no fallback proximity — its per-string review cost is the |
| 227 | highest of the three, and machine-translated Basque is the least |
| 228 | trustworthy of the three. |
| 229 | - There is no natural "ship together" grouping: the v0.9.2 wave already |
| 230 | bundles the locales that share acceptance criteria (Latin-script |
| 231 | fr/de/ca/id, Cyrillic uk, Devanagari hi). gl/eu share only the |
| 232 | review-capacity constraint, which neither clears. |
| 233 | |
| 234 | **Cost/demand evidence behind the decision:** a complete TUI pack is |
| 235 | 1,299 keys (~8–12k words) plus an ongoing obligation to retranslate every |
| 236 | changed English string in lockstep — the parity gate makes silent drift a |
| 237 | CI failure, so an unmaintained pack is worse than none. No community |
| 238 | member has requested gl or eu (no issues, no PRs, no translations offered), |
| 239 | while the gl/eu base tags already route cleanly through |
| 240 | `web/middleware.ts` the day a champion appears. We do not ship packs we |
| 241 | cannot get natively reviewed, and we do not advertise unshipped packs. |
| 242 | |
| 243 | Revisit when a native-speaker champion appears for either language, or if |
| 244 | Catalan uptake after v0.9.2 suggests demand. Both base tags (`gl`, `eu`) |
| 245 | route through `web/middleware.ts` with no middleware change when that |
| 246 | happens. |
| 247 | |
| 248 | ## Related issues |
| 249 | |
| 250 | - #3091 — Website parity with JA + VI README locales |
| 251 | - #3092 — Russian README + website localization |
| 252 | - #3093 — Korean, Spanish, Brazilian Portuguese next-wave locales |
| 253 | - #3087 — Post-rebrand README source text refresh |
| 254 | - #4057 — `zh-Hant` scoped as a partial TUI pack with English fallback |
| 255 | - #4787 — This matrix's TUI table + the locale-drift CI gates |
| 256 | - #4788 — French, German, Catalan TUI localization |
| 257 | - #4789 — Indonesian localization |
| 258 | - #4790 — Hindi localization + Devanagari terminal-shaping spike |
| 259 | - #4791 — Ukrainian localization alongside Russian |
| 260 | - #4749 — Catalan UI language + Galician/Basque assessment |
| 261 |