diff --git a/CLAUDE.md b/CLAUDE.md index 26f55c16..4bebed32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -477,6 +477,10 @@ explaining why it was safe. owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level, never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to remember, and the credential-resolution work that has to land first +- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the + web UI to more than one language. What exists and what it cannot do yet, why the server should return + error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key + naming convention, and the four phases. Read before adding a user-facing string - `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env` into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 00000000..af0e778d --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,345 @@ +# 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` — 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, 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 of `left`/`right`. Tailwind has `ms-`/`me-`/`ps-`/`pe-` for this. +- **`dir` on ``**, 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.** + +``` +... + +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 `` (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.** 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 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. diff --git a/scripts/i18n/scan-hardcoded.ts b/scripts/i18n/scan-hardcoded.ts new file mode 100755 index 00000000..07d9ffe7 --- /dev/null +++ b/scripts/i18n/scan-hardcoded.ts @@ -0,0 +1,160 @@ +#!/usr/bin/env bun +/** + * Counts candidate hardcoded user-facing strings in the web UI. + * + * Two jobs. It sizes the localization work (docs/localization.md quotes its output), and once an area + * reaches zero it becomes the guard that stops the debt coming back — see ZEROED below. + * + * It is a heuristic and it is deliberately a FLOOR. It cannot see multi-line JSX text, ternaries + * inside JSX, strings passed down as props, or anything a plugin renders. A falling number means + * progress; a zero means "nothing this scanner can see", not "nothing left". + * + * bun scripts/i18n/scan-hardcoded.ts # summary by area + * bun scripts/i18n/scan-hardcoded.ts --files # worst files first + * bun scripts/i18n/scan-hardcoded.ts --list ui.button # print the strings themselves + * bun scripts/i18n/scan-hardcoded.ts --check # non-zero exit if a zeroed area regressed + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const AREAS = [ + { label: 'officer-web (shell)', root: 'src/apps/officer-web' }, + { label: 'officerdev (panels)', root: 'src/workspaces/officerdev/src' }, + { label: 'shared components', root: 'src/workspaces/components' }, +] as const; + +/** + * Areas that have been converted and must stay converted. Add an area here the moment it reaches + * zero — enabling every area on day one would just be red for weeks and get ignored. + */ +const ZEROED: string[] = []; + +const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/; + +// A quoted literal, single or double. Kept as two groups because JS has no branch reset. +const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`; + +const PATTERNS: { kind: string; rx: RegExp }[] = [ + // >Some visible words< + { kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s* { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries) { + const path = join(dir, entry); + if (SKIP.test(path)) continue; + let isDir = false; + try { + isDir = statSync(path).isDirectory(); + } catch { + continue; + } + if (isDir) yield* walk(path); + else if (path.endsWith('.tsx') || path.endsWith('.ts')) yield path; + } +} + +type Hit = { kind: string; value: string; file: string }; + +function scan(root: string): { files: number; hits: Hit[] } { + const hits: Hit[] = []; + let files = 0; + + for (const file of walk(root)) { + files++; + let source: string; + try { + source = readFileSync(file, 'utf8'); + } catch { + continue; + } + for (const { kind, rx } of PATTERNS) { + rx.lastIndex = 0; + for (const match of source.matchAll(rx)) { + const value = match.slice(1).find((group) => group !== undefined); + if (isHuman(value)) hits.push({ kind, value: value.trim(), file }); + } + } + } + return { files, hits }; +} + +const args = new Set(process.argv.slice(2)); +const listPrefix = process.argv.includes('--list') ? process.argv[process.argv.indexOf('--list') + 1] : undefined; + +const results = AREAS.map((area) => ({ ...area, ...scan(area.root) })); +const kinds = PATTERNS.map((p) => p.kind); +const pad = (s: string | number, n: number) => String(s).padStart(n); + +console.log(`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`); +for (const r of results) { + const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join(''); + console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`); +} + +const all = results.flatMap((r) => r.hits); +const distinct = new Set(all.map((h) => h.value)); +const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value)); + +console.log(`${'TOTAL'.padEnd(22)} ${pad(results.reduce((n, r) => n + r.files, 0), 5)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`); +console.log(); +console.log(`occurrences ${all.length}`); +console.log(`distinct strings ${distinct.size}`); +console.log(` needing params ${interpolated.size}`); + +if (args.has('--files')) { + console.log('\nworst files:'); + const perFile = new Map(); + for (const h of all) perFile.set(h.file, (perFile.get(h.file) ?? 0) + 1); + for (const [file, n] of [...perFile].sort((a, b) => b[1] - a[1]).slice(0, 25)) { + console.log(` ${pad(n, 4)} ${file}`); + } +} + +if (listPrefix) { + console.log(`\nstrings in files matching "${listPrefix}":`); + for (const h of all.filter((x) => x.file.includes(listPrefix))) console.log(` [${h.kind}] ${h.value}`); +} + +if (args.has('--check')) { + const regressed = results.filter((r) => ZEROED.includes(r.label) && r.hits.length > 0); + if (regressed.length > 0) { + console.error('\nThese areas were converted and have regressed:'); + for (const r of regressed) console.error(` ${r.label}: ${r.hits.length} hardcoded strings`); + console.error('\nUse a catalog key. See docs/localization.md.'); + process.exit(1); + } + console.log(`\ncheck: ok (${ZEROED.length} area(s) held at zero)`); +}