not built. an execution plan written to be handed to an agent who did not have the conversation, so it carries the reasons and not just the steps. what it found: there is already a hand-rolled i18n package with typed keys, two catalogs and 46 keys — and exactly three components using it. against 884 distinct hardcoded strings across 436 files. so the system is not missing, it is 5% deployed, and that gap is the job. the decisions worth arguing with are D1 and D6. D1: the server should never emit user-facing prose, it returns a stable code and the UI owns every word — with the trap that a code must not leak more than the prose did, or a deliberately vague 403 becomes an enumeration oracle. D6: catalogs per workspace rather than one file, because one 900-key en.json is a merge conflict on every parallel edit. the scanner reproduces the numbers in the doc and doubles as the guard once an area reaches zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
16 KiB
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:
- No plural rules.
1 file/2 fileshas no expression.Intl.PluralRulesis built into the runtime and unused. - 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. - No fallback chain.
resolveKeyfalls back toenand nothing else, sopt-BRcannot inherit frompt. - One catalog, one workspace.
locales/lives insideofficer-web. The 213 panel files inofficerdevand the 91 incomponentshave no catalog of their own. - Silent, ugly misses. A missing key renders its own dot-path (
settings.profile.foo) to the user. No warning, no test. - Twenty languages offered, two that exist. The picker in
Languages.tsxlists ~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.
// 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, with fallback chains
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:
pt-BR → pt → en
en-GB → en
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.
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.
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 ofleft/right. Tailwind hasms-/me-/ps-/pe-for this. diron<html>, driven by the locale, wired now and set toltrfor 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, notui.button.saveTextand neverui.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.cancelonce. 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
Errormessages 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.
resolve.ts: fallback chain (D2), plural selection (D7), dev-mode miss warning (D8).I18nProvider: accept a merged map; exposeuseFormat().I18nBridge: deep-merge the three catalogs (D6); setdiron<html>(D3).Languages.tsx: regional tags, and filter the picker to locales that actually exist.- Settings migration: widen stored bare codes on read.
- Scaffold
locales/inofficerdevandcomponents, each with its typed hook. - 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. 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 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 tsgoclean. A wrong key is a type error, so this is the primary check. Note for this machine:earlyoom(-r 3600) SIGTERMstsgounder memory pressure — it dies around 15s withCommand 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-PTand 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
- Routes. Deferred deliberately. Nothing here depends on it, and localized URLs bring canonicalization and redirect questions that are not worth answering yet.
- Who translates? Machine translation for
pt-PTandpt-BRreviewed by the owner is the obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered. - Plugin frontends are out of scope and will need a catalog-merging contract in the plugin manifest. Their strings are not counted above.
- The
translateTosetting already insettings.languagesis about translating content, not the UI. It is a different feature that happens to sit in the same object — do not conflate them. - 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.