Files
galaxy-game/ui/docs/i18n.md
T
Ilia Denisov 1e62837c68
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m11s
feat(ui): locale persistence + i18n completeness guards (F3)
An audit found the client already i18n-first: one hard-coded UI string
(the battle-scene aria-label, now keyed) and en/ru already share an
identical 692-key set.

- Persist the locale: i18n.setLocale writes localStorage (galaxy-locale)
  and the store boots from stored > browser detection > default, so a
  language switch survives reloads.
- tests/i18n-completeness.test.ts: en/ru key-set parity, non-empty
  values, and locale persistence.
- Docs: ui/docs/i18n.md; mark F3 done in ui/PLAN-finalize.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:48:13 +02:00

6.0 KiB

i18n (UI)

The UI client ships with a minimal locale primitive used by the login form, the root layout, and the lobby. The goal is just enough infrastructure to translate user-visible strings, switch the active language at runtime, and forward the caller's choice to the gateway. Swapping this primitive for a fuller solution with message-format pluralisation, lazy loading, and translator workflows is deferred to the finalization plan (../Plan-finalize.md); until then, the surface here covers every authenticated and unauthenticated screen the client renders.

Surface

src/lib/i18n/
├── index.svelte.ts       # I18nStore singleton, types, SUPPORTED_LOCALES
└── locales/
    ├── en.ts             # English dictionary (default, source of truth)
    └── ru.ts             # Russian dictionary (mirrors en.ts keys)

The exported singleton (i18n) is a Svelte 5 runes class with one reactive field, locale, and a t(key, params?) lookup. Components read translations through i18n.t('login.title') and re-render automatically when i18n.locale changes.

The runes singleton is a .svelte.ts file because Svelte 5 only processes $state runes inside .svelte, .svelte.js, and .svelte.ts modules.

Adding a language

Two-step change inside src/lib/i18n/:

  1. Drop locales/<bcp47-primary-subtag>.ts mirroring the shape of locales/en.ts. The TypeScript signature on each non-English file is Record<keyof typeof en, string>, so the compiler refuses to build until every key in en.ts is translated.
  2. Register the new file in the SUPPORTED_LOCALES array in index.svelte.ts. That single list drives the language picker (UI) and the runtime lookup table.

For example, adding French:

// src/lib/i18n/locales/fr.ts
import type en from "./en";
const fr: Record<keyof typeof en, string> = {
    "common.language": "langue",
    /* …translate every other key… */
};
export default fr;
// src/lib/i18n/index.svelte.ts
import frTranslations from "./locales/fr";

export type Locale = "en" | "ru" | "fr";

export const SUPPORTED_LOCALES: readonly LocaleEntry[] = [
    { code: "en", nativeName: "English", translations: enTranslations },
    { code: "ru", nativeName: "Русский", translations: ruTranslations },
    { code: "fr", nativeName: "Français", translations: frTranslations },
];

No other code change is required: the picker, the detection helper, the t() function and the gateway forwarding all derive from SUPPORTED_LOCALES.

Detection

detectInitialLocale(preferences?) returns the first SUPPORTED_LOCALES entry whose code matches the primary subtag of any preference, or DEFAULT_LOCALE (English) when nothing matches.

The web target calls it without arguments, in which case the helper reads navigator.languages (or navigator.language as fallback). Native wrappers (Wails, Capacitor) will pass their system locale once the desktop/mobile targets land (see ../ROADMAP.md); the helper is platform-agnostic by design.

The boot locale resolves once at module load (no async init): an explicit stored choice wins, otherwise browser/system detection, otherwise DEFAULT_LOCALE. Callers that mutate the locale (the language pickers on /login and in the account menu) call i18n.setLocale(next), which persists the choice to localStorage (key galaxy-locale) so it survives reloads. An unrecognised stored value is ignored and falls back to detection.

Forwarding the locale to the gateway

The login form passes the active i18n.locale to sendEmailCode(baseUrl, email, { locale }). The auth API places the value inside the JSON body (locale field) rather than the Accept-Language header:

await sendEmailCode(GATEWAY_BASE_URL, trimmed, { locale: i18n.locale });

The body field is the canonical channel because Safari/WebKit silently drops JS-set Accept-Language headers (a long-standing WebKit fingerprinting mitigation). The gateway reads the body field with priority over the request Accept-Language, and non-Safari clients can still rely on the header alone — the gateway treats body and header as a single fallback chain. See gateway/internal/restapi/public_auth.go for the resolution path and docs/FUNCTIONAL.md §1.2 for the contract.

The confirm-email-code endpoint does not carry the locale. Per docs/FUNCTIONAL.md §1.3, the preferred language is captured at challenge issuance and replayed from the challenge row.

Key conventions

  • Dotted keys grouped by feature area: login.*, lobby.*, common.*. New screens own their own prefix.
  • Templates may carry simple {name} placeholders. The t() helper substitutes them with the caller-provided value via plain string replacement; values are written to the DOM unescaped, so callers must feed user-safe strings.
  • Lookup falls back to the default locale and finally to the literal key when a key is missing in the active locale. The TypeScript signature on each locale file enforces complete coverage at build time, so runtime fallback is the safety net for a freshly added language that has not finished its translation pass.
  • The translation file is the single source of truth — components never hardcode user-visible English text; everything goes through i18n.t(...).

Testing

  • tests/i18n.test.ts covers detectInitialLocale, i18n.setLocale, parameter interpolation, and the unknown-key fallback.
  • tests/i18n-completeness.test.ts enforces en/ru key-set parity (no key present in one locale but missing in the other), non-empty values, and locale persistence (a stored choice is restored on the next load).
  • tests/login-page.test.ts asserts the language picker renders with native names, switching the locale re-renders the form text, and sendEmailCode receives the active locale.
  • tests/auth-api.test.ts asserts the locale is forwarded through the JSON body of send-email-code.
  • tests/e2e/auth-flow.spec.ts covers the dropdown-driven switch end-to-end on every Playwright project, including Safari (where Accept-Language is unsettable from JS).