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-allowlist.ts | the notation the literal gate tolerates, each entry with its reason |
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);
A figure whose unit depends on the row goes through one table rather than a
format call per site. An alert rule’s threshold and last value are the model:
ui/src/lib/alert-signals.ts maps each signal to its unit, the scale between
what the API stores and what the form takes (error_rate is a 0-1 fraction
typed as a percentage), its default threshold and its formatter, so the card,
the form and the request body cannot disagree (#2125). Intl joins some units to
their number with a plain space (ru 840 мс), so a figure on a narrow card
binds the two with a no-break space.
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.
Copy that arrives as data
check:literals only sees strings written into components, so prose sent by
the control plane slips past it and renders in English whatever the locale.
Where a backend value is shown to the user, send a stable identifier and look
the copy up by it rather than rendering the server’s text. The experimental
note is the worked example: GET /api/v1/stability carries an English note
for API consumers, and the rail renders stability.notes.<id> instead
(#1401). ui/scripts/stability-notes.test.ts keeps those keys in step with
SUBSYSTEMS in crates/rolter-core/src/stability.rs; see
Stability markers.
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 that is not in a catalog
It scans src/components/**, src/pages/**, src/lib/** and src/App.tsx
for four shapes:
| Kind | What it matches |
|---|---|
dialog | a string literal passed to window.confirm / alert / prompt |
prop | prose assigned to a user-facing prop, destructured default or object key — the names are listed below |
text | prose sitting directly between JSX tags, interpolation included |
error | the message of a thrown Error / ApiError, which LoadError prints, or one handed to a message setter such as setError(…) / setFormError(…) |
The user-facing names are a closed list, USER_FACING_PROPS in
literals.ts, so a string under className, type or id is never read:
| Names | Where the dashboard renders them |
|---|---|
title, subtitle, description, label, placeholder, emptyText, aria-label | headers, fields, empty states, accessible names |
confirmLabel, cancelLabel, saveLabel | dialog and sheet buttons |
desc | RelatedLink, FeatureFlags and the settings screens’ rows |
hint, info | Field and SwitchRow — the line under the control and the InfoHint beside it |
text, tooltip | InfoHint, and a hover explanation |
detail, message, body | a toast’s second line, a notice’s message or body |
note | ScopeNote and the cost attribution notes |
error, errorMessage | Field‘s validation line, the create dialogs’ failure line |
alt | an image’s accessible name |
header, group | a table column’s heading, the header a ComboboxOption sits under |
eyebrow | PageLead and SettingsPanel’s kicker |
xLabel, yLabel, unit, xUnit, yUnit, centerLabel, centerSub | the charts’ axis titles and units, the donut’s centre |
removeLabel, ariaLabel, empty, experimentalNote, unavailableReason | a chip’s remove button, a Segmented group’s name, a screen’s empty slot, a flag |
aria-description, aria-roledescription, aria-valuetext, aria-placeholder | the rest of what a screen reader speaks |
heading, caption, legend | the html elements’ own captions |
The list is closed, not frozen: when a component takes copy under a name that is
not in it, add the name in the same PR, with a case in literals.test.ts (#1545).
Where a single lowercase word is copy
Everywhere else a bare lowercase word — chat, inherit, idle — is far more
often a code than a label, so it is not reported. In a position that renders
the string as it stands, the dashboard’s lowercase style makes it copy, and the
gate reads it (#1745):
- the literal value of a copy prop:
aria-label="close",placeholder="unchanged" - a JSX text node:
<span>disabled</span>, including one that opens with a glyph,+ Add ruleor← Back(#1872). The glyph beside a translated label,+ {t("pages.alerting.rules.add")}, is notation and stays quiet - a value a rendered expression can evaluate to — a ternary branch, a
??/||/&&fallback:{open ? "hide" : "show"},{row.target || "unknown"}. An operand is not a value:kind === "chat",fmt(n, "compact")and{ dateStyle: "medium" }stay quiet - a template with one word beside a value:
`${n} tokens` - a message setter’s argument, and a local the screen renders by name
An object key is not such a position. { value: "json", label: "json" } is an
option echoing its own wire value as often as it is a label, and the key is
read with the ordinary rules.
Three more shapes are read wherever copy goes:
- concatenation. A fragment glued on with
+whose edge is a space —name + " settings","Owned by " + owner— is a piece of a sentence and is reported, capitalised or not. Build the sentence as one catalog entry with its interpolation instead; word order is the translator’s call - an indexed table.
{HINTS[mode]}ortitle={COPY.save}reads every value ofconst HINTS = { … }in the same file, word by word - prose beside any expression. Children are walked element by element, so a
sentence next to
{lagging > 0 && …}— whose>used to hide the whole run — or a label after a conditional spinner is read. The walk follows JSX nesting, so code after the outermost element closes is never taken for text
Copy that crosses a line of code
The scan reads the whole tree at once, not file by file (#1765). Four more shapes are followed to where the string is written:
- interpolations. The values of a
t("k", { … })options object and of a<Trans values={{ … }}>are spliced into translated copy, so a literal there is copy:t("toast.deleted", { what: "rule" }). i18next’s own switches —count,context,ns,lngand the rest ofT_OPTION_KEYS— are skipped - helpers. A call that is the whole of a rendered expression —
{stateLabel(row)},hint={hint(on)}— reads the helper’sreturnvalues (a declaredfunctionor an arrow bound to aconst). A return that opens with a tag is a component’s render, which the JSX rules already read. The helper is followed one call deep: a helper that returns another helper’s result is not read through - shown state.
const [log, setLog] = useState(…)whose value is rendered bare ({notice}) or mapped ({log.map(…)}) makessetLoga place copy goes, and so is any local function that calls it:append("● connected"). An updater function (setLog((l) => […])) is code, and state that is never rendered, such as a tab id, is not read - imports. A table, constant or helper imported from another scanned file
and rendered in this one (
{STATUS[s]},{EMPTY},label={roleLabel(r)}) is read in the file that exports it.@/and relative specifiers are resolved against the scanned set,asrenames included, and the finding names the exporting file, since that is where the fix or the allow-list entry goes
A status glyph in front of a word does not make it notation — ● connected
reads like connected — but path and flag punctuation does: /model,
--verbose.
Still out of reach, and left to review:
- a
useStateseed or other sample input: seed it from the catalog,useState(() => t("…")), as the playground does - a namespace import (
import * as copy), a default export, and a re-export chain; none of them carries copy in the dashboard today - anything the
src/lib/i18n/modules export, which the scan skips as the machinery itself
The key of a t(…) call is blanked before matching, so a translated string
costs nothing and hides nothing beside it; its options are read as above. Acronyms, identifiers, URLs and bare
numbers are not copy and are ignored, and so are class lists. Case does not
make a class list: lowercase copy such as request failed: {…} or no events yet is reported like its capitalised spelling (#1546). A phrase is skipped as a
class list only when every token is a utility, meaning it carries a dash, a
bracket, a slash, a percent, a digit or a variant colon (px-3.5, w-[9px],
sm:block, grid-cols-[1fr_2fr], bottom-[calc(100%+6px)]), or it is one of
the bare utilities and css keywords (flex, relative, truncate, none,
auto, srgb, …), with a css function’s parentheses and commas ignored. One
plain word makes it prose. Stories, tests and the two story fixtures (story-harness.tsx,
shell-harness.tsx) 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.
No baseline: copy goes in the catalogs, notation in the allow-list
The gate shipped with literals-baseline.json, a recorded list of several
hundred literals that predated it and that it tolerated. It could only shrink,
but it read as clean on every run while most of the ru dashboard was English
(#958). That debt has been paid off and the baseline is gone: a screen is not
done until every string an operator reads is in the catalogs, translated in
every locale.
What the scan still reports and nobody would translate — a request parameter
shown as n=1, a version as v{…}, a {…} rpm unit, a developer invariant no
operator can reach — is listed in ui/src/lib/i18n/literals-allowlist.ts. Each
entry maps a file and the exact reported text to the reason it is not copy:
"src/pages/Playground.tsx": {
"n=1": "the OpenAI images `n` request parameter, shown as the value sent",
},
There is no command that records findings into it. An entry is written by hand
and reviewed, the gate fails on an entry with an empty reason, and it fails on an
entry whose text is no longer in the source, so a string that was translated
cannot come back unnoticed. When the scanner misreads code as prose, as it did
with arbitrary Tailwind values, fix the scanner with a case in
literals.test.ts rather than allow-listing the string.
What the scanner sees
It reads the whole file, not one line at a time (#1200), so it finds: every
literal on a dense one-line component, prose that wraps across lines inside a
tag, the strings inside an expression in text or prop position
({pending ? "Saving…" : "Save"}, title={edit ? "Edit" : "Add"}), prose that
shares a text node with an interpolation (#1355) along with the strings inside
the expressions beside it (#1371), a window.confirm whose string sits on the
next line, and the message of a thrown Error/ApiError — which LoadError
prints — whether it is a string or a template literal (#1390).
Every {…} in children position is read, not only an element’s single one
(#1594): a button that renders a spinner beside its label writes two in a row,
{pending && <Loader2 />}{draining ? "Return to service" : "Drain"}, and the
second used to sit behind a } where the pattern only looked after a >. The
tags nested inside such an expression are blanked before its strings are read —
a className on a nested <span> is a prop, not children.
It also follows copy that reaches the screen without sitting in JSX (#1537):
- a user-facing key in an object literal,
{ label: `Other (${n})` }or{ title: "Rate limits" }— the shape of every tab list, range picker and chart slice; - a dropdown’s whole vocabulary. The migration off
<select>(#968) moved option copy out of<option>text and intolabel:,description:andgroup:onComboboxOption, so those keys are where it is read now (#1594); - a table column’s
header:, which is data and was never a JSX text node; - a local binding whose name is rendered bare where copy goes,
const cta = add ? "Create" : "Save"followed by<Button>{cta}</Button>ortitle={cta}. A binding that only ever reaches code is not read, and neither is a function.
A template literal records like a mixed text node, each ${…} collapsed to
{…}, and it counts only when its prose does: ${a}/${b} and /v1/${id} carry
no sentence and are left alone. It scans src/components,
src/pages, src/lib and App.tsx; the i18n machinery, stories and tests are
skipped. Comments, regex literals and the key of each t() call are blanked
before matching; the call’s options stay, to be read as interpolations.
A sentence that carries a value
<p>Charged to {team.name} monthly</p> is one finding, Charged to {…} monthly — the runs of one element are joined and every interpolation collapses
to {…}. That is the shape a catalog entry has to hold, and it is the copy that
most needs one, because interpolation order differs by language. Reporting each
run on its own would record Charged to and monthly, neither of which anybody
would translate.
A region with no prose in it is not copy and is not reported, so {a} · {b},
{count} ({total}) and a lone {status} are never reported. {" "} is
whitespace rather than a value, so it collapses to a space, not a placeholder.
The usual thresholds still apply on the joined string: three characters, at
least one lowercase letter, and not a class list, a URL or a bare number.
Which > ends a tag
A text node is whatever sits between a > and the next <, so the scan is only
as good as its answer to “is this > the end of a tag”. A generic
(React.useState<Row[]>({…})) and a comparison (a > b && c < d) both used to
read as one, and the code behind them was recorded as copy — extends React.HTMLAttributes and b && c were recorded as copy (#1370).
The question is answerable from the < end, so the scan starts there: a tag
opens as <Name, </Name or <> and never flush against an identifier, which
is what a generic does (Promise<, Record<). From that < the tag runs to
its own >, skipping attribute strings and everything inside {…} — the =>
in onClick={() => close()} is an arrow, not the end of the button’s tag.
What it still cannot see
A binding is only followed when its own name is rendered bare (#1537), so copy
held in one that reaches the screen some other way stays invisible: through a
member expression ({bag.cta}), through a call ({upper(cta)}, {tabs.map(…)}
over an array of labels), destructured out of something else
(const { cta } = pickCopy()), or never rendered at all. Only const and let
are read, and a binding holding a function or an arrow is skipped on purpose —
its body is the rest of a component, not a held value.
A sentence assembled by concatenation is not reassembled. Each quoted piece is
matched on its own, so {"Charged to " + team + " monthly"} is reported as
Charged to and nothing else, and a piece that is a single lowercase word
({who + " monthly"}) reads as code and is dropped entirely. Write it as one
catalog entry with a placeholder instead — which is what the language needs
anyway, since interpolation order differs.
A string under a prop or object key outside USER_FACING_PROPS is never read,
by design: caption="Save now" is as invisible as className is. Add the name
to that list, with a case in literals.test.ts, rather than working around it.
A copy map used to be invisible for the same reason — its keys are codes, not
prop names — and since #1599 it is not: an object whose every member is a
code: "string" pair, with more than one member, has its values read as copy.
const MODES = { off: "Off", on: "Streaming on" } fed to a combobox through
Object.entries now reports both labels. Build the options from t() instead
of mapping a code to English.
What that rule deliberately stands down for, since a lookup table full of wire
values is the normal case: a value that is not a phrase and was not capitalised
(cache-first, provider_name, /api/v1/keys, bg-green-500, scope.org,
POST); an object where any member is not a plain string, which makes it a
config object rather than a table; and one whose keys name a prop rather than
a wire value (id, className, kind, and the rest of NOT_A_TABLE_KEY) —
there the copy-carrying names in USER_FACING_PROPS are the precise rule and
this one would only add noise. A language’s own name (Bash, TypeScript) is
a real finding under this rule and belongs in the allow-list, which is where
the ones in code-block.tsx now sit.
Why the set does not depend on formatting
The findings have to be a property of the code, not of where its lines break.
They were not, and it broke the ratchet twice (#1143): re-wrapping dense JSX
moved strings in and out of the detected set with nothing added or removed from
the source, and accept="image/*" opened a block comment for the
comment-stripping regex, blanking every literal between it and the next */ a
hundred lines further down — seven strings in Playground.tsx, including
aria-label="Attach image", were invisible to the gate that way.
So maskSource in literals.ts runs first. It is a small TS/JSX tokenizer that
tells strings, template literals, comments and regex literals apart, blanks what
is never shipped, and collapses every whitespace run in code position to a
single space. Two spellings of the same component — one line or twenty — mask to
the same string, so they yield the same literal set; literals.test.ts asserts
exactly that on a fixture written both ways. The mask carries an offset map back
into the original file, so a finding still reports the line you have to edit.
It is still 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 the masked
source plus a regex reads accurately enough to answer it. A real AST pass is not
on offer anyway: the typescript package the dashboard depends on is the Go
port, which ships no in-process createSourceFile to walk.
Two heuristics in the tokenizer are worth knowing about, because both trade a
theoretical miss for a practical one. A / after < or } is never read as a
regex literal, since in .tsx those are </Foo> and {x}</Foo> and swallowing
a closing tag would cost the rest of the component. And a quote with no closing
quote before the end of the line is ordinary text, not a string — otherwise the
apostrophe in JSX prose would eat the copy after it.
A t("…") call’s key is blanked out of a line before it is matched, rather than
the line being skipped whole. The difference matters on the dense screens: they are
written one long statement per component, so “the rest of this line” is
routinely the rest of the component, and a single translated header used to hide
every literal beside it (#1092). Blanking keeps the column count, so a reported
position still points where the string is.