i18n: language picker in the bottom-right corner

Replaces the EN/ES/RU buttons in the Visual Presets dock with a picker
that scales to many languages: a globe button with the current code
next to the POWER UP chip, opening a list of languages named in their
own language with their codes, the current one first and checked. A
search field appears past 8 languages. Keyboard: arrows, Home/End,
Enter/Space, Esc, type-ahead; keys stay in the list while it is open.
Choosing a language still asks before dropping unsaved work, then
reloads. Phones get the globe alone and a full-width sheet; the layout
uses logical properties for right-to-left languages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-16 02:03:17 +04:00
1 parent 7fd1dd867f
commit 6031c95a81
21 files changed
+1381 -139

No files matched your search

+23 -27
View File
@@ -1,6 +1,6 @@
// K-27: the dock language switch reloads the page, so it asks first while the
// K-27: the language picker reloads the page, so it asks first while the
// reload would lose work (unsaved CCTV calibration, the open cockpit, anything
// a module registers).
// a module registers). Choosing the language already active does nothing.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { StyleManager } from './applicationShell.js';
@@ -11,20 +11,9 @@ import {
setLocale,
} from '../i18n/index.js';
function fakeButton() {
const listeners = {};
return {
dataset: {},
classList: { toggle() {} },
setAttribute() {},
addEventListener: (type, listener) => (listeners[type] = listener),
click: () => listeners.click(),
};
}
/** Render the switch into a fake dock with the given shell state. */
/** Wire the shell's language choice with the given shell state. No picker
* markup here: the picker's own behaviour is covered by localePicker.test. */
function renderSwitch({ calDirty = false, cockpit = false } = {}) {
const buttons = [];
const previous = {
document: globalThis.document,
location: globalThis.location,
@@ -34,10 +23,7 @@ function renderSwitch({ calDirty = false, cockpit = false } = {}) {
const reloads = [];
const prompts = [];
let answer = true;
globalThis.document = {
querySelector: () => ({ appendChild: (button) => buttons.push(button) }),
createElement: fakeButton,
};
globalThis.document = { getElementById: () => null };
globalThis.location = {
href: 'https://gev.example/#v=2',
assign() {},
@@ -56,12 +42,11 @@ function renderSwitch({ calDirty = false, cockpit = false } = {}) {
},
cockpitView: { active: cockpit },
_confirmLocaleReload: StyleManager.prototype._confirmLocaleReload,
_chooseLocale: StyleManager.prototype._chooseLocale,
};
StyleManager.prototype._initLocaleSelector.call(shell);
const button = (locale) =>
buttons.find((candidate) => candidate.dataset.locale === locale);
return {
button,
choose: (locale) => shell._chooseLocale(locale),
reloads,
prompts,
answer: (value) => (answer = value),
@@ -83,7 +68,7 @@ test.after(() => {
test('nothing to lose: the switch reloads without asking', () => {
const view = renderSwitch();
try {
view.button('es').click();
view.choose('es');
assert.deepEqual(view.prompts, []);
assert.equal(view.reloads.length, 1);
} finally {
@@ -95,13 +80,13 @@ test('unsaved calibration or the open cockpit: the switch asks first', () => {
const view = renderSwitch({ calDirty: true, cockpit: true });
try {
view.answer(false);
view.button('ru').click();
view.choose('ru');
assert.equal(view.prompts.length, 1);
assert.match(view.prompts[0], /unsaved CCTV calibration/);
assert.match(view.prompts[0], /the open cockpit/);
assert.equal(view.reloads.length, 0, 'declined: no reload');
view.answer(true);
view.button('ru').click();
view.choose('ru');
assert.equal(view.reloads.length, 1, 'confirmed: reload');
} finally {
view.restore();
@@ -116,7 +101,7 @@ test('modules register their own unsaved work', () => {
const view = renderSwitch();
try {
view.answer(false);
view.button('es').click();
view.choose('es');
assert.match(view.prompts[0], /drawings on the map/);
assert.equal(view.reloads.length, 0);
} finally {
@@ -132,7 +117,7 @@ test('the prompt reads in the active language', () => {
const view = renderSwitch({ calDirty: true });
try {
view.answer(false);
view.button('en').click();
view.choose('en');
assert.equal(view.prompts.length, 1);
assert.doesNotMatch(view.prompts[0], /Switching language/);
} finally {
@@ -140,3 +125,14 @@ test('the prompt reads in the active language', () => {
setLocale('en');
}
});
test('the language already active neither asks nor reloads', () => {
const view = renderSwitch({ calDirty: true });
try {
assert.equal(view.choose('en'), 'unchanged');
assert.deepEqual(view.prompts, []);
assert.equal(view.reloads.length, 0);
} finally {
view.restore();
}
});