localization: every locale ships a complete catalog, no regional overlays
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 <noreply@anthropic.com>
This commit is contained in:
+57
-36
@@ -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
|
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.
|
also about **5% deployed**, and that gap is the whole job.
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
| --- | --- |
|
| ----------------------------------------- | -------------------------------------------------------------------------- |
|
||||||
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` |
|
| `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/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/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** |
|
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
|
||||||
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
|
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
|
||||||
|
|
||||||
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation,
|
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation,
|
||||||
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a
|
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — 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
|
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
|
||||||
runtime and unused.
|
runtime and unused.
|
||||||
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use
|
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
|
3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit
|
||||||
from `pt`.
|
from `pt`.
|
||||||
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
|
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`:
|
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
|
||||||
|
|
||||||
| area | files | occurrences |
|
| area | files | occurrences |
|
||||||
| --- | --- | --- |
|
| --------------------- | ------- | ----------- |
|
||||||
| `officer-web` (shell) | 132 | 556 |
|
| `officer-web` (shell) | 132 | 556 |
|
||||||
| `officerdev` (panels) | 213 | 567 |
|
| `officerdev` (panels) | 213 | 567 |
|
||||||
| shared `components` | 91 | 47 |
|
| shared `components` | 91 | 47 |
|
||||||
| **total** | **436** | **1170** |
|
| **total** | **436** | **1170** |
|
||||||
|
|
||||||
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
|
**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
|
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
|
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
|
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,
|
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
|
||||||
and it is the owner's own language, so getting it wrong is felt immediately.
|
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.
|
||||||
|
|
||||||
```
|
This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog
|
||||||
pt-BR → pt → en
|
carried the shared bulk and a regional file carried only the differences. That is the conventional design
|
||||||
en-GB → en
|
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.**
|
**A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
|
||||||
`pt.json` holds everything both Portugueses agree on; `pt-BR.json` holds only what Brazil says
|
Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
|
||||||
differently. Two near-identical full catalogs would drift within a month.
|
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.
|
`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
|
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
|
||||||
regional tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer
|
tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
|
||||||
widening on read over a database migration — a stale bare code should resolve, not fail.
|
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
|
### 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
|
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
|
||||||
lines of glue, listed in "What it cannot do yet".
|
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.
|
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
|
||||||
|
|
||||||
### D5. Keys, not English source text
|
### D5. Keys, not English source text
|
||||||
@@ -223,7 +240,7 @@ app.errors.MINESWEEPER_NOT_RUNNING
|
|||||||
- `namespace` — `app` / `panel` / `ui`, per D6.
|
- `namespace` — `app` / `panel` / `ui`, per D6.
|
||||||
- `area` — the route or panel it belongs to; matches the directory.
|
- `area` — the route or panel it belongs to; matches the directory.
|
||||||
- `component` — the file, lowercased.
|
- `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:
|
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.
|
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
|
||||||
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
|
- **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
|
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
|
||||||
names, database values.
|
names, database values.
|
||||||
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
|
- **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
|
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.
|
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
|
**A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
|
||||||
chain. Fails the build on a gap, the same way `bun docs:check` fails on a stale number. This is the
|
key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
|
||||||
one guard that stops a locale silently degrading into English.
|
`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
|
**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
|
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.
|
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
|
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
|
||||||
manifest. Their strings are not counted above.
|
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.
|
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
|
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.
|
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
|
||||||
|
|||||||
Reference in New Issue
Block a user