Co-authored-by: Kandev Agent <agent@kandev.dev>
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/sonnermessages, 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
pseudocatalog is generated fromen, 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.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 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/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 (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.
- 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>.jsonforenandpseudo.enis hand-authored (keys are stable ids; the English copy lives only here);pseudois generated fromenbyscripts/generate-pseudo-locale.mjsand 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, defaulten).
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 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 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 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. 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 (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 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.
- 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.