feat(ui): locale persistence + i18n completeness guards (F3)
An audit found the client already i18n-first: one hard-coded UI string (the battle-scene aria-label, now keyed) and en/ru already share an identical 692-key set. - Persist the locale: i18n.setLocale writes localStorage (galaxy-locale) and the store boots from stored > browser detection > default, so a language switch survives reloads. - tests/i18n-completeness.test.ts: en/ru key-set parity, non-empty values, and locale persistence. - Docs: ui/docs/i18n.md; mark F3 done in ui/PLAN-finalize.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-1
@@ -69,7 +69,17 @@ axe-core scan; full keyboard reachability with visible focus.
|
|||||||
Tests: axe-core integration tests on every top-level view; Playwright
|
Tests: axe-core integration tests on every top-level view; Playwright
|
||||||
keyboard-only navigation.
|
keyboard-only navigation.
|
||||||
|
|
||||||
## F3 — Localisation completeness
|
## F3 — Localisation completeness — done
|
||||||
|
|
||||||
|
An audit found the client already i18n-first (the single hard-coded UI
|
||||||
|
string, the battle-scene `aria-label`, is now keyed); en/ru already have
|
||||||
|
identical key sets (692 keys). Added locale **persistence** to the i18n
|
||||||
|
store (`localStorage` `galaxy-locale`: stored choice > browser detection
|
||||||
|
> default) so a switch survives reloads, and
|
||||||
|
`tests/i18n-completeness.test.ts` enforcing en/ru key-set parity,
|
||||||
|
non-empty values, and persistence. A noisy literal-text lint was
|
||||||
|
deliberately skipped — the structural parity test plus the i18n-first
|
||||||
|
convention cover drift. Docs: `ui/docs/i18n.md`.
|
||||||
|
|
||||||
(From Phase 35.) Goal: every visible string is translated (en + ru).
|
(From Phase 35.) Goal: every visible string is translated (en + ru).
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -82,12 +82,13 @@ Native wrappers (Wails, Capacitor) will pass their system locale
|
|||||||
once the desktop/mobile targets land (see ../ROADMAP.md); the
|
once the desktop/mobile targets land (see ../ROADMAP.md); the
|
||||||
helper is platform-agnostic by design.
|
helper is platform-agnostic by design.
|
||||||
|
|
||||||
The detection runs once at module load — there is no asynchronous
|
The boot locale resolves once at module load (no async init):
|
||||||
init step. Callers that mutate the locale (e.g. the language picker
|
an explicit stored choice wins, otherwise browser/system detection,
|
||||||
on `/login`) call `i18n.setLocale(next)` directly. The choice is
|
otherwise `DEFAULT_LOCALE`. Callers that mutate the locale (the language
|
||||||
**not** persisted between page reloads; the next visit re-runs
|
pickers on `/login` and in the account menu) call `i18n.setLocale(next)`,
|
||||||
detection. Persistence is deferred to the finalization plan
|
which **persists** the choice to `localStorage` (key `galaxy-locale`) so
|
||||||
(../Plan-finalize.md).
|
it survives reloads. An unrecognised stored value is ignored and falls
|
||||||
|
back to detection.
|
||||||
|
|
||||||
## Forwarding the locale to the gateway
|
## Forwarding the locale to the gateway
|
||||||
|
|
||||||
@@ -135,6 +136,9 @@ at challenge issuance and replayed from the challenge row.
|
|||||||
- `tests/i18n.test.ts` covers `detectInitialLocale`,
|
- `tests/i18n.test.ts` covers `detectInitialLocale`,
|
||||||
`i18n.setLocale`, parameter interpolation, and the unknown-key
|
`i18n.setLocale`, parameter interpolation, and the unknown-key
|
||||||
fallback.
|
fallback.
|
||||||
|
- `tests/i18n-completeness.test.ts` enforces en/ru key-set parity (no
|
||||||
|
key present in one locale but missing in the other), non-empty values,
|
||||||
|
and locale persistence (a stored choice is restored on the next load).
|
||||||
- `tests/login-page.test.ts` asserts the language picker renders
|
- `tests/login-page.test.ts` asserts the language picker renders
|
||||||
with native names, switching the locale re-renders the form
|
with native names, switching the locale re-renders the form
|
||||||
text, and `sendEmailCode` receives the active locale.
|
text, and `sendEmailCode` receives the active locale.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ combat filter, so a shot never collapses to a single visual node.
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import type { BattleReport } from "../../api/battle-fetch";
|
import type { BattleReport } from "../../api/battle-fetch";
|
||||||
import {
|
import {
|
||||||
CORE_CONTEXT_KEY,
|
CORE_CONTEXT_KEY,
|
||||||
@@ -310,7 +311,7 @@ combat filter, so a shot never collapses to a single visual node.
|
|||||||
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
|
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="battle scene"
|
aria-label={i18n.t("game.battle.scene_label")}
|
||||||
data-testid="battle-scene"
|
data-testid="battle-scene"
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
|
|||||||
@@ -106,15 +106,36 @@ function isLocale(value: string): value is Locale {
|
|||||||
return SUPPORTED_LOCALES.some((entry) => entry.code === value);
|
return SUPPORTED_LOCALES.some((entry) => entry.code === value);
|
||||||
}
|
}
|
||||||
|
|
||||||
class I18nStore {
|
/** `localStorage` key holding the user's explicit locale choice. */
|
||||||
locale: Locale = $state(detectInitialLocale());
|
export const LOCALE_STORAGE_KEY = "galaxy-locale";
|
||||||
|
|
||||||
|
function readStoredLocale(): Locale | null {
|
||||||
|
if (typeof localStorage === "undefined") return null;
|
||||||
|
const value = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
return value !== null && isLocale(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* setLocale changes the active locale. Components reading
|
* initialLocale resolves the boot locale: an explicit stored choice
|
||||||
|
* wins, otherwise the browser/system preference, otherwise the default.
|
||||||
|
*/
|
||||||
|
function initialLocale(): Locale {
|
||||||
|
return readStoredLocale() ?? detectInitialLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
class I18nStore {
|
||||||
|
locale: Locale = $state(initialLocale());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* setLocale changes the active locale and persists the choice to
|
||||||
|
* `localStorage`, so it survives a reload. Components reading
|
||||||
* `i18n.t(...)` re-render automatically through the rune.
|
* `i18n.t(...)` re-render automatically through the rune.
|
||||||
*/
|
*/
|
||||||
setLocale(next: Locale): void {
|
setLocale(next: Locale): void {
|
||||||
this.locale = next;
|
this.locale = next;
|
||||||
|
if (typeof localStorage !== "undefined") {
|
||||||
|
localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -617,6 +617,7 @@ const en = {
|
|||||||
"game.battle.not_found": "battle not found",
|
"game.battle.not_found": "battle not found",
|
||||||
"game.battle.back_to_report": "back to report",
|
"game.battle.back_to_report": "back to report",
|
||||||
"game.battle.back_to_map": "back to map",
|
"game.battle.back_to_map": "back to map",
|
||||||
|
"game.battle.scene_label": "battle scene",
|
||||||
"game.battle.controls.play": "play",
|
"game.battle.controls.play": "play",
|
||||||
"game.battle.controls.pause": "pause",
|
"game.battle.controls.pause": "pause",
|
||||||
"game.battle.controls.step_forward": "step forward",
|
"game.battle.controls.step_forward": "step forward",
|
||||||
|
|||||||
@@ -621,6 +621,7 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.battle.not_found": "сражение не найдено",
|
"game.battle.not_found": "сражение не найдено",
|
||||||
"game.battle.back_to_report": "к отчёту",
|
"game.battle.back_to_report": "к отчёту",
|
||||||
"game.battle.back_to_map": "к карте",
|
"game.battle.back_to_map": "к карте",
|
||||||
|
"game.battle.scene_label": "сцена боя",
|
||||||
"game.battle.controls.play": "запустить",
|
"game.battle.controls.play": "запустить",
|
||||||
"game.battle.controls.pause": "пауза",
|
"game.battle.controls.pause": "пауза",
|
||||||
"game.battle.controls.step_forward": "шаг вперёд",
|
"game.battle.controls.step_forward": "шаг вперёд",
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import en from "../src/lib/i18n/locales/en";
|
||||||
|
import ru from "../src/lib/i18n/locales/ru";
|
||||||
|
|
||||||
|
// Structural guard against translation drift: the en bundle defines the
|
||||||
|
// TranslationKey type, so any key added to one locale but not the other
|
||||||
|
// (or left blank) must fail here rather than silently fall back at
|
||||||
|
// runtime.
|
||||||
|
describe("i18n bundle completeness", () => {
|
||||||
|
it("en and ru expose identical key sets", () => {
|
||||||
|
const onlyEn = Object.keys(en).filter((k) => !(k in ru));
|
||||||
|
const onlyRu = Object.keys(ru).filter((k) => !(k in en));
|
||||||
|
expect({ onlyEn, onlyRu }).toEqual({ onlyEn: [], onlyRu: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no empty translation values", () => {
|
||||||
|
for (const [k, v] of Object.entries(en)) {
|
||||||
|
expect(v.length, `en.${k}`).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(ru)) {
|
||||||
|
expect(v.length, `ru.${k}`).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The store is a module singleton constructed on first import (it reads
|
||||||
|
// localStorage in its initialiser), so each case clears storage and
|
||||||
|
// resets the module registry, then imports a freshly-built store.
|
||||||
|
const LOCALE_KEY = "galaxy-locale";
|
||||||
|
|
||||||
|
describe("locale persistence", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists an explicit choice and restores it on the next load", async () => {
|
||||||
|
const first = await import("../src/lib/i18n/index.svelte");
|
||||||
|
first.i18n.setLocale("ru");
|
||||||
|
expect(localStorage.getItem(LOCALE_KEY)).toBe("ru");
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
const second = await import("../src/lib/i18n/index.svelte");
|
||||||
|
expect(second.i18n.locale).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to browser detection when nothing is stored", async () => {
|
||||||
|
const mod = await import("../src/lib/i18n/index.svelte");
|
||||||
|
expect(["en", "ru"]).toContain(mod.i18n.locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unrecognised stored value", async () => {
|
||||||
|
localStorage.setItem(LOCALE_KEY, "fr");
|
||||||
|
const mod = await import("../src/lib/i18n/index.svelte");
|
||||||
|
expect(["en", "ru"]).toContain(mod.i18n.locale);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user