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:
Ilia Denisov
2026-05-07 16:14:40 +02:00
parent 22b0710d04
commit 9101aba816
20 changed files with 918 additions and 66 deletions
+26 -4
View File
@@ -22,6 +22,20 @@ export interface SendEmailCodeResult {
challengeId: string;
}
export interface SendEmailCodeOptions {
/**
* locale is forwarded inside the JSON body and read by the
* gateway in preference to the request `Accept-Language` header.
* The body field is the canonical channel because Safari/WebKit
* silently drops JS-set `Accept-Language` headers, while the
* body round-trips correctly on every supported engine. When the
* caller omits this option the browser-default Accept-Language
* remains the gateway's only signal and the auth-mail uses the
* system locale.
*/
locale?: string;
}
export interface ConfirmEmailCodeInput {
challengeId: string;
code: string;
@@ -61,24 +75,32 @@ export class AuthError extends Error {
export async function sendEmailCode(
baseUrl: string,
email: string,
options?: SendEmailCodeOptions,
): Promise<SendEmailCodeResult> {
const requestBody: Record<string, string> = { email };
if (options?.locale !== undefined && options.locale !== "") {
requestBody.locale = options.locale;
}
const response = await fetch(joinUrl(baseUrl, SEND_EMAIL_CODE_PATH), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email }),
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw await readAuthError(response);
}
const body = (await response.json()) as { challenge_id?: unknown };
if (typeof body.challenge_id !== "string" || body.challenge_id.length === 0) {
const responseBody = (await response.json()) as { challenge_id?: unknown };
if (
typeof responseBody.challenge_id !== "string" ||
responseBody.challenge_id.length === 0
) {
throw new AuthError(
"internal_error",
"gateway returned a malformed send-email-code response",
response.status,
);
}
return { challengeId: body.challenge_id };
return { challengeId: responseBody.challenge_id };
}
/**
+150
View File
@@ -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();
+40
View File
@@ -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;
+41
View File
@@ -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;
+4 -9
View File
@@ -2,6 +2,7 @@
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { startRevocationWatcher } from "$lib/revocation-watcher";
@@ -45,18 +46,12 @@
{#if session.status === "loading"}
<main class="status">
<p>loading</p>
<p>{i18n.t("common.loading")}</p>
</main>
{:else if session.status === "unsupported"}
<main class="status">
<h1>browser not supported</h1>
<p>
Galaxy requires Ed25519 in WebCrypto. The minimum supported browser
versions are listed in the
<a href="https://github.com/galaxy/galaxy/blob/main/ui/docs/storage.md"
>storage topic doc</a
>.
</p>
<h1>{i18n.t("common.browser_not_supported_title")}</h1>
<p>{i18n.t("common.browser_not_supported_body")}</p>
</main>
{:else}
{@render children()}
+10 -8
View File
@@ -3,6 +3,7 @@
import { createEdgeGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { i18n } from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
@@ -64,22 +65,23 @@
</script>
<main>
<h1>you are logged in</h1>
<h1>{i18n.t("lobby.title")}</h1>
<p>
device session id: <code data-testid="device-session-id"
>{session.deviceSessionId ?? ""}</code
>
{i18n.t("lobby.device_session_id_label")}:
<code data-testid="device-session-id">{session.deviceSessionId ?? ""}</code>
</p>
{#if accountLoading}
<p>loading account…</p>
<p>{i18n.t("lobby.account_loading")}</p>
{:else if displayName !== null}
<p>
hello, <span data-testid="account-display-name">{displayName}</span>!
<p data-testid="account-greeting">
{i18n.t("lobby.greeting", { name: displayName })}
</p>
{:else if accountError !== null}
<p role="alert" data-testid="account-error">{accountError}</p>
{/if}
<button onclick={logout} data-testid="lobby-logout">logout</button>
<button onclick={logout} data-testid="lobby-logout">
{i18n.t("lobby.logout")}
</button>
</main>
<style>
+128 -22
View File
@@ -6,6 +6,7 @@
sendEmailCode,
} from "../../api/auth";
import { GATEWAY_BASE_URL } from "$lib/env";
import { i18n, SUPPORTED_LOCALES } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
type Step = "email" | "code";
@@ -32,13 +33,15 @@
if (pending) return;
const trimmed = email.trim();
if (trimmed.length === 0) {
error = "email must not be empty";
error = i18n.t("login.email_required");
return;
}
pending = true;
error = null;
try {
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed);
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
locale: i18n.locale,
});
challengeId = result.challengeId;
code = "";
step = "code";
@@ -54,16 +57,16 @@
if (pending) return;
const trimmedCode = code.trim();
if (trimmedCode.length === 0) {
error = "code must not be empty";
error = i18n.t("login.code_required");
return;
}
if (challengeId === null) {
error = "challenge expired, please request a new code";
error = i18n.t("login.challenge_expired");
step = "email";
return;
}
if (session.keypair === null) {
error = "device key is not ready, please reload the page";
error = i18n.t("login.device_key_not_ready");
return;
}
pending = true;
@@ -82,7 +85,7 @@
challengeId = null;
code = "";
step = "email";
error = "code expired or already used, please request a new one";
error = i18n.t("login.code_expired_or_used");
} else {
error = describe(err);
}
@@ -101,7 +104,9 @@
pending = true;
error = null;
try {
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed);
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
locale: i18n.locale,
});
challengeId = result.challengeId;
code = "";
} catch (err) {
@@ -120,16 +125,68 @@
</script>
<main>
<h1>sign in to Galaxy</h1>
<header>
<h1>{i18n.t("login.title")}</h1>
<div class="language-picker">
<svg
class="globe"
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
focusable="false"
>
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M3 12h18"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M12 3a13 13 0 0 1 0 18M12 3a13 13 0 0 0 0 18"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
<label class="visually-hidden" for="login-language-select">
{i18n.t("common.language")}
</label>
<select
id="login-language-select"
data-testid="login-language-select"
bind:value={i18n.locale}
>
{#each SUPPORTED_LOCALES as locale (locale.code)}
<option value={locale.code}>{locale.nativeName}</option>
{/each}
</select>
</div>
</header>
{#if step === "email"}
<form onsubmit={submitEmail} aria-busy={pending}>
<form
onsubmit={submitEmail}
aria-busy={pending}
autocomplete="off"
>
<label>
email
{i18n.t("login.email_label")}
<input
type="email"
name="email"
autocomplete="email"
name="galaxy-login-email"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={email}
disabled={pending}
required
@@ -141,19 +198,28 @@
disabled={pending}
data-testid="login-email-submit"
>
{pending ? "sending" : "send code"}
{pending ? i18n.t("login.sending") : i18n.t("login.send_code")}
</button>
</form>
{:else}
<form onsubmit={submitCode} aria-busy={pending}>
<p data-testid="login-code-target">code sent to {email}</p>
<form
onsubmit={submitCode}
aria-busy={pending}
autocomplete="off"
>
<p data-testid="login-code-target">
{i18n.t("login.code_sent_to", { email })}
</p>
<label>
code
{i18n.t("login.code_label")}
<input
type="text"
name="code"
name="galaxy-login-code"
inputmode="numeric"
autocomplete="one-time-code"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={code}
disabled={pending}
required
@@ -161,7 +227,7 @@
/>
</label>
<button type="submit" disabled={pending} data-testid="login-code-submit">
{pending ? "verifying" : "verify"}
{pending ? i18n.t("login.verifying") : i18n.t("login.verify")}
</button>
<div class="secondary">
<button
@@ -170,7 +236,7 @@
disabled={pending}
data-testid="login-resend"
>
send a new code
{i18n.t("login.send_new_code")}
</button>
<button
type="button"
@@ -178,7 +244,7 @@
disabled={pending}
data-testid="login-change-email"
>
change email
{i18n.t("login.change_email")}
</button>
</div>
</form>
@@ -196,6 +262,46 @@
max-width: 32rem;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
header h1 {
margin: 0;
}
.language-picker {
display: inline-flex;
align-items: center;
gap: 0.5rem;
opacity: 0.85;
}
.globe {
flex: none;
}
.language-picker select {
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
form {
display: flex;
flex-direction: column;
@@ -203,7 +309,7 @@
margin-top: 1.5rem;
}
label {
form > label {
display: flex;
flex-direction: column;
gap: 0.25rem;