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-allowlist.tsthe notation the literal gate tolerates, each entry with its reason
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);

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

  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.

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:

KindWhat it matches
dialoga string literal passed to window.confirm / alert / prompt
propprose assigned to a user-facing prop, destructured default or object key — the names are listed below
textprose sitting directly between JSX tags, interpolation included
errorthe 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:

NamesWhere the dashboard renders them
title, subtitle, description, label, placeholder, emptyText, aria-labelheaders, fields, empty states, accessible names
confirmLabel, cancelLabel, saveLabeldialog and sheet buttons
descRelatedLink, FeatureFlags and the settings screens’ rows
hint, infoField and SwitchRow — the line under the control and the InfoHint beside it
text, tooltipInfoHint, and a hover explanation
detail, message, bodya toast’s second line, a notice’s message or body
noteScopeNote and the cost attribution notes
error, errorMessageField‘s validation line, the create dialogs’ failure line
altan image’s accessible name
header, groupa table column’s heading, the header a ComboboxOption sits under
eyebrowPageLead and SettingsPanel’s kicker
xLabel, yLabel, unit, xUnit, yUnit, centerLabel, centerSubthe charts’ axis titles and units, the donut’s centre
removeLabel, ariaLabel, empty, experimentalNote, unavailableReasona chip’s remove button, a Segmented group’s name, a screen’s empty slot, a flag
aria-description, aria-roledescription, aria-valuetext, aria-placeholderthe rest of what a screen reader speaks
heading, caption, legendthe 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 rule or ← 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]} or title={COPY.save} reads every value of const 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, lng and the rest of T_OPTION_KEYS — are skipped
  • helpers. A call that is the whole of a rendered expression — {stateLabel(row)}, hint={hint(on)} — reads the helper’s return values (a declared function or an arrow bound to a const). 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(…)}) makes setLog a 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, as renames 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 useState seed 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 into label:, description: and group: on ComboboxOption, 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> or title={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.