Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy, game-state + secondary topic docs) and ui/README for the single-URL app-shell (in-memory screens/views, Back→lobby via shallow routing, sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a Phase-10 supersede note (implemented; standalone-compatible). Fix stale code comments (session-store auth gate, report-sections spec contract). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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/:
- Drop
locales/<bcp47-primary-subtag>.tsmirroring the shape oflocales/en.ts. The TypeScript signature on each non-English file isRecord<keyof typeof en, string>, so the compiler refuses to build until every key inen.tsis translated. - Register the new file in the
SUPPORTED_LOCALESarray inindex.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 the login screen 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. Thet()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.tscoversdetectInitialLocale,i18n.setLocale, parameter interpolation, and the unknown-key fallback.tests/i18n-completeness.test.tsenforces 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.tsasserts the language picker renders with native names, switching the locale re-renders the form text, andsendEmailCodereceives the active locale.tests/auth-api.test.tsasserts the locale is forwarded through the JSON body ofsend-email-code.tests/e2e/auth-flow.spec.tscovers the dropdown-driven switch end-to-end on every Playwright project, including Safari (where Accept-Language is unsettable from JS).