From 3dd37d053b88774ad3f9b4a0581ea75180651d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 03:21:21 +0000 Subject: [PATCH] localization: every locale ships a complete catalog, no regional overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D2 had pt-BR inherit from pt so a base carried the shared bulk and a regional file carried the differences. Conventional, and rejected by the owner for a product reason that outranks the engineering one. The failure mode of an overlay is a missing European Portuguese key falling through to Brazilian — and the two differ most in exactly the technical vocabulary this UI is full of. The platform sells into European Portuguese first, so that failure is a reader getting a neighbouring variant's words on whichever strings nobody remembered to override, silently. So every locale is a whole file, translated, or it does not ship. The drift objection is answered by promoting the key-parity test from advisory to load-bearing: a locale missing a key `en` has fails the build, so an incomplete catalog cannot ship at all. Completeness is enforced rather than papered over. It is also less code — resolveKey looks in one catalog and then English, with no chain to walk and no merge order — and it removes the question of which file a given string lives in. Consequence for the work: a new locale is a full machine-translated pass reviewed on its own, not a diff against a base. pt-PT and pt-BR get one each, separately, because the whole point is that neither borrows from the other. Co-Authored-By: Claude Opus 5 --- docs/localization.md | 93 +++++++++++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 36 deletions(-) diff --git a/docs/localization.md b/docs/localization.md index af0e778d..a978cb5c 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -28,13 +28,13 @@ the server does instead, which is the one decision that reaches outside this bou There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is also about **5% deployed**, and that gap is the whole job. -| | | -| --- | --- | -| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` | -| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` | -| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider | -| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** | -| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` | +| | | +| ----------------------------------------- | -------------------------------------------------------------------------- | +| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` | +| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` | +| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider | +| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** | +| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` | What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation, fallback to `en`, and **type-safe keys** via `TranslationKeys` — a wrong key is a @@ -45,7 +45,7 @@ typecheck failure, which is worth more than it sounds when one agent is editing 1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the runtime and unused. 2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use - the *browser's* locale, not the user's chosen one, so a Portuguese UI shows American dates. + the _browser's_ locale, not the user's chosen one, so a Portuguese UI shows American dates. 3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit from `pt`. 4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in @@ -59,12 +59,12 @@ typecheck failure, which is worth more than it sounds when one agent is editing Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`: -| area | files | occurrences | -| --- | --- | --- | -| `officer-web` (shell) | 132 | 556 | -| `officerdev` (panels) | 213 | 567 | -| shared `components` | 91 | 47 | -| **total** | **436** | **1170** | +| area | files | occurrences | +| --------------------- | ------- | ----------- | +| `officer-web` (shell) | 132 | 556 | +| `officerdev` (panels) | 213 | 567 | +| shared `components` | 91 | 47 | +| **total** | **436** | **1170** | **884 distinct strings**, of which **88 need interpolation**. Against 46 keys today. @@ -101,29 +101,46 @@ precise to quote. Prose drifts; a code does not. And the server stops needing to refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle -the prose was careful not to be. When in doubt, one code for one *response*, not one per branch. +the prose was careful not to be. When in doubt, one code for one _response_, not one per branch. -### D2. Regional locales, BCP 47, with fallback chains +### D2. Regional locales, BCP 47, and every catalog is complete -`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, -and it is the owner's own language, so getting it wrong is felt immediately. +`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and +it is the owner's own language, so getting it wrong is felt immediately. -Resolution walks a chain and stops at the first hit: +**Every locale ships a COMPLETE catalog. There is no regional inheritance and no overlay.** A file holds +every key the UI has, translated for that locale, or it does not ship. -``` -pt-BR → pt → en -en-GB → en -``` +This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog +carried the shared bulk and a regional file carried only the differences. That is the conventional design +and it was rejected for a product reason that outranks the engineering one: -So a **base catalog carries the shared bulk and a regional overlay carries only the differences.** -`pt.json` holds everything both Portugueses agree on; `pt-BR.json` holds only what Brazil says -differently. Two near-identical full catalogs would drift within a month. +**A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian +Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is +being sold into the European Portuguese market first. So the failure mode of an overlay is a Portuguese +reader getting Brazilian phrasing on whichever strings nobody remembered to override, silently, and with no +signal that it happened. Owner's call, and it is the right one: a reader must never be served a neighbouring +variant's words. + +The engineering objection to full catalogs was drift — two near-identical files diverging within a month. +That is answered by making the **key-parity test load-bearing** rather than advisory (see Tooling): a locale +missing a key that `en` has fails the build, so an incomplete catalog cannot ship. Completeness is enforced +by the test rather than by a fallback quietly papering over a gap. + +It is also less code. `resolveKey` looks in the active locale and then, only if the key is genuinely absent, +in `en` — which per D8 is a bug the parity test should already have caught. There is no chain to walk, no +base-versus-overlay merge order, and no question about which file a given string lives in. `Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work. -**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to -regional tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer -widening on read over a database migration — a stale bare code should resolve, not fail. +**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional +tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read +over a database migration — a stale bare code should resolve, not fail. + +**Consequence for translation work:** a new locale is a whole catalog, not a diff. Machine-translate the +full file and have it reviewed, rather than hand-writing overrides against a base. For `pt-PT` and `pt-BR` +that means two complete passes, reviewed separately, because the whole point is that neither borrows from +the other. ### D3. RTL — prepare, do not ship @@ -148,7 +165,7 @@ The gap is smaller than it looks, because the hard parts are in the runtime alre `Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150 lines of glue, listed in "What it cannot do yet". -Revisit this only if message *formatting* gets genuinely complex — nested selects, gendered forms. +Revisit this only if message _formatting_ gets genuinely complex — nested selects, gendered forms. ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition. ### D5. Keys, not English source text @@ -223,7 +240,7 @@ app.errors.MINESWEEPER_NOT_RUNNING - `namespace` — `app` / `panel` / `ui`, per D6. - `area` — the route or panel it belongs to; matches the directory. - `component` — the file, lowercased. -- `element` — what it *is*: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`. +- `element` — what it _is_: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`. Rules that keep 1200 keys navigable: @@ -245,7 +262,7 @@ Getting this wrong is worse than missing a string, because it corrupts data or b - **Agent and chat output.** Whatever Claude or opencode wrote. It is content. - **User content** — file names, paths, terminal output, code, commit messages, note bodies. -- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error *codes* +- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_ (the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process names, database values. - **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is @@ -304,9 +321,13 @@ Without these, 1200 keys rot. All three are small and belong in phase 0. hardcoded string by area and file. It is how the numbers above were produced, and it is the progress meter: the count goes down as work lands. -**A key-parity test.** For each shipped locale, every key that `en` has must resolve through the -chain. Fails the build on a gap, the same way `bun docs:check` fails on a stale number. This is the -one guard that stops a locale silently degrading into English. +**A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every +key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way +`bun docs:check` fails on a stale number. + +With full catalogs and no overlays, this test is the only thing standing between "complete translation" and +"silently English in patches". It replaces the fallback that used to hide a gap, so it has to be as +unavoidable as the typecheck: a missing key is not a warning, it is a red build. **A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling @@ -339,7 +360,7 @@ else will ever notice. obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered. 3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin manifest. Their strings are not counted above. -4. **The `translateTo` setting already in `settings.languages`** is about translating *content*, not +4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not the UI. It is a different feature that happens to sit in the same object — do not conflate them. 5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared between accounts needs a locale decision. D1 mostly removes this by removing server prose.