localization: the 'what exists today' table described a world three phases ago

Every row was wrong — bare en.json, 46 keys, three components, 5% deployed — and
it is the first thing whoever picks up phase 3 reads. platform-59 flagged it and
left it alone rather than colliding with me mid-edit, which was the right call.

Kept as a before/now table rather than replaced. The shape of the gap is the
argument for the phases, and deleting the 'before' column would leave them looking
arbitrary.

Also records two things the plan did not anticipate, both found by doing the work:
TranslationKeys walked into a plural object so t('key', {count}) — the whole point
of D7 — would not compile while the workaround did, which no test could have caught
because phase 1 had no plurals. And the typed wrappers rebuilt t every render,
which is the trap CLAUDE.md already documents against useClient().

And the shape this catalogue format cannot express: a sentence with an inline link
splits into a key whose Portuguese value is '.'. Flagged rather than solved, since
a node-level interpolation helper is a design decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 05:43:49 +00:00
co-authored by Claude Opus 5
parent 454a0f4244
commit 2f8a2b60d5
+34 -25
View File
@@ -25,35 +25,44 @@ the server does instead, which is the one decision that reaches outside this bou
## 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.
**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.
| | |
| ----------------------------------------- | -------------------------------------------------------------------------- |
| `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` |
| | 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** |
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.
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 it cannot do yet**, each of which is a task below:
**What remains:** `src/workspaces/officerdev` — the panels — and then translation. `panel.*` is scaffolded,
wired into the bridge, and empty.
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.
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