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

197 lines
12 KiB
Markdown

---
status: building
created: 2026-07-27
owner: 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.