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>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// Lightweight i18n primitive used by the login form, the root
|
||||
// layout, and the lobby placeholder. The translation table is a
|
||||
// per-locale flat dictionary keyed by dotted strings; lookup falls
|
||||
// back to the default (English) locale when a key is missing.
|
||||
//
|
||||
// Adding a new language is a two-step change inside this folder:
|
||||
// 1. drop a `locales/<bcp47>.ts` file mirroring the shape of
|
||||
// `locales/en.ts` (TypeScript enforces matching keys via the
|
||||
// `Record<keyof typeof en, string>` annotation in `ru.ts`);
|
||||
// 2. register the file in `SUPPORTED_LOCALES` below — that single
|
||||
// list drives the language picker UI and the runtime lookup
|
||||
// table at the same time.
|
||||
//
|
||||
// The locale state is exposed through a Svelte 5 runes singleton
|
||||
// (`i18n`) so components stay reactive without ceremony:
|
||||
// `<p>{i18n.t('login.title')}</p>` re-renders whenever
|
||||
// `i18n.locale` changes. Phase 35 will swap this primitive for a
|
||||
// fuller solution once message-format pluralisation and lazy
|
||||
// loading become necessary.
|
||||
|
||||
import enTranslations from "./locales/en";
|
||||
import ruTranslations from "./locales/ru";
|
||||
|
||||
export type Locale = "en" | "ru";
|
||||
export type TranslationKey = keyof typeof enTranslations;
|
||||
|
||||
export interface LocaleEntry {
|
||||
readonly code: Locale;
|
||||
readonly nativeName: string;
|
||||
readonly translations: Readonly<Record<TranslationKey, string>>;
|
||||
}
|
||||
|
||||
export const SUPPORTED_LOCALES: readonly LocaleEntry[] = [
|
||||
{
|
||||
code: "en",
|
||||
nativeName: "English",
|
||||
translations: enTranslations,
|
||||
},
|
||||
{
|
||||
code: "ru",
|
||||
nativeName: "Русский",
|
||||
translations: ruTranslations,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "en";
|
||||
|
||||
const TRANSLATIONS_BY_LOCALE: Record<
|
||||
Locale,
|
||||
Readonly<Record<TranslationKey, string>>
|
||||
> = SUPPORTED_LOCALES.reduce(
|
||||
(acc, entry) => {
|
||||
acc[entry.code] = entry.translations;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<Locale, Readonly<Record<TranslationKey, string>>>,
|
||||
);
|
||||
|
||||
/**
|
||||
* detectInitialLocale returns the best supported locale match for
|
||||
* the supplied BCP 47 preference list. The web target passes
|
||||
* `navigator.languages`; native wrappers pass the system locale
|
||||
* (one entry). The first preference whose primary subtag matches
|
||||
* a `SUPPORTED_LOCALES` entry wins; otherwise [DEFAULT_LOCALE].
|
||||
*/
|
||||
export function detectInitialLocale(
|
||||
preferences?: readonly string[],
|
||||
): Locale {
|
||||
const prefs = preferences ?? readBrowserPreferences();
|
||||
for (const tag of prefs) {
|
||||
const primary = primarySubtag(tag);
|
||||
if (primary === null) {
|
||||
continue;
|
||||
}
|
||||
const found = SUPPORTED_LOCALES.find((entry) => entry.code === primary);
|
||||
if (found !== undefined) {
|
||||
return found.code;
|
||||
}
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
function readBrowserPreferences(): readonly string[] {
|
||||
if (typeof navigator === "undefined") {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) {
|
||||
return navigator.languages;
|
||||
}
|
||||
if (typeof navigator.language === "string" && navigator.language !== "") {
|
||||
return [navigator.language];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function primarySubtag(tag: string): Locale | null {
|
||||
const trimmed = tag.trim().toLowerCase();
|
||||
if (trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const code = trimmed.split(/[-_]/)[0] ?? "";
|
||||
return isLocale(code) ? code : null;
|
||||
}
|
||||
|
||||
function isLocale(value: string): value is Locale {
|
||||
return SUPPORTED_LOCALES.some((entry) => entry.code === value);
|
||||
}
|
||||
|
||||
class I18nStore {
|
||||
locale: Locale = $state(detectInitialLocale());
|
||||
|
||||
/**
|
||||
* setLocale changes the active locale. Components reading
|
||||
* `i18n.t(...)` re-render automatically through the rune.
|
||||
*/
|
||||
setLocale(next: Locale): void {
|
||||
this.locale = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* t looks up `key` in the active locale, falling back to the
|
||||
* default locale when the key is missing. `params` is an optional
|
||||
* `{name -> value}` map; placeholders in the template (`{name}`)
|
||||
* are replaced literally with no escaping — callers are expected
|
||||
* to feed user-safe values.
|
||||
*/
|
||||
t(key: TranslationKey, params?: Record<string, string>): string {
|
||||
const active = TRANSLATIONS_BY_LOCALE[this.locale];
|
||||
const fallback = TRANSLATIONS_BY_LOCALE[DEFAULT_LOCALE];
|
||||
const template = active[key] ?? fallback[key] ?? key;
|
||||
if (params === undefined) {
|
||||
return template;
|
||||
}
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) => {
|
||||
const value = params[name];
|
||||
return value === undefined ? match : value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* resetForTests forces the singleton back to its module-load
|
||||
* state. Production code never calls this; the Vitest harness
|
||||
* uses it to keep cases independent.
|
||||
*/
|
||||
resetForTests(initial: Locale = detectInitialLocale()): void {
|
||||
this.locale = initial;
|
||||
}
|
||||
}
|
||||
|
||||
export const i18n = new I18nStore();
|
||||
@@ -0,0 +1,40 @@
|
||||
// English translation dictionary. Keys are dotted strings grouped
|
||||
// by feature area (`login.*`, `lobby.*`, `common.*`); values are
|
||||
// the user-visible text. Adding a new key here also requires adding
|
||||
// it to every other locale dictionary in this folder, otherwise the
|
||||
// `t()` helper falls back to English at runtime.
|
||||
|
||||
const en = {
|
||||
"common.language": "language",
|
||||
"common.loading": "loading…",
|
||||
"common.browser_not_supported_title": "browser not supported",
|
||||
"common.browser_not_supported_body":
|
||||
"Galaxy requires Ed25519 in WebCrypto. See supported browsers.",
|
||||
|
||||
"login.title": "sign in to Galaxy",
|
||||
"login.email_label": "email",
|
||||
"login.email_required": "email must not be empty",
|
||||
"login.send_code": "send code",
|
||||
"login.sending": "sending…",
|
||||
"login.code_label": "code",
|
||||
"login.code_required": "code must not be empty",
|
||||
"login.code_sent_to": "code sent to {email}",
|
||||
"login.verify": "verify",
|
||||
"login.verifying": "verifying…",
|
||||
"login.send_new_code": "send a new code",
|
||||
"login.change_email": "change email",
|
||||
"login.challenge_expired":
|
||||
"challenge expired, please request a new code",
|
||||
"login.code_expired_or_used":
|
||||
"code expired or already used, please request a new one",
|
||||
"login.device_key_not_ready":
|
||||
"device key is not ready, please reload the page",
|
||||
|
||||
"lobby.title": "you are logged in",
|
||||
"lobby.device_session_id_label": "device session id",
|
||||
"lobby.greeting": "hello, {name}!",
|
||||
"lobby.account_loading": "loading account…",
|
||||
"lobby.logout": "logout",
|
||||
} as const;
|
||||
|
||||
export default en;
|
||||
@@ -0,0 +1,41 @@
|
||||
// Russian translation dictionary. The keys are identical to the
|
||||
// English dictionary in `en.ts`; the values are the human Russian
|
||||
// text. Adding a new key requires updating every locale file in
|
||||
// this folder so the `t()` helper does not fall back to English.
|
||||
|
||||
import type en from "./en";
|
||||
|
||||
const ru: Record<keyof typeof en, string> = {
|
||||
"common.language": "язык",
|
||||
"common.loading": "загрузка…",
|
||||
"common.browser_not_supported_title": "браузер не поддерживается",
|
||||
"common.browser_not_supported_body":
|
||||
"Galaxy требует поддержки Ed25519 в WebCrypto. См. список поддерживаемых браузеров.",
|
||||
|
||||
"login.title": "вход в Galaxy",
|
||||
"login.email_label": "электронная почта",
|
||||
"login.email_required": "адрес не должен быть пустым",
|
||||
"login.send_code": "отправить код",
|
||||
"login.sending": "отправляем…",
|
||||
"login.code_label": "код",
|
||||
"login.code_required": "код не должен быть пустым",
|
||||
"login.code_sent_to": "код отправлен на {email}",
|
||||
"login.verify": "подтвердить",
|
||||
"login.verifying": "проверяем…",
|
||||
"login.send_new_code": "отправить новый код",
|
||||
"login.change_email": "изменить адрес",
|
||||
"login.challenge_expired":
|
||||
"запрос устарел, запросите новый код",
|
||||
"login.code_expired_or_used":
|
||||
"код устарел или уже использован, запросите новый",
|
||||
"login.device_key_not_ready":
|
||||
"ключ устройства ещё не готов, перезагрузите страницу",
|
||||
|
||||
"lobby.title": "вы вошли в систему",
|
||||
"lobby.device_session_id_label": "идентификатор сессии устройства",
|
||||
"lobby.greeting": "здравствуйте, {name}!",
|
||||
"lobby.account_loading": "загрузка профиля…",
|
||||
"lobby.logout": "выйти",
|
||||
};
|
||||
|
||||
export default ru;
|
||||
Reference in New Issue
Block a user