Phase 3 guidance said to expect roughly half of FileBrowser to be content that
stays English. useFileBrowserApp.ts, the largest file in the phase, was 100%
convertible — not one hit was content.
The distinction that actually holds: a panel LISTING things renders content, and a
file name in a row is the user's. A panel REPORTING about those things owns the
sentence — `Renamed to "{{name}}"` is ours and the file name is a parameter, which
is where content belongs. So the question is whether a string is a frame around
content or the content itself, not whether the panel touches file names.
It matters for plurals too: six hand-rolled ones in that file became real entries
because Portuguese reorders them, which a frame with a count spliced in cannot
express.
Recorded because the wrong version of this rule came from me, and left alone it
would have had somebody decline conversions that were correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
415 lines
22 KiB
Markdown
415 lines
22 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
|
||
|
||
**Updated 2026-08-23, after phases 0, 1 and 2.** This section described the pre-phase-0 world for long
|
||
enough that the next reader would have started from a false picture — bare `en.json`, 46 keys, three
|
||
components. All of that is now history and is kept below only as the "before" column, because the shape of
|
||
the gap is the argument for the plan and deleting it would leave the phases looking arbitrary.
|
||
|
||
| | before phase 0 | now |
|
||
| -------------------------- | ---------------------- | --------------------------------------------- |
|
||
| catalogs | `en.json`, `pt.json` | `en-GB.json`, `pt-PT.json` per workspace |
|
||
| keys | 46 | **581** — 538 `app.*`, 43 `ui.*` |
|
||
| workspaces with a catalog | 1 (`officer-web`) | 3 (`officer-web`, `components`, `officerdev`) |
|
||
| components using it | 3 | all of `officer-web` and `components` |
|
||
| plural rules | none | `Intl.PluralRules`, per locale |
|
||
| dates and numbers | browser locale | `useFormat()`, the account's locale |
|
||
| a missing key | rendered its dot-path | falls back to English, warns in dev |
|
||
| locales offered vs shipped | ~20 offered, 2 existed | only shipped locales are offered |
|
||
| parity enforcement | none | **a gap fails the build** |
|
||
|
||
Still standing from the original description, and still true: the package is hand-rolled, has no npm
|
||
dependency, and gives type-safe keys — a wrong key is a typecheck failure, which is worth more than it
|
||
sounds when one agent is editing hundreds of files.
|
||
|
||
**What remains:** `src/workspaces/officerdev` — the panels — and then translation. `panel.*` is scaffolded,
|
||
wired into the bridge, and empty.
|
||
|
||
Two things learned doing phases 1 and 2 that the plan did not anticipate:
|
||
|
||
- **`TranslationKeys` walked into a plural object**, so `t('key', { count })` — the entire point of D7 —
|
||
did not compile while `t('key.one')` did. The documented feature was uncompilable and the workaround was
|
||
not, which no test would ever have noticed because phase 1 contained no plurals. Fixed with type-level
|
||
assertions.
|
||
- **The typed wrappers rebuilt `t` on every render**, which is the trap `CLAUDE.md` already records against
|
||
`useClient()` and the Jellyfin player. It matters the moment `t` lands in a `useMemo` dependency list,
|
||
which phase 2 needed immediately. Memoised.
|
||
|
||
**A shape this format cannot express:** a sentence with an inline link. Splitting around the anchor gives a
|
||
key whose Portuguese value is `"."`. The cheap fix is rephrasing so the link falls at a clause boundary;
|
||
there is no node-level interpolation helper and adding one is a design decision, not a patch. Worth
|
||
raising if a phase turns out to be full of them rather than absorbing it forty times.
|
||
|
||
### 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.
|
||
- **Not everything a panel renders is content, and "panel plus file names" is the wrong test.** The
|
||
guidance during phase 3 was to expect roughly half of FileBrowser's hits to be content and stay English.
|
||
`useFileBrowserApp.ts`, the largest file in the phase at 47 hits, turned out **100% convertible**.
|
||
|
||
The reason is worth keeping. A panel that LISTS things renders content — a file name in a row is the
|
||
user's, and translating it would be absurd. A panel REPORTING about those things owns the sentence:
|
||
`Renamed to "{{name}}"` is ours, and the file name sits inside it as a parameter, which is exactly where
|
||
content belongs. So the test is not "does this panel touch file names" but "is the string a frame around
|
||
content, or the content itself".
|
||
|
||
It also matters for plurals. Six hand-rolled ones in that file became real entries because Portuguese
|
||
reorders them — `1 item eliminado` against `Deleted 1 item` — which a frame with a count spliced into it
|
||
cannot express at all.
|
||
|
||
- **A label quoted from ANOTHER product's interface.** "Click **Enable**", "set **App name**", "choose
|
||
**Internal**" in a setup guide. These read like prose and are identifiers: their whole job is to match
|
||
what the reader will actually see on Google's screen, and Google localises by the reader's _Google_
|
||
account language, not by ours. Translating them sends somebody hunting for "Ativar" in a console that
|
||
says "Enable".
|
||
|
||
So translate the connective prose around them and leave the quoted label in English. Do not key it with
|
||
identical values in both catalogs either — that is a key whose two sides can never legitimately differ,
|
||
which is a maintenance cost bought for nothing.
|
||
|
||
Decided 2026-08-23 on `GoogleOAuthConfig.tsx`, where 55 of the file's 67 strings are of this kind.
|
||
|
||
- **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.
|
||
|
||
**One constraint that contract has to answer, found while converting the shared components
|
||
(2026-08-23):** those components now call `useTranslation`, so they resolve against whatever
|
||
`I18nProvider` is above them — and with none above them they render their own dot-paths. Today that
|
||
is safe by accident: there are exactly two React roots, and the one that is not `officer-web`
|
||
(`apps/marketplace`) imports nothing from `components/`. A plugin frontend mounting its own root, or
|
||
rendering a shared component outside `I18nBridge`, would put `ui.button.cancel` in front of a person.
|
||
|
||
So the contract needs to say where a plugin's provider comes from — inherit the host's, or mount its
|
||
own seeded with the host's catalogs — rather than leaving each plugin to remember. Note that a
|
||
provider-absent fallback inside the `components` package was considered and rejected: it would have
|
||
split resolution across two mechanisms and contradicted D6, which puts the merge at the bridge.
|
||
|
||
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.
|