返回 CodeWhale
LOCALIZATION.md
根目录 / docs / LOCALIZATION.md
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
261 lines MARKDOWN