Files
hmbown--codewhale/docs/LOCALIZATION.md
Hmbown 88167ed7b7 fix(web): correct locale-inverted headings, stale provider list, and dead components
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.
2026-08-03 22:48:21 -07:00

261 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (~812k 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