# 0064. The UI is localised in English and Japanese with a typed dictionary and no library

Status: Superseded by 0070 · Date: 2026-10-26 · Design section: UI, Roadmap phase 10
Decided: The UI's fixed wording lives in two typed objects, `en` and `ja`, in `ui/src/lib/i18n.ts`, read through one `t()` function, with no i18n dependency; the language is the saved choice, else `navigator.languages`, else English, switched on screen and kept in `localStorage` with nothing on the server; names, field names, state words from Argo CD or the cluster, log lines and the body of apiserver or Talos errors are shown as received; dates and sizes go through `Intl` for the chosen language

## Context

Roadmap phase 10 lists "UI localisation". The users include Japanese speakers. The UI is Preact with
preactcn; React and `preact/compat` are not allowed (project rule), which rules out the React-based i18n
libraries (react-i18next, react-intl, LinguiJS's React bindings). The screens hold about 220 fixed strings.

## Decision

**1. No library.** `ui/src/lib/i18n.ts` has `en` (an `as const` object) and `ja`, typed
`Record<keyof typeof en, string>`. A key missing from `ja`, or only in `ja`, does not compile. `translate(lang,
key, params)` replaces `{name}` markers. A test also checks at run time that both have the same keys, that no
entry is empty, and that both fill the same placeholders, because a compiler check does not see a `ja` entry
that lost a `{n}`. There are no plural forms: the strings are written so a count is only ever a number in
a label ("Pending approval (3)"), which both languages can say without grammar. If a language with plural
rules is added, `Intl.PluralRules` is already in the platform and the cost is one function.

**2. Choosing the language.** `lang.ts` keeps the current language in a module variable. At first use it is
`localStorage["ichor-ui-lang"]` if that is `en` or `ja`, else the first tag of `navigator.languages` whose primary
subtag is `en` or `ja`, else English. A selector in the page header changes it, writes `localStorage`, sets
`<html lang>` and redraws. Each language is named in itself ("English", "日本語"). Storage and `navigator` are read
behind try/catch, so a blocked store only means the choice does not outlive the tab. Nothing is sent to the
server and no server setting exists: the language is a property of the person, not of the installation.
Components call `useT()`, which subscribes them to a change; plain functions (`describeError`, `formatBytes`,
`enumLabel`) read the current language directly.

**3. What is translated.** The screens' own fixed text: titles, column headings, buttons, hints, empty and
loading states, dialog text, the headline `describeError` puts above an error, and the display names of the proto
enums (`HandoverPhase`, `ConfigLayer`, ...). An enum value without a dictionary entry still falls back to its
title-cased proto name, so a value added to the API later shows up readable, not missing.

Not translated, on purpose:

- Names of custom resources, namespaces, hostnames, and Kubernetes, Talos and proto field names (`TalosConfigPatch`,
  `matchLabels`, `ichorUI.oidc`, the document kinds `SysctlConfig` and so on), which a person must be able to type
  or search for exactly.
- The body of an error from the apiserver, Talos or ichor-ui (ADR 0045). `describeError` translates only the
  headline; the detail is shown as received, so what is searched for in the docs or an issue is what the server said.
- State words that other systems return (Argo CD's `Synced`, `OutOfSync`, `Healthy`; condition `type` and `status`)
  and the log lines.
- The CLI. This ADR is about the UI; the CLI's output is what scripts and bug reports quote.

**4. Dates and numbers.** `formatTimestamp` and `formatBytes` take the language and use `Intl.DateTimeFormat`
(`dateStyle: medium`, `timeStyle: medium`) and `Intl.NumberFormat`. Sizes stay binary (1024) and keep the labels
`B KB MB GB TB` in both languages: the unit symbols are the same in Japanese usage, and changing the base or the
labels per language would make one number read as two sizes.

## Consequences

- No dependency, no build step, and no bundle growth beyond the strings (a few KB).
- Adding a language is one object and one entry in `langs` / `langNames`; the compiler lists what is missing.
- The English text is the source of truth. A change to an English string does not tell the translator; reviewers
  change both objects in one commit, which the key check enforces only for presence, not for meaning.
- Strings are translated whole, with placeholders, so word order differs by language where it has to.
  The one sentence with a link in it (the effective-config note) is stored in three pieces around the link.
- The Japanese is a first draft by the implementer and wants a read-through by a native speaker.
- Page components are still not unit-tested (ADR 0045); the language logic is, as pure functions in `ui/src/lib`.
