Co-authored-by: Kandev Agent <agent@kandev.dev>
15 KiB
status, created, updated, owner
| status | created | updated | owner |
|---|---|---|---|
| building | 2026-07-27 | 2026-08-02 | jcfs |
Internationalization (i18n)
Why
Kandev's i18n foundation lets migrated web surfaces externalize copy, switch locale, and format dates and numbers per language, but English is still the only human language shipped. Adding Simplified Chinese makes the existing localized surface usable by Chinese-speaking users while preserving English as the source language and the pseudo-locale as the migration QA oracle.
What
- The web UI SHALL render all user-facing text through a translation layer
(i18next
t("ns:key")/ react-i18next<Trans>), not from inline literals. "User-facing text" covers visible copy,placeholder,aria-label/aria-description,title,alt, toast/sonnermessages, form-validation messages, empty-state and error copy, and confirm/dialog text. - English SHALL remain the source locale. The shipped human locales SHALL be
enandzh-cn; copy lives in per-namespace JSON catalogs keyed by stable ids. Thepseudocatalog is generated fromen, never hand-edited. - The app SHALL expose a language switcher in Settings. It lists English and Simplified Chinese by their fixed endonyms. Selecting a locale re-renders the UI without a full reload and persists the choice across restarts. Development and E2E builds additionally list the pseudo-locale (see below).
- The app SHALL ship a pseudo-locale (
pseudo) that transforms every extracted message (accents + padding, e.g.Ĉřéàţé ţàśķ) so QA can visually confirm a string was externalized and detect truncation. The pseudo-locale is only selectable in dev/e2e builds, not in production builds. - Date, time, relative-time, and number formatting SHALL be locale-aware,
driven by the active locale rather than hardcoded English strings. The custom
apps/web/lib/utils/time.tstimeAgohelper is replaced by a locale-aware relative-time formatter, anddate-fnscalls take the active locale. - The document
<html lang>attribute SHALL reflect the active locale (both in the Go-rendered shell's default and after client locale changes). - The
@kandev/uishared primitives' built-in strings (≈12 accessibility/ default labels) SHALL be translatable without the package depending on the app's i18n runtime — viadefaultProps-style overridable string props or a lightweight injected dictionary, so the package stays framework-agnostic. - A build-time lint guard SHALL fail on hardcoded user-facing JSX text and
untranslated
placeholder/aria-label/titleattributes, so migrated code cannot regress. The guard SHALL be scoped to an allowlist of already-migrated paths, not applied repo-wide: a global error blocks every unrelated PR that adds a literal and makes a long-lived migration branch unmergeable. Each migration PR extends the allowlist by the path it converts, so coverage only ever grows and un-migrated code is never a blocker. - CI SHALL verify keys and catalogs do not drift: every
ns:keyreferenced in source must exist in theencatalog, andpseudomust remain in strict generated sync withen. Both are authored or generated in the same change as the code, so both SHALL fail the build. (Catalogs are NOT regenerated from source — with key-based ids the English copy exists only in the catalog.) - Real-locale parity SHALL be reported as a warning and SHALL NOT fail the
build. Translation is performed out of band by a third party, so gating on it
blocks an English-only change for work nobody in the merge path can do. This is
a deliberate reversal of the original requirement: #2243 introduced
zh-cntogether with a hard parity gate, #2261 added English keys twenty minutes later, andmainwent red although neither change was wrong and no ordering of the two could have avoided it. - The full sweep SHALL externalize every user-facing string across
apps/web/components/(1,121 files),apps/web/app/(285 files), and the@kandev/uiprimitives, leaving no user-facing English literal outside the translation layer (allowlisted exceptions documented — see Out of scope).
Delivery
The sweep SHALL be delivered incrementally, one directory per PR, not as a
single change. A single PR covering it touches ~1,130 files and conflicts with
main continuously; the first attempt could not be kept alive long enough to
merge. Sequence:
- Foundation: runtime, catalogs, backend locale plumbing, language switcher, CI gates, codemods, the scoped lint guard, and one page (Settings → General → Appearance) migrated end-to-end as the worked example.
- First real language: ship
zh-cncatalogs for every existing namespace, register the locale in the frontend and backend, and add advisory catalog parity reporting for every committed real locale. - Per-directory migrations: each PR externalizes one directory, appends it
to
i18nGuardFiles, and adds its screen to the pseudo-coverage spec. - Close-out:
@kandev/uiprimitives, the shared-task artifacts, and removing the env guard on the pseudo-coverage e2e spec once coverage is total.
Data model
i18n adds no server-side persistent entities. It introduces:
- Message catalogs — checked-in JSON under
apps/web/src/locales/<locale>/<namespace>.jsonforen,zh-cn, andpseudo.enis hand-authored (keys are stable ids; the English copy lives only here);zh-cnis a human-maintained translation, maintained out of band, whose namespace and key sets aim to mirrorenbut may lag it — a gap warns rather than failing, and i18next falls back toenat runtime;pseudois generated fromenbyscripts/generate-pseudo-locale.mjsand is never hand-edited. Catalogs load at runtime via Vite glob import — no build plugin. - Locale preference — the user's chosen locale, one value from the supported
set. A dedicated
kandev_localecookie is the authoritative persisted value read by the Go shell so the initial<html lang>and first paint match the selected locale. It is hydrated into the in-memory user-settings/store mirror for the switcher UI. Field:locale: string(BCP-47-ish tag, defaulten).
There is no SQLite or user-settings persistence for locale. The cookie is the single persisted source of truth and is hydrated into the store at boot.
API surface
No new HTTP/WS contract is required to add a locale. Two integration points:
- Go shell (
apps/backend/internal/webapp/shell.go): reads thekandev_localecookie (defaulten) and emits<html lang="<locale>">plus, optionally, the locale in the boot payload (runtime.locale) so the client activates the right catalog before first paint without a flash. - Client boot (
apps/web/src/boot-payload.ts):BootPayload.runtimegains an optionallocalefield consumed by the i18n bootstrap.
Frontend module contract (new):
apps/web/lib/i18n/—i18ninstance,activateLocale(locale),SUPPORTED_LOCALES,DEFAULT_LOCALE, and a React<I18nProvider>wrapper mounted inapp-shell.tsx.apps/web/lib/i18n/formats.ts— locale-awareformatDate,formatRelative(replacingtimeAgo),formatNumber,formatByteswrappers overIntl/date-fnswith the active locale.
Scenarios
- GIVEN a fresh install with no locale cookie, WHEN the UI loads,
THEN
<html lang>isen, all text renders from theencatalog, and the language switcher shows English selected. - GIVEN the Settings language switcher, WHEN the user selects
简体中文, THEN every migrated message present in the
zh-cncatalog resolves from it, any message not yet translated falls back to itsenvalue rather than rendering the raw key,<html lang>becomeszh-cn, and the selection survives a reload through thekandev_locale=zh-cncookie. (The catalog may lagen, because real-locale parity is advisory — the fallback is what makes that safe to ship.) - GIVEN no locale cookie and
Accept-Language: zh-CN, WHEN the Go shell or a server-rendered browser error is returned, THEN locale negotiation selectszh-cn, the shell emits<html lang="zh-cn">, and the backend message comes from the Simplified Chinese catalog. - GIVEN
zh-cnis active, WHEN number and date/time helpers format a value, THEN they passzh-cntoIntl; AND GIVENpseudois active, THEN those helpers continue to use English formatting data. - GIVEN the Settings language switcher, WHEN the user selects the
pseudo-locale in a dev build, THEN visible strings render with accented
padded glyphs (e.g.
Ĉřéàţé), the choice persists across reload, and<html lang>updates topseudo. - GIVEN the pseudo-locale active, WHEN QA scans each screen, THEN any remaining plain-English (un-accented) user-facing string is a defect (string not externalized) — this is the acceptance check for "full sweep".
- GIVEN a timestamp 5 minutes old, WHEN it renders via the relative-time
formatter under
en, THEN it reads5m ago(behavior-compatible with the removedtimeAgo), and under a locale-aware format reflects that locale. - GIVEN a developer adds a component with a hardcoded JSX string
<span>Delete</span>, WHEN lint runs (pre-commit/CI), THEN the lint guard fails and names the offending file/line. - GIVEN a developer references a key with no catalog entry, WHEN CI runs
i18n:check, THEN the build fails naming the missing key; AND GIVENengained a key without regeneratingpseudo, THEN the same check fails on catalog drift. - GIVEN the
@kandev/uiDialogclose button, WHEN rendered in the app, THEN its "Close" label comes from the app's catalog (overridable), and WHEN the package is used standalone with no dictionary, THEN it falls back to its English default without throwing. - GIVEN a plural key (
x_one/x_other) rendered with acount, WHEN count is 1 vs many, THEN i18next selects the matching form. - GIVEN a production build, WHEN the language switcher renders, THEN English and 简体中文 are offered and the pseudo-locale is not.
- GIVEN an English namespace or key changes, WHEN
i18n:checkruns, THEN every real locale missing or adding a namespace or key is reported as a warning naming the locale, namespace, and missing or extra entries, AND the command still exits 0. - GIVEN the
pseudocatalog has drifted fromen, WHENi18n:checkruns, THEN it fails, even when a real locale is also out of parity — the advisory path must not mask the gating one.
Failure modes
- Missing translation for the active locale → i18next falls back to
en, and an entirely unknown key renders as the key itself; the UI never blanks or throws. Thei18n:checkCI gate is what keeps unknown keys out. - Invalid/unknown persisted locale (cookie or store) → bootstrap coerces to
DEFAULT_LOCALE(en) and does not throw. - Malformed catalog JSON → the Vite glob import fails the build (caught in CI), never shipped.
@kandev/uiused without an injected dictionary → primitives use their EnglishdefaultProps; no runtime dependency on the app i18n instance.
Persistence guarantees
- The locale preference survives a kandev restart through the
kandev_localecookie. - Message catalogs are source-controlled build inputs; nothing i18n-related is written to the server DB or worktrees at runtime.
- No in-flight session, queued run, or WS state is affected by a locale change; switching locale is a pure client re-render.
Out of scope
- Shipping human languages other than English and Simplified Chinese.
- Translating the CLI launcher, logs, or ACP/agent output, and the ~1,100
diagnostic API error strings (
http.Error/gin.H{"error": …}) — the SPA maps API failures onto its own translated copy, so those stay English by design. Backend copy that Go renders directly to a browser IS localized viaapps/backend/internal/i18n— the SPA-unavailable error pages and the shared-task artifacts Go writes as HTML/Markdown (see docs/i18n.md). - Translating user/domain content carried in the boot payload and store
(task titles, workflow/step names, repo names, chat messages, diff content,
agent transcripts) — these are user data, never sent through
t(). - RTL layout support (bidi/mirroring) — deferred;
<html dir>staysltr. - Translating third-party integration provider names, brand names, code identifiers, keyboard-shortcut glyphs, and other proper nouns — these stay as-is and may be allowlisted for the lint guard.
- A translation-management platform / vendor sync (Crowdin, Lokalise, etc.).
Resolved decisions
- Locale storage: a dedicated
kandev_localecookie is the source of truth (so the Go shell sets<html lang>on first paint with no flash); it is hydrated into user-settings/store for the switcher UI. With no cookie set the shell falls back toAccept-Language(q-value ordered, region subtags matched against their base) before defaulting toen. - Lint guard: adopt
eslint-plugin-i18next'sno-literal-stringwith a curatedwords.excludeallowlist for brand names, code identifiers, and symbols — and a separate path allowlist (i18nGuardFiles) restricting the error to migrated directories. Note the plugin anchors everywords.excludeentry as^…$, so prefix-only patterns silently never match. - Catalog format: commit JSON catalogs per namespace; loaded directly at runtime. Because keys (not source text) are the ids, CI verifies key/catalog drift rather than re-extracting — regeneration would destroy the English copy.
- Simplified Chinese locale id: use lowercase
zh-cnas the canonical persisted and document locale. Locale negotiation is case-insensitive, so BCP-47 input such aszh-CNresolves tozh-cn. The switcher label is the fixed endonym简体中文, not a translated message. - Real-locale parity: every committed real locale should contain every
English namespace and exactly the same keys, including plural suffixes and
<Trans>structures — but this is advisory, not enforced, because translation happens out of band (see the CI requirement above). Runtime English fallback is what makes an incomplete catalog safe to merge; it remains a safety net rather than a substitute for a complete checked-in catalog. - The parity check is structural, not translational: it compares shapes, so a
green run does not mean "translated". It catches a missing/extra key or
namespace, an empty or non-string value, a dropped
{{placeholder}}, and a dropped or renumbered<Trans>tag. It does not catch a value identical to English, a catalog copy-pasted wholesale fromen, or swapped_one/_otherforms. The identical-to-English tolerance is required — brand nouns and technical literals must stay verbatim — so tightening it needs a per-key exemption list or a whole-file proportion threshold, not avalue === enrule. - Library: react-i18next (superseding the initial Lingui implementation) — chosen so the English copy lives in one place and components stay free of inline source text.