
# Internationalization (i18n)

The dashboard is being prepared for multiple languages. This is **infrastructure plus one proven example, not a launch**: only `en` exists today, there is no language switcher yet, and most components still have hardcoded English strings. That is expected. This page is the pattern new code and future migration passes follow, not a status report on how much of the app has moved over.

## Why react-i18next

`react-i18next` + `i18next`, not Lingui or next-intl, for a plain Vite + React SPA (not Next.js):

- Smaller learning curve than Lingui's extraction-based workflow: plain `t('key')` calls, no separate extraction/compile step in the build.
- Real namespace-based lazy loading, which matters here: a feature's strings should not bloat the bundle every other page pays for.
- Mature TypeScript support via module augmentation (`CustomTypeOptions`), so a translation key is checked like any other identifier.
- The most widely adopted choice for this exact stack, which means more examples, more Stack Overflow answers, and a lower bus-factor risk than a smaller library.

## File structure: one namespace per feature area

Translation content lives under `web/src/locales/<lng>/<namespace>.json`. Only `en` exists so far:

```
web/src/locales/en/common.json    # shared strings: button labels, generic toasts
web/src/locales/en/deploys.json   # deploy-related cards: auto-rollback, badges, ...
```

A namespace maps to a feature area, not to one component. `deploys.json` already holds keys for three different cards (`autoRollback`, `autoRollbackSloBurn`, `badgeSettings`) because they are all deploy-settings UI, not three namespaces. Nest keys by component/feature inside the namespace file:

```json
{
  "autoRollback": {
    "title": "Auto-rollback on crashloop",
    "enabledLabel": "Enabled",
    "toast": {
      "enabled": "Auto-rollback on crashloop enabled.",
      "errorTitle": "Could not update auto-rollback."
    }
  }
}
```

When a genuinely new feature area shows up (billing, teams, whatever comes next), give it its own namespace file rather than growing an unrelated one. When in doubt, a namespace should be small enough that loading it on one route does not pull in strings for ten others.

## Loading: lazy, per namespace, not all up front

`web/src/i18n/index.ts` wires a small custom backend (`web/src/i18n/lazyBackend.ts`) instead of bundling every namespace into the main chunk:

```ts
function importNamespace(language: string, namespace: string) {
  return import(`../locales/${language}/${namespace}.json`)
}
```

Vite statically analyzes that template literal (the directory and extension are fixed, only `language`/`namespace` vary) and gives every `locales/*/*.json` file its own chunk. A namespace's JSON only downloads the first time some component calls `useTranslation('thatNamespace')`, not on initial page load. This is the same shape as route-level code splitting (see `web/vite.config.ts`'s TanStack Router plugin): the dashboard should not ship the log viewer's bundle, and it should not ship every feature's strings either.

`react: { useSuspense: true }` means a component suspends while its namespace's chunk is loading. `web/src/main.tsx` wraps the whole app in one `<Suspense fallback={<PageSpinner />}>` for this, the same `PageSpinner` already used as route `pendingComponent` fallback elsewhere, so a namespace load looks like any other route-level loading state.

`common` is the `defaultNS` and is listed in `ns: ['common']` at init time, so it loads once on boot alongside everything else already needed to render a first screen. Every other namespace is lazy by default: just start calling `useTranslation('yourNamespace')` and it loads itself.

## Adding a new translatable string

1. Find (or create) the right namespace file under `web/src/locales/en/`.
2. Add the key, nested under the component or feature it belongs to. Keep the key name describing *what the string is for*, not the English text itself (`enabledLabel`, not `enabled_text`).
3. In the component:

```tsx
import { useTranslation } from 'react-i18next'

export function SomeCard() {
  const { t } = useTranslation('deploys')
  return <p>{t('someFeature.someLabel')}</p>
}
```

4. For interpolation, use `{{placeholder}}` in the JSON value and pass it as the second argument:

```json
{ "toast": { "modeSet": "Auto-rollback on SLO burn set to: {{mode}}." } }
```

```tsx
t('autoRollbackSloBurn.toast.modeSet', { mode: label })
```

5. Every user-facing string counts, including ones that are easy to forget: `aria-label`s, toast titles/descriptions, tooltip text, `alt` text. A screen reader user or someone reading a toast is still a user. If it is not user-facing (a `data-testid`, an internal log message, a CSS class name), it does not belong in a namespace file.
6. If the component needs strings from more than one namespace, pass an array: `useTranslation(['deploys', 'common'])`, then `t('common:actions.copy')` with the namespace prefix for the non-default one.

## TypeScript key safety

`web/src/i18n/resources.ts` augments i18next's own types using the real `en` JSON files as the source of truth:

```ts
import type common from '../locales/en/common.json'
import type deploys from '../locales/en/deploys.json'

declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common'
    resources: {
      common: typeof common
      deploys: typeof deploys
    }
  }
}
```

With this in place, `t('autoRollback.titl')` (a typo) is a `tsc` type error at the call site, not a silent fallback to the raw key string at runtime. This was verified directly: deliberately introducing a bad key into a migrated component and running `npx tsc -b` produced a real `TS2345` error pointing at the call site; removing the typo made it compile again.

This also means adding a key only in the component's call site without adding it to the JSON file first does not work. The JSON file is the schema. Add the key there first.

`tsconfig.app.json` needs `"resolveJsonModule": true` for this to work (JSON imports, including the `import type` ones above, need it); that is already set.

## What is migrated so far

`AutoRollbackCard`, `AutoRollbackSLOBurnCard`, and `BadgeSettingsCard` (all under `web/src/components/`) are the proof of pattern: every user-facing string in these three files, including toast copy and `aria-label`s, comes from `deploys.json` or `common.json`. Nothing else has been touched. The rest of the dashboard still has hardcoded English strings everywhere, on purpose: this was infrastructure plus one real example, not a full sweep.

## Extending this to the next component

1. Read the component's current content fresh (not from memory of an older version), since copy changes independently of i18n work.
2. Pick (or create) the right namespace file.
3. Move every user-facing string into it, nested under a key for that component/feature.
4. Replace the hardcoded strings with `t()` calls, importing `useTranslation` from `react-i18next`.
5. Run `npx tsc -b` and `npx eslint src` in `web/`. A missing or mistyped key fails `tsc`, not just a visual check.
6. If the component renders real content behind a suspense boundary already (e.g. `useSuspenseQuery`), nothing extra is needed: the existing `<Suspense>` wrapping in `main.tsx` and in tests covers the namespace load too. If it does not, make sure some ancestor still has a `<Suspense>` boundary (it almost always already does).
7. Write or extend a component test using a dedicated `i18next.createInstance()` seeded with the real namespace JSON via `I18nextProvider`, not the app's lazy-loading singleton: tests want deterministic, already-loaded translations, not a network/chunk-loading race. See `web/src/components/AutoRollbackCard.test.tsx` for the full pattern.

## Not in scope here (yet)

- No second language. No real French/Spanish/etc. translations exist, and none should until there is a real reason to ship one.
- No language switcher UI. `i18next-browser-languagedetector` is wired into `web/src/i18n/index.ts` so adding one later is additive, not another migration, but building the switcher itself is a separate task.
- No blanket migration. Hundreds of components still hardcode English strings. That is fine for now; migrate opportunistically (new code, and when a component is touched for other reasons) rather than as a dedicated sweep.
