6cd89a78d3
Co-authored-by: Kandev Agent <agent@kandev.dev>
197 lines
12 KiB
Markdown
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.
|