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
| Path | What it is |
|---|---|
ui/src/lib/i18n/index.ts | i18next init, locale detection, setLocale() |
ui/src/lib/i18n/locales/en.json | base catalog — the source of truth for keys |
ui/src/lib/i18n/locales/ru.json | Russian catalog |
ui/src/lib/i18n/format.ts | useFormat() — Intl number/currency/date bound to the active locale |
ui/src/lib/i18n/parity.ts | the parity rules, shared by the test and the CI gate |
ui/src/lib/i18n/literals.ts | the hardcoded-literal rules, likewise shared |
ui/src/lib/i18n/literals-baseline.json | the recorded pre-existing literals — debt, written down |
ui/src/components/LocalePicker.tsx | the switcher in the sidebar footer |
ui/scripts/check-i18n.ts | bun run check:i18n — the catalog-parity CI gate |
ui/scripts/check-literals.ts | bun 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:
| Prefix | Holds |
|---|---|
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 / .subtitle | the 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
- Copy
ui/src/lib/i18n/locales/en.jsonto<code>.jsonand translate it. Leavelocale.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. - Register it in
ui/src/lib/i18n/index.ts: add the code toLOCALES, a native name toLOCALE_NAMES, a two-letter badge toLOCALE_SHORT, and a dynamic import toloaders. - 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:
- the stored choice in
localStorage["rolter.locale"], - the browser’s
navigator.languages, matched on the primary subtag soru-RUresolves toru, 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:
| Kind | What it matches |
|---|---|
dialog | a string literal passed to window.confirm / alert / prompt |
prop | prose assigned to a user-facing prop or destructured default — title, label, placeholder, description, aria-label, cancelLabel, … |
text | prose 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.