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>
367 lines
19 KiB
Markdown
367 lines
19 KiB
Markdown
# Localization
|
||
|
||
**Status: DESIGN, NOT BUILT — with a working 5% skeleton already in the tree.**
|
||
|
||
This is an execution plan. It is written to be handed to an agent who has not had the conversation
|
||
that produced it, so it states the decisions and the reasons for them, not just the steps.
|
||
|
||
Every count here is a **measurement taken on 2026-08-23**, not a claim that stays true — the whole
|
||
point is for it to fall. Re-derive with `bun scripts/i18n/scan-hardcoded.ts` rather than trusting the
|
||
number beside it, and do not wire these into `docs:check`: a guard that fails when the count drops
|
||
would be a guard against progress.
|
||
|
||
---
|
||
|
||
## What this is
|
||
|
||
Every word the UI shows a person should come from a catalog keyed by locale, so the platform can be
|
||
read in a language other than English. Routes stay English (`/minesweeper`, `/chat`) — that is a URL
|
||
decision, deferred, and nothing here depends on it.
|
||
|
||
The scope is **the web UI only**. Not the server, not the plugins, not email or push. See D1 for what
|
||
the server does instead, which is the one decision that reaches outside this boundary.
|
||
|
||
---
|
||
|
||
## What exists today, and what it cannot do
|
||
|
||
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` |
|
||
|
||
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
|
||
typecheck failure, which is worth more than it sounds when one agent is editing 400 files.
|
||
|
||
**What it cannot do yet**, each of which is a task below:
|
||
|
||
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.
|
||
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
|
||
`officerdev` and the 91 in `components` have no catalog of their own.
|
||
5. **Silent, ugly misses.** A missing key renders its own dot-path (`settings.profile.foo`) to the
|
||
user. No warning, no test.
|
||
6. **Twenty languages offered, two that exist.** The picker in `Languages.tsx` lists ~20 codes
|
||
including Hebrew and Greek. Choosing any of the eighteen silently yields English.
|
||
|
||
### The size of the remaining work
|
||
|
||
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** |
|
||
|
||
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
|
||
|
||
That is a floor, not a ceiling. The scanner cannot see multi-line JSX, ternaries inside JSX, strings
|
||
passed down as props, or anything a plugin renders. Budget for **1200–1500 keys**.
|
||
|
||
---
|
||
|
||
## Decisions
|
||
|
||
### D1. The web UI only — and the server returns codes, not prose
|
||
|
||
The server should **never emit user-facing prose**. It returns a stable machine-readable code; the UI
|
||
owns every word a person reads and localizes it like any other string.
|
||
|
||
```jsonc
|
||
// not this
|
||
{ "error": "The Minesweeper session is not running" }
|
||
// this
|
||
{ "code": "MINESWEEPER_NOT_RUNNING" }
|
||
```
|
||
|
||
This is better than translating the server, for reasons that have nothing to do with translation. A
|
||
code maps to exactly one failure site, so it is greppable and a support conversation has something
|
||
precise to quote. Prose drifts; a code does not. And the server stops needing to know who is reading.
|
||
|
||
- **Phase 1 establishes the convention** so new code stops adding debt: new error paths emit codes,
|
||
and the catalog gets an `errors.*` namespace.
|
||
- **Phase 2 migrates the existing ~53** prose strings in `src/servers`. Not in this plan's scope.
|
||
- **Unmapped code** renders a generic localized line **with the code visible**. Never a blank, never
|
||
a raw key.
|
||
|
||
**The trap, and it is a real one: a code must not leak more than the prose it replaces.** Where 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
|
||
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.
|
||
|
||
### 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.
|
||
|
||
**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
|
||
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:
|
||
|
||
**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.
|
||
|
||
**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
|
||
|
||
No right-to-left locale ships in this phase. But since 436 files are being opened anyway, they get
|
||
opened once:
|
||
|
||
- **CSS logical properties** as components are touched — `margin-inline-start`, `padding-inline`,
|
||
`text-align: start` — instead of `left`/`right`. Tailwind has `ms-`/`me-`/`ps-`/`pe-` for this.
|
||
- **`dir` on `<html>`**, driven by the locale, wired now and set to `ltr` for every shipped locale.
|
||
|
||
The cost during a pass we are already making is close to nothing. The cost of a second pass over 436
|
||
files later is not. Hebrew stays in the picker but is not selectable until a catalog exists (see the
|
||
"twenty offered, two that exist" defect above — fix it by filtering the picker to shipped locales).
|
||
|
||
### D4. Extend the existing package. Do not adopt a library
|
||
|
||
`react-i18next`, `lingui` and `formatjs` are all better than what is here, and all of them are
|
||
dependencies. This repository froze its lockfile and hand-rolled a secret store rather than take
|
||
one, for reasons written down in `CLAUDE.md` → "Installs are frozen".
|
||
|
||
The gap is smaller than it looks, because the hard parts are in the runtime already: `Intl.PluralRules`,
|
||
`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.
|
||
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
|
||
|
||
### D5. Keys, not English source text
|
||
|
||
`t('settings.profile.changePassword.title')`, never `t('Change Password')`.
|
||
|
||
Source-text keys (the gettext and lingui style) read nicely and mean an untranslated string still
|
||
renders. They also mean **editing an English word invalidates every translation of it**, and this
|
||
codebase already has typed keys — a wrong key is a compile error today, and that is not worth
|
||
trading away.
|
||
|
||
### D6. Catalogs live per workspace, merged at the bridge
|
||
|
||
Not one god file. Three:
|
||
|
||
```
|
||
src/apps/officer-web/locales/ → namespace app.*
|
||
src/workspaces/officerdev/src/locales/ → namespace panel.*
|
||
src/workspaces/components/locales/ → namespace ui.*
|
||
```
|
||
|
||
`I18nBridge` deep-merges them before handing the map to the provider. Each workspace exports its own
|
||
typed `useTranslation` the way `officer-web/lib/i18n.ts` does today.
|
||
|
||
Two reasons, and the second is the one that matters for execution. Strings belong next to the code
|
||
that renders them, so a panel stays self-contained. And **one 900-key `en.json` is a merge conflict
|
||
on every parallel edit** — with several agents or several days of work, a single file is a queue.
|
||
|
||
Namespaces also make an unqualified key impossible to place wrongly: `ui.*` is shared, so anything
|
||
under it must not mention a feature.
|
||
|
||
### D7. Plurals and formatting go through `Intl`
|
||
|
||
A plural value becomes an object; a string stays a string:
|
||
|
||
```jsonc
|
||
"panel.files.selected": { "one": "{{count}} file selected", "other": "{{count}} files selected" }
|
||
```
|
||
|
||
`t()` picks the branch with `new Intl.PluralRules(locale).select(count)` when `count` is passed.
|
||
Categories are per-locale — English and Portuguese use `one`/`other`, Arabic uses six — so the shape
|
||
must be an open record, never a two-field type.
|
||
|
||
A `useFormat()` hook returns `date`, `time`, `dateTime`, `number` and `relative`, each bound to the
|
||
active locale. **The ~23 `toLocale*` call sites move to it.** Left alone they follow the browser's
|
||
locale and quietly contradict the rest of the UI.
|
||
|
||
### D8. A missing key falls back to English and is loud in dev
|
||
|
||
Never render a dot-path to a person. Resolution walks the chain, ends at `en`, and only if English is
|
||
missing too does it render the key — which at that point is a bug the parity test below should have
|
||
caught.
|
||
|
||
In dev, a miss logs once per key with the key and the locale. In production it is silent.
|
||
|
||
---
|
||
|
||
## The key naming convention
|
||
|
||
Three segments, sometimes four. **Name the role, never the text.**
|
||
|
||
```
|
||
<namespace>.<area>.<component>.<element>
|
||
|
||
app.settings.profile.changePassword.title
|
||
panel.files.contextMenu.rename
|
||
panel.chat.composer.placeholder
|
||
ui.button.cancel
|
||
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`.
|
||
|
||
Rules that keep 1200 keys navigable:
|
||
|
||
- **Never key by the English words.** `ui.button.save`, not `ui.button.saveText` and never
|
||
`ui.button.Save`.
|
||
- **Never build a key at runtime.** `t(\`panel.${kind}.title\`)` defeats both the type checker and
|
||
the extractor. Use an explicit map from the dynamic value to literal keys.
|
||
- **`ui.*` may not name a feature.** If a shared component needs feature words, the caller passes
|
||
them as a prop.
|
||
- **One key per meaning, not per occurrence.** "Cancel" in twelve dialogs is `ui.button.cancel` once.
|
||
But a word that is the same in English and different elsewhere — "Post" the noun and the verb —
|
||
gets two keys. When unsure, split: merging later is safe, splitting later is not.
|
||
|
||
---
|
||
|
||
## What must never be translated
|
||
|
||
Getting this wrong is worse than missing a string, because it corrupts data or breaks a contract.
|
||
|
||
- **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_
|
||
(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
|
||
reading a terminal at 4am, and they should stay greppable in one language.
|
||
- **"Officer"**, and product names generally.
|
||
|
||
---
|
||
|
||
## Execution
|
||
|
||
Four phases. Each is a commit that leaves the tree green — the app must run in English at every point,
|
||
because a half-converted UI is unreviewable.
|
||
|
||
### Phase 0 — infrastructure, no visible change
|
||
|
||
Everything in "What it cannot do yet". Nothing in this phase touches a component.
|
||
|
||
1. `resolve.ts`: fallback chain (D2), plural selection (D7), dev-mode miss warning (D8).
|
||
2. `I18nProvider`: accept a merged map; expose `useFormat()`.
|
||
3. `I18nBridge`: deep-merge the three catalogs (D6); set `dir` on `<html>` (D3).
|
||
4. `Languages.tsx`: regional tags, and **filter the picker to locales that actually exist**.
|
||
5. Settings migration: widen stored bare codes on read.
|
||
6. Scaffold `locales/` in `officerdev` and `components`, each with its typed hook.
|
||
7. Land the tooling below.
|
||
|
||
Phase 0 is the whole plan's risk. It is also small, and it is the only part where a wrong decision is
|
||
expensive to reverse — review it before phase 1 starts.
|
||
|
||
### Phase 1 — shared components (91 files, ~47 strings)
|
||
|
||
Smallest, highest reuse, and it establishes `ui.*` before anything depends on it. Do this first so
|
||
phases 2 and 3 can reach for `ui.button.cancel` instead of inventing it twice.
|
||
|
||
### Phase 2 — the shell (132 files, ~556 strings)
|
||
|
||
`officer-web`: settings, dock, header, screens. Highest-traffic text and the part a reviewer will
|
||
look at first. Convert per directory, one commit per area.
|
||
|
||
### Phase 3 — the panels (213 files, ~567 strings)
|
||
|
||
`officerdev` apps. The largest surface and the most parallelizable, because panels barely share
|
||
strings. **One panel per commit** — this is where per-workspace catalogs earn their keep, but two
|
||
agents in the same panel will still conflict.
|
||
|
||
### Then: `pt-PT`
|
||
|
||
Only after English is fully keyed. Translating against a moving catalog is wasted work.
|
||
|
||
---
|
||
|
||
## Tooling and guardrails
|
||
|
||
Without these, 1200 keys rot. All three are small and belong in phase 0.
|
||
|
||
**`scripts/i18n/scan-hardcoded.ts`** — already written and committed. Reports every candidate
|
||
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 — 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
|
||
it globally on day one would just be red forever.
|
||
|
||
**Also worth having:** an unused-key report. Keys outlive the components that used them, and nothing
|
||
else will ever notice.
|
||
|
||
---
|
||
|
||
## Verification
|
||
|
||
- `bunx tsgo` clean. A wrong key is a type error, so this is the primary check.
|
||
**Note for this machine:** `earlyoom` (`-r 3600`) SIGTERMs `tsgo` under memory pressure — it dies
|
||
around 15s with `Command failed`. That is not a type error. Free memory or scope the check.
|
||
- `bun test` — parity test green for every shipped locale.
|
||
- `bun scripts/i18n/scan-hardcoded.ts` — count strictly decreasing per phase.
|
||
- By hand: switch the locale to `pt-PT` and walk the converted area. Untranslated strings should read
|
||
as **English, never as dot-paths**.
|
||
- Check a plural at 0, 1 and 2, and a date, in both locales. These are the two things that look fine
|
||
in English and wrong everywhere else.
|
||
|
||
---
|
||
|
||
## Open questions
|
||
|
||
1. **Routes.** Deferred deliberately. Nothing here depends on it, and localized URLs bring
|
||
canonicalization and redirect questions that are not worth answering yet.
|
||
2. **Who translates?** Machine translation for `pt-PT` and `pt-BR` reviewed by the owner is the
|
||
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
|
||
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.
|