Files
platform/docs/localization.md
T
pastilhasandClaude Opus 5 9dfa83e607 localization: how to add a locale, and it is built now
the doc said "DESIGN, NOT BUILT — a working 5% skeleton". six locales ship,
three catalogues, parity in both directions and a test that catches a dead
one. it has said the wrong thing since the day the work landed.

and there was no procedure for the thing anybody would actually come here
to do. seven files: three catalogues, three registrations, and the picker.
written out because the failure it is written from was somebody doing four
of them — es-ES, de-DE and fr-FR once shipped translated, in perfect key
parity, listed in the picker, and completely dead, because all three
registration files still imported only en-GB and pt-PT.

nothing caught that at the time. the section names what catches it now, so
whoever adds the seventh language knows `bun test` settles it rather than
having to trust that it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 03:45:43 +00:00

24 KiB
Raw Blame History

Localization

Status: BUILT and shipping six locales — en-GB, pt-PT, es-ES, de-DE, fr-FR, sv-SE. The mechanism is done; what remains is coverage, which is the count-taking below.

To add a seventh language, read "Adding a locale" at the bottom and nothing else. It is seven files and the order does not matter, but missing one is silent — see the failure it is written from.

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.

// 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:

"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.


Adding a locale

Seven files. Six are mechanical; the seventh is the one that gets missed.

Three catalogues, one per workspace. Copy en-GB.json beside itself and translate the values, keeping every key:

src/apps/officer-web/locales/<code>.json          app.*    the shell, screens, settings
src/workspaces/officerdev/src/locales/<code>.json  panel.*  the windowed apps
src/workspaces/components/locales/<code>.json      ui.*     shared components

Three registrations, one per catalogue. Each is an import plus a line in a map:

src/apps/officer-web/lib/catalogues.ts
src/workspaces/officerdev/src/locales/index.ts
src/workspaces/components/locales/index.ts

And the picker, which is the one that gets missed the other way round:

src/apps/officer-web/lib/locales.ts    SHIPPED_LOCALES

Why this is spelled out rather than left to reading the code

es-ES, de-DE and fr-FR once shipped translated, in perfect key parity, listed in the picker — and completely dead. All three registration files still imported only en-GB and pt-PT, so the merge produced three empty catalogues and the UI rendered raw keys in three of five languages.

Nothing caught it. The parity tests compare the JSON files against each other and every file was correct; a locale that is never imported is invisible to them by construction. The picker was right. Four edits in four files with no single place that knew all four had happened.

What checks you now

Run bun test and it is settled. Two things stand where nothing stood:

  • src/apps/officer-web/lib/catalogues.test.ts asserts every locale in SHIPPED_LOCALES resolves to a populated catalogue in all three namespaces, and matches en-GB's size. A locale in the picker but not imported fails here. So does one imported but empty.
  • The three parity.test.ts files read their own directory rather than listing locales by hand, so a new <code>.json is checked the moment it exists — for missing keys AND extra ones, because findMissingKeys alone reports half of a rename.

Then bunx tsgo and bunx prettier --write on what you touched, as everywhere else.