Files
kandev/docs/specs/platform/i18n.md
T
2026-08-01 23:11:18 +02:00

12 KiB

status, created, owner
status created owner
building 2026-07-27 jcfs

Internationalization (i18n)

Why

Every user-facing string in the Kandev web UI is a hardcoded English literal, so the product cannot be presented in any other language and non-English speakers are locked out. There is no infrastructure to externalize strings, switch locale, or format dates and numbers per the user's language. This feature makes the entire web frontend locale-ready with English as the source language, so translations can be added later without touching component code.

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.
  • English SHALL be the source locale and the only shipped human language in this iteration. Copy lives in per-namespace JSON catalogs keyed by stable ids; the pseudo catalog is generated from en, never hand-edited.
  • The app SHALL expose a language switcher in Settings. With only English available it lists English plus a pseudo-locale (see below); selecting a locale re-renders the UI in that locale without a full reload and persists the choice across restarts.
  • 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 carry the same key set. (Catalogs are NOT regenerated from source — with key-based ids the English copy exists only in the catalog.)
  • 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 (this iteration): 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. Per-directory migrations: each PR externalizes one directory, appends it to i18nGuardFiles, and adds its screen to the pseudo-coverage spec.
  3. Close-out: @kandev/ui primitives, 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>.json for en and pseudo. en is hand-authored (keys are stable ids; the English copy lives only here); pseudo is generated from en by scripts/generate-pseudo-locale.mjs and is never hand-edited. Loaded at runtime via Vite glob import — no build plugin.
  • Locale preference — the user's chosen locale, one value from the supported set. Stored client-side in the existing user-settings mechanism (persisted the same way other UI preferences are, so it survives restart) and mirrored to a cookie read by the Go shell so the initial <html lang> and first paint match the persisted locale. Field: locale: string (BCP-47-ish tag, default en).

There is no new SQLite table; the preference rides existing user-settings persistence. If user-settings has no suitable slot, a dedicated cookie (kandev_locale) is the source of truth for the shell and is hydrated into the store at boot.

API surface

No new HTTP/WS contract is required for English-only shipping. 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.

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 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 a production build, WHEN the language switcher renders, THEN the pseudo-locale is not offered as a selectable option.

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.
  • @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 (cookie + user-settings).
  • 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 any real second language (no es/pt/fr/etc. translations in this iteration — infrastructure + English source + pseudo-locale only).
  • 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.
  • 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.