Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dashboard localization (i18n)

The dashboard SPA under ui/ is translated with i18next + react-i18next. English is the base catalog; Russian ships alongside it. Switching language is instant — react-i18next re-renders the tree, nothing reloads and no route changes.

Everything is bundled. There is no CDN, no language-detection service and no runtime fetch to a translation host, because rolter must run fully air-gapped.

Layout

PathWhat it is
ui/src/lib/i18n/index.tsi18next init, locale detection, setLocale()
ui/src/lib/i18n/locales/en.jsonbase catalog — the source of truth for keys
ui/src/lib/i18n/locales/ru.jsonRussian catalog
ui/src/lib/i18n/format.tsuseFormat() — Intl number/currency/date bound to the active locale
ui/src/lib/i18n/parity.tsthe parity rules, shared by the test and the CI gate
ui/src/lib/i18n/literals.tsthe hardcoded-literal rules, likewise shared
ui/src/lib/i18n/literals-baseline.jsonthe recorded pre-existing literals — debt, written down
ui/src/components/LocalePicker.tsxthe switcher in the sidebar footer
ui/scripts/check-i18n.tsbun run check:i18n — the catalog-parity CI gate
ui/scripts/check-literals.tsbun run check:literals — the hardcoded-literal CI gate

en is imported statically because it is the fallback and must always resolve. Every other locale is a dynamic import(), so Vite emits it as its own chunk and a user only downloads the language they actually pick.

Key naming

Keys are nested and grouped by where the copy lives:

PrefixHolds
common.*words reused everywhere — Cancel, Delete, Search…
shell.*the app frame: sidebar chrome, user menu, screen header
nav.<screen-key>one sidebar label per nav entry
screens.<screen-key>.title / .subtitlethe screen header, also used by the stub
pages.<screen-key>.*copy inside a screen
auth.*, scope.*, locale.*the login screen, scope switcher, language picker

<screen-key> is the nav key, which is also the route path — so a screen’s label, header and body copy are all found by the same string.

Keep keys semantic (pages.dashboard.noTraffic), never the English text itself: re-wording the English copy should not force a key rename.

Writing translatable copy

import { useTranslation } from "react-i18next";

const { t } = useTranslation();
return <h2>{t("pages.dashboard.recentTitle")}</h2>;

Interpolation — name the value, never concatenate:

// en.json
"roleWithOrg": "Admin · {{org}}"
t("shell.roleWithOrg", { org: orgName })

Plurals — use count, and let i18next pick the form. English needs two forms, Russian needs four; the catalogs differ on purpose and the gate knows the difference:

// en.json                    // ru.json
"errors_one":   "{{count}} error",     "errors_one":   "{{count}} ошибка",
"errors_other": "{{count}} errors"     "errors_few":   "{{count}} ошибки",
                                       "errors_many":  "{{count}} ошибок",
                                       "errors_other": "{{count}} ошибки"
t("pages.dashboard.errors", { count: errors })

Markup inside a sentence — use <Trans> with numbered slots, so each language can put the emphasised part where its grammar wants it:

"deleteHint": "This removes <0>{{name}}</0>. This cannot be undone."
<Trans
  i18nKey="scope.deleteHint"
  values={{ name }}
  components={[<span key="name" className="font-mono" />]}
/>

Never interpolate a noun into a sentence frame. "Add {{level}}" cannot be declined correctly in Russian (and most inflected languages). Spell each case out as its own key — scope.addOrg, scope.addTeam, scope.addProject — and select it with a lookup table in the component.

Numbers, money and dates

Use useFormat(), never bare toLocaleString(). A bare call follows the browser locale, so a ru-RU browser rendered Russian separators inside an otherwise English panel.

const fmt = useFormat();
fmt.number(1234567);     // 1,234,567   / 1 234 567
fmt.currency(0.0004);    // $0.0004     (sub-cent costs stay visible)
fmt.percent(0.734);      // 73.4%
fmt.dateTime(row.ts);

Adding a locale

  1. Copy ui/src/lib/i18n/locales/en.json to <code>.json and translate it. Leave locale.en / locale.ru / … as native names in every catalog — a language is always listed in its own language, so a user who picked the wrong one can find their way back.
  2. Register it in ui/src/lib/i18n/index.ts: add the code to LOCALES, a native name to LOCALE_NAMES, a two-letter badge to LOCALE_SHORT, and a dynamic import to loaders.
  3. Run bun run check:i18n — it will list every key you still owe, and every plural form the language requires.

No other file changes. The picker builds itself from LOCALES.

Locale selection

detectLocale() resolves, in order:

  1. the stored choice in localStorage["rolter.locale"],
  2. the browser’s navigator.languages, matched on the primary subtag so ru-RU resolves to ru,
  3. en.

Picking a language persists the choice and sets <html lang>. This is dashboard-only state — it is not written to the account, so it needs no control-plane change. (Per-user server-side persistence is tracked separately.)

Keeping catalogs honest

cd ui
bun run check:i18n     # the CI gate
bun test src/lib/i18n  # the same rules, plus runtime behaviour

The gate fails on: a key missing from a locale (which would silently fall back to English mid-sentence), an orphaned key whose screen is gone, an empty string, a dropped {{placeholder}} or <0> slot, and an incomplete set of plural forms. Both entry points share ui/src/lib/i18n/parity.ts, so bun test and CI cannot disagree.

When you add or re-word copy, update en.json and every other catalog in the same PR — a half-translated catalog is a CI failure, not a follow-up.

Catching strings that never reach a catalog

Catalog parity is a property of the catalogs. It cannot see the failure that actually happens: a string hardcoded in JSX, which is not in any catalog by definition. That is how EditorSheet shipped window.confirm("Discard unsaved changes?") and a default "Cancel" label with a green merge gate (#871).

cd ui
bun run check:literals            # fail on any literal not in the baseline
bun run check:literals --update   # re-record the baseline (only to shrink it)

It scans src/components/** and src/pages/** for three shapes:

KindWhat it matches
dialoga string literal passed to window.confirm / alert / prompt
propprose assigned to a user-facing prop or destructured default — title, label, placeholder, description, aria-label, cancelLabel, …
textprose sitting directly between JSX tags

A line containing t( is skipped, because that line is already doing the right thing. Acronyms, identifiers, URLs and bare numbers are not copy and are ignored. Stories and tests are not scanned: a story’s job is to render a component with concrete sample text, and routing that through the catalogs would make the stories test the catalogs instead of the component.

Why there is a baseline

The rule is repo-wide and the debt predates it — several hundred literals already exist. Failing on all of them would mean either a several-hundred-string translation PR nobody asked for, or a gate that is permanently red and therefore ignored. So the existing set is recorded in literals-baseline.json and the gate fails on anything new.

The baseline only shrinks. When you translate a recorded literal, the gate tells you the entry is stale and asks you to drop it, so the same string cannot come back unnoticed.

It is a lexical scan, not a TypeScript parse. The question is “does a user-visible English string appear where a t() call belongs”, and every candidate is a string literal or a JSX text node — both of which a regex reads accurately enough to answer it, without a compiler dependency in a script that runs on every push.