Web UI internationalization (i18n)¶
How the web UI's translation layer works and how to add a language. The roadmap and surface analysis live in docs/i18n-scoping.md; this doc describes what is implemented: the catalog infrastructure, full English string extraction, and the shipped locales.
Shipped locales: English (en, default/fallback), German (de), Spanish
(es), French (fr), Arabic (ar, RTL), Hebrew (he, RTL), Japanese
(ja), Simplified Chinese (zh), Yiddish (yi, RTL).
RTL layout (Phase 3)¶
The stylesheets use logical properties throughout (margin-inline-start,
inset-inline-end, text-align: start/end, border-inline-start, …), so
flex/grid layout and spacing mirror automatically under <html dir="rtl">.
Keep it that way: never add physical left/right spacing or alignment to
assets/web/styles unless it is genuinely direction-neutral (centering
left: 50% + translateX(-50%), spinner border accents).
What logical properties cannot express lives in the clearly-marked RTL
section at the end of components.css:
- Switch-knob travel (
translateX) is physical —[dir='rtl']overrides negate the distance for the equipment/schedule/settings switches. - Toasts slide in from the inline-end corner — an RTL keyframe variant.
- Time-axis geometry is pinned
direction: ltr(.sched-trk,.sched-axis, chart cards): the JS positions bars/markers with left-based percentages, and time axes read left→right even in RTL locales. Only the geometry elements are pinned; the surrounding cards, day chips, legends and labels mirror normally.
Known issue (pre-existing, both directions): the alerts dropdown overflows at narrow viewports where the nav wraps — tracked separately.
Fonts and scripts (Phase 4)¶
The Latin brand faces (Hanken Grotesk for UI text, Bricolage Grotesque for
display headings, vendored in assets/web/vendor/fonts/) stay primary in every
locale. Non-Latin scripts are covered two different ways:
- Arabic / Hebrew — vendored Noto Sans Arabic and Noto Sans
Hebrew variable woff2 files (
assets/web/vendor/fonts/noto-arabic.woff2,noto-hebrew.woff2, wght 400–700) declared infonts.csswith script-onlyunicode-ranges. Because of the range slices they behave as per-glyph fallbacks: they sit after Hanken Grotesk in the shared--font-family/--font-ui/--font-displaystacks inapp.cssand are consulted only for their scripts' codepoints, so Latin text (numbers pinned LTR, brand names, codes) keeps the brand faces even underlang="ar"/lang="he". This makes both scripts render identically across platforms instead of whateversystem-uiresolves to. Yiddish (yi) is written entirely within the Hebrew Unicode block (U+0590-05FF), so it needs no font entry of its own — the Hebrewunicode-rangeslice already covers it since the fallback is codepoint-, notlang-attribute-, driven. - Japanese / Chinese — no vendored faces (CJK subsets are megabytes);
html[lang='ja']andhtml[lang='zh']blocks inapp.cssswap the stacks to named system faces (ja:Hiragino Kaku Gothic ProN,Yu Gothic UI,Meiryo,Noto Sans JP; zh:PingFang SC,Microsoft YaHei,Noto Sans SC) ahead ofsystem-ui, again keeping Hanken Grotesk first for Latin glyphs.
Chart.js does not read CSS custom properties, so trends-view.js repeats the
UI stack in its TREND_FONT constant — keep it in sync with --font-ui if
the stacks change.
Adding a locale in a new script = add a unicode-range-sliced @font-face
(vendored subset) or a html[lang='xx'] stack override, plus the e2e
font-load assertion in e2e/i18n.spec.ts if vendored.
Architecture¶
- Catalogs are plain JS registration files under
assets/web/i18n/, one per locale, each assigning a flat"namespace.key" → stringmap intowindow.AquaI18n.catalogs.<locale>. English (i18n/en.js) is included statically inindex.htmlbefore every other app script, so the default and fallback catalog is always available synchronously — no fetch race, no flash of raw keys, works offline. A saved non-English locale is loaded synchronously at boot too (parser-blockingdocument.writefrom the runtime), so a returning user's first paint — including any boot-time toast — is already in their language. Catalogs for locales switched to during a session load on demand via<script>injection and swap in reactively. - Runtime is
assets/web/scripts/i18n.js: the locale registry (SUPPORTED_LOCALES), an Alpine store ($store.i18n), the$t('key', {params})/$tn('key', count)template magics, andwindow.AquaI18n.t()for plain-function call sites (stores, views, components). Lookup order: active locale → English → the key itself (with a one-time console warning, so a missed extraction is visible in dev tools). - Locale resolution: explicit user choice (Settings → Appearance →
Language) is stored in
localStorage(instant boot) and mirrored to the server underpreferences.ui.locale(cross-device; the server value wins on conflict). A user who never chose followsnavigator.language.<html lang>and<html dir>track the active locale; locales registered withdir: 'rtl'flip the document direction (Arabic, Hebrew, and Yiddish ship as RTL — see "RTL layout" above for the logical-properties pass that makes it work). /api/preferencesuimerge: the opaqueuiobject merges shallowly at its top level on PUT, soui.localeandui.chemistryBands(written by independent frontend features) never clobber each other; a top-level null deletes the key. Seeassets/web/api/swagger.yamlandTestSuite_PreferencesService/ApplyJson_UiMergesTopLevelKeys.
Key conventions¶
- Flat dot keys, namespaced by surface:
nav.*,login.*,dash.*,detail.*,diag.*,devcard.*,trends.*,sched.*,settings.*,about.*, plus sharedcommon.*,status.*,cmd.*,alert.*,toast.*,time.*,tier.*,chem.*,swg_health.*,swg_reason.*,day.*. - Placeholders are
{name}and are substituted byt(); never rebuild sentences by string concatenation — word order differs across languages. - Number-typed placeholder values are rendered with
Intl.NumberFormatfor the active locale, so the digits themselves localize (Arabic renders Eastern Arabic-Indic numerals — pinned via the registry'snumberLocale: 'ar-u-nu-arab'because barearresolves to Latin digits in some engines). Pass numbers as numbers; a pre-formatted string (e.g.toFixed()output) passes through untouched. - Keys ending in
_htmlmay contain markup (bound withx-html; the content is static catalog text, never server data). Translations must preserve the tags. - Plural keys carry a CLDR category suffix (
.one,.other, …) and are looked up viatn(key, count)/$tnusingIntl.PluralRules(e.g.sched.conflicts.one/sched.conflicts.other).
Value formatting (Phase 1)¶
Displayed values format through window.AquaI18n helpers, which follow the
selected locale (not the browser locale) including the registry's
numberLocale pin, and re-render reactively on a locale switch:
formatNumber(value, intlOptions)— locale digits/separators; template sugar$n(value)and fixed-decimals$nf(value, digits).formatTemperature(value, units)— takes the wire dual-unit object ({celsius, fahrenheit}) and the servertemperature_unitspreference ('Celsius' | 'Fahrenheit'), renders viaIntlunit formatting. The pool store holds the preference ($store.pool.displayUnits, loaded with the initial data, pushed by Settings on save) and exposes the display getters (poolTemp,poolSetpointDisplay, …) — raw wire values stay in state so a units/locale change re-renders everything.formatTime(value)/formatDateTime(value)—Intl.DateTimeFormatin the active locale (server start time, last-update, last-seen stamps).
The WebSocket TemperatureUpdate payload carries the same raw dual-unit
objects as REST (the old pre-formatted string path hardcoded Celsius). On the
MQTT side, the HA setpoint number entities follow the preference
(template/unit/range + command interpretation, republished on change), while
temperature sensors stay declared °C — HA converts sensors itself (see
docs/mqtt-home-assistant.md).
The Trends chart follows the preference too: history samples are stored in
°C and trends-view.js converts at display time — the temperature panel's
axis, lines, hover readout, chips and min/max/avg stats all move together
(the panel title carries the active unit). The canvas cannot use Alpine
bindings, so the view's x-effect reads the i18n revision/locale and
$store.pool.displayUnits to trigger a redraw when they change while the
view is open.
Structured errors and alerts (Phase 2)¶
- HTTP errors: JSON endpoints return
{"error": <english>, "code": <snake_case>, "params": {...}}(built byHTTP::MakeErrorResponse;ApiErrorschema in swagger.yaml).erroris the stable English contract; the UI translates via theerror.<code>catalog key when one exists (AquaI18n.apiError(data, fallback)— unknown codes fall back to the server's English message, so a new backend code degrades gracefully until its catalog entries land). Adding a code in C++ means addingerror.<code>to every locale catalog. - Alerts:
AlertTransition(WS + webhook) carriesparamsnext to the Englishdetail(e.g.{salt_ppm, threshold_ppm},{health: "Warning_LowSalt"}). The alerts store renders its translated detail from thealert_detail.<condition>template + params (chlorinator health maps throughAquaUI.swgHealthKey→swg_health.*), falling back to the Englishdetailwhen params are absent.
What is deliberately NOT translated¶
- Device-originated text: aux/device labels, pool names, panel screen lines, controller program names — these arrive over RS-485 (or are user-configured) and pass through verbatim.
- Technical tokens: backend enum values used as wire/comparison values
(the display layer maps the known ones via
status.*/swg_health.*/swg_reason.*catalog keys and falls back to the raw name for unknown values --swg_reason.*covers theChlorinatorGeneratingReasonnames that explain why the SWG is at its current output), log severity levels and channel names, JSON field names, MQTT payloads, units attached to raw diagnostics, endpoint paths, the brand name. - Backend prose (HTTP error bodies, alert
detailtext) still arrives in English — converting those to symbolic codes + params is Phase 2 of the scoping doc.
Adding a language¶
(Contributor-facing workflow, PR conventions, and translation rules: CONTRIBUTING.md § Contributing translations.)
- Copy
assets/web/i18n/en.jstoassets/web/i18n/<code>.js, change the registration line's locale code, and translate the values (never the keys). Keep{placeholder}names,<code>…</code>contents, technical tokens and brand names untranslated. - Register the locale in
SUPPORTED_LOCALESinassets/web/scripts/i18n.jswith its own-language name (endonym) and text direction (ltr/rtl). PinnumberLocaleif the bare code's default numbering system is wrong or inconsistent across engines (see thearentry). - If the language uses a non-Latin script, add font coverage per "Fonts and
scripts" above (a
unicode-range-sliced vendored subset or ahtml[lang='xx']system stack, plus the e2e font-load assertion if vendored). - Run the guard:
pwsh scripts/check-i18n-keys.ps1 -Root <repo-root>. - The new catalog ships automatically: the build copies and packages
assets/webrecursively, and the service worker precachesi18n/en.js(other locales are cached on first use by the runtime caching layer).
Testing / guard rails¶
Three layers keep i18n from silently rotting as features evolve:
scripts/check-i18n-keys.ps1(static; runs in CI as thei18n-catalogsjob in.github/workflows/ci.yml): every key referenced in code must exist inen.js; every locale must have exact key parity withen.js; every translated value must carry exactly the English value's{placeholders}. "Referenced" covers not just directt()/$t()/$tn()calls but also keys reached through indirection:labelKey:/roleKey:bindings, catalog keys stored as values in lookup tables / object maps (any catalog namespace — e.g.'NormalOperation': 'devcard.op_state_normal'inui-constants.js), and the known dynamic-prefix families (tier.,sched.act_,alert_detail.). A key that a UI reaches only through such a map is guarded exactly like a direct call — a missing one fails CI rather than rendering the raw key.e2e/i18n.spec.ts— runtime behavior: lookup/placeholder/plural/ fallback semantics, the Settings language picker, localStorage persistence across reload, per-locale catalog loading, and the<html lang>/<html dir>contract (Arabic ⇒rtl).e2e/i18n.spec.ts— guard rails: walking every route must produce zero[i18n] missing catalog keyconsole warnings (catches at()call whose key was never added), and a runtime pseudo-locale scan (every catalog value wrapped in⟦…⟧, then the DOM is walked for visible Latin text without the markers) catches brand-new hardcoded strings that bypassed the catalog entirely. The scan also checks user-visible attributes (title,placeholder,aria-label,alt) on every element — including hidden ones, since a posture-gated affordance (e.g. the guest lock hints,auth.locked_*) must come from the catalog even when the run's auth posture never displays it. Legitimate untranslated content (device labels, wire tokens, endpoints, URL/filename-shaped placeholders) is covered by the spec's exempt-selector list, its token/shape regexes, or adata-i18n-exemptattribute on the element.
Known blind spot: the pseudo-locale scan ignores text with fewer than
three consecutive Latin letters, so short duration/unit abbreviations
("1m", "7d") pass unnoticed — those must go through the time.abbr_*
catalog keys and need human review when touched (this exact miss shipped
once in the chart filters).
Backend side: /api/preferences ui merge behavior is covered by
TestSuite_PreferencesService/ApplyJson_UiMergesTopLevelKeys.