# Internationalization (i18n) Kandev's web UI is internationalized with [i18next](https://www.i18next.com) + [react-i18next](https://react.i18next.com). English is the source locale, European Portuguese (`pt-pt`), Simplified Chinese (`zh-cn`), Traditional Chinese Taiwan (`zh-tw`), Traditional Chinese Hong Kong (`zh-hk`), and Japanese (`ja`, endonym `日本語`) are the shipped human locales, and a generated **pseudo-locale** is used for QA. Requirements live in [`docs/specs/platform/requirements/i18n.md`](specs/platform/requirements/i18n.md). Traditional Chinese catalogs are derived from `zh-cn` via OpenCC plus a product glossary; see [`docs/specs/platform/requirements/traditional-chinese-locales.md`](specs/platform/requirements/traditional-chinese-locales.md) and [`docs/plans/traditional-chinese-locales/`](plans/traditional-chinese-locales). The migration is **complete**. Every routed web UI surface renders through `t()` / ``, and `i18nGuardFiles` now enumerates the directories that hold them. Running the guard across `components app hooks lib` reports literals in only the unrouted QA pages under `app/demo/`. New copy is still guarded everywhere, so a hardcoded literal is now a regression rather than unfinished migration. Two enforcement layers, and it matters which is which: - **New code is guarded everywhere.** Copy you add — in a new file, or on a line you changed — must go through `t()` / `` whatever directory it is in. See [the new-code ratchet](#but-new-code-is-guarded-everywhere). - **Whole files are guarded everywhere.** The eslint literal rule is an error on every `.ts`/`.tsx` file (tests and `e2e/` excluded), and `check-nonjsx-copy.mjs` scans the whole tree by exclusion. Both were scoped to `i18nGuardFiles` while the migration ran; that list is now the migration record, not the bound. See [The guard is scoped](#the-guard-is-scoped-on-purpose) and [Externalizing a path](#externalizing-a-path). - **The five real locales gate the build.** `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw` and `ja` are complete, and adding an `en` key without translating it fails `pnpm run i18n:check`. See [Real locales](#real-locale-catalogs-gate). ## TL;DR for adding a string 1. Add the English text to the right namespace file, e.g. `apps/web/src/locales/en/settings.json`: ```json { "deleteExecutor": "Delete executor" } ``` 2. Reference it by `namespace:key`: ```tsx import { useTranslation } from "react-i18next"; const { t } = useTranslation(); toast.success(t("settings:executorSaved")); ``` 3. Regenerate the pseudo catalog: `pnpm run i18n:pseudo`. **Namespaces** map to product areas. Current namespaces are `agents`, `automations`, `azuredevops`, `chat`, `common` (shared/anything cross-cutting), `configChat`, `executors`, `github`, `gitlab`, `jira`, `linear`, `sentry`, `settings`, `sidebar`, `system`, `workflows`, and `workspaces`. Each migrated area can add the namespace it needs (`statusBar`, `kanban`, `task`, …). A string used in two or more areas belongs in `common` so it is translated once. A new namespace needs no registration: `lib/i18n/index.ts` discovers catalogs by glob. Adding the same namespace to every real locale in the same change is the ideal, but it is **not required and does not gate** — translation is handled out of band, so `i18n:check` reports a real-locale gap as a warning. See [Real locales warn, they do not gate](#real-locales-warn-they-do-not-gate). ### Interpolation ```json { "deletedItems": "Deleted {{count}} items" } ``` ```tsx t("common:deletedItems", { count }); ``` ### Plurals i18next uses suffixed keys; the call site passes `count`: ```json { "sessions_one": "{{count}} session", "sessions_other": "{{count}} sessions" } ``` ```tsx t("kanban:sessions", { count: sessionCount }); ``` ### Copy containing markup Use react-i18next's ``. Tag indices count **every** child (text nodes included), and the tag body is the element's text: ```tsx Type {phrase} to continue. ``` ```json { "typeToConfirm": "Type <1>{{phrase}} to continue." } ``` Pass `values` explicitly whenever the message interpolates. Because the indices are positional, **the message and the JSX are coupled**: adding or removing a child renumbers everything after it, and the message then points at the wrong node. That does not fail loudly — the copy renders as duplicated fragments with empty tags. Even a prettier reflow can trigger it, by changing which whitespace-only text nodes JSX keeps. `scripts/check-trans-indices.mjs` (part of `i18n:check`) fails when a `` does not land on an element child, and `scripts/fix-trans-indices.mjs --write` remaps drifted indices onto the actual element positions. Never put a `t()` call inside `` children: it adds an expression child, shifting every index after it. ### Outside React `.ts` helpers and non-component functions cannot use the hook — import the module-level `t`, which binds to the shared instance: ```ts import { t } from "@/lib/i18n"; export const label = () => t("common:saved"); ``` Note this resolves at call time; a value captured at module load will not update on a locale switch. ## Plurals Never write the plural ending yourself. i18next selects a form from `count`: ```ts t("task:openTasks", { count: n }); // task:openTasks_one = "{{count}} open task" // task:openTasks_other = "{{count}} open tasks" ``` Passing the morpheme as a value — `t("task:openTasks", { n, s: n === 1 ? "" : "s" })` — renders correctly in English and is untranslatable everywhere else: the plural rule ends up at the call site, so a language with three or six forms has nowhere to express them, and a translator editing the catalog cannot fix it. `scripts/check-inline-plurals.mjs` (part of `i18n:check`) rejects that shape, and `scripts/fix-inline-plurals.mjs --write` converts existing ones. Add `_one`/`_other` to _every_ message that interpolates `{{count}}`, even where English does not inflect (`{{count}} selected`) — other languages agree the participle with the number, and identical forms cost nothing. When the varying word is a whole word rather than an ending (a verb, `"shown"` vs `"hidden"`), it is copy: give each case its own key and select between them, rather than interpolating the word. ## Do not translate User/domain data (task titles, chat, diffs, repo names), code identifiers, `data-testid`, route strings, enum/sentinel values, and brand nouns (Kandev, GitHub, Jira, …). **Never translate a string that is also compared with `===` or used as a map key.** The worst case is a type-to-confirm dialog: the user must type `DELETE ALL NOW` verbatim and the handler compares it with `===`, so translating the copy tells them to type a word the gate can never accept — the dialog becomes impossible to satisfy, and nothing fails until a second language ships. Keep the token in a constant and interpolate it, so the shown and compared values cannot drift: ```tsx const CONFIRM_TOKEN = "RESET"; // sentinel, never translated t("settings:typeToConfirm", { token: CONFIRM_TOKEN }); ``` The guard's `words.exclude` carries a multi-word SCREAMING-CASE pattern for this shape. Note that `eslint-plugin-i18next` wraps every entry as `^…$`, so a prefix-only pattern silently never matches — write the full match. The same hazard, milder, applies to any display string that doubles as a comparison key or `Map` key (command-palette group names, header titles). Key off a stable id and translate only at render. `kanban-header.tsx` shipped `title === "Home"` for a while — true only in English, and silently the wrong branch everywhere else. ### …and to any display string that is also PERSISTED A string that is both shown to the user and written to storage cannot simply be wrapped in `t()`: translating it in place writes whichever locale happened to be active into the user's saved record, where it stays after a switch. Three places in the app are like this, and all three use the same split — a canonical English value for storage beside a catalog key for display: | Module | Persisted | Displayed | | ---------------------------------------------- | ---------------------------------- | ------------------------------------------ | | `lib/state/layout-manager/constants.ts` | `PANEL_REGISTRY[id].title` | `titleKey` via `panelTitle()` | | `lib/layout/layout-profiles.ts` | `BUILT_IN_LAYOUT_PROFILES[i].name` | `nameKey` via `builtInLayoutProfileName()` | | `lib/state/slices/ui/sidebar-view-builtins.ts` | `DEFAULT_VIEW.name` | `sidebarViewName()` | Get the **direction** right, and write a test that pins it. Dockview panel titles pass through the serializer twice: `toSerializedDockview` feeds `api.fromJSON` and so must localize, while `panelFromDockviewPanel` is what `fromDockviewApi` captures into a saved layout and so must canonicalize. The first version of that split had them reversed; every unit test passed, because the test had been written to the same wrong model. What caught it was the pseudo-locale oracle — which could only see it because the layouts screen's `allow` list had stopped claiming those titles were expected-English. The other seeded-preset modules (`my-github/search-bar.ts`, `my-jira/presets.ts`, `azure-devops-workspace-defaults.ts`) are deliberately English for the same reason and have **not** been split, because their values are also sent to an agent or matched against a server-side default. Each says so in a header comment. ## Commands > Requires **Node 24** (`apps/package.json` `engines`). ```bash cd apps/web pnpm run i18n:check # the five repo-wide gates (runs in CI: Frontend Tests) pnpm run i18n:pseudo # regenerate the pseudo catalog from en pnpm run lint:i18n # preview the guard on a path that is NOT yet migrated pnpm run i18n:ratchet # new-code guard + allowlist-only-grows (pre-commit + CI) node scripts/check-nonjsx-copy.mjs # copy lint cannot see (part of i18n:check) pnpm run i18n:removed # did this change rewrite any English? (diff-based, advisory) pnpm run i18n:parity # per-locale missing/extra keys (report, never a gate) pnpm run i18n:sweep # copy the jsx-only rule cannot see (report, never a gate) # Codemods — always inspect the diff; each declines what it cannot prove safe. node scripts/externalize-strings.mjs --write components app hooks lib node scripts/fix-inline-plurals.mjs --write components app hooks lib node scripts/fix-trans-indices.mjs --write components app ``` `lint:i18n` uses `eslint.i18n.config.mjs`, which applies the guard to whatever paths you pass. It predates the repo-wide rule and survives as a way to run the guard over one directory in isolation; it never gates a merge. The real gate is `pnpm lint`, where the rule is an **error on every source file**. `i18n:check` is not scoped either — its checks scan `components`, `app`, `hooks`, `lib`, `src`, and published Markdown in `docs/public` repo-wide, and it runs in CI (Frontend Tests → _Run i18n checks_). `check-nonjsx-copy.mjs` is no longer the exception it used to be: it defaulted to `i18nGuardFiles`, which left 1141 of 2564 source files judged by neither gate, and the non-JSX positions it exists to find are exactly what lives in the plain `.ts` modules that rarely made the list. It now scans the whole tree by exclusion. That is deliberate: each check pins a bug that fails **silently at runtime** rather than merely leaving a string in English, so there is no reason to let it into unmigrated code either. Unmigrated source files pass trivially, because a file that never calls `t()` cannot break the source checks. The em-dash check also scans published Markdown under `docs/public`. The published-docs workflow runs the same check through `scripts/validate-public-docs.mjs`, so documentation-only changes are covered. The same repository-wide scanner runs as the `public-copy-em-dash` pre-commit hook on every commit, so violations are caught before CI. Run `pre-commit run public-copy-em-dash --all-files` to invoke that hook directly. `i18n:check` is a **drift** check, not a re-extraction: with key-based i18next the English copy exists only in the catalog and cannot be recovered from source, so regenerating would destroy it. It fails on keys missing from the catalog and on `en`/`pseudo` divergence, warns about orphaned entries, validates every `` element's tag indices against its JSX children (see below), rejects English plural endings passed as interpolation values, rejects `t()` at module scope, and rejects hardcoded copy in the non-JSX positions the eslint rule cannot inspect. Module scope matters because the module-level `t` resolves when the file is imported: `const ROWS = [{ label: t("k") }]` pins that copy to the boot locale, so it renders correctly and simply never changes on a switch. The pseudo-locale cannot see it — the text _is_ translated. Store the key and resolve it at render, or make the value a component. ### Pseudo is not bundled in production `pseudo` is the **largest** catalog we have — accented characters are multi-byte — and no production user can ever select it, because `selectableLocales` filters it out of the switcher. So a plain `vite build` does not compile it in at all: `lib/i18n/index.ts` globs it separately behind `__KANDEV_PSEUDO_LOCALE_BUNDLED__`, which `vite.config.ts` defines from `shouldBundlePseudoLocale` in `lib/i18n/bundling.ts`. The branch folds away and rolldown never pulls those JSON modules into the graph. Filtering the merged catalog at runtime instead would still ship every byte, which is the entire problem. **Lazy loading is not a substitute for this, and the two compose rather than overlap.** Catalogs are fetched on demand (see [Catalog loading](#catalog-loading-and-why-en-is-the-exception)), so pseudo would be a chunk nobody requests in production — but an unrequested chunk is still built, still deployed, and still reachable by anyone who can set the `kandev_locale` cookie. The build-time constant is folded into the _lazy_ glob for exactly that reason: on a plain `vite build` no pseudo chunk is emitted at all. Nothing asserts on that absence, so check it by hand when either mechanism changes — `grep -rl "Ŕēţŕŷ" dist/assets/*.js` must return nothing. | invocation | pseudo? | | ----------------------------------------------------------------------- | ------- | | `pnpm dev`, `vitest` (Vite `serve`) | yes | | `pnpm build` / `make build-web` / release / desktop | **no** | | `pnpm build:e2e` / `make build-web-e2e` (`KANDEV_I18N_BUNDLE_PSEUDO=1`) | yes | `import.meta.env.PROD` cannot make this decision: the e2e harness serves a **production** bundle, so it is true there too — and e2e is exactly where pseudo has to survive, because `e2e/tests/i18n/pseudo-coverage.spec.ts` is the only oracle that sees copy the jsx-only guard structurally cannot. That is why the e2e runner (`e2e/scripts/run-e2e.sh`) and the CI e2e build job both use the `:e2e` target. **If you add another build path that e2e runs against, use that target** — the coverage spec fails loudly rather than silently when pseudo is absent (every screen falls back to `en` and reads as un-externalized), but it fails as a wall of findings that look like a migration regression rather than a build misconfiguration. `SUPPORTED_LOCALES` still lists `pseudo` unconditionally, and must: that type drives the `en` ↔ `pseudo` parity gate in `i18n:check` and `i18n:pseudo`. The exclusion is a bundling decision only. To eyeball pseudo in a production-mode local instance (rather than `pnpm dev`), build with `make build-web-e2e` instead of `make build-web`. ## The guard is scoped, on purpose `i18next/no-literal-string` is an **error**, but only for the paths listed in `i18nGuardFiles` (`apps/web/eslint.i18n.options.mjs`). Everything else is unaffected. This is the single most important design decision here, and it was learned the expensive way. The first attempt shipped the rule globally over `components/**` and `app/**`. That makes every PR authored anywhere in the app — PRs with nothing to do with i18n — fail lint the moment they add a label, and it makes the migration branch conflict with `main` continuously. That branch went through ten conflict-resolution rounds in two days and could not be kept alive long enough to merge. Scoping inverts it. The guard can only ever tighten: - Paths already migrated are protected, so a regression there is a hard error. - Paths not yet migrated are invisible to it, so nobody else is blocked and there is no treadmill. - Every migration PR moves one path from the second group to the first. Entries are globs, so use a file glob while a directory is partly migrated. When it is done, leave those entries where they are: **adding a path is the only safe edit to this list.** `check-guard-allowlist.mjs` fails if an entry disappears while its path still exists — globs included, resolved with `fs.globSync`. So **never remove an entry** to make a build pass, and equally, never tidy several file globs into one `dir/**`. Both produce the same diff to the array, the check cannot tell them apart, and refusing to guess is the point: one of them silently un-protects migrated copy. A broader glob added _alongside_ the existing entries passes; swapping them for it does not. The same check rejects a path listed **twice**. An exact duplicate changes no behaviour, so nothing else looks for one — the eslint rule is indifferent to a repeated pattern, and the removal check above only inspects entries that _left_ the array. That is how #2214 landed two past every gate the repo then had. What a duplicate costs is the record: it claims an earlier migration's coverage as the new PR's. Entries that a broader glob already covers are deliberate and stay — they record which PR migrated which half of a group — so this is an exact-match check, not a redundancy check. #### An entry can be born dead, and only a glob expansion shows it Both checks above ask what happened to an entry _between_ two states. Neither asks the prior question: **does this entry match any file at all?** An entry that matched nothing on the day it was added is indistinguishable from a healthy one — it never leaves the array, it is never a duplicate, and `pnpm lint` passes because the rule simply has no files to apply to. The list grows, the ratchet reports "N added", and the path is unguarded. A dynamic route is the way to hit this, because Next-style directories are named in glob syntax. `[id]` is a **character class** matching a single `i` or `d`: ```bash node -e 'console.log(require("fs").globSync("app/settings/workspace/[id]/automations/**/*.tsx").length)' # 0 node -e 'console.log(require("fs").globSync("app/settings/workspace/[[]id[]]/automations/**/*.tsx").length)' # 4 ``` The Automations migration (#2247) shipped the unescaped form, and review caught it. So when an entry contains `[`, escape it as `[[]…[]]`. `check-guard-allowlist.mjs` now rejects an entry that matches no file, so this particular class cannot recur — the three checks it runs are deliberately separate because each is blind to the others' failure: | check | sees | | --------- | --------------------------------------------------------------------------- | | removal | an entry that **left** the array while its path still exists | | duplicate | an entry listed **twice** | | unmatched | an entry that **never matched anything**, including on the day it was added | The first two compare two _states_ of the array, which is why neither can see a born-dead entry. If you add a check here, add the test that shows the existing ones miss what it catches — `scripts/lib/guard-allowlist.test.ts` pins that for all three. ### …but new code is guarded everywhere Scoping the whole-file guard leaves a hole: a brand-new component under a path that is not on the list would be unchecked. `check-new-code-i18n.mjs` closes it by judging the **change** rather than the file: | | Judged on | | ------------------------------ | ------------------------------------------ | | A file the change **added** | the whole file — it carries no legacy debt | | A file the change **modified** | only the lines it touched | Existing literals elsewhere in a modified file are somebody else's migration, so this can be repo-wide without recreating the treadmill. It is the same contract `golangci-lint --new-from-rev` already enforces for Go in `.pre-commit-config.yaml`, and it runs in both pre-commit and CI. So there are two independent ratchets, and both only tighten: - **`i18nGuardFiles`** — whole files, listed paths. Grew one directory per PR through the migration; now grows when a PR adds a path that renders copy. - **the new-code ratchet** — new lines, everywhere. Always on. ## Externalizing a path The directory-by-directory sweep of the screens is **finished** — #2367 localized `app/office`, the last un-migrated area. This loop is what you still run for a path you are adding, for a helper directory the sweep did not cover (`lib/sidebar` is one), or to remediate a blind-spot find like the SCREAMING_CASE tables in PR `#2409`: ```bash cd apps/web pnpm run lint:i18n components/ # 1. the work-list node scripts/externalize-strings.mjs --write components/ # 2. mechanical pass # 3. review the diff, fix what it declined pnpm run i18n:pseudo # 4. regenerate pseudo pnpm run lint:i18n components/ # 5. clean? ``` 6. Add the path to `i18nGuardFiles` in `eslint.i18n.options.mjs`. **This is the step that makes the work stick** — without it the directory drifts straight back. 7. If the area has a screen worth crawling, add it to `SCREENS` in `e2e/tests/i18n/pseudo-coverage.spec.ts`. That spec is a **hard CI gate** — it runs unconditionally in the E2E `chromium` project — so adding a screen commits the whole DOM that screen renders, not just your directory. It is a regression floor rather than a completeness proof: it still cannot see a hardcoded string under four letters, or one sharing a text node with migrated copy, which is why step 9 below is still worth doing by hand. 8. `pnpm run i18n:check && pnpm lint --max-warnings 0 && pnpm exec vitest run`. 9. Switch to **Pseudo (QA)** in Settings → General → Appearance and look at the screen. Anything still in plain English was missed — see below for why lint cannot tell you this. Use `pnpm dev`; a production build does not bundle the pseudo catalog and will not offer the option (see _Pseudo is not bundled in production_). ### The guard has blind spots `i18next/no-literal-string` only inspects **literals in JSX**. Template literals _are_ now checked (`should-validate-template: true` — measured at +2 to +5 findings per un-migrated directory, all of them real copy), but it still cannot see a `confirm()` argument or copy returned from a plain `.ts` helper. It has one more blind spot worth knowing, because it is the shape config tables take: **a literal assigned to a SCREAMING_CASE identifier is skipped entirely.** ```tsx const HEADER = Archive board; // NOT flagged const header = Archive board; // flagged const ROWS = [{ label: "Disk usage" }]; // NOT flagged ``` That is why module-scope config objects survived the sweep, and why the new-code ratchet inherits the same hole. `check-module-scope-t.mjs` catches the adjacent case (a module-scope `t()` call, frozen at the boot locale) but not module-scope literal copy. Reviewing a new `const TABLE = [...]` of labels by eye is still required — and note the shape one step out from a config table is just as invisible: **a plain function returning object literals**, e.g. a `buildRows()` that returns `[{ label: "Disk usage" }]`, is never inspected either. **Walk the hooks your routes call, not just the components.** A closure derived from component imports misses them, and they hold no JSX so the string count reports them as zero. Settings → Agents shipped that way and needed a follow-up for a thrown `Error` and a `setError` fallback in two `hooks/domains/settings/` modules; Settings → System → Storage caught ten toast titles in `use-storage-maintenance.ts` only because it re-derived its closure instead of trusting the count. Install/update flows and any maintenance action are the toast-heavy cases. A count from `pnpm run lint:i18n` is a floor on the visible half, never a work-list. **Default values in a destructuring pattern are a third invisible shape, and the fix is not the obvious one.** `ActionButtonContent` carried three: ```tsx export function ActionButtonContent({ pendingLabel = "Running...", // NOT flagged — not a literal in JSX successLabel = "Done", errorLabel = "Failed", }: Props) { ``` These are evaluated in the parameter list, which runs _before_ the component body, so a `const { t } = useTranslation()` in the body is not in scope for them — you cannot fix this in place. Resolve the fallback inside the body instead (`const pending = pendingLabel ?? t("system:jobRunning")`). Prop defaults on a shared presentational component are a common home for this. ### …and the blind spots are now a gate Everything above is still true about `i18next/no-literal-string`. What changed is that a second detector runs over the same files: `scripts/check-nonjsx-copy.mjs` (logic in `scripts/lib/nonjsx-copy.mjs`), part of `pnpm run i18n:check`. It inspects exactly the positions the rule cannot: | shape | example | | --------------- | ----------------------------------------------------------- | | `config-table` | `const ROWS = [{ label: "Disk usage" }]` | | `helper-return` | `function badge() { return "Waiting for input" }` | | `param-default` | `function C({ label = "Running..." })` | | `display-call` | `toast({ title: "Rename failed" })`, `setPreviewError("…")` | | `copy-property` | `{ description: "…" }` outside a table | It scans the **whole tree**, by exclusion (`node_modules`, `dist`, `e2e`, `scripts`, `src/locales`, `*.test.*`, `*.test-helpers.*`, `*.test-utils.*`, `*.d.ts`) rather than by allowlist, so a new directory is covered the day it is created. It used to default to `i18nGuardFiles`, for the same treadmill reason the eslint rule was scoped — but the arithmetic did not work out: 1141 of 2564 source files were judged by neither gate, and non-JSX positions are precisely what lives in the plain `.ts` modules that rarely made the list. A run over that missing scope found 325 findings. Unlike a lint rule, this check has no per-PR-file cost — it is one script over the tree — so there was never much reason to scope it. The exemption marker must be a `//` LINE comment. `exemptionsFor` matches `/i18n-exempt:?(.*)$/` without the `m` flag, so `$` binds to the end of the comment's whole text: a marker on the first line of a `/** */` block never matches and is silently ignored. `check-new-code-i18n.mjs` runs the same detector over added files and changed lines everywhere, which is what guards a directory before anyone thinks about it. Template literals are read too, judged on their **longest static chunk** rather than on the chunks joined: `` `Every day at ${at} ${zone}` `` is copy, while `` `${w}px ${h}em` `` is not. That rule has a known blind spot, and it is worth naming because it is not obvious from the outside: a template whose static chunks are each individually too small still reads as non-copy even when the sentence plainly is copy. `` `${n} tasks · ${m} sessions · ${d}` `` is the worked example — its longest chunk is `" tasks · "`, a single lowercase word, which `looksLikeCopy` rejects. Judging the chunks joined would fix this one and break `` `${w}px ${h}em` ``, so the floor stays where it is. What catches this class is a human reading the diff, and the pseudo-locale. #### Silencing one, and why the reason is mandatory ```ts // i18n-exempt: persisted preset seed; localizing needs a key/value split. const DEFAULT_PR_PRESETS = [ { label: "Review", hint: "Read the diff, flag issues" }, ]; ``` The marker attaches to the enclosing **statement** or **function** — one comment covers a whole config table, or a whole prompt builder with four `return`s. A marker with no reason after the colon is _reported_, not honoured. That is the load-bearing part. Every shape this check finds has a legitimate form, and they are not distinguishable from the string: a value persisted in whichever locale created it, a prompt sent verbatim to a model, a token compared with `===`, a brand noun. The ~60 exemptions in the tree today are each one of those, and every one says which. #### What it still cannot see It reads literals, so copy assembled at runtime from fragments is invisible, and it takes the author's word for an exemption. The pseudo-locale remains the completeness check. Two limits are worth knowing concretely: - A **single lowercase word** (`"ended"`, `"watching"`) is below the copy threshold — too little signal to separate from an enum value. Its neighbours in the same helper usually are above it, which is how those two were found. - A string built by concatenation (`prefix + name`) is not a literal. `externalize-strings.mjs` covers those shapes — it walks `.ts` as well as `.tsx` and handles template literals and dialog/toast arguments — but **a zero from `pnpm lint` does not by itself mean a file is fully externalized**. The pseudo-locale is the real completeness check: anything still rendering plain English under it was missed. `EXCLUDED` in that script lists paths whose strings are deliberately left alone (`lib/api`, `lib/types`, layout serialization, fixtures) and why. ### Sweeping for what the guards cannot see `pnpm run i18n:sweep ` (`scripts/i18n-sweep.mjs`) is a **human** tool for the blind spots above — deliberately not wired into CI or pre-commit, and it always exits 0. It prints two separate sections, because mixing "this is broken" with "look at this" gets a report read as neither: 1. **English plural concatenation** — `{n} file{n === 1 ? "" : "s"}`, and the hoisted `const plural = count === 1 ? "" : "s"` form. These are defects, and **no gate we own sees them**: `no-literal-string` is jsx-only, and `check-inline-plurals.mjs` only inspects morphemes passed _through_ `t()`. A repo-wide sweep found 44 of them across 36 files. One helper had 2 of its 11 call sites visible to lint — migrating the visible two would have left nine broken while the directory reported clean. **30 of those 44 are under `components/task`**, the largest directory still to migrate, so run the sweep on every `components/task` batch specifically rather than treating it as a generic step. 2. **Prose literals in non-JSX positions** — for eye review, never automatically a defect. Two caveats. **Prompt builders are deliberately English**: their content is sent verbatim to a model, so translating it would be a bug (`components/github/pr-checks-section.tsx` and `pr-ci-popover.tsx` are the known-good examples, and the tool prints this caveat itself). And the section is a _narrow sample_, not an inventory — its noise filter drops any literal starting with a letter, so it surfaces bracketed log prefixes and media queries but not a `{ label: "Disk usage" }` config table. A short section is not evidence a directory is clean. Because lint is jsx-only, finding counts from `pnpm run lint:i18n` **understate the real work by roughly 15%** — measured at ~14 extra strings on a 99-string batch. Budget for that when sizing a directory, and keep the pseudo-locale as the completeness check. ## How it's wired - **Runtime**: `apps/web/lib/i18n/` — the i18next instance, `activateLocale()`, `` (mounted outermost in `app-shell.tsx`), `resolveInitialLocale` (payload → cookie → `en`), and `formats.ts` (locale-aware date/number/relative time; `timeAgo` is a deprecated alias for `formatRelative`). - **Catalogs**: `src/locales//.json`, discovered through Vite's glob import — no macro transform. **`en` is bundled eagerly; every other locale is a lazily-fetched chunk** (see below). The one build plugin is `kandev:i18n-pseudo-locale-bundling` in `vite.config.ts`, which decides whether the `pseudo` catalog is compiled in at all. - **Locale source of truth**: the `kandev_locale` cookie. The Go shell (`apps/backend/internal/webapp/shell.go`) reads it to set `` and seed the boot payload, so first paint matches with no flash. ### Catalog loading, and why `en` is the exception Bundling every locale put all of them in the initial download — 1.4 MB raw / 389 KB gzipped, growing linearly with each locale added, so that a user could read one language they had downloaded four of. Only `en` is eager now; the rest are fetched per locale. Two things make that safe rather than a source of flashing keys: - **`en` must be synchronous.** It is `DEFAULT_LOCALE` and `fallbackLng`, and `returnNull: false` means a missing key renders as _the key itself_. If `en` were not in memory when `i18next.init()` returns, every key on screen would paint as `common:save` until a chunk landed — silent and ugly rather than loud. Keeping it eager also keeps `ensureInitialized` synchronous, which the boot path depends on: react-i18next suspends on an uninitialized instance and there is no Suspense boundary above the React root. - **The active locale is known before React mounts.** The Go shell resolves it into the boot payload, so `main.tsx` awaits exactly ONE locale (`preloadLocale`) inside the promise it already waited on for the payload, _before_ `createRoot().render()`. Nothing renders against a half-loaded catalog. For `en` that await resolves immediately and the boot is unchanged. A runtime switch goes through `activateLocale`, which loads **before** `changeLanguage` rather than on the `languageChanged` event — `changeLanguage` re-renders the whole tree, so registering afterwards would show one frame of English. A failed chunk never throws: the app degrades to English under the requested ``, and `installVitePreloadRecovery` reloads once on `vite:preloadError`. `vite.config.ts` groups each locale's namespaces into a single chunk. i18next is seeded with every namespace and any surface may use any of them, so a locale is always loaded whole; a chunk per namespace would cost a non-English boot 29 round trips for the same bytes. Unit tests are the one place that still wants everything in memory — suites call `i18n.changeLanguage("pseudo")` and assert on the next line — so `vitest.setup.ts` awaits `loadAllLocalesForTests()` once. `pseudo-coverage.spec.ts` already tolerates this, and it is worth knowing why it does not need a change here. Its `waitForScreen` anchors assert per screen that the route rendered **and** that the anchor's copy went accented — the second half was written as insurance against exactly this, a catalog arriving asynchronously, back when it was still hypothetical. `` alone would not have done: `shell.go` writes it server-side, so it is correct before any script runs. `e2e/tests/i18n/lazy-catalogs.spec.ts` is the gate. It asserts against a real production bundle that an English boot fetches no other catalog, that activating one fetches exactly its chunk, and that a cold `zh-cn` boot renders translated rather than key names. - **Shared UI**: `@kandev/ui` primitives carry their own hardcoded accessibility labels ("Close", "Toggle Sidebar", "Loading", "Go to next page"). The package must not import the app's i18n runtime — it has to stay usable standalone — so these are localized at the **call site**, not in the package. Check the shape before inventing a seam, because the two halves differ: - **A label in an ATTRIBUTE needs nothing.** Every one of them sits on an element that spreads `{...props}` _after_ it, so the overridable string prop the spec asks for already exists and the English literal is only its default. `Breadcrumb`'s `aria-label="breadcrumb"` was this shape: the fix was `aria-label={t("common:breadcrumb")}` at its three consumers (`page-topbar`, `task-top-bar-title`, `OfficeSimplePane`) plus a comment in the package recording the contract. A `label?: string` prop would have duplicated a prop React already gives you. Same shape, still un-overridden: `SidebarRail`'s `aria-label`/`title`, `Spinner`'s `aria-label="Loading"`, and `Pagination`'s three. - **A label that is a TEXT CHILD does need a seam**, because a consumer cannot reach it through `{...props}`: the `` in `DialogContent`, `SidebarTrigger` and `BreadcrumbEllipsis` ("Close", "Toggle Sidebar", "More"), and `PaginationPrevious`/`PaginationNext`'s visible "Previous"/"Next". Each wants an optional label prop defaulting to today's text. Open — the dialog "Close" is the one a pseudo walk keeps surfacing, because every integration settings page renders it. A third-party primitive is the same call: sonner names its toast region `Notifications alt+T` and accepts `containerAriaLabel`, which `AppToaster` in `src/app-shell.tsx` passes. That has to be its own component — `` is mounted _by_ `AppShell`, so a `t()` call in `AppShell`'s own render sits outside the provider and would not re-render on a locale switch. - **Tests**: `vitest.setup.ts` calls `initI18nForTests()`. react-i18next falls back to the default instance when no provider is present, so no render wrapper is needed and every test's render tree is unchanged. ## Backend `apps/backend/internal/i18n` localizes the strings the **backend renders directly to a browser**. That is two surfaces, and between them they are now complete: the plain-text error pages served when the SPA bundle cannot be delivered (`webapp.*`), and the shared-task artifacts — `share.html`, the gist README, and the gist description — that Go writes as HTML/Markdown (`share.*`). Catalogs are embedded JSON (`locales/.json`, flat `key: message`) mirroring the frontend's shape; `pseudo` is generated from `en` by the frontend's `pnpm run i18n:pseudo`, which writes both catalogs so the QA locale is a complete oracle. ```go locale := i18n.FromRequest(r) // cookie -> Accept-Language -> "en" http.Error(w, i18n.T(locale, "webapp.shellUnavailable"), http.StatusServiceUnavailable) ``` ### Interpolation and plurals `Tf` is the only supported way to build a message that carries a value. Never `fmt.Sprintf` a translated string — that freezes the argument order in English, and a translator cannot move a placeholder that is not in the catalog. ```go i18n.Tf(locale, "share.pageTitle", map[string]any{"title": title}) // "{{title}} — kandev share" i18n.Tf(locale, "share.messageCount", map[string]any{"count": n}) // share.messageCount_one / _other ``` Plurals use the same `_one` / `_other` suffixes as the frontend, selected from a `count` var; a key with no plural forms falls back to the base message. Values are substituted verbatim, so escaping is the call site's job. Interpolate anything the pseudo-locale must not transliterate — filenames, URLs, brand names — rather than writing it into the catalog, or the QA locale turns it into a dead pointer. ### Locale for artifacts that outlive the request A shared task is a static file on GitHub: when a reader opens the link they make no request to us, so there is no cookie or `Accept-Language` to read. The locale is resolved once from the **creating** request and threaded as an ordinary argument down `httpCreate → CreateShare → Backend.Upload → the builders`. Not a context value, not package state — the builders stay pure functions of (snapshot, locale), which is what makes the per-locale render tests possible. See [ADR 2026-08-01](decisions/2026-08-01-share-artifact-locale.md). `i18n.Normalize` is the single source of truth for locale support, shared with `webapp.NormalizeLocale`, so `` and message lookup can never disagree. A missing key echoes the key (never blank), and an unsupported locale falls back to `en` rather than erroring. ### What the backend deliberately does NOT translate Almost everything else. The ~1,100 `http.Error` / `gin.H{"error": …}` strings are **diagnostics**, not display copy: the SPA maps API failures onto its own translated toasts rather than rendering the server's text. Translating them would make logs and support harder while helping no user. Also untranslated: log lines, agent/ACP output, and CLI output. Inside the localized surfaces, three categories look like copy but are not, and the share builders mix all of them into the same lines: - **Wire values.** `roleUser` / `roleAssistant` / `roleSystem` come from the message store. `messageRoleAttrs` returns a CSS class, a label, and an emoji together — only the label is copy, and its fallback branch echoes the raw role. - **Identifiers.** Brand names, URLs, filenames (`share.html`, `snapshot.json`), and CSS class names. In "Built with [kandev](…)", only "Built with" is copy. - **Emoji**, which stay in the Go format string rather than the catalog. **Contract for new user-facing backend output:** prefer returning a stable error _code_ the frontend translates. Only use `i18n.T` when Go itself renders the bytes a user reads. ## Pseudo-locale QA (the completeness oracle) Switch to **Pseudo (QA)** in Settings → General → Appearance (dev/e2e builds only). Every catalog message renders accented, e.g. `Cancel` → `Ćàńćēĺ`. Any remaining **plain-English** text on screen is a string that was never routed through `t()` — that is the acceptance check for a migrated screen, and the only one that sees the guard's blind spots. Interpolation placeholders and `` tag markers are preserved by the generator. The by-hand walk is for a screen you are migrating. For the screens already migrated, `e2e/tests/i18n/pseudo-coverage.spec.ts` automates most of this check and **runs as a hard gate in CI** — it is no longer behind an env var. A red run there names the strings and the screen; externalize them rather than allowlisting them. It does not replace the walk. The spec reports a plain-ASCII run of four letters or more in a text node with no accented character in it — so a hardcoded string shorter than that, or one sitting in the same text node as migrated copy, still reaches you only by eye. Both limits are load-bearing rather than oversights; the spec's header records what happens when you relax them. One thing it shows you is **not** a miss: an interpolated API or network payload stays English by design, so it renders unaccented and is still correct. See [the interpolated-value limit](#and-it-is-weakest-at-an-interpolated-value). Two things it cannot show you at all — the second is detailed below: 1. **Module-scope `t()`.** That copy _is_ translated, just frozen at the boot locale, so it renders accented and looks correct while never responding to a switch. `check-module-scope-t.mjs` is what catches it. 2. **An un-migrated value interpolated into a migrated frame** — it renders inside accented copy and reads as done. A third used to belong on that list — copy that never becomes a text node — and the spec now reads it directly. That pass is described next, because what it does _not_ check still matters when you migrate. ### It reads attribute copy, which nothing on screen shows you **Copy carried in an attribute — `aria-label`, `aria-description`, `title`, `placeholder`, `alt` — renders nothing visible, so a human eyeballing the pseudo-locale can never catch it.** The Settings → System → Storage review demonstrated the gap by reverting an `aria-label` to an English template literal: nothing failed. `findUnlocalizedCopy` in `e2e/tests/i18n/pseudo-coverage.spec.ts` therefore runs a second pass over `document.querySelectorAll("[aria-label],…")` alongside the `NodeFilter.SHOW_TEXT` walk, applying the same accented/word-like tests and the same allowlist stripping. Findings name the attribute and the element that carries it, since a bare string gives you nowhere to look: ```text aria-label on button#language-select: "Display language (pseudo)" ``` **Read the control line before you trust a green screen.** A migrated attribute renders as `Mōŕē ĩńfōŕmàţĩōń`, which has no 4-letter ASCII run and so is dropped at the `wordlike` gate before the accented test ever sees it. Soundness is fine — a real miss is pure ASCII — but it means a working pass and a pass whose selector matched _nothing_ produce the same empty output. One agent scanning by hand concluded their scan was broken on exactly this. The spec therefore asserts `inspectedAttributes > 0` per screen, prints how many rendered fully accented, and pins accent detection once against `#language-select`'s known-migrated `aria-label`. If you scan by hand, emit a known-migrated attribute as your own positive control. **But note exactly what that control establishes, because it is less than it looks.** `inspectedAttributes > 0` proves the pass _inspected something_. It does not prove it inspected _the screen under test_ — the sidebar, topbar and status bar contribute over a hundred attributes on every route, and they are accented long before a `SCREENS` route mounts out of its lazy chunk (`settings-routes` is 2.8 MB; Office has its own). For a while the only thing separating "route rendered" from "scan the DOM" was a `waitForTimeout(1_000)`, and with both chunks blocked so that _no screen rendered at all_, 13-16 of the 21 tests still reported clean — varying run to run, because it was a race the settle could not see. That is the worst direction for a gate to fail in: under CI load a slower render makes a green result _more_ likely, so the check is most permissive exactly when the tree is most likely to be broken. Each `SCREENS` entry now carries a required `anchor` — a selector that cannot match until that route has rendered — and the walk does not start until it matches _and_ the matched element's text is accented. Settings screens anchor on a `data-testid` their own component renders; Office screens anchor on `main[data-office-route=""]`, the route outlet stamped with the resolved path, because the SPA mounts Office pages with empty collections and any anchor inside the populated branch would never match. Adding a screen to `SCREENS` means adding its anchor, and the anchor is the part to think about. **Generalize the lesson rather than the fix: a positive control asserting that a check inspected something does not establish that it inspected the thing under test.** When you add a control, ask what the _weakest_ state of the system is that still satisfies it — here, "the app shell painted" satisfied it, and that state is reached on every route whether or not the route ever arrives. Four things about that pass are deliberate, and worth knowing before you read its output: - **It does not reuse the text pass's zero-size-rect check.** A visually hidden control with an `aria-label` is the exact case the pass exists to catch, and every `sr-only` node measures as good as zero. It uses `checkVisibility()` instead — `display: none`, `visibility: hidden` and skipped `content-visibility` subtrees are dropped, because then nobody receives the label; opacity and off-screen positioning are kept, because the accessibility tree keeps them. - **An empty value is never reported.** `alt=""` is load-bearing markup — it marks an image as decorative so screen readers skip it — and flagging it would push authors into writing alt text for spacers. - **Findings are keyed by attribute and value, not by element**, with a `(×N)` count. One un-migrated shared component rendered on twenty rows would otherwise bury every other string on the screen. - **One accented character does not clear an attribute**, unlike the text pass. An `aria-label` built as `` `Collapse ${label}` `` renders `Collapse Ĝēńēŕàĺ` — an un-migrated English _frame_ around a migrated value, which accent-clears-all would call done. Allowlist stripping is what separates that from the legitimate case, where the ASCII fragment is allowlisted _data_ rather than the frame (`Àćţĩōńś ƒōŕ Agent` on the layouts screen strips to nothing). Such findings are tagged `— English frame, migrated value`. The guard is still the primary defence: those five attributes are in `jsx-attributes.include`, so on an allowlisted path the lint rule flags the literal before it ever renders. The oracle is the second check that attribute copy previously had none of — on a path not yet allowlisted, or for a fragment the `words.exclude` patterns skip. Prefer a `t()` call over a template literal in an attribute either way, so the value stays greppable. ### …and it is weakest at an interpolated value Plain-English-on-screen is not a complete signal. When an un-migrated value is interpolated into a **migrated** frame, the frame renders accented and the payload reads as part of the sentence: ```text agents:unableToStartUpdate = "Unable to start update: {{error}}" → Ữńàƀĺē ţō śţàŕţ ūþďàţē: Agent installation is already in progress. ``` That scans as done. Both instances found this way survived a twelve-screen pseudo walk of the migrated surface before anyone noticed them. So when a message interpolates a value, ask **where the value originates**. That distinction is the whole check, and it is a two-minute grep over the paths you migrated: ```bash cd apps/web grep -rnE 't\("[^"]+", \{ (error|message)' components app hooks | grep -v '\.test\.' ``` - **An API or network error payload stays English by design.** `String(err)` from a failed request, or a `result.error` the backend returned, is a diagnostic — see [Backend](#what-the-backend-deliberately-does-not-translate) for why the ~1,100 server error strings are out of scope. Around 37 catalog keys already interpolate one; none of them is a miss. Where such a call needs a fallback for a missing payload, the _fallback_ is copy and is translated (`t("linear:testFailed", { error: result.error || t("linear:unknownError") })`) while the payload itself is not. - **A client-side literal reaching a translated frame is a miss.** A string thrown, `setError`'d, or assigned in one of your own hooks or helpers is copy you wrote, and neither the lint rule nor the pseudo-locale can see it. **A translated toast followed by an English `throw` is correct, not a miss.** This shape recurs across the migrated GitHub and Jira settings and should not be churned: ```tsx } catch { toast({ description: t("github:failedToSaveQuickActions"), variant: "error" }); // Signals failure to the settings save coordinator, which only records the // contributor id — this message never reaches a user, so it is not copy. throw new Error("Failed to save quick actions"); } ``` The user already saw translated copy; the throw is control flow for `useSettingsSaveContributor`. Translating it would change nothing on screen. While the migration is in flight, an unmigrated screen is expected to be full of plain English — pseudo only proves anything about screens whose components are on the `i18nGuardFiles` allowlist. ### Walk it by consumer, not by route Which screens you walk matters as much as what you look for. A component's copy is verified by walking **every route that mounts it** — not the routes your migration owns. The natural method fails here. Walking the import closure _from your own pages_ traverses the graph in the wrong direction: it finds what your routes render, and by construction never finds a route that renders _you_. One migration hit it in both directions — the cards it converted mount only on a sibling migration's route, and a card that sibling owned mounts on its own. Neither could have been verified from its own pages alone. `job-progress-indicator.tsx` is the worked example. It reported **0** findings, because its `Queued` / `Running` / `Done` / `Failed` come out of a plain `switch` in a non-JSX function, and it has four consumers spanning two directories (`database-stats-card`, `disk-usage-card`, `backups-table`, and Storage's `storage-quarantine-card`). Three separate migrations rendered it, and each left it — a component every one of them touched, and none of them owned. The System migration finally took it and walked Storage's already-merged route to confirm. Per component you touch: ```bash grep -rn '/page.tsx` twin for every route, and the twins were migrated at a different time by a different PR: | surface | copy | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | `SETTINGS_ROUTES` — live, what users see | `Create a diagnostic ZIP with frontend and backend logs.` | | `app/settings/system/logs/page.tsx` — dead → `settings:logsPageDescription` | `Download a bounded diagnostic ZIP containing frontend and backend logs.` | Pointing the live route at the existing key changed the sentence on a shipping page. `pnpm lint`, `pnpm run i18n:check`, the ratchet and 8,000+ unit tests were all green — the key resolved, the catalogs matched, nothing was hardcoded. The copy was simply _different_. `feature-toggles` had drifted the same way in the same directory. Nothing in the toolchain can catch this, because every check verifies that a key resolves, not that it resolves to the string the route used to render. The only thing that caught it was a single Playwright assertion: ``` e2e/tests/system/logs-page.spec.ts:16 getByText('Create a diagnostic ZIP with frontend and backend logs.') ``` One of nine routes happened to pin its description. The other eight could have drifted with nothing failing. **Diff the two surfaces before reusing a key**, and when a route group's headers are duplicated anywhere, pin them: ```bash # What does the live surface actually render today? git show origin/main:apps/web/src/settings-routes.tsx | grep -A6 '"/settings//"' # What does the existing key say? node -e 'console.log(require("./apps/web/src/locales/en/.json").)' ``` `apps/web/components/settings/system/system-route-copy.test.ts` is the shape of the guard: a table of every route's title and description as English, asserted byte-for-byte. It converts "I diffed them carefully" into something that stays true after you leave. `pnpm run i18n:removed` is the **mechanical form of that diff**, and it needs no foresight about which strings to pin. It compares the strings present before and after your change and reports any that left the source without reappearing as a catalog value — so it covers every file in the change, not the one that happened to have a spec. On the case above it reports the live sentence immediately, because `Create a diagnostic ZIP…` is nowhere in any catalog. It parses with Babel rather than matching quoted strings, because **JSX text carries no quotes** — `Back to Workspaces` is most migrated copy and a regex cannot see it. Three acceptance rules keep it quiet on a clean migration: a value extracted into `{{interpolation}}`, either half of a `` sentence split around an element, and an `_one`/`_other` pair. **The hazard has two shapes, and only one of them is above.** The case in this section is an _attribute_ — `description="…"` on a route shell — which is quoted, so even a crude scan finds it. The other is _element text_, which is not quoted and is where most migrated copy lives; a reused key that rewrites a button or a link label leaves nothing for a quoted-string search to find. Do not read this section as "route descriptions drift". Any copy rendered from two places can. It is **advisory and deliberately not in CI**: it is diff-based, so it needs a base ref (`i18n:check` is repo-wide on purpose), and a finding is a prompt to _classify_ rather than proof of a bug — each is a value extraction, a literal hoisted to a constant, or the real thing. It narrows the diff for a human; it does not decide. `--base ` overrides the `origin/main` fork point, and `--all` widens past prose to identifier-shaped strings. Some benign findings recur and are worth recognising rather than investigating: an array of fragments collapsed into one comma-separated value is accepted because every fragment survives verbatim inside the join — the safe property is that the collapse is **concatenative**, not that it is an array, and a collapse that also _rewords_ is reported, correctly. The shape to be wary of is not "array collapsed to string", it is "collapse that also edits". ### Prove it can fail, before you believe it passed **A pass from this check is worth exactly what its ability to fail is worth.** After a clean run, reword one string your diff removed, confirm the check reports it, and restore. This is not diligence; it is the only way to tell a check that read your diff and found nothing from one that could not have failed. Both print the same tick. That distinction is not hypothetical. Every defect found in this tool during review had the same signature — _it returned a pass it had not earned_ — and not one was visible to its own unit tests, because those assume a well-formed catalog and a committed tree. A message that was nothing but a placeholder compiled to a match-anything pattern and accepted every string in the repository. The "after" side read `HEAD` while the file list came from the working tree, so uncommitted edits were invisible and it printed a tick _with the correct file count_. Each was found by someone running it for real and declining to believe the result. **The probe must be a rewrite, and it must change the opening words.** Two probes proved nothing before this was written down, and neither was the author's fault: - an **append** is absorbed by rule 3 by design (`message.includes(norm)`), so `"Try asking"` → `"Try asking now"` always passes; - a rewrite that **keeps the opening words** is absorbed by rule 2, because the literal prefix is what the pattern anchors on — a probe on a `"Delete {{name}}"`-shaped message that still begins `Delete ` tests nothing. A good probe changes the whole sentence: `"What do you want to change?"` → `"What would you like to configure?"` is reported. **Its coverage is partial, and a clean run is evidence rather than proof.** Rule 2 is only as tight as the literal text around a message's placeholders, so a message with too little of it earns no pattern at all — it needs two words of prose. That threshold is not arbitrary. One short word clears any "is there a word?" bar while leaving the pattern almost unbounded: `"Delete {{name}}"` compiled to `/^Delete [\s\S]+?$/` and accepted _every_ sentence beginning "Delete ", which left 19 of 43 strings unprotected in the first file this check was pointed at. Measured by simulating a rewrite of every removed string, requiring two words took detection from 79 to **89 of 96** on one migration while adding **zero** findings to either of two real diffs. The seven that remain are messages whose prose is genuinely too thin to distinguish a rewrite from anything else, so treat a clean run as evidence, not proof — that is why the mutation probe above is a separate step, and why "the check was clean" is not on its own an answer to "did the English change?". One limit is structural rather than a tuning choice, and is worth knowing before anyone tries to close it: `"{{count}} of {{total}}"` (a legitimate value extraction) and `"{{a}} of {{b}}"` (far too permissive to anchor anything) both reduce to the residue `"of"` and compile to the _same_ regex. Nothing computed from the message can accept one and reject the other. Both are rejected, so a string like `"3 of 9"` is reported and classified by hand — the right direction for an advisory check, since a false positive costs someone ten seconds and a false negative is the failure the check exists to prevent. **The general rule is: verify against the authority, not against a copy.** The route table is the authority for what renders — a `page.tsx` that renders a component is not evidence that page is the live surface, and reading one led to a changelog surface being treated as live when `SETTINGS_ROUTES` never mounted it. `origin/main` is the authority for what ships — a fetched or pasted copy of a script is not the script, and testing one produced a defect report against code already fixed on the branch. The subtlest instance was an agent quoting a colleague's description of a file while its own measurement of that file sat unread in its terminal. All three look like diligence: something was read, something was run. None was the thing that decides. The underlying defect is the duplication itself. Unreferenced pages that re-render live copy will drift again, and the next migration inherits the same trap — which is why deleting a dead twin is a correctness change, not tidiness. Do it in its own PR, not inside a copy-only migration. ## Adding a real language Use a canonical lowercase locale id such as `zh-cn` or `ja`. Locale negotiation accepts case variants such as `zh-CN` and unambiguous region suffixes such as `ja-JP` (collapsed onto `ja`); cookies and `` use the canonical id. For Traditional Chinese Taiwan / Hong Kong specifically, prefer converting from `zh-cn` with `node scripts/convert-zh-cn-to-zh-hant.mjs --write` (OpenCC + `scripts/lib/zh-hant-glossary.json`) rather than re-translating from English. See `docs/plans/traditional-chinese-locales/glossary.md`. 1. Add the locale to `SUPPORTED_LOCALES` and give it a fixed endonym in `LOCALE_LABELS` in `apps/web/lib/i18n/index.ts`. Add the same id to `supportedLocales` in `apps/backend/internal/i18n/i18n.go`. Keep the frontend and backend lists synchronized. Also wire `date-locale.ts` when the locale needs a non-English date-fns pack. 2. Copy every `apps/web/src/locales/en/*.json` namespace into `apps/web/src/locales//` (or generate from `zh-cn` for Traditional Chinese). Translate values only: never rename keys, plural suffixes, interpolation placeholders, `` tags, command tokens, shortcuts, filenames, URLs, or brand names. 3. Add `apps/backend/internal/i18n/locales/.json` with exactly the same keys as the backend `en.json` and translate its values. 4. Add focused frontend tests for registration, selection, activation, cookie and boot restoration, ``, and `Intl` locale forwarding. Add backend tests for canonicalization, cookie and `Accept-Language` negotiation, message lookup, exact catalog parity, and the first shell response. 5. Run `pnpm run i18n:check` from `apps/web`. It discovers every real locale except `en` and generated `pseudo`, and **fails** on any missing or extra namespace or key, a dropped placeholder or tag, an empty value, or a value left identical to English; see below. Regenerate `pseudo` only from English with `pnpm run i18n:pseudo`; never copy a human translation into it. 6. In a development or E2E build, select the new language under **Settings > General > Appearance**, reload, and switch back to English. Confirm translated copy, the locale cookie, ``, interpolation, layout, and then use `pseudo` to find English literals on already migrated surfaces. A complete locale catalog guarantees that every externalized message has a translation. Externalization itself is complete: the literal rule is repo-wide. ### Real-locale catalogs gate `en` and `pseudo` have always gated. Real-locale parity used to be a **warning**, and the reasoning was sound at the time: translation happened out of band, so gating on it made an ordinary English-only PR fail CI for work nobody in the merge path could do. It was not hypothetical — #2243 introduced `zh-cn` together with the parity gate, #2261 added 13 English `chat` keys twenty minutes later, and `main` went red because the two could not have been ordered to avoid it. The cost of that policy came due. Measured before this change, against 8317 `en` entries: | locale | missing keys | values identical to English | extra keys | | ------ | -----------: | --------------------------: | ---------: | | pt-pt | 312 | 232 | 9 | | zh-cn | 217 | 82 | 7 | | zh-hk | 415 | 81 | 1 | | zh-tw | 415 | 81 | 1 | None of it visible in a green build. The four catalogs were completed first, then `ja` joined them, so the failure mode has flipped: the risk is no longer "a PR is blocked on a translator" but "a PR silently re-opens the gap". So the pass **gates**, and the cost is that adding user-facing copy means adding it in six languages (`en` plus five real locales). That is the real cost of shipping those languages; it was previously being deferred rather than paid. For `zh-tw` and `zh-hk`, do not hand-translate: run `pnpm run i18n:zh-hant`, which converts `zh-cn` through OpenCC plus the product glossary and emits proper regional variants (`裝置` for TW, `設備` for HK). Review the output. #### Identical-to-English, and the verbatim registry A catalog copy-pasted wholesale from `en` passes every structural check, because they all compare shapes. The identical-to-English check closes that, in two tiers so the exemption mechanism never becomes a shrinking allowlist: 1. **Rule.** If the English value is not copy by `looksLikeCopy` — a brand noun, an acronym, a wire value, a frame of nothing but `{{placeholders}}` — every locale is expected to keep it verbatim and nothing is declared. That is the same predicate `i18next/no-literal-string` and `check-nonjsx-copy.mjs` use, so the three cannot disagree about what a translatable string is. 2. **Declaration.** What survives tier 1 is prose. Prose that reads the same in the target language is a judgement someone made, so it is declared with a reason: `src/locales/_verbatim.json` for values every locale keeps (git verbs, `pull request`, product names), `src/locales//_verbatim.json` for the language-specific coincidences (`Total`, `Manual`, `Menu` in pt-PT). Both files are self-cleaning. A reasonless entry fails. An entry for a key that no longer exists fails. A per-locale entry whose locale has since translated the value fails, and a shared entry fails once **no** locale keeps it verbatim. And deleting an entry does not make anything pass — it makes the identical value fatal again. That is what distinguishes it from an allowlist you can shrink. #### What the check does and does not decide A green run still does **not** mean "well translated" — it means the catalog is structurally complete and nobody left English in a translatable slot. Measured, one mutation at a time: | mutation | result | | ------------------------------------- | ------------------------------ | | value identical to English | caught (unless declared) | | whole catalog copy-pasted from `en` | caught | | empty / whitespace / non-string value | caught | | missing key, extra key | caught | | missing namespace, extra namespace | caught | | dropped `{{placeholder}}` | caught | | dropped or renumbered `` tag | caught | | swapped `_one` / `_other` | **not caught** | | a fluent but wrong translation | **not caught** — a human's job | The identical-to-English row used to read "not caught", and the reasoning was that brand nouns and technical literals must stay verbatim (`automations` alone has several — `GitHub`, `Webhook`, `Webhook URL`), so a naive `value === en` would report each of them. That is true of a naive check; the two-tier form above does not have the problem, because `looksLikeCopy` already knows a brand noun from a sentence. `scripts/check-i18n-keys.test.ts` pins both the caught and the uncaught cases so this stays honest.