Files
galaxy-game/ui/docs/i18n.md
T
Ilia Denisov 9101aba816 phase 7+: i18n primitive + login language picker + autocomplete-off
Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the
login form, the layout blocker page, and the lobby placeholder.
SUPPORTED_LOCALES drives both the picker and the runtime lookup;
adding a language is a two-step change inside `src/lib/i18n/`.

Login form gains a globe-icon language dropdown (English / Русский
in their native names), defaulting to navigator.languages with `en`
as the fallback. Switching the locale re-renders the form in place;
on submit, the locale rides in the JSON body of `send-email-code`
because Safari/WebKit silently drops JS-set Accept-Language. Gateway
gains a body `locale` field that takes priority over the request
header for preferred-language resolution.

Email and code inputs disable browser autofill / suggestions
(`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` +
`spellcheck=false`) so Keychain / address-book pickers and
remembered-value dropdowns no longer fire on focus.

Cross-cuts:
- backend & gateway openapi: clarify that body `locale` is honored.
- docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority.
- gateway tests: body `locale` overrides Accept-Language; blank
  body `locale` falls back to header.
- new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 16:14:40 +02:00

5.6 KiB

i18n (UI)

The UI client ships with a minimal locale primitive used by the phase-7 login form, the root layout, and the lobby placeholder. 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. Phase 35 will swap this primitive for a fuller solution once message-format pluralisation, lazy loading, and translator workflows become necessary; 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 Phase 31/32 lands; the helper is platform-agnostic by design.

The detection runs once at module load — there is no asynchronous init step. Callers that mutate the locale (e.g. the language picker on /login) call i18n.setLocale(next) directly. The choice is not persisted between page reloads in Phase 7; the next visit re-runs detection. Persistence is a phase-35 concern.

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/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).