Shortcut facts (this is why the contract test moved):
docs/public-surface-facts.json declared both `Tab` and `Shift+Tab` as
`when: "composer idle"`, and web/lib/public-surface-contract.test.ts pinned
that string into README.md and docs/KEYBINDINGS.md. Both halves are false —
crates/tui/src/tui/ui.rs:6978 gates Tab on `!app.input.is_empty()`, and
ui.rs:6363-6367 gates Shift+Tab only on the modal stack. The matrix now says
"composer empty" / "always (suppressed only under a non-Config modal)" and the
test asserts the corrected wording plus a negative guard so the idle claim
cannot come back. docs/MODES.md:24 already said "when the composer is empty";
it was the matrix and the test that were stale.
/runtime rendered the Chinese-primary H1 for every locale:
web/app/[locale]/runtime/page.tsx:84-88 had two byte-identical ternary
branches, both `Runtime & 集成 Integrations`. That contradicts
web/lib/i18n/dictionaries/types.ts:33-46 ("Never hardcode Han characters at a
call site") and docs/LOCALIZATION.md:80-82 ("no locale renders another
language's script by accident"). Every other page follows the correct pattern
(constitution/page.tsx:60-65). Fixed both branches; same class, smaller blast
radius, in feed/page.tsx:114 ("Section 03 · 动态" hardcoded in the EN branch).
FAQ provider list was 25 hand-maintained names against 40 real routes:
web/lib/facts.generated.ts derives 40 providers from `pub enum ApiProvider`,
and models/page.tsx already renders `facts.providers.length`. The FAQ omitted
15, including xAI, Baidu Qianfan, Meta Model API, and all four Model Studio
routes. Because ja/ko/ru/es/pt-BR/uk/vi/id fall back to English page bodies,
9 of 10 locales saw the stale list. The count is now derived from FACTS and
the enumeration is explicitly a sample ("including …") pointing at /models.
Other corrected claims:
- docs/subagents/page.tsx omitted the `consultant` role. It is one of the eight
in FLEET_ROLE_SCHEMA_VALUES (crates/tui/src/tools/subagent/mod.rs:376-385),
advertised to the model, and already in web/lib/content/vocabulary.ts:183-186.
- lib/media-manifest.ts:99-100 promised a recording "from the v0.9.2 release
candidate" on a live page (/docs/guide via components/session-media.tsx:47).
v0.9.2 and v0.9.3 both shipped; restated against 0.9.4.
- lib/docs-map.ts:39-40 advertised Nix and Scoop as contents of /install. Both
are real (docs/INSTALL.md:356, :507) but the site page has neither; the
description now names what the page actually contains.
- web/README.md:99/101/122/127 — "en / zh, every page is bilingual" (10 locales
route and non-zh get English bodies), a home-page section list naming four
sections that do not exist, and "EN ↔ ZH toggle" for what
components/locale-switcher.tsx:13-14 builds as an N-locale dropdown.
- docs/LOCALIZATION.md stated three different TUI key counts (1248 in the table,
1,153 at :234). `python3 scripts/check-tui-locale-parity.py` prints 1299 for
every pack. The zh-Hant row claimed 499/1248 "missing keys fall back to
English at runtime" — it is 1299/1299, so nothing falls back; the row now says
so and flags the PARTIAL_PACKS promotion as an open decision. The web
reference shape said 51/60 keys twenty-three lines above its own correct
52/62; check:locales prints 52/62.
Deleted (verified zero importers, no barrel, no glob loader, no CI reference):
- components/stat-grid.tsx — pulled from community/page.tsx in c268cc375, which
added the guard at lib/public-copy.test.ts:141 asserting the page must NOT
use it. That guard reads page source as a string and is kept.
- components/mermaid-diagram.tsx — its only consumer was removed in 5abe250f3e
("Cut: … the Mermaid architecture diagram"). Removed with it: the dead
`.mermaid-frame` rules in app/globals.css and the `mermaid` production
dependency, via `npm uninstall mermaid` so package-lock.json stays consistent
with package.json (CI runs `npm ci`, which hard-fails on drift).
Verified in web/: npm test exit=0 (28 files, 235 tests), check:facts exit=0,
check:locales exit=0, check:docs exit=0, lint exit=0.
16 KiB
Localization Matrix
Canonical tracking document for every locale Codewhale ships, is actively building, is planning, or has explicitly deferred.
Scope note (2026-07-12): this matrix covers three surfaces — the TUI locale packs (
crates/tui/locales/), the translated READMEs (repo root), and the website (web/). The three ship on different cadences, so a locale can be shipped on one surface and planned on another; the per-surface tables below are the per-surface truth. The website registry isweb/lib/i18n/config.ts(ALL_LOCALES): the locale switcher and route generation both derive from it.
Customer-visible copy also follows the Codewhale voice and terminal charter; commands, key names, and glyphs remain code-owned around localized prose.
Last updated: 2026-08-03 (v0.9.4 website dictionary spine, #4934).
Source-of-truth README: README.md (English, post-#3087).
Status legend
| Status | Meaning |
|---|---|
| shipped | Live on codewhale.net and/or published as a standalone README, or a TUI pack at exact en.json parity |
| partial | Shipped but intentionally incomplete; missing scope falls back to English and the partial status is visible |
| planned | Explicitly prioritized for the next wave |
| deferred | Acknowledged as wanted but not yet scheduled; needs layout QA, bridge support, or community champion |
TUI locale packs
The TUI packs under crates/tui/locales/ are the largest translation
surface in the repo. en.json is the reference; a pack is complete
only at exact raw key parity with it, enforced by
scripts/check-tui-locale-parity.py (CI) and the parity tests in
crates/tui/src/localization.rs. See crates/tui/locales/AGENTS.md for the
authoring contract.
| Locale | File | Keys vs en.json (1299) |
Status | Notes |
|---|---|---|---|---|
| English | en.json |
1299/1299 | shipped | Reference pack. |
| Japanese | ja.json |
1299/1299 | shipped | Complete. |
| Simplified Chinese | zh-Hans.json |
1299/1299 | shipped | Complete. |
| 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. |
| Brazilian Portuguese | pt-BR.json |
1299/1299 | shipped | Complete. |
| 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. |
| Vietnamese | vi.json |
1299/1299 | shipped | Complete. |
| Korean | ko.json |
1299/1299 | shipped | Complete. |
| Catalan | ca.json |
1299/1299 | shipped | Complete (#4749/#4788). Awaiting native-speaker review. |
| German | de.json |
1299/1299 | shipped | Complete (#4788). Awaiting native-speaker review. |
| French | fr.json |
1299/1299 | shipped | Complete (#4788). Awaiting native-speaker review. |
| Indonesian | id.json |
1299/1299 | shipped | Complete (#4789). Awaiting native-speaker review. |
| 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. |
| Russian | ru.json |
1299/1299 | shipped | Complete (#3092). Cyrillic script fixtures guard against mixed-language copy. Awaiting native-speaker review. |
| Ukrainian | uk.json |
1299/1299 | shipped | Complete (#4791). Cyrillic script fixtures keep it distinct from Russian (no ы/э/ъ; і/ї/є/ґ present). Awaiting native-speaker review. |
Website locales
The website derives routing, the switcher, sitemap, and hreflang from
ALL_LOCALES in web/lib/i18n/config.ts — one canonical registry, no
second taxonomy. partial locales route and are selectable with a
visible (partial) badge in the switcher; their dictionaries
(web/lib/i18n/dictionaries/<code>/) cover shared chrome (masthead, nav,
mobile menu, theme toggle, live ticker, footer, switcher) and the home page,
held to exact key parity with the English reference by
npm run check:locales and web/lib/i18n/dictionaries.test.ts.
Everything outside that scope renders the English page copy — a deliberate
fallback, never a dictionary key on screen.
As of #4934 (v0.9.4) there is one dictionary path for every routed
locale, Chinese included. web/app/[locale]/page.tsx,
web/components/nav.tsx, and web/components/footer.tsx no longer carry an
isZh / foreign copy branch: they read getHome(locale) and
getChrome(locale). web/lib/i18n/dictionaries/zh/ now exists (it used to
be inline TSX), and nav/footer link sets are generated once in
web/lib/i18n/links.ts so every locale gets the identical route shape.
Reference shape: ChromeDict 52 keys, HomeDict 62 keys. Bilingual
secondary nav labels, the masthead seal and issue line, the ticker live
label, and the per-locale Intl date tag are dictionary values — no locale
renders another language's script by accident.
| Locale | Code | Status | Notes |
|---|---|---|---|
| English | en |
shipped | Source text and the reference dictionary shape. Every page has an EN route. |
| 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. |
| Japanese | ja |
partial | #3091. Chrome + home page localized via dictionary; other page bodies/metadata fall back to English. |
| Vietnamese | vi |
partial | #3091. Same scope as Japanese. |
| Korean | ko |
partial | #3093. Same scope as Japanese. |
| Russian | ru |
partial | #3092. Same scope as Japanese. |
| Ukrainian | uk |
partial | #4791 — shipped alongside Russian, same scope. |
| Spanish | es |
partial | #3093. Same scope as Japanese. |
| Brazilian Portuguese | pt-BR |
partial | #3093. Same scope as Japanese. |
| French | fr |
planned | #4788 — TUI pack shipped in v0.9.2; website next wave. |
| German | de |
planned | #4788 — TUI pack shipped in v0.9.2; website next wave. |
| Catalan | ca |
planned | #4749/#4788 — TUI pack shipped in v0.9.2; website next wave. |
| Indonesian | id |
partial | #4789. Same scope as Japanese. |
| Hindi | hi |
planned | #4790 — TUI pack shipped in v0.9.2; website next wave. |
| Arabic | ar |
deferred | RTL candidate. Deferred until layout/typography QA exists (bidirectional text, mirrored chrome, number formatting). |
Every partial locale carries the full 52/62 key set (see
npm run check:locales); the chrome and home page are genuinely translated,
not English pass-through — dictionaries.test.ts fails on an English
prose value in a non-English pack. The new v0.9.4 strings are
machine-translated to the same standard as the rest of each pack and are
awaiting native-speaker review, consistent with the TUI packs above.
Remaining website scope for the partial locales (next wave): per-page body
copy and generateMetadata titles/descriptions beyond the home page, the
{ en, zh } shared-content modules under web/lib/content/, the
TerminalPlayer scene excerpts in web/components/thinking-trace.tsx, and
the KIND_LABEL pairs in web/components/feed-card.tsx. The dictionary
layer, routing, hreflang, and switcher already cover them, so filling in a
page is a dictionary edit, not plumbing. That remaining English is exactly
what the (partial) badge is honest about.
README locales
| Locale | File | Status | Parity check |
|---|---|---|---|
| English | README.md |
shipped | Canonical source |
| Simplified Chinese | README.zh-CN.md |
shipped | scripts/check-readme-translations.py (stamp + fences + URLs + sections) |
| Japanese | README.ja-JP.md |
shipped | Same |
| Vietnamese | README.vi.md |
shipped | Same |
| Korean | README.ko-KR.md |
shipped | Same |
| Latin American Spanish | README.es-419.md |
shipped | Same |
| Brazilian Portuguese | README.pt-BR.md |
shipped | Same |
| Russian | README.ru.md |
shipped | Same (#3092). Awaiting native-speaker review. |
| Ukrainian | README.uk.md |
shipped | Same (#4791). Awaiting native-speaker review. |
| Indonesian | README.id.md |
shipped | Same (#4789). Awaiting native-speaker review. |
Drift checks
| Check | Tool | Status |
|---|---|---|
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) |
README translations stay in sync with README.md |
scripts/check-readme-translations.py |
Shipped (CI Lint job) |
| README locale links symmetric | scripts/check-readme-locales.sh |
Shipped (CI Lint job) |
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) |
| 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) |
| 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) |
| Accept-Language routes deterministically to all routed locales | web/lib/i18n/detect.test.ts (middleware delegates to lib/i18n/detect.ts) |
Shipped (#3091) |
| Locale selector lists all routed locales with partial badges | web/lib/i18n/config.test.ts (switcher + router derive from one registry) |
Shipped (#3091) |
| hreflang alternates cover every routed locale | web/lib/page-meta.test.ts |
Shipped (#3091) |
| 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) |
| 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) |
| 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) |
| No shipped locale renders a missing-message marker | no_shipped_locale_renders_a_missing_message_marker in crates/tui/src/localization.rs |
Shipped |
How to add a locale
A locale is not "added" until all three surfaces below either ship it or
carry an explicit planned/partial/deferred row in this matrix.
1. TUI pack
- Create
crates/tui/locales/<tag>.jsonwith every key inen.json, followingcrates/tui/locales/AGENTS.md(placeholders stay literal; product terms stay English per pack convention; preserve intentional leading/trailing spaces). - Add the
Localevariant plus itstag/translation_target_name/parse_locale/shipped/shipped_completearms incrates/tui/src/localization.rs, and theinclude_str!arm in the test module. - Wire the typed settings schema (
UiLocaleincrates/tui/src/config_ui.rs) plus the pickers and displays that enumerate locales: onboarding language picker (crates/tui/src/tui/onboarding/language.rs— a test forces every shipped locale to be offered), setup-wizard match arms, and the locale display arms in the/configand changelog commands. Keep the schema/round-trip invariant tied toLocale::shipped()so these surfaces cannot silently drift. - Run
python3 scripts/check-tui-locale-parity.pyandcargo test -p codewhale-tui localization. - If the pack must ship incomplete, declare it partial (see
zh-Hant/ #4057): keep it out ofshipped_complete(), mark it inis_partial_pack(), and add it toPARTIAL_PACKSinscripts/check-tui-locale-parity.pywith a tracking issue.
2. README
- Translate
README.mdintoREADME.<tag>.md, preserving structure, commands, and the #3087 factual history. - Cross-link it from the language line in
README.mdand from the other translated READMEs. - Restamp per
scripts/check-readme-translations.py, then runpython3 scripts/check-readme-translations.pyandbash scripts/check-readme-locales.sh.
3. Website
- Add/flip the locale entry in
ALL_LOCALESinweb/lib/i18n/config.ts— the switcher, routes, middleware, sitemap, and hreflang derive from it, so no per-locale switcher edit is needed. Use thepartialstatus for locales that ship the chrome+home dictionary scope before full page parity. - Create
web/lib/i18n/dictionaries/<code>/chrome.tsandhome.tsfollowing the English reference shape (dictionaries/en/). - Middleware detection needs no change for base tags; region variants and
base→variant mappings live in
web/lib/i18n/detect.ts. - Run
cd web && npm run check:locales && npm test && npm run build.
4. Matrix
Update the TUI, README, and Website tables above — one row per surface, with per-surface status.
Assessments
Galician (gl) and Basque (eu) — 2026-07-25, per #4749
Assessed alongside the Catalan pack (#4749 / #4788), which asked whether Galician and Basque are "similar-value European additions" worth shipping in the same wave.
Decision: defer both. Rationale:
- The case #4788 makes for Catalan is specifically that it "has an unusually strong software-localization tradition and an active volunteer community" — a review-capacity argument, not a market-size one. That argument does not transfer: Galician and Basque have materially smaller localization communities, so a pack for either would ship with no realistic path to native-speaker review.
- Galician speakers have a workable fallback already: the shipped
es-419pack (andpt-BRis lexically close). Basque is a language isolate with no fallback proximity — its per-string review cost is the highest of the three, and machine-translated Basque is the least trustworthy of the three. - There is no natural "ship together" grouping: the v0.9.2 wave already bundles the locales that share acceptance criteria (Latin-script fr/de/ca/id, Cyrillic uk, Devanagari hi). gl/eu share only the review-capacity constraint, which neither clears.
Cost/demand evidence behind the decision: a complete TUI pack is
1,299 keys (~8–12k words) plus an ongoing obligation to retranslate every
changed English string in lockstep — the parity gate makes silent drift a
CI failure, so an unmaintained pack is worse than none. No community
member has requested gl or eu (no issues, no PRs, no translations offered),
while the gl/eu base tags already route cleanly through
web/middleware.ts the day a champion appears. We do not ship packs we
cannot get natively reviewed, and we do not advertise unshipped packs.
Revisit when a native-speaker champion appears for either language, or if
Catalan uptake after v0.9.2 suggests demand. Both base tags (gl, eu)
route through web/middleware.ts with no middleware change when that
happens.
Related issues
- #3091 — Website parity with JA + VI README locales
- #3092 — Russian README + website localization
- #3093 — Korean, Spanish, Brazilian Portuguese next-wave locales
- #3087 — Post-rebrand README source text refresh
- #4057 —
zh-Hantscoped as a partial TUI pack with English fallback - #4787 — This matrix's TUI table + the locale-drift CI gates
- #4788 — French, German, Catalan TUI localization
- #4789 — Indonesian localization
- #4790 — Hindi localization + Devanagari terminal-shaping spike
- #4791 — Ukrainian localization alongside Russian
- #4749 — Catalan UI language + Galician/Basque assessment