Skip to content

ADR 14: Frontend Internationalization (Lingui, Catalogs, UI Boundaries)

Status: Accepted
Last Updated: 2026-06-11 (pds-portal per ADR 42)

Context

The SaaS dashboard (ADR 08: Hosting and Frontend Stack) must support multiple locales without bloating the shared UI package or coupling design tokens to a specific i18n runtime. We need:

  • Predictable, reviewable translations (not only English at runtime).
  • A single source of truth for message identifiers used in Mithril components.
  • Clear boundaries so @substratum/ui-kit remains reusable and does not import Lingui.

Decision

  1. Library and pipeline: Use Lingui in apps/file-explorer, apps/installer-gui, apps/landing, apps/admin, and apps/pds-portal (ADR 42). Catalogs are hand-maintained JSON under each app’s src/locales/<locale>/messages.json (or src/i18n/locales/ where an app uses that layout). The lingui compile step (Nx target compile-locales per app) produces ES module runtime catalogs (e.g. messages.mjs with compileNamespace: "es") so the browser never loads CommonJS globals such as module.

  2. Explicit IDs, no extract-as-truth: Message keys live in per-app message-ids.ts files (e.g. apps/file-explorer/src/i18n/message-ids.ts, apps/installer-gui/src/i18n/message-ids.ts). Call sites use i18n._({ id: MSG…, message: 'English default' }) for developer-visible fallbacks while catalogs supply translated strings.

  3. UI kit boundary: @substratum/ui-kit does not depend on Lingui. Shell chrome (navigation labels, help, language control accessibility text, etc.) receives plain string props from the app (e.g. Layout shell copy and languagePickerAriaCopy). The app resolves strings with i18n._ and passes them down.

  4. Locale set consistency: Shipped locales must match across each app’s:

    • lingui.config.ts (per app or aligned with repository root),
    • supportedLocales in that app’s i18n bootstrap,
    • one messages.json per locale per app with the same key set within that app.

    Apps: file-explorer, installer-gui, landing, admin, pds-portal — keep locale lists aligned where product requires parity (ADR 33 §8; portal Phase 1 requires LanguagePicker per ADR 42 NR2).

    Every new message ID must be added to every shipped messages.json for that app in the same change as the code (see each app’s AGENTS.md).

  5. Activation and persistence: Locale is detected from localStorage (e.g. substratum.locale) with a browser-language fallback; switching locale reloads the compiled catalog and triggers a Mithril redraw. Route renders that depend on i18n must not run before a locale is active (guard on i18n.locale where needed).

Consequences

  • Positive: Translators and reviewers work in JSON; IDs are stable and grep-friendly; ui-kit stays framework-agnostic; Vite + ESM catalogs avoid module is not defined in the browser.
  • Negative: No lingui extract as the workflow source of truth—each string requires updating all locale files; forgetting a locale breaks parity. Build depends on running compile (or CI) whenever catalogs change.
  • Operational detail: Step-by-step checklist for agents lives in apps/file-explorer/AGENTS.md, apps/installer-gui/AGENTS.md, and (when added) the operator admin app AGENTS.md; this ADR is the architectural record only.
  • Operator admin UI: ADR 38apps/admin: same Lingui rules and locale set; isolated app code, shared @substratum/ui-kit (ADR 33 §8).
  • PDS portal: ADR 42apps/pds-portal: same Lingui rules, locale set, LanguagePicker, and substratum.locale persistence from Phase 1.