diff --git a/src/__tests__/i18n/eager-tables.test.ts b/src/__tests__/i18n/eager-tables.test.ts
new file mode 100644
index 00000000..93bf1142
--- /dev/null
+++ b/src/__tests__/i18n/eager-tables.test.ts
@@ -0,0 +1,74 @@
+/**
+ * English is the only interface table on the eager bundle; the other six are their own chunks
+ * (`i18n/locales/index.ts`). This is what keeps them off it: the merged record in
+ * `i18n/translations.ts` is for tests and scripts, and the moment a module the app loads imports
+ * it, all seven tables are back on the start-up payload — 155 KB gzip of the first paint.
+ */
+import { describe, it, expect } from 'vitest';
+// @ts-ignore - node:fs is untyped here (no @types/node)
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+// @ts-ignore - node:path is untyped here (no @types/node)
+import { join, resolve, relative } from 'node:path';
+import { LOCALES } from '../../i18n/locales';
+
+declare const __dirname: string;
+
+const SRC = resolve(__dirname, '../..');
+const LOCALES_DIR = join(SRC, 'i18n', 'locales');
+
+/** An import of the merged record, whichever relative depth it is written from. */
+const MERGED_IMPORT = /from '(?:[^']*\/)?translations'/;
+
+function filesUnder(dir: string): string[] {
+ const out: string[] = [];
+ const walk = (d: string): void => {
+ for (const name of readdirSync(d)) {
+ const full = join(d, name);
+ if (statSync(full).isDirectory()) walk(full);
+ else if (/\.tsx?$/.test(name)) out.push(full);
+ }
+ };
+ walk(dir);
+ return out;
+}
+
+const toPosix = (file: string): string => file.replace(/\\/g, '/');
+
+/** Every module the app can reach: `src` minus the test tree, which legitimately reads the merged
+ * record, and minus `locales/` itself, whose whole job is to hold the tables. */
+const runtimeFiles = (): string[] => filesUnder(SRC).filter((file) => {
+ const path = toPosix(file);
+ return !path.includes('/__tests__/') && !path.includes('/i18n/locales/');
+});
+
+describe('eager interface tables', () => {
+ it('nothing the app loads imports the merged record', () => {
+ const offenders = runtimeFiles()
+ .filter((file) => MERGED_IMPORT.test(readFileSync(file, 'utf8')))
+ .map((file) => toPosix(relative(SRC, file)));
+ expect(offenders, 'read the table for the locale in hand, not all seven').toEqual([]);
+ });
+
+ it('the eager table set is English alone', () => {
+ // `translateFor` falls back to English for every key a locale is missing, so it is the one table
+ // that cannot arrive late. Anything else imported here is a chunk that stopped being a chunk.
+ const context = readFileSync(join(SRC, 'i18n', 'context.tsx'), 'utf8');
+ const eager = [...context.matchAll(/from '\.\/locales\/([a-z]+)'/g)].map((m) => m[1]);
+ expect(eager).toEqual(['en']);
+ });
+
+ it('every table on disk is one the loader can fetch', () => {
+ // Adding `de.ts` without listing it in LOCALES (settings) and LOADERS (the fetch) would leave a
+ // language that can be read but never loaded.
+ const onDisk = readdirSync(LOCALES_DIR)
+ .filter((name) => /^[a-z]{2}\.ts$/.test(name))
+ .map((name) => name.slice(0, -3))
+ .sort();
+ expect([...LOCALES].sort()).toEqual(onDisk);
+ const loader = readFileSync(join(LOCALES_DIR, 'index.ts'), 'utf8');
+ for (const locale of onDisk) {
+ if (locale === 'en') continue;
+ expect(loader, `${locale} has a table but no loader`).toContain(`import('./${locale}')`);
+ }
+ });
+});
diff --git a/src/__tests__/i18n/locale-table-loading.test.tsx b/src/__tests__/i18n/locale-table-loading.test.tsx
new file mode 100644
index 00000000..9cce651d
--- /dev/null
+++ b/src/__tests__/i18n/locale-table-loading.test.tsx
@@ -0,0 +1,64 @@
+/**
+ * A locale's interface table is its own chunk (`i18n/locales/index.ts`). These cover what happens
+ * around that arrival: it reaches a tree that already rendered, an interface key outranks a window's
+ * own prose, a chunk is fetched once however many callers ask, and a chunk that failed is retried by
+ * the next call rather than remembered as broken.
+ *
+ * Every case uses a key no shipped table carries, so the installed tables (vitest.setup.ts) cannot
+ * answer for the layer under test.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+import { I18nProvider, registerBaseStrings, registerExtraStrings, translateFor, useT } from '../../i18n/context';
+import { useEditorStore } from '../../state/store';
+
+const chunk = vi.hoisted(() => ({ attempts: 0 }));
+vi.mock('../../i18n/locales/id', () => {
+ chunk.attempts += 1;
+ if (chunk.attempts === 1) throw new Error('chunk failed to load');
+ return { id: { 'probe.retried': 'Berjaya selepas cuba semula' } };
+});
+
+const { ensureLocaleStrings } = await import('../../i18n/locales');
+
+function Probe() {
+ const t = useT();
+ return {t('probe.late')};
+}
+
+describe('a table that arrives after the first frame', () => {
+ it('reaches the tree that already rendered with the fallback', () => {
+ useEditorStore.setState({ locale: 'fr' });
+ render();
+ expect(screen.getByTestId('probe').textContent).toBe('probe.late');
+ act(() => { registerBaseStrings({ fr: { 'probe.late': 'tardif' } }); });
+ expect(screen.getByTestId('probe').textContent).toBe('tardif');
+ });
+});
+
+describe('the two late-arriving layers', () => {
+ it('keeps the interface wording when a window registers the same key', () => {
+ registerBaseStrings({ ru: { 'probe.shared': 'интерфейс' } });
+ registerExtraStrings({ ru: { 'probe.shared': 'справка' } });
+ expect(translateFor('ru', 'probe.shared')).toBe('интерфейс');
+ });
+});
+
+describe('a chunk that fails to load', () => {
+ it('is fetched again by the next call instead of being remembered as broken', async () => {
+ // The mock factory's throw arrives wrapped by the runner, so the failure itself is what is
+ // asserted here, not its text.
+ await expect(ensureLocaleStrings('id')).rejects.toThrow();
+ expect(chunk.attempts).toBe(1);
+
+ await expect(ensureLocaleStrings('id')).resolves.toBeUndefined();
+ expect(chunk.attempts).toBe(2);
+ expect(translateFor('id', 'probe.retried')).toBe('Berjaya selepas cuba semula');
+ });
+
+ it('is fetched once however many callers ask for it', async () => {
+ const before = chunk.attempts;
+ await Promise.all([ensureLocaleStrings('id'), ensureLocaleStrings('id'), ensureLocaleStrings('id')]);
+ expect(chunk.attempts).toBe(before);
+ });
+});
diff --git a/src/__tests__/ui/shell/use-locale-switch.test.ts b/src/__tests__/ui/shell/use-locale-switch.test.ts
new file mode 100644
index 00000000..32333ae4
--- /dev/null
+++ b/src/__tests__/ui/shell/use-locale-switch.test.ts
@@ -0,0 +1,77 @@
+/**
+ * Switching language fetches that language's table before committing the preference. Two rules are
+ * covered here: the LATEST choice wins even when an earlier fetch lands afterwards, and a failure
+ * keeps the language the reader is in while saying so.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, renderHook } from '@testing-library/react';
+import { useEditorStore } from '../../../state/store';
+
+const { ensureLocaleStrings, showToast } = vi.hoisted(() => ({
+ ensureLocaleStrings: vi.fn(),
+ showToast: vi.fn(),
+}));
+vi.mock('../../../i18n/locales', () => ({ ensureLocaleStrings: (locale: string) => ensureLocaleStrings(locale) }));
+vi.mock('../../../core/runtime/toast-bus', () => ({ showToast: (text: string, type: string) => showToast(text, type) }));
+
+const { useLocaleSwitch } = await import('../../../ui/shell/windows/use-locale-switch');
+
+/** A promise this test settles when it chooses, so a fetch can be held open across a later choice. */
+function deferred() {
+ let resolve!: () => void;
+ const promise = new Promise((res) => { resolve = res; });
+ return { promise, resolve };
+}
+
+beforeEach(() => {
+ ensureLocaleStrings.mockReset();
+ showToast.mockReset();
+ useEditorStore.setState({ locale: 'en' });
+});
+
+describe('useLocaleSwitch', () => {
+ it('commits the language whose table arrived', async () => {
+ ensureLocaleStrings.mockResolvedValue(undefined);
+ const { result } = renderHook(() => useLocaleSwitch());
+ await act(async () => { await result.current('fr'); });
+ expect(useEditorStore.getState().locale).toBe('fr');
+ expect(showToast).not.toHaveBeenCalled();
+ });
+
+ it('lets the latest choice win when an earlier fetch lands afterwards', async () => {
+ const first = deferred();
+ ensureLocaleStrings.mockReturnValueOnce(first.promise).mockResolvedValue(undefined);
+ const { result } = renderHook(() => useLocaleSwitch());
+
+ const earlier = result.current('ja'); // held open, ticket 1
+ await act(async () => { await result.current('fr'); });
+ expect(useEditorStore.getState().locale).toBe('fr');
+
+ // The earlier fetch lands late: it must not take the interface back to a language already left.
+ first.resolve();
+ await act(async () => { await earlier; });
+ expect(useEditorStore.getState().locale).toBe('fr');
+ });
+
+ it('keeps the language the reader is in, and says so, when the fetch fails', async () => {
+ ensureLocaleStrings.mockRejectedValue(new Error('chunk failed'));
+ const { result } = renderHook(() => useLocaleSwitch());
+ await act(async () => { await result.current('ru'); });
+ expect(useEditorStore.getState().locale).toBe('en');
+ expect(showToast).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not report a failure a later choice has already superseded', async () => {
+ const failing = deferred();
+ ensureLocaleStrings.mockReturnValueOnce(failing.promise).mockResolvedValue(undefined);
+ const { result } = renderHook(() => useLocaleSwitch());
+
+ const earlier = result.current('ru');
+ await act(async () => { await result.current('fr'); });
+
+ failing.resolve();
+ await act(async () => { await earlier; });
+ expect(useEditorStore.getState().locale).toBe('fr');
+ expect(showToast).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/agent/exec/runner.ts b/src/agent/exec/runner.ts
index 25b068fd..075e9f0a 100644
--- a/src/agent/exec/runner.ts
+++ b/src/agent/exec/runner.ts
@@ -36,7 +36,7 @@ import { regionBounds } from '../tools/tools-common';
import { createExecutor, wireSchemas, type DelegateOpts } from './executor';
import { reviewText, ReviewWorkerLease } from '../../io/moderation/text/reviewer';
import { translateFor } from '../../i18n/context';
-import { translations } from '../../i18n/translations';
+import { isLocale } from '../../i18n/locales';
/** Same default `runJob` itself falls back to when `contextWindow` is omitted; kept explicit here
* since `budgetTokens` (unlike `contextWindow`) has no built-in fallback. */
@@ -128,8 +128,9 @@ async function orderRefused(text: string): Promise {
}
function refusalText(uiLocale: string | undefined): string {
- const locale = uiLocale !== undefined && uiLocale in translations ? uiLocale as keyof typeof translations : 'en';
- return translateFor(locale, 'agent.refusal.content');
+ // An absent or unrecognised tag falls back to English, which is also where `translateFor` lands
+ // for a key the locale is missing.
+ return translateFor(uiLocale !== undefined && isLocale(uiLocale) ? uiLocale : 'en', 'agent.refusal.content');
}
export function createRunner(cfg: RunnerConfig): {
diff --git a/src/i18n/context.tsx b/src/i18n/context.tsx
index dca79f2f..77f26c4d 100644
--- a/src/i18n/context.tsx
+++ b/src/i18n/context.tsx
@@ -1,5 +1,5 @@
-import { createContext, useContext, useCallback, useEffect, type ReactNode } from 'react';
-import { translations } from './translations';
+import { createContext, useContext, useCallback, useEffect, useSyncExternalStore, type ReactNode } from 'react';
+import { en } from './locales/en';
import { useEditorStore } from '../state/store';
import { brandName } from '../version';
import type { Locale, LocalizedName } from '../core/model/types';
@@ -14,28 +14,68 @@ export function localizedName(name: LocalizedName, locale: Locale): string {
const I18nContext = createContext((key) => key);
/**
- * Late-arriving string tables, registered by a lazy chunk for its own keys (the Help Center's
- * tables are megabytes of prose nobody pays for until the window opens). An overlay only ever ADDS
- * keys: the main tables always win, so a chunk cannot re-word the interface.
+ * Late-arriving string tables, in two layers that are managed separately:
+ *
+ * - `baseTables` — a locale's INTERFACE table, one per locale, arriving with the language that reads
+ * it (`locales/index.ts`).
+ * - `extraTables` — prose a window registers for its own keys: the Help Center's tables are megabytes
+ * nobody pays for until the window opens.
+ *
+ * Base outranks extra, so a help string that happens to use an interface key cannot re-word the
+ * interface it is displayed in. Both only ADD keys: the eager table (English) wins over either.
*/
+let baseTables: Partial>> = {};
let extraTables: Partial>> = {};
-/** Merge a per-locale table set into the overlay. Idempotent per call site by construction: the
- * caller registers a module-level constant, and re-merging the same table changes nothing. */
-export function registerExtraStrings(tables: Partial>>): void {
- const next: typeof extraTables = { ...extraTables };
+let version = 0;
+const stringsListeners = new Set<() => void>();
+
+/** Re-render when a table lands: the interface is painted before its locale's table has to be in
+ * hand, so an arrival has to reach the components that already rendered in the fallback. */
+export function subscribeStrings(listener: () => void): () => void {
+ stringsListeners.add(listener);
+ return () => { stringsListeners.delete(listener); };
+}
+
+export function stringsVersion(): number {
+ return version;
+}
+
+function merge(store: Partial>>, tables: Partial>>): typeof store {
+ const next = { ...store };
for (const [locale, table] of Object.entries(tables) as [Locale, Record][]) {
next[locale] = { ...next[locale], ...table };
}
- extraTables = next;
+ version += 1;
+ for (const listener of stringsListeners) listener();
+ return next;
+}
+
+/** Merge a locale's interface table. Idempotent per call site: the caller registers a module-level
+ * constant, and re-merging the same table changes nothing. */
+export function registerBaseStrings(tables: Partial>>): void {
+ baseTables = merge(baseTables, tables);
+}
+
+/** Merge a window's own tables into the prose overlay. */
+export function registerExtraStrings(tables: Partial>>): void {
+ extraTables = merge(extraTables, tables);
}
+/**
+ * The table that ships on the eager bundle. English alone: it is the fallback every other locale
+ * leans on, so it has to be readable before the first render. The other six arrive through
+ * `ensureLocaleStrings` (`locales/index.ts`), which keeps six tables of interface strings off the
+ * start-up payload.
+ */
+const eagerTables: Partial>> = { en };
+
/** Resolve a key for a specific locale (falling back to English) + interpolate `{name}` params.
* The `{app}` token is always resolved from the central brand name (see version.ts), so no
* translation string ever hardcodes the project name. */
export function translateFor(locale: Locale, key: string, params?: Record): string {
- let text = translations[locale]?.[key] ?? extraTables[locale]?.[key]
- ?? translations['en'][key] ?? extraTables['en']?.[key] ?? key;
+ let text = eagerTables[locale]?.[key] ?? baseTables[locale]?.[key] ?? extraTables[locale]?.[key]
+ ?? en[key] ?? extraTables['en']?.[key] ?? key;
text = text.split('{app}').join(brandName(locale));
if (params) {
// split/join, not replace: a param value is literal text, never a replacement pattern.
@@ -52,7 +92,10 @@ export function translate(key: string, params?: Record)
export function I18nProvider({ children }: { children: ReactNode }) {
const locale = useEditorStore((s) => s.locale);
- const t: TFunction = useCallback((key, params) => translateFor(locale, key, params), [locale]);
+ // A table that lands after the first frame has to reach the components already rendered with the
+ // fallback, so the provider reads the arrival counter and the translate function is keyed on it.
+ const revision = useSyncExternalStore(subscribeStrings, stringsVersion, stringsVersion);
+ const t: TFunction = useCallback((key, params) => translateFor(locale, key, params), [locale, revision]);
// The deployment owns the document head; a saved editor locale applies only to the app subtree.
useEffect(() => {
document.getElementById('root')?.setAttribute('lang', locale === 'zh' ? 'zh-CN' : locale);
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 7ee44542..20994276 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -145,6 +145,7 @@ export const en: Record = {
'planet.start_over_hint': 'This clears everything you have built, and cannot be undone.',
'modal.settings_title': 'Settings',
'modal.settings_language': 'Language',
+ 'modal.settings_language_failed': 'Could not load that language — pick it again to retry.',
'modal.settings_ui_scale': 'UI scale',
'modal.settings_grid': 'Grid lines',
'modal.settings_chunks': 'Chunk bounds',
diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts
index 6f03fe3f..299e95cb 100644
--- a/src/i18n/locales/fr.ts
+++ b/src/i18n/locales/fr.ts
@@ -145,6 +145,7 @@ export const fr: Record = {
'planet.start_over_hint': 'Tout ce que vous avez construit ici sera supprimé, et cette action est irréversible.',
'modal.settings_title': 'Paramètres',
'modal.settings_language': 'Langue',
+ 'modal.settings_language_failed': 'Impossible de charger cette langue — sélectionnez-la à nouveau pour réessayer.',
'modal.settings_ui_scale': 'Échelle de l’interface',
'modal.settings_grid': 'Grille',
'modal.settings_chunks': 'Limites de chunk',
diff --git a/src/i18n/locales/id.ts b/src/i18n/locales/id.ts
index 54f73319..9bb6020b 100644
--- a/src/i18n/locales/id.ts
+++ b/src/i18n/locales/id.ts
@@ -145,6 +145,7 @@ export const id: Record = {
'planet.start_over_hint': 'Semua yang Anda bangun di pulau ini akan dihapus dan tidak dapat dibatalkan.',
'modal.settings_title': 'Pengaturan',
'modal.settings_language': 'Bahasa',
+ 'modal.settings_language_failed': 'Gagal memuat bahasa itu — pilih lagi untuk mencoba ulang.',
'modal.settings_ui_scale': 'Skala UI',
'modal.settings_grid': 'Garis kisi',
'modal.settings_chunks': 'Batas chunk',
diff --git a/src/i18n/locales/index.ts b/src/i18n/locales/index.ts
new file mode 100644
index 00000000..13cfb2f7
--- /dev/null
+++ b/src/i18n/locales/index.ts
@@ -0,0 +1,53 @@
+/*
+ * The interface tables, one chunk per locale, registered when they arrive. English stays on the
+ * eager bundle: `translateFor` falls back to it for every key a locale is missing, so it has to be
+ * readable before the first render. The other six are read only by someone reading that language,
+ * and merged into a single chunk they were the largest item on the start-up payload — 535 KB raw /
+ * 155 KB gzip, all of it modulepreloaded. `i18n/translations.ts` keeps the merged record for tests,
+ * scripts and the legal-page generator; nothing on the runtime path imports it.
+ */
+import type { Locale } from '../../core/model/types';
+import { registerBaseStrings } from '../context';
+
+/** Every locale a saved preference may name, in the order the settings list them. */
+export const LOCALES: readonly Locale[] = ['en', 'zh', 'ja', 'ru', 'th', 'id', 'fr'];
+
+/** True for a string that names a locale we ship tables for (a stored preference, an agent's
+ * reported UI language, a URL parameter). */
+export function isLocale(value: string): value is Locale {
+ return (LOCALES as readonly string[]).includes(value);
+}
+
+/** Static specifiers, so the bundler gives each table its own chunk and fetches exactly one. */
+const LOADERS: Record, () => Promise>>> = {
+ zh: () => import('./zh').then((m) => ({ zh: m.zh })),
+ ja: () => import('./ja').then((m) => ({ ja: m.ja })),
+ ru: () => import('./ru').then((m) => ({ ru: m.ru })),
+ th: () => import('./th').then((m) => ({ th: m.th })),
+ id: () => import('./id').then((m) => ({ id: m.id })),
+ fr: () => import('./fr').then((m) => ({ fr: m.fr })),
+};
+
+/** In-flight or finished loads, so concurrent callers (boot, and a switch before it lands) share one
+ * import instead of racing two. */
+const pending = new Map>();
+
+/**
+ * Register `locale`'s interface table.
+ *
+ * Resolves without a request for English. Safe to call repeatedly: the second call returns the same
+ * promise. A failed fetch is dropped from the memo rather than remembered, so a later attempt
+ * retries — the caller still sees the rejection, because a language that did not arrive is a fact
+ * the caller decides on (boot renders anyway; the switcher keeps the old language and offers a retry).
+ */
+export function ensureLocaleStrings(locale: Locale): Promise {
+ if (locale === 'en') return Promise.resolve();
+ const running = pending.get(locale);
+ if (running) return running;
+ const load = LOADERS[locale]().then(registerBaseStrings, (err: unknown) => {
+ pending.delete(locale);
+ throw err;
+ });
+ pending.set(locale, load);
+ return load;
+}
diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts
index a866ce72..698071f2 100644
--- a/src/i18n/locales/ja.ts
+++ b/src/i18n/locales/ja.ts
@@ -145,6 +145,7 @@ export const ja: Record = {
'planet.start_over_hint': 'この島に建てたものはすべて消え、取り消せません。',
'modal.settings_title': '設定',
'modal.settings_language': '言語',
+ 'modal.settings_language_failed': 'その言語を読み込めませんでした — もう一度選ぶと再試行します。',
'modal.settings_ui_scale': 'UIスケール',
'modal.settings_grid': 'グリッド線',
'modal.settings_chunks': 'チャンク境界',
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index e3561703..f4abc2bd 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -145,6 +145,7 @@ export const ru: Record = {
'planet.start_over_hint': 'Всё построенное на этом острове будет удалено, и отменить это нельзя.',
'modal.settings_title': 'Настройки',
'modal.settings_language': 'Язык',
+ 'modal.settings_language_failed': 'Не удалось загрузить этот язык — выберите его снова, чтобы повторить.',
'modal.settings_ui_scale': 'Масштаб интерфейса',
'modal.settings_grid': 'Сетка',
'modal.settings_chunks': 'Границы чанков',
diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts
index 356081c3..f85fe889 100644
--- a/src/i18n/locales/th.ts
+++ b/src/i18n/locales/th.ts
@@ -145,6 +145,7 @@ export const th: Record = {
'planet.start_over_hint': 'ทุกสิ่งที่คุณสร้างไว้บนเกาะนี้จะถูกลบ และย้อนกลับไม่ได้',
'modal.settings_title': 'ตั้งค่า',
'modal.settings_language': 'ภาษา',
+ 'modal.settings_language_failed': 'โหลดภาษานั้นไม่สำเร็จ — เลือกอีกครั้งเพื่อลองใหม่',
'modal.settings_ui_scale': 'ขนาดอินเทอร์เฟซ',
'modal.settings_grid': 'เส้นตาราง',
'modal.settings_chunks': 'ขอบเขตส่วนแผนที่',
diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts
index 07bc34a5..4677ff6f 100644
--- a/src/i18n/locales/zh.ts
+++ b/src/i18n/locales/zh.ts
@@ -145,6 +145,7 @@ export const zh: Record = {
'planet.start_over_hint': '当前建设将被清空,无法撤销。',
'modal.settings_title': '设置',
'modal.settings_language': '语言',
+ 'modal.settings_language_failed': '无法加载该语言 —— 再次选择即可重试。',
'modal.settings_ui_scale': '界面缩放',
'modal.settings_grid': '网格线',
'modal.settings_chunks': '区块边界',
diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts
index 59dd1008..9abfa6fc 100644
--- a/src/i18n/translations.ts
+++ b/src/i18n/translations.ts
@@ -1,3 +1,12 @@
+/*
+ * Every interface table merged into one record — FOR TESTS AND SCRIPTS ONLY.
+ *
+ * The runtime does not import this, and must not: pulling it in puts all seven tables back on the
+ * eager bundle, which is the 155 KB gzip the start-up payload just shed. The app reads English
+ * eagerly (`i18n/context.tsx`) and every other locale through `locales/index.ts:ensureLocaleStrings`,
+ * which registers the table as an i18n overlay when its chunk arrives.
+ * `__tests__/i18n/eager-tables.test.ts` fails if this module reappears on the runtime graph.
+ */
import type { Locale } from '../core/model/types';
import { en } from './locales/en';
import { zh } from './locales/zh';
@@ -7,7 +16,7 @@ import { th } from './locales/th';
import { id } from './locales/id';
import { fr } from './locales/fr';
-// The Help Center's tables are deliberately NOT merged here: they are an order of magnitude
+// The Help Center's tables are deliberately NOT merged here either: they are an order of magnitude
// longer than any other surface's strings, so they live in the help chunk and arrive through
// `context.tsx:registerExtraStrings` when that chunk loads (`locales/help/index.ts`).
export const translations: Record> = { en, zh, ja, ru, th, id, fr };
diff --git a/src/main.tsx b/src/main.tsx
index 83dceee3..3082c062 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -8,6 +8,7 @@ import App from './App';
import { printConsoleBanner } from './console-banner';
import { publishCursorPreference } from './ui/design/cursors/cursor-vars';
import { useEditorStore } from './state/store';
+import { ensureLocaleStrings } from './i18n/locales';
import { preloadScene3D } from './canvas/map3d/preload';
// Before the first render: the global `html { cursor: var(…) }` rule falls back to the OS keyword
@@ -27,8 +28,16 @@ printConsoleBanner();
document.documentElement.style.fontFamily = APP_FONT_FAMILY;
-createRoot(document.getElementById('root')!).render(
+const FIRST_FRAME_WAIT_MS = 150;
+const mount = () => createRoot(document.getElementById('root')!).render(
,
);
+// A non-English session has one small fetch here for its interface table. Waiting for it keeps the
+// first frame from being a flash of English, but only up to a deadline: a fetch that never settles
+// still paints, and the table re-renders the interface when it lands (see i18n/context.tsx).
+void Promise.race([
+ ensureLocaleStrings(useEditorStore.getState().locale).catch(() => {}),
+ new Promise((resolve) => { setTimeout(resolve, FIRST_FRAME_WAIT_MS); }),
+]).then(mount);
diff --git a/src/ui/chrome/modals/SettingsModal.tsx b/src/ui/chrome/modals/SettingsModal.tsx
index bf9f097c..ac3072cb 100644
--- a/src/ui/chrome/modals/SettingsModal.tsx
+++ b/src/ui/chrome/modals/SettingsModal.tsx
@@ -442,16 +442,14 @@ function UiScaleSlider({ label }: { label: string }) {
// Language pills — native endonyms so each is recognizable in its own script,
// all seven visible at once. Order matches the translation source spreadsheet
-// (zh first).
-const LOCALES: { code: Locale; label: string }[] = [
- { code: 'zh', label: '中文' },
- { code: 'en', label: 'English' },
- { code: 'ja', label: '日本語' },
- { code: 'ru', label: 'Русский' },
- { code: 'th', label: 'ไทย' },
- { code: 'id', label: 'Indonesia' },
- { code: 'fr', label: 'Français' },
-];
+// (zh first). The LABELS are the only thing here that is not derivable: `Record`
+// makes a locale whose table exists but whose pill was forgotten a compile error, rather than a
+// language a reader can be in but cannot pick.
+const LOCALE_LABELS: Record = {
+ zh: '中文', en: 'English', ja: '日本語', ru: 'Русский', th: 'ไทย', id: 'Indonesia', fr: 'Français',
+};
+const LOCALES: { code: Locale; label: string }[] = (['zh', 'en', 'ja', 'ru', 'th', 'id', 'fr'] as const)
+ .map((code) => ({ code, label: LOCALE_LABELS[code] }));
export interface SettingsModalProps {
/** Drives the shared `ModalShell` open/close choreography. Defaults to `true`
diff --git a/src/ui/shell/windows/Windows.tsx b/src/ui/shell/windows/Windows.tsx
index 69fd9d79..cc4f3b83 100644
--- a/src/ui/shell/windows/Windows.tsx
+++ b/src/ui/shell/windows/Windows.tsx
@@ -14,6 +14,7 @@
import { Suspense, lazy, useCallback, useEffect, useRef } from 'react';
import type { Arrival, ArrivalLine } from '../../../core/runtime/arrival-bus';
import { announceArrival } from '../../../core/runtime/arrival-bus';
+import { useLocaleSwitch } from './use-locale-switch';
import { currentKit } from '../../../kit/context';
import type { TransferCounts } from '../../../kit/operations';
import { newMap, transferMap } from '../../../kit/operations';
@@ -114,7 +115,9 @@ export function Windows() {
const helpMounted = helpEver.current;
const locale = useEditorStore((s) => s.locale);
- const setLocale = useEditorStore((s) => s.setLocale);
+ // A language whose table is a separate chunk is fetched before the preference is committed; see
+ // `use-locale-switch.ts` for the ordering rule and the failure path.
+ const changeLocale = useLocaleSwitch();
const showGrid = useEditorStore((s) => s.showGrid);
const setShowGrid = useEditorStore((s) => s.setShowGrid);
const showChunkBounds = useEditorStore((s) => s.showChunkBounds);
@@ -164,7 +167,7 @@ export function Windows() {
motionPref={motionPref}
systemCursors={systemCursors}
quality3d={quality3d}
- onLocaleChange={setLocale}
+ onLocaleChange={changeLocale}
onShowGridChange={setShowGrid}
onShowChunksChange={setShowChunkBounds}
onMotionPrefChange={setMotionPref}
diff --git a/src/ui/shell/windows/use-locale-switch.ts b/src/ui/shell/windows/use-locale-switch.ts
new file mode 100644
index 00000000..59464398
--- /dev/null
+++ b/src/ui/shell/windows/use-locale-switch.ts
@@ -0,0 +1,33 @@
+/**
+ * A language whose table is its own chunk (see i18n/locales/index.ts): fetch it, then commit the
+ * preference, so the interface never switches to a language it cannot speak yet.
+ *
+ * The ticket makes the LATEST choice win. A switch made while an earlier fetch is still in flight is
+ * the one the reader asked for last, so only it may commit the preference or report a failure — an
+ * earlier fetch that resolves late must not drag the interface back to a language already left.
+ *
+ * A failed fetch keeps the previous language, says so, and leaves the failed chunk unremembered, so
+ * the picker's next press is the retry.
+ */
+import { useCallback, useRef } from 'react';
+import type { Locale } from '../../../core/model/types';
+import { translate } from '../../../i18n/context';
+import { ensureLocaleStrings } from '../../../i18n/locales';
+import { showToast } from '../../../core/runtime/toast-bus';
+import { useEditorStore } from '../../../state/store';
+
+export function useLocaleSwitch(): (next: Locale) => Promise {
+ const setLocale = useEditorStore((s) => s.setLocale);
+ const latest = useRef(0);
+
+ return useCallback(async (next: Locale) => {
+ const ticket = ++latest.current;
+ try {
+ await ensureLocaleStrings(next);
+ } catch {
+ if (ticket === latest.current) showToast(translate('modal.settings_language_failed'), 'error');
+ return;
+ }
+ if (ticket === latest.current) setLocale(next);
+ }, [setLocale]);
+}
diff --git a/vitest.setup.ts b/vitest.setup.ts
index 7413a48f..bcba58d4 100644
--- a/vitest.setup.ts
+++ b/vitest.setup.ts
@@ -40,3 +40,16 @@ if (typeof HTMLCanvasElement !== 'undefined') {
value: () => null,
});
}
+
+// Every locale's interface table, installed before any test runs, so a test that renders in French
+// reads French without fetching anything. It goes in through `registerBaseStrings` — the layer a
+// locale's own table occupies at runtime — so the lookup path under test is the one that ships.
+//
+// Loaded dynamically, and awaited: a static import would be evaluated before the storage stub above
+// (ESM imports run first, and the store reads localStorage as it loads), and an un-awaited one would
+// let tests start before the tables are in.
+const [{ registerBaseStrings }, { translations }] = await Promise.all([
+ import('./src/i18n/context'),
+ import('./src/i18n/translations'),
+]);
+registerBaseStrings(translations);