Skip to content

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 in fonts.css with script-only unicode-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-display stacks in app.css and are consulted only for their scripts' codepoints, so Latin text (numbers pinned LTR, brand names, codes) keeps the brand faces even under lang="ar" / lang="he". This makes both scripts render identically across platforms instead of whatever system-ui resolves to. Yiddish (yi) is written entirely within the Hebrew Unicode block (U+0590-05FF), so it needs no font entry of its own — the Hebrew unicode-range slice already covers it since the fallback is codepoint-, not lang-attribute-, driven.
  • Japanese / Chinese — no vendored faces (CJK subsets are megabytes); html[lang='ja'] and html[lang='zh'] blocks in app.css swap 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 of system-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" → string map into window.AquaI18n.catalogs.<locale>. English (i18n/en.js) is included statically in index.html before 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-blocking document.write from 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, and window.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 under preferences.ui.locale (cross-device; the server value wins on conflict). A user who never chose follows navigator.language. <html lang> and <html dir> track the active locale; locales registered with dir: '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/preferences ui merge: the opaque ui object merges shallowly at its top level on PUT, so ui.locale and ui.chemistryBands (written by independent frontend features) never clobber each other; a top-level null deletes the key. See assets/web/api/swagger.yaml and TestSuite_PreferencesService/ApplyJson_UiMergesTopLevelKeys.

Key conventions

  • Flat dot keys, namespaced by surface: nav.*, login.*, dash.*, detail.*, diag.*, devcard.*, trends.*, sched.*, settings.*, about.*, plus shared common.*, status.*, cmd.*, alert.*, toast.*, time.*, tier.*, chem.*, swg_health.*, swg_reason.*, day.*.
  • Placeholders are {name} and are substituted by t(); never rebuild sentences by string concatenation — word order differs across languages.
  • Number-typed placeholder values are rendered with Intl.NumberFormat for the active locale, so the digits themselves localize (Arabic renders Eastern Arabic-Indic numerals — pinned via the registry's numberLocale: 'ar-u-nu-arab' because bare ar resolves to Latin digits in some engines). Pass numbers as numbers; a pre-formatted string (e.g. toFixed() output) passes through untouched.
  • Keys ending in _html may contain markup (bound with x-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 via tn(key, count) / $tn using Intl.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 server temperature_units preference ('Celsius' | 'Fahrenheit'), renders via Intl unit 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.DateTimeFormat in 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 by HTTP::MakeErrorResponse; ApiError schema in swagger.yaml). error is the stable English contract; the UI translates via the error.<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 adding error.<code> to every locale catalog.
  • Alerts: AlertTransition (WS + webhook) carries params next to the English detail (e.g. {salt_ppm, threshold_ppm}, {health: "Warning_LowSalt"}). The alerts store renders its translated detail from the alert_detail.<condition> template + params (chlorinator health maps through AquaUI.swgHealthKey → swg_health.*), falling back to the English detail when 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 the ChlorinatorGeneratingReason names 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 detail text) 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.)

  1. Copy assets/web/i18n/en.js to assets/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.
  2. Register the locale in SUPPORTED_LOCALES in assets/web/scripts/i18n.js with its own-language name (endonym) and text direction (ltr/rtl). Pin numberLocale if the bare code's default numbering system is wrong or inconsistent across engines (see the ar entry).
  3. If the language uses a non-Latin script, add font coverage per "Fonts and scripts" above (a unicode-range-sliced vendored subset or a html[lang='xx'] system stack, plus the e2e font-load assertion if vendored).
  4. Run the guard: pwsh scripts/check-i18n-keys.ps1 -Root <repo-root>.
  5. The new catalog ships automatically: the build copies and packages assets/web recursively, and the service worker precaches i18n/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:

  1. scripts/check-i18n-keys.ps1 (static; runs in CI as the i18n-catalogs job in .github/workflows/ci.yml): every key referenced in code must exist in en.js; every locale must have exact key parity with en.js; every translated value must carry exactly the English value's {placeholders}. "Referenced" covers not just direct t()/$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' in ui-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.
  2. 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).
  3. e2e/i18n.spec.ts — guard rails: walking every route must produce zero [i18n] missing catalog key console warnings (catches a t() 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 a data-i18n-exempt attribute 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.