Compare commits

...
Author SHA1 Message Date
pastilhas b640ec486d i18n: default-language picker offers only shipped locales; widen stored bare codes on read 2026-08-23 03:43:35 +00:00
pastilhas 0bd8c6c16a i18n: officer-web catalog goes regional (en-GB, pt-PT), bridge deep-merges app/panel/ui and sets dir 2026-08-23 03:43:01 +00:00
pastilhas 6ac9187c52 i18n: scaffold panel.* and ui.* locale catalogs, wire panelCatalogs into officerdev's barrel 2026-08-23 03:42:53 +00:00
pastilhas dcd11a3b17 i18n: plural selection via Intl.PluralRules, dev-mode miss warning, useFormat, key-parity helper 2026-08-23 03:42:46 +00:00
pastilhasandClaude Opus 5 3dd37d053b localization: every locale ships a complete catalog, no regional overlays
D2 had pt-BR inherit from pt so a base carried the shared bulk and a regional file
carried the differences. Conventional, and rejected by the owner for a product
reason that outranks the engineering one.

The failure mode of an overlay is a missing European Portuguese key falling
through to Brazilian — and the two differ most in exactly the technical vocabulary
this UI is full of. The platform sells into European Portuguese first, so that
failure is a reader getting a neighbouring variant's words on whichever strings
nobody remembered to override, silently.

So every locale is a whole file, translated, or it does not ship. The drift
objection is answered by promoting the key-parity test from advisory to
load-bearing: a locale missing a key `en` has fails the build, so an incomplete
catalog cannot ship at all. Completeness is enforced rather than papered over.

It is also less code — resolveKey looks in one catalog and then English, with no
chain to walk and no merge order — and it removes the question of which file a
given string lives in.

Consequence for the work: a new locale is a full machine-translated pass reviewed
on its own, not a diff against a base. pt-PT and pt-BR get one each, separately,
because the whole point is that neither borrows from the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:21:21 +00:00
pastilhas 64894ad96e deploy report 2026.08.23.02 2026-08-23 02:59:30 +00:00
31 changed files with 461 additions and 57 deletions
+2
View File
@@ -210,6 +210,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"embla-carousel-react": "^8.5.1", "embla-carousel-react": "^8.5.1",
"hooks": "*", "hooks": "*",
"i18n": "*",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
@@ -271,6 +272,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"hooks": "workspace:*", "hooks": "workspace:*",
"i18n": "workspace:*",
"state": "workspace:*", "state": "workspace:*",
"widgets": "workspace:*", "widgets": "workspace:*",
}, },
+69
View File
@@ -0,0 +1,69 @@
# 2026.08.23.02 — deploy report
**5 of 5 target hosts succeeded.** Two hosts were not targets: `officer-dev` (the rollout ran from it)
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`).
Every target went the same way, in the plan's order: `git fetch origin --tags`, `git merge --ff-only
deploy/2026.08.23.02`, `bun db:push`, `bun run plugins:update`, `pm2 restart officer`. No tree was dirty
in tracked files, so no host was skipped for that. Every host fast-forwarded cleanly to
`0a8fe1c2` — the tag commit, and exactly that commit on all five.
The tag and `package.json` agree (`2026.08.23.02`), and the plan file was present at the tag.
## Per host
| Host | HEAD before | HEAD after | db:push | plugins:update | `officer` restart_time | Result |
| ---------------------------- | ----------- | ---------- | ------- | -------------- | ---------------------- | ------- |
| `alpha` | `e6f1388a` | `0a8fe1c2` | applied | 12/12 in 47s | 67 → 68, online | ok |
| `fofos` | `e6f1388a` | `0a8fe1c2` | applied | 1/1 in 7s | 21 → 22, online | ok |
| `macbookpro` | `b22e9d86` | `0a8fe1c2` | applied | no plugins | 8 → 9, online | ok |
| `production-pertento-bridge` | `68d60e40` | `0a8fe1c2` | applied | 1/1 in 5s | 0 → 1, online | ok |
| `deadpool-rezio-bridge` | `e6f1388a` | `0a8fe1c2` | applied | 2/2 in 7s | 6 → 7, online | ok |
| `officer-dev` | — | — | — | — | — | SKIPPED |
| `edge-pertento-bridge` | — | — | — | — | — | SKIPPED |
`bun db:push` reported `Changes applied` on all five. It prompted for nothing — `sendoff_devices` and
`sendoff_senders` are new tables, so the diff was additive.
## What restarted, beyond what the plan asked for
The plan named `officer` only, and `officer` is the only process this deploy restarted deliberately.
`bun run plugins:update` restarts each plugin's own sidecar as it goes — that is the script's behaviour,
not a fault, and it is the larger part of what moved:
- `alpha` — twelve plugin sidecars bounced during the plugins step (atelier, bitcoin-wallet, email,
gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault).
- `fofos` — one (`officer-gitea`).
- `production-pertento-bridge` — one (`officer-gitea`).
- `deadpool-rezio-bridge` — two (`officer-email`, `officer-offscale`).
- `macbookpro` — none; no plugins are installed there.
After each restart, `pm2 jlist` showed `officer` `online` with `restart_time` incremented, and no process
on any host was `errored`.
## Hosts the plan flagged
- **`production-pertento-bridge`** was one release behind (`68d60e40`, which is `deploy/2026.08.22.02`)
and fast-forwarded past `deploy/2026.08.23.01` to this tag in one step. Its `known_hosts` entry was
absent as expected; the first connection pinned the ED25519 key it offered. Expected, per the plan.
- **`edge-pertento-bridge`** was not contacted at all. Nothing was spent on it.
- **`macbookpro`** was awake and needed the documented PATH append (`/opt/homebrew/bin`) for the `bun`
steps, with `pm2` run separately through `$SHELL -ic`.
## Things worth knowing, none of them failures
- `fofos` carries one untracked file, `scripts/chk.ts`. A note, not a skip — a fast-forward cannot touch
it, and it was left alone.
- `production-pertento-bridge` prints an nvm/`.npmrc` `globalconfig` warning before every command run
through `$SHELL -ic`. It is noise; every command it prefixed succeeded.
- `macbookpro` prints `command not found: starship` on stderr from `.zshrc`. Harmless, but it corrupts
`pm2 jlist` output if stderr is merged into stdout — read that host's JSON with `2>/dev/null`.
## What was not done
- **`officer-dev` was not deployed.** Its checkout is already at `0a8fe1c2` (the release was cut here),
but nothing was pulled, no schema was pushed, no plugin was updated and no process was restarted. The
rollout ran from this machine, and restarting `officer` here ends the agent doing it. That host needs
a deliberate `bun db:push`, `bun run plugins:update` and `pm2 restart officer` from the owner before
`/sendoff` will work on it — the schema change means the missing `bun db:push` is not cosmetic.
- No host got `bun install`. The plan forbade it: `bun.lock` is unchanged across this release.
+46 -25
View File
@@ -29,7 +29,7 @@ There is already an i18n system. It is small, hand-rolled, has no npm dependency
also about **5% deployed**, and that gap is the whole job. also about **5% deployed**, and that gap is the whole job.
| | | | | |
| --- | --- | | ----------------------------------------- | -------------------------------------------------------------------------- |
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` | | `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/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/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
@@ -45,7 +45,7 @@ typecheck failure, which is worth more than it sounds when one agent is editing
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the 1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
runtime and unused. runtime and unused.
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use 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. 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 3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit
from `pt`. from `pt`.
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in 4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
@@ -60,7 +60,7 @@ typecheck failure, which is worth more than it sounds when one agent is editing
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`: Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
| area | files | occurrences | | area | files | occurrences |
| --- | --- | --- | | --------------------- | ------- | ----------- |
| `officer-web` (shell) | 132 | 556 | | `officer-web` (shell) | 132 | 556 |
| `officerdev` (panels) | 213 | 567 | | `officerdev` (panels) | 213 | 567 |
| shared `components` | 91 | 47 | | shared `components` | 91 | 47 |
@@ -101,29 +101,46 @@ precise to quote. Prose drifts; a code does not. And the server stops needing to
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns 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 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 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. 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 ### 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, `pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
and it is the owner's own language, so getting it wrong is felt immediately. it is the owner's own language, so getting it wrong is felt immediately.
Resolution walks a chain and stops at the first hit: **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
pt-BR → pt → en carried the shared bulk and a regional file carried only the differences. That is the conventional design
en-GB → en and it was rejected for a product reason that outranks the engineering one:
```
So a **base catalog carries the shared bulk and a regional overlay carries only the differences.** **A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
`pt.json` holds everything both Portugueses agree on; `pt-BR.json` holds only what Brazil says Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
differently. Two near-identical full catalogs would drift within a month. 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. `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 **Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
regional tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
widening on read over a database migration — a stale bare code should resolve, not fail. 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 ### D3. RTL — prepare, do not ship
@@ -148,7 +165,7 @@ The gap is smaller than it looks, because the hard parts are in the runtime alre
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150 `Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
lines of glue, listed in "What it cannot do yet". lines of glue, listed in "What it cannot do yet".
Revisit this only if message *formatting* gets genuinely complex — nested selects, gendered forms. 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. ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
### D5. Keys, not English source text ### D5. Keys, not English source text
@@ -223,7 +240,7 @@ app.errors.MINESWEEPER_NOT_RUNNING
- `namespace` — `app` / `panel` / `ui`, per D6. - `namespace` — `app` / `panel` / `ui`, per D6.
- `area` — the route or panel it belongs to; matches the directory. - `area` — the route or panel it belongs to; matches the directory.
- `component` — the file, lowercased. - `component` — the file, lowercased.
- `element` — what it *is*: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`. - `element` — what it _is_: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
Rules that keep 1200 keys navigable: Rules that keep 1200 keys navigable:
@@ -245,7 +262,7 @@ Getting this wrong is worse than missing a string, because it corrupts data or b
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content. - **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
- **User content** — file names, paths, terminal output, code, commit messages, note bodies. - **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* - **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 (the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
names, database values. names, database values.
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is - **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
@@ -304,9 +321,13 @@ Without these, 1200 keys rot. All three are small and belong in phase 0.
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress 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. 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 **A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
chain. Fails the build on a gap, the same way `bun docs:check` fails on a stale number. This is the key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
one guard that stops a locale silently degrading into English. `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 **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 enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
@@ -339,7 +360,7 @@ else will ever notice.
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered. 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 3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
manifest. Their strings are not counted above. manifest. Their strings are not counted above.
4. **The `translateTo` setting already in `settings.languages`** is about translating *content*, not 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. 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 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. between accounts needs a locale decision. D1 mostly removes this by removing server prose.
@@ -3,6 +3,7 @@ import { X } from 'lucide-react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n'; import { useTranslation } from '@/lib/i18n';
import { SHIPPED_LOCALES } from '@/lib/locales';
const LANGUAGES = [ const LANGUAGES = [
{ code: 'en', label: 'English' }, { code: 'en', label: 'English' },
@@ -56,7 +57,8 @@ export const Languages = () => {
const removeSpoken = (code: string) => { const removeSpoken = (code: string) => {
const next = spoken.filter((s) => s !== code); const next = spoken.filter((s) => s !== code);
const updates = { ...settings.languages, spoken: next }; const updates = { ...settings.languages, spoken: next };
if (defaultLang === code) updates.default = next[0] ?? 'en'; // `default` (UI localization) draws from SHIPPED_LOCALES, not `spoken` — removing a spoken
// language never leaves it without a valid value.
if (translateTo === code) updates.translateTo = next[0] ?? 'en'; if (translateTo === code) updates.translateTo = next[0] ?? 'en';
save(updates); save(updates);
}; };
@@ -104,7 +106,7 @@ export const Languages = () => {
)} )}
</div> </div>
{/* Default language */} {/* Default language — UI localization, so it only offers locales that actually ship (D2) */}
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span> <span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
<select <select
@@ -112,9 +114,9 @@ export const Languages = () => {
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })} onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer" className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
> >
{spoken.map((code) => ( {SHIPPED_LOCALES.map(({ code, label }) => (
<option key={code} value={code}> <option key={code} value={code}>
{getLabel(code)} {label}
</option> </option>
))} ))}
</select> </select>
+30 -5
View File
@@ -1,11 +1,30 @@
import { useEffect } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { TranslationMap } from 'i18n'; import type { TranslationMap } from 'i18n';
import { I18nProvider } from 'i18n'; import { I18nProvider, localeDirection } from 'i18n';
import { panelCatalogs } from 'officerdev';
import { uiCatalogs } from '@/components/locales';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import en from '../locales/en.json'; import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
import pt from '../locales/pt.json'; import appEnGB from '../locales/en-GB.json';
import appPtPT from '../locales/pt-PT.json';
const translations: TranslationMap = { en, pt }; const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT };
/**
* Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
*/
const translations: TranslationMap = Object.fromEntries(
SHIPPED_LOCALES.map(({ code }) => [
code,
{
app: appCatalogs[code] ?? {},
panel: panelCatalogs[code] ?? {},
ui: uiCatalogs[code] ?? {},
},
]),
);
type I18nBridgeProps = { type I18nBridgeProps = {
children: ReactNode; children: ReactNode;
@@ -15,8 +34,14 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
const { settings } = useSettings(); const { settings } = useSettings();
const locale = settings.languages.default; const locale = settings.languages.default;
// Prepared for RTL (D3) — every shipped locale is `ltr` today, but the wiring exists so a future
// RTL catalog needs no second pass over the tree.
useEffect(() => {
document.documentElement.dir = localeDirection(locale);
}, [locale]);
return ( return (
<I18nProvider translations={translations} locale={locale}> <I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
{children} {children}
</I18nProvider> </I18nProvider>
); );
+4 -2
View File
@@ -1,12 +1,14 @@
import type { TranslationKeys } from 'i18n'; import type { TranslationKeys } from 'i18n';
import type { InterpolationParams } from 'i18n'; import type { InterpolationParams } from 'i18n';
import { useTranslation as useTranslationBase } from 'i18n'; import { useTranslation as useTranslationBase } from 'i18n';
import type en from '../locales/en.json'; import type en from '../locales/en-GB.json';
type AppTranslationKey = TranslationKeys<typeof en>; type AppTranslationKey = TranslationKeys<typeof en>;
// The `app.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
// pass the same bare key the JSON file uses.
export const useTranslation = () => { export const useTranslation = () => {
const { t: baseT, locale } = useTranslationBase(); const { t: baseT, locale } = useTranslationBase();
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(key, params); const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params);
return { t, locale }; return { t, locale };
}; };
+14
View File
@@ -0,0 +1,14 @@
/**
* The UI locales that actually ship — regional BCP 47 tags, per docs/localization.md D2. This is the
* one list `I18nBridge` merges catalogs for and `Languages.tsx` offers for UI localization; it is
* deliberately separate from the broader "languages you speak" / "translate to" lists, which are a
* different feature (see the doc's open question 4) and are not limited to locales with a catalog.
*/
export type ShippedLocale = { code: string; label: string };
export const SHIPPED_LOCALES: ShippedLocale[] = [
{ code: 'en-GB', label: 'English (UK)' },
{ code: 'pt-PT', label: 'Português (Portugal)' },
];
export const FALLBACK_LOCALE = 'en-GB';
@@ -0,0 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { findMissingKeys } from 'i18n';
import en from './en-GB.json';
import ptPT from './pt-PT.json';
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and pt-PT lacks is a red build,
// not a warning — there is no regional fallback to paper over the gap.
describe('officer-web (app.*) locale catalogs', () => {
test('pt-PT has every key en-GB has', () => {
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
});
@@ -0,0 +1 @@
{}
@@ -0,0 +1,8 @@
import type { TranslationMap } from 'i18n';
import enGB from './en-GB.json';
import ptPT from './pt-PT.json';
/** Shared components' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `ui.*`. */
export const uiCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
export { useTranslation } from './useTranslation';
@@ -0,0 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { findMissingKeys } from 'i18n';
import en from './en-GB.json';
import ptPT from './pt-PT.json';
// D2: every shipped locale ships a COMPLETE catalog. Phase 0 scaffolds this catalog with no keys yet
// (ui.* strings land in phase 1) — the test still runs so it is load-bearing from day one.
describe('shared components (ui.*) locale catalogs', () => {
test('pt-PT has every key en-GB has', () => {
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
});
@@ -0,0 +1 @@
{}
@@ -0,0 +1,13 @@
import type { InterpolationParams, TranslationKeys } from 'i18n';
import { useTranslation as useTranslationBase } from 'i18n';
import type en from './en-GB.json';
type UiTranslationKey = TranslationKeys<typeof en>;
// The `ui.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
// pass the same bare key the JSON file uses. `ui.*` may not name a feature (see the naming convention).
export const useTranslation = () => {
const { t: baseT, locale } = useTranslationBase();
const t = (key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params);
return { t, locale };
};
+1
View File
@@ -45,6 +45,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"embla-carousel-react": "^8.5.1", "embla-carousel-react": "^8.5.1",
"hooks": "*", "hooks": "*",
"i18n": "*",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
+8 -4
View File
@@ -11,15 +11,19 @@ export const I18nContext = createContext<I18nContextValue>({
type I18nProviderProps = { type I18nProviderProps = {
translations: TranslationMap; translations: TranslationMap;
locale: string; locale: string;
/** The catalog `resolveKey` falls back to on a miss — a bug path, per D8, not a regional chain. */
fallbackLocale?: string;
children: ReactNode; children: ReactNode;
}; };
export const I18nProvider = ({ translations, locale, children }: I18nProviderProps) => { export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
const value = useMemo<I18nContextValue>(() => { const value = useMemo<I18nContextValue>(() => {
const t = (key: string, params?: InterpolationParams) => const t = (key: string, params?: InterpolationParams) => {
interpolate(resolveKey(translations, locale, key), params); const count = typeof params?.count === 'number' ? params.count : undefined;
return interpolate(resolveKey(translations, locale, key, fallbackLocale, count), params);
};
return { t, locale }; return { t, locale };
}, [translations, locale]); }, [translations, locale, fallbackLocale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>; return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}; };
+5 -2
View File
@@ -1,4 +1,7 @@
export { I18nProvider, I18nContext } from './I18nProvider'; export { I18nProvider, I18nContext } from './I18nProvider';
export { useTranslation } from './useTranslation'; export { useTranslation } from './useTranslation';
export { resolveKey, interpolate } from './resolve'; export { useFormat } from './useFormat';
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys } from './types'; export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
export { findMissingKeys } from './parity';
export type { MissingKey } from './parity';
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
+27
View File
@@ -0,0 +1,27 @@
import { isPluralForms } from './resolve';
type Catalog = Record<string, unknown>;
const flattenKeys = (obj: Catalog, prefix = ''): string[] =>
Object.entries(obj).flatMap(([k, v]) => {
const path = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v) && !isPluralForms(v)) {
return flattenKeys(v as Catalog, path);
}
return [path];
});
export type MissingKey = { locale: string; key: string };
/**
* Per D2, every shipped locale is a COMPLETE catalog — no regional inheritance. So a key `en` has
* and a locale's own file lacks is not a fallback case, it's a red build (docs/localization.md,
* "Tooling and guardrails").
*/
export const findMissingKeys = (en: Catalog, locales: Record<string, Catalog>): MissingKey[] => {
const enKeys = flattenKeys(en);
return Object.entries(locales).flatMap(([locale, catalog]) => {
const ownKeys = new Set(flattenKeys(catalog));
return enKeys.filter((key) => !ownKeys.has(key)).map((key) => ({ locale, key }));
});
};
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, test } from 'bun:test';
import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
import { findMissingKeys } from './parity';
import type { TranslationMap } from './types';
describe('resolveKey', () => {
const translations: TranslationMap = {
'pt-PT': { greeting: 'Olá {{name}}' },
'en-GB': { greeting: 'Hello {{name}}', onlyInEnglish: 'English only' },
};
test('resolves from the active locale', () => {
expect(resolveKey(translations, 'pt-PT', 'greeting', 'en-GB')).toBe('Olá {{name}}');
});
test('falls back to the fallback locale on a miss', () => {
expect(resolveKey(translations, 'pt-PT', 'onlyInEnglish', 'en-GB')).toBe('English only');
});
test('renders the raw key when even the fallback is missing', () => {
expect(resolveKey(translations, 'pt-PT', 'nowhere', 'en-GB')).toBe('nowhere');
});
test('never walks a chain between two non-fallback locales', () => {
const withThird: TranslationMap = { ...translations, 'pt-BR': {} };
// pt-BR has no key of its own and must NOT inherit pt-PT's — only the fallback locale counts.
expect(resolveKey(withThird, 'pt-BR', 'greeting', 'en-GB')).toBe('Hello {{name}}');
});
test('selects a plural branch via Intl.PluralRules', () => {
const plurals: TranslationMap = {
'en-GB': { files: { one: '{{count}} file', other: '{{count}} files' } },
};
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 1)).toBe('{{count}} file');
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 2)).toBe('{{count}} files');
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 0)).toBe('{{count}} files');
});
});
describe('interpolate', () => {
test('substitutes params', () => {
expect(interpolate('Hello {{name}}', { name: 'Ana' })).toBe('Hello Ana');
});
test('leaves an unknown placeholder untouched', () => {
expect(interpolate('Hello {{name}}', {})).toBe('Hello {{name}}');
});
test('passes a plain string through with no params', () => {
expect(interpolate('Hello')).toBe('Hello');
});
});
describe('isPluralForms', () => {
test('accepts an open record of CLDR categories', () => {
expect(isPluralForms({ one: 'a', other: 'b' })).toBe(true);
expect(isPluralForms({ other: 'b' })).toBe(true);
});
test('rejects a plain nested catalog node', () => {
expect(isPluralForms({ title: 'a', description: 'b' })).toBe(false);
});
test('rejects non-objects', () => {
expect(isPluralForms('a string')).toBe(false);
expect(isPluralForms(null)).toBe(false);
expect(isPluralForms(['one', 'other'])).toBe(false);
});
});
describe('localeDirection', () => {
test('ltr for English and Portuguese', () => {
expect(localeDirection('en-GB')).toBe('ltr');
expect(localeDirection('pt-PT')).toBe('ltr');
});
test('rtl for Arabic and Hebrew, by language subtag', () => {
expect(localeDirection('ar')).toBe('rtl');
expect(localeDirection('ar-EG')).toBe('rtl');
expect(localeDirection('he-IL')).toBe('rtl');
});
});
describe('findMissingKeys', () => {
test('flags a key en has and a locale lacks', () => {
const en = { common: { save: 'Save', cancel: 'Cancel' } };
const ptPT = { common: { save: 'Guardar' } };
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([{ locale: 'pt-PT', key: 'common.cancel' }]);
});
test('a complete catalog has no gaps', () => {
const en = { common: { save: 'Save' } };
const ptPT = { common: { save: 'Guardar' } };
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
test('a plural-forms leaf counts as one key, not one per category', () => {
const en = { files: { one: '{{count}} file', other: '{{count}} files' } };
const ptPT = { files: { one: '{{count}} ficheiro', other: '{{count}} ficheiros' } };
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
});
Binary file not shown.
+6
View File
@@ -2,6 +2,12 @@ export type TranslationMap = Record<string, Record<string, unknown>>;
export type InterpolationParams = Record<string, string | number>; export type InterpolationParams = Record<string, string | number>;
/**
* A pluralizable catalog entry — CLDR's category set (Intl.PluralRules), never a fixed
* `{one, other}` shape, because some locales need up to all six (see docs/localization.md D7).
*/
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>>;
export type I18nContextValue = { export type I18nContextValue = {
t: (key: string, params?: InterpolationParams) => string; t: (key: string, params?: InterpolationParams) => string;
locale: string; locale: string;
+32
View File
@@ -0,0 +1,32 @@
import { useContext, useMemo } from 'react';
import { I18nContext } from './I18nProvider';
type DateInput = Date | number | string;
/**
* Date, time and number formatting bound to the active locale, via `Intl` (D7) — the replacement for
* the ~23 `toLocale*()` call sites that otherwise follow the browser's locale and quietly contradict
* the rest of the UI. Moving those call sites is phase 1-3 work; this is just the hook they move to.
*/
export const useFormat = () => {
const { locale } = useContext(I18nContext);
return useMemo(() => {
const date = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(locale, options).format(new Date(value));
const time = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(locale, { timeStyle: 'short', ...options }).format(new Date(value));
const dateTime = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', ...options }).format(new Date(value));
const number = (value: number, options?: Intl.NumberFormatOptions) =>
new Intl.NumberFormat(locale, options).format(value);
const relative = (value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions) =>
new Intl.RelativeTimeFormat(locale, options).format(value, unit);
return { date, time, dateTime, number, relative };
}, [locale]);
};
+1
View File
@@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"hooks": "workspace:*", "hooks": "workspace:*",
"i18n": "workspace:*",
"state": "workspace:*", "state": "workspace:*",
"widgets": "workspace:*" "widgets": "workspace:*"
} }
+2 -2
View File
@@ -1,5 +1,7 @@
export * from './hooks'; export * from './hooks';
export * from './channels'; export * from './channels';
// The panel apps' own i18n catalog — I18nBridge merges it into the app-wide map under `panel.*`.
export { panelCatalogs } from './locales';
// For the shell: a screen naming itself better than its route can — see usePageTitle's precedence. // For the shell: a screen naming itself better than its route can — see usePageTitle's precedence.
export { usePageTitleOverride, usePublishPageTitle } from './page-title'; export { usePageTitleOverride, usePublishPageTitle } from './page-title';
export type { PageTitleOverride } from './page-title'; export type { PageTitleOverride } from './page-title';
@@ -49,13 +51,11 @@ export type {
CommandHqCostSummary, CommandHqCostSummary,
} from './apps/CommandHQ'; } from './apps/CommandHQ';
// /calendar and /contacts select with a query param rather than a path segment, so what the screens need // /calendar and /contacts select with a query param rather than a path segment, so what the screens need
// from the app is the param name and the link builder. // from the app is the param name and the link builder.
export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared'; export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared'; export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
export { export {
useFilesAPI, useFilesAPI,
useTasks, useTasks,
@@ -0,0 +1 @@
{}
@@ -0,0 +1,8 @@
import type { TranslationMap } from 'i18n';
import enGB from './en-GB.json';
import ptPT from './pt-PT.json';
/** Panel apps' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `panel.*`. */
export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
export { useTranslation } from './useTranslation';
@@ -0,0 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { findMissingKeys } from 'i18n';
import en from './en-GB.json';
import ptPT from './pt-PT.json';
// D2: every shipped locale ships a COMPLETE catalog. Phase 0 scaffolds this catalog with no keys yet
// (no panel is converted in this phase) — the test still runs so it is load-bearing from day one.
describe('officerdev (panel.*) locale catalogs', () => {
test('pt-PT has every key en-GB has', () => {
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
});
@@ -0,0 +1 @@
{}
@@ -0,0 +1,13 @@
import type { InterpolationParams, TranslationKeys } from 'i18n';
import { useTranslation as useTranslationBase } from 'i18n';
import type en from './en-GB.json';
type PanelTranslationKey = TranslationKeys<typeof en>;
// The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
// pass the same bare key the JSON file uses.
export const useTranslation = () => {
const { t: baseT, locale } = useTranslationBase();
const t = (key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params);
return { t, locale };
};
+13 -2
View File
@@ -5,6 +5,13 @@ import { useAuth } from 'hooks/useAuth';
const QUERY_KEY = ['USER_SETTINGS']; const QUERY_KEY = ['USER_SETTINGS'];
/**
* UI locales went from bare codes to regional BCP 47 tags (docs/localization.md D2). A stale bare
* code must resolve rather than fail, and this is a one-time widening on read, not a DB migration.
*/
const WIDEN_BARE_LOCALE: Record<string, string> = { en: 'en-GB', pt: 'pt-PT' };
const widenLocale = (code: string): string => WIDEN_BARE_LOCALE[code] ?? code;
const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
chat: { ...DEFAULT_SETTINGS.chat, ...saved.chat }, chat: { ...DEFAULT_SETTINGS.chat, ...saved.chat },
ai: { ai: {
@@ -14,7 +21,11 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
}, },
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks }, tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance }, appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages }, languages: {
...DEFAULT_SETTINGS.languages,
...saved.languages,
default: widenLocale(saved.languages?.default ?? DEFAULT_SETTINGS.languages.default),
},
tts: { ...DEFAULT_SETTINGS.tts, ...saved.tts }, tts: { ...DEFAULT_SETTINGS.tts, ...saved.tts },
}); });
@@ -121,7 +132,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
}, },
languages: { languages: {
spoken: ['en'], spoken: ['en'],
default: 'en', default: 'en-GB',
translateTo: 'en', translateTo: 'en',
}, },
tts: { tts: {