Co-authored-by: Kandev Agent <agent@kandev.dev>
59 KiB
Internationalization (i18n)
Kandev's web UI is internationalized with i18next +
react-i18next. English is the source locale,
European Portuguese (pt-pt) and Simplified Chinese (zh-cn) are the shipped
human locales, and a generated pseudo-locale is used for QA. Requirements live in
docs/specs/platform/i18n.md.
The migration is complete. Every routed web UI surface renders through t()
/ <Trans>, 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()/<Trans>whatever directory it is in. See the new-code ratchet. - Whole files are guarded only once migrated. The eslint literal rule errors
across an entire file only for the paths in
i18nGuardFiles, so you are never asked to migrate lines you did not touch. See The guard is scoped and Migrating a directory.
TL;DR for adding a string
- Add the English text to the right namespace file, e.g.
apps/web/src/locales/en/settings.json:{ "deleteExecutor": "Delete executor" } - Reference it by
namespace:key:import { useTranslation } from "react-i18next"; const { t } = useTranslation(); <Button>{t("settings:deleteExecutor")}</Button> <input placeholder={t("settings:searchExecutors")} /> toast.success(t("settings:executorSaved")); - 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.
Interpolation
{ "deletedItems": "Deleted {{count}} items" }
t("common:deletedItems", { count });
Plurals
i18next uses suffixed keys; the call site passes count:
{ "sessions_one": "{{count}} session", "sessions_other": "{{count}} sessions" }
t("kanban:sessions", { count: sessionCount });
Copy containing markup
Use react-i18next's <Trans>. Tag indices count every child (text nodes
included), and the tag body is the element's text:
<Trans i18nKey="settings:typeToConfirm" values={{ phrase }}>
Type <strong>{phrase}</strong> to continue.
</Trans>
{ "typeToConfirm": "Type <1>{{phrase}}</1> 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 <n> 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
<Trans> 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:
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:
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:
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.
Commands
Requires Node 24 (
apps/package.jsonengines).
cd apps/web
pnpm run i18n:check # the four repo-wide gates (runs in CI: Frontend Tests)
pnpm run i18n:pseudo # regenerate the pseudo catalog from en
pnpm run lint:i18n <path> # preview the guard on a path that is NOT yet migrated
pnpm run i18n:ratchet # new-code guard + allowlist-only-grows (pre-commit + CI)
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 <path> # 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, ignoring the allowlist. It is the work-list generator for a
directory you are about to migrate; it never gates a merge. The real gate is
pnpm lint, where the rule is an error — but only on the allowlist.
Unlike the eslint guard, i18n:check is not scoped to the allowlist — its
four checks scan components, app, hooks and lib repo-wide, and it runs in
CI (Frontend Tests → Run i18n checks). That is deliberate: each one 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 files pass
trivially, because a file that never calls t() cannot break any of the four.
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
<Trans> element's tag indices against its JSX children (see below), rejects
English plural endings passed as interpolation values, and rejects t() at module
scope.
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), 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:
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 an
un-migrated directory 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, migrated paths. Grows one directory per PR.- the new-code ratchet — new lines, everywhere. Always on.
Migrating a directory
One directory per PR. The whole loop:
cd apps/web
pnpm run lint:i18n components/<area> # 1. the work-list
node scripts/externalize-strings.mjs --write components/<area> # 2. mechanical pass
# 3. review the diff, fix what it declined
pnpm run i18n:pseudo # 4. regenerate pseudo
pnpm run lint:i18n components/<area> # 5. clean?
- Add the path to
i18nGuardFilesineslint.i18n.options.mjs. This is the step that makes the work stick — without it the directory drifts straight back. - If the area has a screen worth crawling, add it to
SCREENSine2e/tests/i18n/pseudo-coverage.spec.ts. That spec is a hard CI gate — it runs unconditionally in the E2Echromiumproject — 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. pnpm run i18n:check && pnpm lint --max-warnings 0 && pnpm exec vitest run.- 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.
const HEADER = <span>Archive board</span>; // NOT flagged
const header = <span>Archive board</span>; // 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:
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.
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 <dir> (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:
- English plural concatenation —
{n} file{n === 1 ? "" : "s"}, and the hoistedconst plural = count === 1 ? "" : "s"form. These are defects, and no gate we own sees them:no-literal-stringis jsx-only, andcheck-inline-plurals.mjsonly inspects morphemes passed throught(). 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 undercomponents/task, the largest directory still to migrate, so run the sweep on everycomponents/taskbatch specifically rather than treating it as a generic step. - 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.tsxandpr-ci-popover.tsxare 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(),<I18nProvider>(mounted outermost inapp-shell.tsx),resolveInitialLocale(payload → cookie →en), andformats.ts(locale-aware date/number/relative time;timeAgois a deprecated alias forformatRelative). - Catalogs:
src/locales/<locale>/<namespace>.json, discovered through Vite's glob import — no macro transform.enis bundled eagerly; every other locale is a lazily-fetched chunk (see below). The one build plugin iskandev:i18n-pseudo-locale-bundlinginvite.config.ts, which decides whether thepseudocatalog is compiled in at all. - Locale source of truth: the
kandev_localecookie. The Go shell (apps/backend/internal/webapp/shell.go) reads it to set<html lang>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:
enmust be synchronous. It isDEFAULT_LOCALEandfallbackLng, andreturnNull: falsemeans a missing key renders as the key itself. Ifenwere not in memory wheni18next.init()returns, every key on screen would paint ascommon:saveuntil a chunk landed — silent and ugly rather than loud. Keeping it eager also keepsensureInitializedsynchronous, 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.tsxawaits exactly ONE locale (preloadLocale) inside the promise it already waited on for the payload, beforecreateRoot().render(). Nothing renders against a half-loaded catalog. Forenthat 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 <html lang>, 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. <html lang> 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/uiprimitives 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'saria-label="breadcrumb"was this shape: the fix wasaria-label={t("common:breadcrumb")}at its three consumers (page-topbar,task-top-bar-title,OfficeSimplePane) plus a comment in the package recording the contract. Alabel?: stringprop would have duplicated a prop React already gives you. Same shape, still un-overridden:SidebarRail'saria-label/title,Spinner'saria-label="Loading", andPagination's three. - A label that is a TEXT CHILD does need a seam, because a consumer cannot
reach it through
{...props}: the<span className="sr-only">inDialogContent,SidebarTriggerandBreadcrumbEllipsis("Close", "Toggle Sidebar", "More"), andPaginationPrevious/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+Tand acceptscontainerAriaLabel, whichAppToasterinsrc/app-shell.tsxpasses. That has to be its own component —<I18nProvider>is mounted byAppShell, so at()call inAppShell's own render sits outside the provider and would not re-render on a locale switch. - A label in an ATTRIBUTE needs nothing. Every one of them sits on an
element that spreads
-
Tests:
vitest.setup.tscallsinitI18nForTests(). 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/<locale>.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.
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.
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.
i18n.Normalize is the single source of truth for locale support, shared with
webapp.NormalizeLocale, so <html lang> 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/roleSystemcome from the message store.messageRoleAttrsreturns 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 <n> 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.
Two things it cannot show you at all — the second is detailed below:
- 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.mjsis what catches it. - 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:
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="<path>"], 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-labelis the exact case the pass exists to catch, and everysr-onlynode measures as good as zero. It usescheckVisibility()instead —display: none,visibility: hiddenand skippedcontent-visibilitysubtrees 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-labelbuilt as`Collapse ${label}`rendersCollapse Ĝēńēŕàĺ— 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 (Àćţĩōńś ƒōŕ Agenton 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:
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:
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 aresult.errorthe backend returned, is a diagnostic — see Backend 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:
} 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:
grep -rn '<JobProgressIndicator' apps/web/app apps/web/components
then walk each of those routes under pseudo, whoever owns them. If a consumer belongs to an unmigrated directory, note it in the guard comment rather than silently leaving it — the next migration inherits the answer instead of rediscovering the question.
Copy that belongs to no directory
A file shared by every area has no natural owner and so gets skipped by all of
them. src/settings-routes.tsx holds the live title and description for every
settings route in one SETTINGS_ROUTES map — a SCREAMING_CASE identifier the
guard skips entirely, in a 650-line file no single migration's closure claims.
The rule: a migration converts the entries it owns but must not add such a
file to i18nGuardFiles, because allowlisting it claims a completeness nobody
has. The last area to migrate adds it. Say so in your guard comment, naming which
entries are still English and who owns them.
An existing key is not automatically the right key
When the same copy is rendered from two places and one of them is already migrated, reaching for the key that already exists looks like the careful move. It is the one shape in this guide that can silently rewrite user-visible English, and every gate passes while it does.
Two surfaces rendering "the same" sentence have usually drifted. Settings →
System had an unreferenced app/settings/system/<route>/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:
# What does the live surface actually render today?
git show origin/main:apps/web/src/settings-routes.tsx | grep -A6 '"/settings/<area>/<route>"'
# What does the existing key say?
node -e 'console.log(require("./apps/web/src/locales/en/<ns>.json").<key>)'
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 — <Link>Back to Workspaces</Link> 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 <Trans>
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 <ref> 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 beginsDeletetests 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. Locale negotiation accepts
case variants such as zh-CN, but cookies and <html lang> use the canonical id.
- Add the locale to
SUPPORTED_LOCALESand give it a fixed endonym inLOCALE_LABELSinapps/web/lib/i18n/index.ts. Add the same id tosupportedLocalesinapps/backend/internal/i18n/i18n.go. Keep the frontend and backend lists synchronized. - Copy every
apps/web/src/locales/en/*.jsonnamespace intoapps/web/src/locales/<locale>/. Translate values only: never rename keys, plural suffixes, interpolation placeholders,<Trans>tags, command tokens, shortcuts, filenames, URLs, or brand names. - Add
apps/backend/internal/i18n/locales/<locale>.jsonwith exactly the same keys as the backenden.jsonand translate its values. - Add focused frontend tests for registration, selection, activation, cookie
and boot restoration,
<html lang>, andIntllocale forwarding. Add backend tests for canonicalization, cookie andAccept-Languagenegotiation, message lookup, exact catalog parity, and the first shell response. - Run
pnpm run i18n:checkfromapps/web. It discovers every real locale exceptenand generatedpseudo, and warns on any missing or extra namespace or key — advisory, not a build failure; see below. Regeneratepseudoonly from English withpnpm run i18n:pseudo; never copy a human translation into it. - 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,
<html lang>, interpolation, layout, and then usepseudoto find English literals on already migrated surfaces.
Kandev's migration remains incremental. A complete locale catalog guarantees
that every currently externalized message has a translation; it does not mean
that every screen has been externalized. Unmigrated surfaces continue to render
English until their directory joins i18nGuardFiles.
Real locales warn, they do not gate
en and pseudo gate. en is authored in the same PR as the code and pseudo
is generated from it, so a mismatch between them is always the author's to
fix in the change that caused it. Real-locale parity is a warning.
That is a deliberate policy, not an oversight. Translation happens out of band,
on a third party's cadence, so gating on it makes an ordinary English-only PR
fail CI for work nobody in the merge path can do. It is not hypothetical: #2243
introduced zh-cn together with the parity gate, #2261 added 13 English chat
keys twenty minutes later, and main went red — not because either change was
wrong, but because the two could not have been ordered to avoid it. The gate
fired exactly as designed and blocked the wrong people.
So: add the keys to every real locale when you can, and never let a missing one stop you merging. The warning is addressed to whoever owns the translation.
It is a structural check, not a translation check
Worth stating plainly, because assuming otherwise has already produced two wrong conclusions: a green run does not mean "translated". The pass compares shapes. Measured, one mutation at a time:
| mutation | result |
|---|---|
| value identical to English | not caught |
whole catalog copy-pasted from en |
not caught |
swapped _one / _other |
not caught |
| empty / whitespace / non-string value | caught |
| missing key, extra key | caught |
| missing namespace, extra namespace | caught |
dropped {{placeholder}} |
caught |
dropped or renumbered <n> tag |
caught |
The identical-to-English tolerance is deliberate and cannot be tightened
naively: brand nouns and technical literals must stay verbatim (automations
alone has seven — GitHub, Webhook, Webhook URL, …), so value === en would
report each of them. Closing it needs a per-key exemption list or a
whole-file proportion threshold. Reviewing a translation is a human job;
scripts/check-i18n-keys.test.ts pins both the caught and the uncaught cases so
this stays honest.