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