88167ed7b7
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.
261 lines
16 KiB
Markdown
261 lines
16 KiB
Markdown
# 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
|
||
> is `web/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](VOICE.md); 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
|
||
|
||
1. Create `crates/tui/locales/<tag>.json` with every key in `en.json`,
|
||
following `crates/tui/locales/AGENTS.md` (placeholders stay literal;
|
||
product terms stay English per pack convention; preserve intentional
|
||
leading/trailing spaces).
|
||
2. Add the `Locale` variant plus its `tag`/`translation_target_name`/
|
||
`parse_locale`/`shipped`/`shipped_complete` arms in
|
||
`crates/tui/src/localization.rs`, and the `include_str!` arm in the
|
||
test module.
|
||
3. Wire the typed settings schema (`UiLocale` in
|
||
`crates/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 `/config` and changelog commands. Keep the schema/round-trip invariant
|
||
tied to `Locale::shipped()` so these surfaces cannot silently drift.
|
||
4. Run `python3 scripts/check-tui-locale-parity.py` and
|
||
`cargo test -p codewhale-tui localization`.
|
||
5. If the pack must ship incomplete, declare it partial (see `zh-Hant` /
|
||
#4057): keep it out of `shipped_complete()`, mark it in
|
||
`is_partial_pack()`, and add it to `PARTIAL_PACKS` in
|
||
`scripts/check-tui-locale-parity.py` with a tracking issue.
|
||
|
||
### 2. README
|
||
|
||
1. Translate `README.md` into `README.<tag>.md`, preserving structure,
|
||
commands, and the #3087 factual history.
|
||
2. Cross-link it from the language line in `README.md` and from the other
|
||
translated READMEs.
|
||
3. Restamp per `scripts/check-readme-translations.py`, then run
|
||
`python3 scripts/check-readme-translations.py` and
|
||
`bash scripts/check-readme-locales.sh`.
|
||
|
||
### 3. Website
|
||
|
||
1. Add/flip the locale entry in `ALL_LOCALES` in `web/lib/i18n/config.ts` —
|
||
the switcher, routes, middleware, sitemap, and hreflang derive from it,
|
||
so no per-locale switcher edit is needed. Use the `partial` status for
|
||
locales that ship the chrome+home dictionary scope before full page
|
||
parity.
|
||
2. Create `web/lib/i18n/dictionaries/<code>/chrome.ts` and `home.ts`
|
||
following the English reference shape (`dictionaries/en/`).
|
||
3. Middleware detection needs no change for base tags; region variants and
|
||
base→variant mappings live in `web/lib/i18n/detect.ts`.
|
||
4. 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-419` pack (and `pt-BR` is 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-Hant` scoped 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
|