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,107 @@
|
||||
// Unit tests for the lightweight i18n primitive in
|
||||
// `src/lib/i18n/index.svelte.ts`. The locale singleton is reset
|
||||
// between cases through `resetForTests`; the default constructor
|
||||
// derives the locale from JSDOM's `navigator.language`, so the
|
||||
// reset takes an explicit value when a case needs a deterministic
|
||||
// starting state.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
SUPPORTED_LOCALES,
|
||||
detectInitialLocale,
|
||||
i18n,
|
||||
type Locale,
|
||||
} from "../src/lib/i18n/index.svelte";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
describe("detectInitialLocale", () => {
|
||||
test("matches the first supported primary subtag", () => {
|
||||
expect(detectInitialLocale(["ru-RU", "en-US"])).toBe("ru");
|
||||
expect(detectInitialLocale(["en-GB", "ru"])).toBe("en");
|
||||
expect(detectInitialLocale(["RU"])).toBe("ru");
|
||||
});
|
||||
|
||||
test("skips unsupported tags and falls back to the default locale", () => {
|
||||
expect(detectInitialLocale(["fr-FR", "de-DE", "ja"])).toBe(DEFAULT_LOCALE);
|
||||
expect(detectInitialLocale([])).toBe(DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
test("ignores whitespace and casing", () => {
|
||||
expect(detectInitialLocale([" En-us "])).toBe("en");
|
||||
expect(detectInitialLocale(["RU_ru"])).toBe("ru");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SUPPORTED_LOCALES", () => {
|
||||
test("each entry exposes a code, native name and translation table", () => {
|
||||
for (const entry of SUPPORTED_LOCALES) {
|
||||
expect(entry.code).toMatch(/^[a-z]{2}$/);
|
||||
expect(entry.nativeName.length).toBeGreaterThan(0);
|
||||
expect(entry.translations["login.title"].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("native names cover the two phase-7 locales", () => {
|
||||
const codes = SUPPORTED_LOCALES.map((l) => l.code);
|
||||
const names = SUPPORTED_LOCALES.map((l) => l.nativeName);
|
||||
expect(codes).toEqual(["en", "ru"]);
|
||||
expect(names).toEqual(["English", "Русский"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("i18n.t", () => {
|
||||
test("returns the active locale's translation", () => {
|
||||
i18n.setLocale("en");
|
||||
expect(i18n.t("login.title")).toBe("sign in to Galaxy");
|
||||
i18n.setLocale("ru");
|
||||
expect(i18n.t("login.title")).toBe("вход в Galaxy");
|
||||
});
|
||||
|
||||
test("returns the key itself for an unknown identifier", () => {
|
||||
// `t` is typed `TranslationKey`, but bracket access at runtime
|
||||
// gracefully handles unknown keys — the fallback chain tries
|
||||
// the active locale, then the default locale, then the literal
|
||||
// key. This is the safety net for a future locale that adds
|
||||
// keys before another locale catches up.
|
||||
i18n.setLocale("en");
|
||||
expect(
|
||||
i18n.t("does.not.exist" as unknown as Parameters<typeof i18n.t>[0]),
|
||||
).toBe("does.not.exist");
|
||||
});
|
||||
|
||||
test("interpolates {placeholder} parameters", () => {
|
||||
i18n.setLocale("en");
|
||||
expect(i18n.t("login.code_sent_to", { email: "x@y.z" })).toBe(
|
||||
"code sent to x@y.z",
|
||||
);
|
||||
i18n.setLocale("ru");
|
||||
expect(i18n.t("login.code_sent_to", { email: "x@y.z" })).toBe(
|
||||
"код отправлен на x@y.z",
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves unresolved placeholders intact", () => {
|
||||
i18n.setLocale("en");
|
||||
expect(i18n.t("login.code_sent_to")).toContain("{email}");
|
||||
expect(i18n.t("login.code_sent_to", { other: "x" })).toContain(
|
||||
"{email}",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("i18n.setLocale", () => {
|
||||
test("setLocale updates the reactive state", () => {
|
||||
i18n.setLocale("en");
|
||||
expect(i18n.locale).toBe<Locale>("en");
|
||||
i18n.setLocale("ru");
|
||||
expect(i18n.locale).toBe<Locale>("ru");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user