Files
2026-09-21 23:18:37 +01:00

18 KiB

status, system, requirements, created, updated, owners
status system requirements created updated owners
draft platform
REQ-PLATFORM-I18N-001
REQ-PLATFORM-JAPANESE-LOCALE-001
2026-07-27 2026-09-21
jcfs

Internationalization (i18n) System Design

Purpose and boundaries

This design preserves the technical source detail for REQ-PLATFORM-I18N-001 during migration.

Requirement mapping

Requirement Design section
REQ-PLATFORM-I18N-001 Migrated source detail
REQ-PLATFORM-JAPANESE-LOCALE-001 Migrated source detail

Migrated source detail

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/sonner messages, form-validation messages, empty-state and error copy, and confirm/dialog text.
  • Native UI plugins SHALL register plugin-owned catalogs through the public plugin SDK. Kandev SHALL isolate them by verified plugin identity, require English fallback copy, expose the active locale through imperative and reactive scoped translation APIs, and remove their resources with the plugin lifecycle. Plugin catalogs SHALL NOT mutate built-in or sibling-plugin namespaces.
  • English SHALL remain the source locale. The shipped human locales SHALL be en, pt-pt, zh-cn, zh-tw, zh-hk, and ja; copy lives in per-namespace JSON catalogs keyed by stable ids. The pseudo catalog is generated from en, never hand-edited. Traditional Chinese details live in traditional-chinese-locales.md; Japanese details live in japanese-locale.md.
  • The app SHALL expose a language switcher in Settings. It lists English, European Portuguese, Simplified Chinese, Traditional Chinese (Taiwan), Traditional Chinese (Hong Kong), and Japanese 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.ts timeAgo helper is replaced by a locale-aware relative-time formatter, and date-fns calls 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/ui shared primitives' built-in strings (≈12 accessibility/ default labels) SHALL be translatable without the package depending on the app's i18n runtime — via defaultProps-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/title attributes, 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:key referenced in source must exist in the en catalog, and pseudo must remain in strict generated sync with en. 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 fail the build. i18n:check fails on a missing key, an extra key, a dropped placeholder, or a value left identical to English unless declared in _verbatim.json. The shipped real locales are pt-pt, zh-cn, zh-hk, zh-tw, and ja. i18next still falls back to en at runtime when a key is absent.
  • The full sweep SHALL externalize every user-facing string across apps/web/components/ (1,121 files), apps/web/app/ (285 files), and the @kandev/ui primitives, 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:

  1. 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.
  2. First real language: ship zh-cn catalogs for every existing namespace, register the locale in the frontend and backend, and add catalog parity reporting for every committed real locale (later a hard i18n:check gate).
  3. Per-directory migrations: each PR externalizes one directory, appends it to i18nGuardFiles, and adds its screen to the pseudo-coverage spec.
  4. Close-out: the env guard on the pseudo-coverage e2e spec is removed — it runs unconditionally in the E2E chromium project, so a regression on the screens in its SCREENS list fails CI wherever the detector can see it (a plain-ASCII run of four letters or more, in a text node or a copy-bearing attribute). It is a regression floor, not a proof of total coverage; the spec's header records the two detection limits and what relaxing them costs. Still outstanding: @kandev/ui primitives (a separate workspace package, deliberately deferred; its hardcoded strings are not reached by any screen currently in SCREENS) and the shared-task artifacts.

Data model

i18n adds no server-side persistent entities. It introduces:

  • Message catalogs — checked-in JSON under apps/web/src/locales/<locale>/<namespace>.json for en, pt-pt, zh-cn, zh-tw, zh-hk, ja, and pseudo. en is hand-authored (keys are stable ids; the English copy lives only here); pt-pt, zh-cn, and ja are human-maintained translations; zh-tw / zh-hk are derived from zh-cn (OpenCC + product glossary) and then human-reviewed for high-traffic namespaces. Real-locale namespace and key sets SHALL mirror en. A missing or extra key fails i18n:check; i18next still falls back to en at runtime. pseudo is generated from en by scripts/generate-pseudo-locale.mjs and is never hand-edited. Catalogs load at runtime via Vite glob import — no build plugin. Only en is bundled into the initial download: it is the fallback locale, so it has to be in memory the moment i18next initializes or every key paints as its own name. Every other locale is one lazily-fetched chunk, and the boot awaits exactly the active one — resolved by the Go shell into the boot payload — before React mounts.
  • Locale preference — the user's chosen locale, one value from the supported set. A dedicated kandev_locale cookie 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, default en).

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 the kandev_locale cookie (default en) 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.runtime gains an optional locale field consumed by the i18n bootstrap.

Frontend module contract (new):

  • apps/web/lib/i18n/ — i18n instance, activateLocale(locale), SUPPORTED_LOCALES, DEFAULT_LOCALE, and a React <I18nProvider> wrapper mounted in app-shell.tsx.
  • apps/web/lib/i18n/formats.ts — locale-aware formatDate, formatRelative (replacing timeAgo), formatNumber, formatBytes wrappers over Intl / date-fns with the active locale.
  • @kandev/plugin-sdk — PluginRegistry.registerTranslations() plus scoped PluginHostApi.i18n.t() and useTranslation() access. See ADR-2026-08-12-plugin-localization-contract.

Scenarios

  • GIVEN a fresh install with no locale cookie, WHEN the UI loads, THEN <html lang> is en, all text renders from the en catalog, and the language switcher shows English selected.
  • GIVEN the Settings language switcher, WHEN the user selects 简体中文, THEN every migrated message present in the zh-cn catalog resolves from it, any message not yet translated falls back to its en value rather than rendering the raw key, <html lang> becomes zh-cn, and the selection survives a reload through the kandev_locale=zh-cn cookie. Runtime English fallback is a safety net if a key is absent at runtime; it is not a substitute for a complete checked-in catalog, which i18n:check gates.
  • GIVEN no locale cookie and Accept-Language: zh-CN, WHEN the Go shell or a server-rendered browser error is returned, THEN locale negotiation selects zh-cn, the shell emits <html lang="zh-cn">, and the backend message comes from the Simplified Chinese catalog.
  • GIVEN zh-cn is active, WHEN number and date/time helpers format a value, THEN they pass zh-cn to Intl; AND GIVEN pseudo is 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 to pseudo.
  • 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 reads 5m ago (behavior-compatible with the removed timeAgo), 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 GIVEN en gained a key without regenerating pseudo, THEN the same check fails on catalog drift.
  • GIVEN the @kandev/ui Dialog close 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 a count, WHEN count is 1 vs many, THEN i18next selects the matching form.
  • GIVEN two active plugins with the same message key, WHEN either translates that key and the locale changes, THEN each receives only its own message and reactive plugin surfaces rerender. WHEN one plugin is disabled, THEN only its catalog is removed.
  • GIVEN a production build, WHEN the language switcher renders, THEN English, Português (Portugal), 简体中文, 繁體中文(台灣), 繁體中文(香港), and 日本語 are offered and the pseudo-locale is not.
  • GIVEN an English namespace or key changes, WHEN i18n:check runs, THEN every real locale missing or adding a namespace or key fails the command, naming the locale, namespace, and missing or extra entries.
  • GIVEN the pseudo catalog has drifted from en, WHEN i18n:check runs, THEN it fails.

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. The i18n:check CI 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.
  • A locale's catalog chunk fails to fetch → the app renders in en under the requested <html lang>; it never blanks, throws, or blocks the boot. en is bundled, so the fallback is always available. installVitePreloadRecovery reloads once on vite:preloadError, and the next boot retries.
  • @kandev/ui used without an injected dictionary → primitives use their English defaultProps; no runtime dependency on the app i18n instance.

Persistence guarantees

  • The locale preference survives a kandev restart through the kandev_locale cookie.
  • 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, European Portuguese, Simplified Chinese, Traditional Chinese, and Japanese.
  • 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 via apps/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> stays ltr.
  • 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_locale cookie 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 to Accept-Language (q-value ordered, region subtags matched against their base) before defaulting to en.
  • Lint guard: adopt eslint-plugin-i18next's no-literal-string with a curated words.exclude allowlist for brand names, code identifiers, and symbols — and a separate path allowlist (i18nGuardFiles) restricting the error to migrated directories. Note the plugin anchors every words.exclude entry 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-cn as the canonical persisted and document locale. Locale negotiation is case-insensitive, so BCP-47 input such as zh-CN resolves to zh-cn. The switcher label is the fixed endonym 简体中文, not a translated message.
  • Real-locale parity: every committed real locale SHALL contain every English namespace and exactly the same keys, including plural suffixes and <Trans> structures. i18n:check fails the build on a missing key, an extra key, a dropped placeholder, a dropped or renumbered <Trans> tag, or a value left identical to English unless declared in _verbatim.json. Runtime English fallback remains a safety net rather than a substitute for a complete checked-in catalog.
  • The parity check is structural plus identical-to-English: a green run does not mean "well translated". It catches shape defects and unexplained English leftovers. It does not catch awkward-but-translated wording, or swapped _one/_other forms that still exist as keys. Brand nouns and technical literals that must stay English belong in _verbatim.json.
  • 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.