Merge remote-tracking branch 'origin/i18n-spanish' into i18n-spanish

This commit is contained in:
Meir Michanie committed 2026-10-16 02:19:40 +02:00
commit 05fdaaa97c
22 files changed
+1381 -140

No files matched your search

+2 -2
View File
@@ -3209,8 +3209,8 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
`navigator.languages` → English. `?lang=` is not stored: it applies while it `navigator.languages` → English. `?lang=` is not stored: it applies while it
stays in the address, and the copied share link drops it, so a recipient keeps stays in the address, and the copied share link drops it, so a recipient keeps
their own language. The language switch in the Visual Presets dock stores the their own language. The language picker in the bottom-right corner (a globe
choice, removes `?lang=` (keeps it only where storage is blocked) and reloads, button beside POWER UP, `src/ui/localePicker.js`) stores the choice, removes `?lang=` (keeps it only where storage is blocked) and reloads,
so nothing re-renders live; it asks first when the reload would lose work so nothing re-renders live; it asks first when the reload would lose work
(unsaved CCTV calibration, the open cockpit, Draw marks on the board, (unsaved CCTV calibration, the open cockpit, Draw marks on the board,
imported map files, an active voice session — each registered through imported map files, an active voice session — each registered through
+2 -2
View File
@@ -33,7 +33,7 @@ label.textContent = t(FEED_STATE_LABELS[state]);
<input placeholder="Search any location..." data-i18n-placeholder /> <input placeholder="Search any location..." data-i18n-placeholder />
``` ```
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language switch asks before reloading. `data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading.
## Rules ## Rules
@@ -56,7 +56,7 @@ npm run i18n -- --prune # drop entries whose English no longer exists
Changing the English changes the key, so the old translation goes unused and the new text shows as missing. Add the translation to `src/i18n/locales/ru.json` (or ask a translator), prune, and run `npm test`, which runs the same check and fails until every language is complete. Changing the English changes the key, so the old translation goes unused and the new text shows as missing. Add the translation to `src/i18n/locales/ru.json` (or ask a translator), prune, and run `npm test`, which runs the same check and fails until every language is complete.
To see a language, open the app with `?lang=ru`, or use the language switch in the Visual Presets dock. To see a language, open the app with `?lang=ru`, or use the language picker (the globe button in the bottom-right corner).
An empty translation is reported, and so is one that is exactly the English ("Bloom" → "Bloom"), including any plural form left as the English: it is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`. An empty translation is reported, and so is one that is exactly the English ("Bloom" → "Bloom"), including any plural form left as the English: it is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`.
+1 -1
View File
@@ -22,7 +22,7 @@ Every other language is one flat JSON file, `src/i18n/locales/<code>.json`, keye
A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English. A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English.
The page picks its language from `?lang=`, then the choice stored by the language switch in the Visual Presets dock, then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching reloads the page. The page picks its language from `?lang=`, then the choice stored by the language picker (the globe button in the bottom-right corner), then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching reloads the page.
## The check ## The check
+2
View File
@@ -635,6 +635,7 @@
"src/styles/surveillance.js", "src/styles/surveillance.js",
"src/styles/thermal.js", "src/styles/thermal.js",
"src/ui/applicationShell.js", "src/ui/applicationShell.js",
"src/ui/localePicker.js",
"src/ui/cyberSonarControls.js", "src/ui/cyberSonarControls.js",
"src/ui/locationNavigation.js", "src/ui/locationNavigation.js",
"src/ui/cockpitCoordinator.js", "src/ui/cockpitCoordinator.js",
@@ -2074,6 +2075,7 @@
"src/styles/surveillance.js", "src/styles/surveillance.js",
"src/styles/thermal.js", "src/styles/thermal.js",
"src/ui/applicationShell.js", "src/ui/applicationShell.js",
"src/ui/localePicker.js",
"src/ui/cyberSonarControls.js", "src/ui/cyberSonarControls.js",
"src/ui/locationNavigation.js", "src/ui/locationNavigation.js",
"src/ui/cockpitCoordinator.js", "src/ui/cockpitCoordinator.js",
+2 -1
View File
@@ -8,7 +8,8 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
export const DEFAULT_LOCALE = 'en'; export const DEFAULT_LOCALE = 'en';
/** /**
* Shipped locales, in selector order. Adding one: locales/<code>.json, an * Shipped locales. The picker (src/ui/localePicker.js) lists the active one
* first and the rest by LOCALE_NAMES. Adding one: locales/<code>.json, an
* import in index.js, and an entry here (see docs/TRANSLATORS.md). * import in index.js, and an entry here (see docs/TRANSLATORS.md).
*/ */
export const LOCALES = Object.freeze(['en', 'es', 'ru']); export const LOCALES = Object.freeze(['en', 'es', 'ru']);
+5 -1
View File
@@ -22,7 +22,11 @@
"Globe actions": "Acciones del globo", "Globe actions": "Acciones del globo",
"Reset camera and return to full globe view": "Restablecer la cámara y volver a la vista del globo completo", "Reset camera and return to full globe view": "Restablecer la cámara y volver a la vista del globo completo",
"loading frames": "cargando fotogramas", "loading frames": "cargando fotogramas",
"Language": "Idioma", "Language: {language}": "Idioma: {language}",
"Change the interface language": "Cambiar el idioma de la interfaz",
"Interface language": "Idioma de la interfaz",
"Search languages": "Buscar idioma",
"No matching languages": "No hay idiomas que coincidan",
"Loading Google 3D Tiles...": "Cargando Google 3D Tiles...", "Loading Google 3D Tiles...": "Cargando Google 3D Tiles...",
"Loading the keyless globe...": "Cargando el globo sin clave...", "Loading the keyless globe...": "Cargando el globo sin clave...",
"Initializing systems...": "Inicializando sistemas...", "Initializing systems...": "Inicializando sistemas...",
+5 -1
View File
@@ -688,7 +688,11 @@
"Keep location tray open": "Держать панель места открытой", "Keep location tray open": "Держать панель места открытой",
"Search any location": "Поиск любого места", "Search any location": "Поиск любого места",
"Navigation, voice, and visual preset controls": "Управление навигацией, голосом и визуальными пресетами", "Navigation, voice, and visual preset controls": "Управление навигацией, голосом и визуальными пресетами",
"Language": "Язык", "Language: {language}": "Язык: {language}",
"Change the interface language": "Сменить язык интерфейса",
"Interface language": "Язык интерфейса",
"Search languages": "Поиск языка",
"No matching languages": "Подходящих языков нет",
"Search location by name or coordinates": "Поиск места по названию или координатам", "Search location by name or coordinates": "Поиск места по названию или координатам",
"Search any location...": "Поиск любого места...", "Search any location...": "Поиск любого места...",
"Normal": "Обычный", "Normal": "Обычный",
+11 -6
View File
@@ -345,17 +345,22 @@ function buildRow(documentRef, key) {
return row; return row;
} }
/** Move the existing setup action with the theme, retaining its listeners. */ /**
export function bindKeySetupPlacement(documentRef, chip, root) { * Move the existing setup action with the theme, retaining its listeners:
* Cyber puts it in the top toolbar, every other theme keeps it first in its
* markup home (the bottom-right corner row, beside the language picker).
*/
export function bindKeySetupPlacement(documentRef, chip) {
const theme = documentRef?.documentElement; const theme = documentRef?.documentElement;
const toolbar = documentRef?.getElementById?.('top-center-actions'); const toolbar = documentRef?.getElementById?.('top-center-actions');
const Observer = documentRef?.defaultView?.MutationObserver; const Observer = documentRef?.defaultView?.MutationObserver;
if (!theme || !toolbar || !Observer) return () => {}; const home = chip?.parentNode;
if (!theme || !toolbar || !Observer || !home) return () => {};
const sync = () => { const sync = () => {
if (theme.dataset.uiTheme === 'cyber') { if (theme.dataset.uiTheme === 'cyber') {
if (chip.parentNode !== toolbar) toolbar.append(chip); if (chip.parentNode !== toolbar) toolbar.append(chip);
} else if (chip.parentNode !== root.parentNode) { } else if (chip.parentNode !== home) {
root.before(chip); home.prepend(chip);
} }
}; };
const observer = new Observer(sync); const observer = new Observer(sync);
@@ -415,7 +420,7 @@ export async function initKeySetup({
} }
const rowsHost = root.querySelector('[data-key-setup-rows]'); const rowsHost = root.querySelector('[data-key-setup-rows]');
disposePlacement = bindKeySetupPlacement(documentRef, chip, root); disposePlacement = bindKeySetupPlacement(documentRef, chip);
const applyButton = root.querySelector('[data-key-setup-apply]'); const applyButton = root.querySelector('[data-key-setup-apply]');
const closeButton = root.querySelector('[data-key-setup-close]'); const closeButton = root.querySelector('[data-key-setup-close]');
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip; const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
+2 -3
View File
@@ -9,9 +9,8 @@ import {
} from './keySetup.js'; } from './keySetup.js';
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => { test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
const home = {}; const home = { prepend: (node) => { node.parentNode = home; } };
const chip = { parentNode: home, hidden: true }; const chip = { parentNode: home, hidden: true };
const root = { parentNode: home, before: (node) => { node.parentNode = home; } };
let moves = 0; let moves = 0;
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } }; const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
const theme = { dataset: { uiTheme: 'tactical' } }; const theme = { dataset: { uiTheme: 'tactical' } };
@@ -28,7 +27,7 @@ test('setup placement moves the same button only in Cyber and disconnects on tea
documentElement: theme, documentElement: theme,
getElementById: () => toolbar, getElementById: () => toolbar,
defaultView: { MutationObserver: Observer }, defaultView: { MutationObserver: Observer },
}, chip, root); }, chip);
assert.equal(chip.parentNode, home); assert.equal(chip.parentNode, home);
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) { for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
theme.dataset.uiTheme = variant; theme.dataset.uiTheme = variant;
+28 -27
View File
@@ -45,7 +45,7 @@ import {
registerUnsavedWork, registerUnsavedWork,
t, t,
} from '../i18n/index.js'; } from '../i18n/index.js';
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js'; import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
/** /**
* Central UI orchestrator for the God's Eye View application. * Central UI orchestrator for the God's Eye View application.
@@ -715,17 +715,13 @@ export class StyleManager extends ShellFacade {
} }
/** /**
* Renders the command-dock locale switch: one button per shipped locale, * Mounts the interface language picker in the bottom-right corner
* labelled with its code and named in its own language. A click stores the * (src/ui/localePicker.js). Choosing another language stores it and
* choice and reloads, so nothing re-renders live; it asks first when the * reloads, so nothing re-renders live; it asks first when the reload would
* reload would lose work (see _confirmLocaleReload). * lose work (see _chooseLocale).
* @returns {void} * @returns {void}
*/ */
_initLocaleSelector() { _initLocaleSelector() {
const container = document.querySelector(
'#control-panel .dock-locale-switch',
);
if (!container) return;
// Work this shell can see that a reload drops. Other modules register // Work this shell can see that a reload drops. Other modules register
// their own (registerUnsavedWork in src/i18n/index.js). // their own (registerUnsavedWork in src/i18n/index.js).
this._unsavedWorkChecks = [ this._unsavedWorkChecks = [
@@ -740,24 +736,27 @@ export class StyleManager extends ShellFacade {
this.cockpitView?.active ? msg('the open cockpit') : null, this.cockpitView?.active ? msg('the open cockpit') : null,
), ),
]; ];
const active = getLocale(); const root = globalThis.document?.getElementById?.('locale-picker');
for (const locale of LOCALES) { if (!root) return;
const button = document.createElement('button'); this._localePicker = mountLocalePicker({
button.type = 'button'; root,
button.className = 'dock-locale-btn'; current: getLocale(),
button.dataset.locale = locale; onSelect: (locale) => this._chooseLocale(locale),
button.textContent = locale.toUpperCase(); });
button.setAttribute('aria-label', LOCALE_NAMES[locale]); }
button.lang = locale;
const isActive = locale === active; /**
button.classList.toggle('active', isActive); * Switch to `locale`: nothing for the active language, otherwise store it
button.setAttribute('aria-pressed', String(isActive)); * and reload once _confirmLocaleReload agrees.
button.addEventListener('click', () => { * @param {string} locale
if (!this._confirmLocaleReload()) return; * @returns {'unchanged'|'declined'|'switched'}
persistLocaleAndReload(button.dataset.locale); */
}); _chooseLocale(locale) {
container.appendChild(button); return applyLocaleChoice(locale, {
} current: getLocale(),
confirmSwitch: () => this._confirmLocaleReload(),
switchLocale: (next) => persistLocaleAndReload(next),
});
} }
/** /**
@@ -1759,6 +1758,8 @@ export class StyleManager extends ShellFacade {
this._lifetime.destroy(); this._lifetime.destroy();
for (const unregister of this._unsavedWorkChecks?.splice(0) ?? []) for (const unregister of this._unsavedWorkChecks?.splice(0) ?? [])
unregister(); unregister();
this._localePicker?.destroy();
this._localePicker = null;
this._recording.destroy(); this._recording.destroy();
this._panelChrome.destroy(); this._panelChrome.destroy();
this._feedback.destroy(); this._feedback.destroy();
+631
View File
@@ -0,0 +1,631 @@
/**
* Interface language picker: a HUD button in the bottom-right corner that
* opens a listbox of the shipped languages.
*
* The ordering, search filter, keyboard model and choice rule are pure
* functions below, testable without a DOM (localePicker.test.mjs).
* mountLocalePicker() binds them to the #locale-picker markup; the shell only
* mounts it and decides what choosing another language does (confirm, reload).
*
* The list follows the ARIA listbox pattern: the button opens it, focus moves
* into the list (or into the search field, which is a combobox, once there are
* more than LOCALE_SEARCH_THRESHOLD languages), and aria-activedescendant
* marks the active option.
*/
import { t } from '../i18n/index.js';
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js';
/** Above this many languages the list gets a search field. */
export const LOCALE_SEARCH_THRESHOLD = 8;
/** Type-ahead keystrokes closer together than this extend one prefix. */
export const TYPEAHEAD_RESET_MS = 500;
/**
* Text folded for search: no diacritics, lower case. 'Español' → 'espanol',
* 'Ёжик' → 'ежик'.
* @param {unknown} text
* @returns {string}
*/
export function foldSearchText(text) {
return String(text ?? '')
.normalize('NFD')
.replace(/\p{M}+/gu, '')
.toLowerCase()
.trim();
}
/**
* A reader of language names in the interface language, via Intl.DisplayNames.
* Returns '' for anything it cannot name (no Intl support, unknown code).
* @param {string} locale Interface language.
* @param {typeof Intl.DisplayNames} [DisplayNames]
* @returns {(code: string) => string}
*/
export function createLocalNameReader(
locale,
DisplayNames = globalThis.Intl?.DisplayNames,
) {
let names = null;
try {
names = new DisplayNames([locale], { type: 'language', fallback: 'none' });
} catch {
return () => '';
}
return (code) => {
try {
const name = names.of(code);
return typeof name === 'string' && name !== code ? name : '';
} catch {
return '';
}
};
}
/**
* The rows of the list: the current language first, then the rest in
* alphabetical order of their own-language names (collated for the
* interface language). `localName` is the name in the interface language,
* left empty when it only repeats `nativeName`.
* @param {object} input
* @param {readonly string[]} input.locales
* @param {string} input.current
* @param {Record<string, string>} input.nativeNames
* @param {(code: string) => string} [input.localName]
* @returns {Array<{code: string, nativeName: string, localName: string, selected: boolean}>}
*/
export function buildLocaleOptions({
locales,
current,
nativeNames,
localName = () => '',
}) {
const rows = locales.map((code) => {
const nativeName = nativeNames[code] || code;
const translated = localName(code) || '';
return {
code,
nativeName,
localName:
foldSearchText(translated) === foldSearchText(nativeName)
? ''
: translated,
selected: code === current,
};
});
let collate;
try {
collate = new Intl.Collator(current).compare;
} catch {
collate = (a, b) => a.localeCompare(b);
}
const others = rows
.filter((row) => !row.selected)
.sort((a, b) => collate(a.nativeName, b.nativeName));
return [...rows.filter((row) => row.selected), ...others];
}
/**
* Rows matching a search: by own-language name, interface-language name or
* code, ignoring case and diacritics. An empty query keeps every row.
* @template {{code: string, nativeName: string, localName: string}} Row
* @param {Row[]} options
* @param {string} query
* @returns {Row[]}
*/
export function filterLocaleOptions(options, query) {
const needle = foldSearchText(query);
if (!needle) return options.slice();
return options.filter((option) =>
[option.nativeName, option.localName, option.code].some((value) =>
foldSearchText(value).includes(needle),
),
);
}
/**
* The option a navigation key moves to; null for any other key. The list
* does not wrap, as in the ARIA listbox pattern.
* @param {string} key
* @param {number} index Active option, -1 for none.
* @param {number} count
* @returns {number|null}
*/
export function moveActiveIndex(key, index, count) {
if (count <= 0) return null;
switch (key) {
case 'ArrowDown':
return index < 0 ? 0 : Math.min(index + 1, count - 1);
case 'ArrowUp':
return index < 0 ? count - 1 : Math.max(index - 1, 0);
case 'Home':
return 0;
case 'End':
return count - 1;
default:
return null;
}
}
/**
* Type-ahead: the next option after `fromIndex` (wrapping) whose own-language
* or interface-language name starts with `buffer`. A buffer of one repeated
* letter cycles through the options starting with that letter.
* @param {Array<{nativeName: string, localName: string}>} options
* @param {string} buffer
* @param {number} fromIndex
* @returns {number} -1 when nothing matches.
*/
export function typeaheadIndex(options, buffer, fromIndex) {
const folded = foldSearchText(buffer);
if (!folded || !options.length) return -1;
const repeated = [...folded].every((char) => char === folded[0]);
const prefix = repeated ? folded[0] : folded;
// A longer prefix keeps refining the current option; a single letter moves on.
const start = repeated ? fromIndex + 1 : Math.max(fromIndex, 0);
for (let step = 0; step < options.length; step++) {
const index = (start + step) % options.length;
const option = options[index];
if (
foldSearchText(option.nativeName).startsWith(prefix) ||
foldSearchText(option.localName).startsWith(prefix)
)
return index;
}
return -1;
}
/**
* What choosing a language does. The current one changes nothing; another one
* switches after `confirmSwitch()` agrees (it asks when a reload loses work).
* @param {string} locale
* @param {{current: string, confirmSwitch: () => boolean, switchLocale: (locale: string) => void}} actions
* @returns {'unchanged'|'declined'|'switched'}
*/
export function applyLocaleChoice(
locale,
{ current, confirmSwitch, switchLocale },
) {
if (locale === current) return 'unchanged';
if (!confirmSwitch()) return 'declined';
switchLocale(locale);
return 'switched';
}
/**
* The picker's state without a DOM: open/closed, query, visible rows, active
* row and type-ahead. `keydown` answers what a key did so the DOM layer only
* has to render and focus.
* @param {object} input
* @param {readonly string[]} [input.locales]
* @param {string} input.current
* @param {Record<string, string>} [input.nativeNames]
* @param {(code: string) => string} [input.localName]
* @param {number} [input.searchThreshold]
* @param {() => number} [input.now]
*/
export function createLocalePickerModel({
locales = LOCALES,
current,
nativeNames = LOCALE_NAMES,
localName = () => '',
searchThreshold = LOCALE_SEARCH_THRESHOLD,
now = () => Date.now(),
}) {
const options = buildLocaleOptions({
locales,
current,
nativeNames,
localName,
});
const searchable = locales.length > searchThreshold;
let open = false;
let query = '';
let visible = options;
let active = -1;
let typed = '';
let typedAt = -Infinity;
const activateSelected = () => {
const selected = visible.findIndex((option) => option.selected);
active = visible.length ? Math.max(selected, 0) : -1;
};
return {
current,
options,
searchable,
get open() {
return open;
},
get query() {
return query;
},
get visible() {
return visible;
},
get activeIndex() {
return active;
},
get activeOption() {
return visible[active] || null;
},
openList() {
open = true;
query = '';
visible = options;
typed = '';
activateSelected();
},
closeList() {
open = false;
typed = '';
},
setQuery(text) {
query = String(text ?? '');
visible = filterLocaleOptions(options, query);
// The current language stays active while it matches; else the first.
activateSelected();
},
setActive(index) {
if (index >= 0 && index < visible.length) active = index;
},
/**
* @param {{key: string, altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean}} event
* @returns {{handled: boolean, close?: boolean, choose?: string}}
*/
keydown({ key, altKey = false, ctrlKey = false, metaKey = false }) {
if (!open) return { handled: false };
if (key === 'Escape') return { handled: true, close: true };
if (key === 'Enter' || (key === ' ' && !searchable)) {
const option = visible[active];
return option
? { handled: true, choose: option.code }
: { handled: true };
}
// In the search field Home/End move the caret, as in the APG combobox.
if (searchable && (key === 'Home' || key === 'End'))
return { handled: false };
const moved = moveActiveIndex(key, active, visible.length);
if (moved !== null) {
active = moved;
return { handled: true };
}
const printable =
key.length === 1 && key !== ' ' && !altKey && !ctrlKey && !metaKey;
// With a search field, letters are typed into it.
if (!printable || searchable) return { handled: false };
const time = now();
typed = time - typedAt > TYPEAHEAD_RESET_MS ? key : typed + key;
typedAt = time;
const match = typeaheadIndex(visible, typed, active);
if (match >= 0) active = match;
return { handled: true };
},
};
}
/* ── DOM ───────────────────────────────────────────────────────────────── */
const SVG_NS = 'http://www.w3.org/2000/svg';
/** Thin-line globe, check mark and caret, drawn in currentColor. */
const ICONS = Object.freeze({
globe: {
stroke: [
'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18Z',
'M12 3c-2.6 2.5-3.9 5.5-3.9 9s1.3 6.5 3.9 9',
'M12 3c2.6 2.5 3.9 5.5 3.9 9s-1.3 6.5-3.9 9',
'M3 12h18',
'M4.3 7.5h15.4',
'M4.3 16.5h15.4',
],
},
check: { stroke: ['M5 12.5l4.3 4.3L19 7'] },
caret: { fill: ['M7 10h10l-5 5.5Z'] },
});
function icon(documentRef, name, className) {
const svg = documentRef.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
svg.setAttribute('class', className);
const { stroke = [], fill = [] } = ICONS[name];
for (const d of stroke) {
const path = documentRef.createElementNS(SVG_NS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', 'currentColor');
path.setAttribute('stroke-width', '1.4');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.append(path);
}
for (const d of fill) {
const path = documentRef.createElementNS(SVG_NS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'currentColor');
svg.append(path);
}
return svg;
}
function element(documentRef, tag, className) {
const node = documentRef.createElement(tag);
if (className) node.className = className;
return node;
}
/**
* Render the picker into `root` and wire it.
* @param {object} input
* @param {HTMLElement} input.root Empty #locale-picker container.
* @param {string} input.current Active interface language.
* @param {(locale: string) => void} input.onSelect Called after the list
* closes, for a language other than the current one.
* @param {readonly string[]} [input.locales]
* @param {Record<string, string>} [input.nativeNames]
* @param {number} [input.searchThreshold]
* @returns {{open: Function, close: Function, isOpen: () => boolean, destroy: Function}}
*/
export function mountLocalePicker({
root,
current,
onSelect,
locales = LOCALES,
nativeNames = LOCALE_NAMES,
searchThreshold = LOCALE_SEARCH_THRESHOLD,
}) {
const documentRef = root.ownerDocument;
const windowRef = documentRef.defaultView;
const model = createLocalePickerModel({
locales,
current,
nativeNames,
localName: createLocalNameReader(current),
searchThreshold,
});
const id = root.id || 'locale-picker';
const ids = {
popup: `${id}-popup`,
heading: `${id}-heading`,
listbox: `${id}-listbox`,
option: (code) => `${id}-option-${code}`,
};
root.textContent = '';
root.classList.add('locale-picker');
const button = element(documentRef, 'button', 'locale-picker-button');
button.type = 'button';
button.setAttribute('aria-haspopup', 'listbox');
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-controls', ids.popup);
button.setAttribute(
'aria-label',
t('Language: {language}', {
language: nativeNames[current] || current,
}),
);
button.title = t('Change the interface language');
const code = element(documentRef, 'span', 'locale-picker-code');
code.setAttribute('translate', 'no');
code.setAttribute('aria-hidden', 'true');
code.textContent = current.toUpperCase();
button.append(
icon(documentRef, 'globe', 'locale-picker-globe'),
code,
icon(documentRef, 'caret', 'locale-picker-caret'),
);
const popup = element(documentRef, 'div', 'locale-picker-popup');
popup.id = ids.popup;
popup.hidden = true;
const heading = element(documentRef, 'div', 'locale-picker-heading');
heading.id = ids.heading;
heading.textContent = t('Interface language');
popup.append(heading);
const listbox = element(documentRef, 'ul', 'locale-picker-list');
listbox.id = ids.listbox;
listbox.setAttribute('role', 'listbox');
listbox.setAttribute('aria-labelledby', ids.heading);
listbox.tabIndex = -1;
let search = null;
if (model.searchable) {
search = element(documentRef, 'input', 'locale-picker-search');
search.type = 'search';
search.autocomplete = 'off';
search.spellcheck = false;
search.setAttribute('role', 'combobox');
search.setAttribute('aria-autocomplete', 'list');
search.setAttribute('aria-expanded', 'true');
search.setAttribute('aria-controls', ids.listbox);
search.setAttribute('aria-label', t('Search languages'));
search.placeholder = t('Search languages');
popup.append(search);
}
popup.append(listbox);
const empty = element(documentRef, 'p', 'locale-picker-empty');
empty.setAttribute('role', 'status');
empty.textContent = t('No matching languages');
empty.hidden = true;
popup.append(empty);
root.append(button, popup);
const optionNodes = new Map();
for (const option of model.options) {
const item = element(documentRef, 'li', 'locale-picker-option');
item.id = ids.option(option.code);
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', String(option.selected));
item.lang = option.code;
item.dataset.locale = option.code;
const name = element(documentRef, 'span', 'locale-picker-native');
name.textContent = option.nativeName;
// Every row reads the same: the language named in itself, then its code.
// The interface-language name only helps the search find it.
const meta = element(documentRef, 'span', 'locale-picker-meta');
const codeLabel = element(documentRef, 'span', 'locale-picker-option-code');
codeLabel.setAttribute('translate', 'no');
codeLabel.textContent = option.code.toUpperCase();
meta.append(codeLabel);
const text = element(documentRef, 'span', 'locale-picker-text');
text.append(name, meta);
item.append(text);
if (option.selected)
item.append(icon(documentRef, 'check', 'locale-picker-check'));
optionNodes.set(option.code, item);
}
const focusTarget = () => search || listbox;
const render = () => {
const visible = new Set(model.visible.map((option) => option.code));
listbox.replaceChildren(
...model.visible.map((option) => optionNodes.get(option.code)),
);
for (const [localeCode, item] of optionNodes) {
item.classList.toggle(
'is-active',
visible.has(localeCode) && model.activeOption?.code === localeCode,
);
}
empty.hidden = model.visible.length > 0;
const activeNode = model.activeOption
? optionNodes.get(model.activeOption.code)
: null;
if (activeNode) {
focusTarget().setAttribute('aria-activedescendant', activeNode.id);
activeNode.scrollIntoView?.({ block: 'nearest' });
} else {
focusTarget().removeAttribute('aria-activedescendant');
}
};
let swallowSpaceKeyup = false;
const close = ({ restoreFocus = false } = {}) => {
if (!model.open) return;
model.closeList();
popup.hidden = true;
root.classList.remove('is-open');
button.setAttribute('aria-expanded', 'false');
windowRef?.removeEventListener('keydown', onWindowKeyDown, true);
documentRef.removeEventListener('pointerdown', onDocumentPointerDown, true);
if (restoreFocus) button.focus({ preventScroll: true });
};
const choose = (localeCode) => {
close({ restoreFocus: true });
if (localeCode !== current) onSelect(localeCode);
};
const open = () => {
if (model.open) return;
model.openList();
if (search) search.value = '';
popup.hidden = false;
root.classList.add('is-open');
button.setAttribute('aria-expanded', 'true');
render();
// Window capture runs before the document-level shortcut handlers
// (cockpit, hotkeys, push-to-talk), so keys typed in the list stay here.
windowRef?.addEventListener('keydown', onWindowKeyDown, true);
documentRef.addEventListener('pointerdown', onDocumentPointerDown, true);
focusTarget().focus({ preventScroll: true });
};
function onWindowKeyDown(event) {
if (!model.open || event.isComposing) return;
const inside = root.contains(event.target);
if (!inside && event.key !== 'Escape') return;
const result = model.keydown(event);
if (!result.handled) return;
event.preventDefault();
event.stopImmediatePropagation();
if (result.close) close({ restoreFocus: true });
else if (result.choose) {
if (event.key === ' ') swallowSpaceKeyup = true;
choose(result.choose);
} else render();
}
function onDocumentPointerDown(event) {
if (!root.contains(event.target)) close();
}
const onButtonClick = () => {
if (model.open) close({ restoreFocus: true });
else open();
};
const onButtonKeyDown = (event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
event.stopPropagation();
open();
};
// Space chose an option and moved focus here; its keyup must not click.
const onButtonKeyUp = (event) => {
if (event.key !== ' ' || !swallowSpaceKeyup) return;
swallowSpaceKeyup = false;
event.preventDefault();
};
const optionFromEvent = (event) =>
event.target?.closest?.('[role="option"]')?.dataset.locale || null;
const onListClick = (event) => {
const localeCode = optionFromEvent(event);
if (localeCode) choose(localeCode);
};
const onListPointerMove = (event) => {
const localeCode = optionFromEvent(event);
if (!localeCode || model.activeOption?.code === localeCode) return;
model.setActive(
model.visible.findIndex((option) => option.code === localeCode),
);
render();
};
const onSearchInput = () => {
model.setQuery(search.value);
render();
};
const onFocusOut = (event) => {
const next = event.relatedTarget;
if (model.open && next && !root.contains(next)) close();
};
button.addEventListener('click', onButtonClick);
button.addEventListener('keydown', onButtonKeyDown);
button.addEventListener('keyup', onButtonKeyUp);
listbox.addEventListener('click', onListClick);
listbox.addEventListener('pointermove', onListPointerMove);
search?.addEventListener('input', onSearchInput);
root.addEventListener('focusout', onFocusOut);
return {
open,
close,
isOpen: () => model.open,
destroy() {
close();
button.removeEventListener('click', onButtonClick);
button.removeEventListener('keydown', onButtonKeyDown);
button.removeEventListener('keyup', onButtonKeyUp);
listbox.removeEventListener('click', onListClick);
listbox.removeEventListener('pointermove', onListPointerMove);
search?.removeEventListener('input', onSearchInput);
root.removeEventListener('focusout', onFocusOut);
root.textContent = '';
},
};
}
+352
View File
@@ -0,0 +1,352 @@
// The interface language picker without a DOM: row order, search, the
// keyboard model and what choosing a language does.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
LOCALE_SEARCH_THRESHOLD,
TYPEAHEAD_RESET_MS,
applyLocaleChoice,
buildLocaleOptions,
createLocalNameReader,
createLocalePickerModel,
filterLocaleOptions,
foldSearchText,
moveActiveIndex,
typeaheadIndex,
} from './localePicker.js';
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js';
const NAMES = {
en: 'English',
es: 'Español',
ru: 'Русский',
de: 'Deutsch',
fr: 'Français',
pt: 'Português',
tr: 'Türkçe',
uk: 'Українська',
ja: '日本語',
};
const MANY = Object.keys(NAMES);
const codes = (rows) => rows.map((row) => row.code);
test('the current language comes first, the rest by their own names', () => {
assert.deepEqual(
codes(
buildLocaleOptions({
locales: LOCALES,
current: 'ru',
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'en', 'es'],
);
assert.deepEqual(
codes(
buildLocaleOptions({
locales: LOCALES,
current: 'es',
nativeNames: LOCALE_NAMES,
}),
),
['es', 'en', 'ru'],
);
assert.deepEqual(
codes(
buildLocaleOptions({
locales: LOCALES,
current: 'en',
nativeNames: LOCALE_NAMES,
}),
),
['en', 'es', 'ru'],
);
const rows = buildLocaleOptions({
locales: MANY,
current: 'en',
nativeNames: NAMES,
});
assert.deepEqual(codes(rows), [
'en',
'de',
'es',
'fr',
'pt',
'tr',
'ru',
'uk',
'ja',
]);
assert.deepEqual(
rows.map((row) => row.selected),
[true, false, false, false, false, false, false, false, false],
);
});
test('each row names the language in the interface language too, unless that only repeats it', () => {
const local = createLocalNameReader('ru');
const rows = buildLocaleOptions({
locales: LOCALES,
current: 'ru',
nativeNames: LOCALE_NAMES,
localName: local,
});
const byCode = Object.fromEntries(rows.map((row) => [row.code, row]));
assert.equal(byCode.en.localName, 'английский');
assert.equal(byCode.es.localName, 'испанский');
assert.equal(byCode.ru.localName, '', 'русский only repeats Русский');
const english = buildLocaleOptions({
locales: LOCALES,
current: 'en',
nativeNames: LOCALE_NAMES,
localName: createLocalNameReader('en'),
});
assert.equal(english.find((row) => row.code === 'es').localName, 'Spanish');
});
test('without Intl.DisplayNames the interface-language name is simply left out', () => {
const read = createLocalNameReader('ru', null);
assert.equal(read('en'), '');
class Broken {
of() {
throw new RangeError('bad code');
}
}
assert.equal(createLocalNameReader('ru', Broken)('en'), '');
class Echo {
of(code) {
return code;
}
}
assert.equal(
createLocalNameReader('ru', Echo)('xx'),
'',
'a code echoed back is no name',
);
});
test('search folds case and diacritics, and matches either name or the code', () => {
assert.equal(foldSearchText(' Español '), 'espanol');
assert.equal(foldSearchText('Français'), 'francais');
assert.equal(foldSearchText('Ёлка'), 'елка');
const rows = buildLocaleOptions({
locales: MANY,
current: 'ru',
nativeNames: NAMES,
localName: createLocalNameReader('ru'),
});
assert.deepEqual(codes(filterLocaleOptions(rows, 'espan')), ['es']);
assert.deepEqual(codes(filterLocaleOptions(rows, 'ESPAÑ')), ['es']);
assert.deepEqual(codes(filterLocaleOptions(rows, 'francais')), ['fr']);
assert.deepEqual(codes(filterLocaleOptions(rows, 'turkce')), ['tr']);
assert.deepEqual(
codes(filterLocaleOptions(rows, 'испан')),
['es'],
'by the interface-language name',
);
assert.deepEqual(
codes(filterLocaleOptions(rows, 'УКРАИН')),
['uk'],
'й folds to и',
);
assert.deepEqual(
codes(filterLocaleOptions(rows, 'JA')),
['ja'],
'by the code',
);
assert.deepEqual(
codes(filterLocaleOptions(rows, ' ')),
codes(rows),
'empty keeps all',
);
assert.deepEqual(filterLocaleOptions(rows, 'klingon'), []);
});
test('the search field appears only above the threshold', () => {
assert.equal(LOCALE_SEARCH_THRESHOLD, 8);
assert.equal(createLocalePickerModel({ current: 'en' }).searchable, false);
const eight = MANY.slice(0, 8);
assert.equal(
createLocalePickerModel({
locales: eight,
nativeNames: NAMES,
current: 'en',
}).searchable,
false,
);
assert.equal(
createLocalePickerModel({
locales: MANY,
nativeNames: NAMES,
current: 'en',
}).searchable,
true,
);
});
test('choosing the current language changes nothing; another asks, then switches', () => {
const calls = [];
const actions = (answer) => ({
current: 'ru',
confirmSwitch: () => {
calls.push('confirm');
return answer;
},
switchLocale: (locale) => calls.push(`switch:${locale}`),
});
assert.equal(applyLocaleChoice('ru', actions(true)), 'unchanged');
assert.deepEqual(calls, [], 'no prompt, no reload');
assert.equal(applyLocaleChoice('es', actions(false)), 'declined');
assert.deepEqual(calls, ['confirm']);
assert.equal(applyLocaleChoice('es', actions(true)), 'switched');
assert.deepEqual(calls, ['confirm', 'confirm', 'switch:es']);
});
test('navigation keys move without wrapping', () => {
assert.equal(moveActiveIndex('ArrowDown', 0, 3), 1);
assert.equal(moveActiveIndex('ArrowDown', 2, 3), 2);
assert.equal(moveActiveIndex('ArrowUp', 0, 3), 0);
assert.equal(moveActiveIndex('ArrowUp', 2, 3), 1);
assert.equal(moveActiveIndex('ArrowDown', -1, 3), 0);
assert.equal(moveActiveIndex('ArrowUp', -1, 3), 2);
assert.equal(moveActiveIndex('Home', 2, 3), 0);
assert.equal(moveActiveIndex('End', 0, 3), 2);
assert.equal(moveActiveIndex('Tab', 0, 3), null);
assert.equal(moveActiveIndex('ArrowDown', 0, 0), null);
});
test('type-ahead cycles by first letter and refines with more letters', () => {
const rows = buildLocaleOptions({
locales: MANY,
current: 'en',
nativeNames: NAMES,
});
// en, de, es, fr, pt, tr, ru, uk, ja
assert.equal(
typeaheadIndex(rows, 'e', 0),
2,
'from English, e moves on to Español',
);
assert.equal(typeaheadIndex(rows, 'e', 2), 0, 'and wraps back to English');
assert.equal(
typeaheadIndex(rows, 'ee', 2),
0,
'a repeated letter keeps cycling',
);
assert.equal(typeaheadIndex(rows, 'es', 0), 2);
assert.equal(
typeaheadIndex(rows, 'en', 0),
0,
'a longer prefix may stay put',
);
assert.equal(
typeaheadIndex(rows, 'р', 0),
6,
'Русский by its Cyrillic letter',
);
assert.equal(typeaheadIndex(rows, 'q', 0), -1);
});
test('keyboard model: open on the current language, move, choose, close', () => {
const model = createLocalePickerModel({
current: 'ru',
localName: createLocalNameReader('ru'),
});
assert.deepEqual(
model.keydown({ key: 'ArrowDown' }),
{ handled: false },
'closed: ignored',
);
model.openList();
assert.equal(model.activeOption.code, 'ru');
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
assert.equal(model.activeOption.code, 'en');
model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'es');
model.keydown({ key: 'Home' });
assert.equal(model.activeOption.code, 'ru');
model.keydown({ key: 'ArrowUp' });
assert.equal(model.activeOption.code, 'ru', 'no wrap at the top');
assert.deepEqual(model.keydown({ key: 'Enter' }), {
handled: true,
choose: 'ru',
});
model.keydown({ key: 'ArrowDown' });
assert.deepEqual(model.keydown({ key: ' ' }), {
handled: true,
choose: 'en',
});
assert.deepEqual(model.keydown({ key: 'Escape' }), {
handled: true,
close: true,
});
assert.deepEqual(
model.keydown({ key: 'Tab' }),
{ handled: false },
'Tab leaves the list',
);
model.closeList();
assert.equal(model.open, false);
});
test('keyboard model: type-ahead resets after a pause and ignores shortcuts', () => {
let clock = 0;
const model = createLocalePickerModel({ current: 'en', now: () => clock });
model.openList();
assert.deepEqual(model.keydown({ key: 'e' }), { handled: true });
assert.equal(model.activeOption.code, 'es');
clock += 100;
model.keydown({ key: 's' });
assert.equal(model.activeOption.code, 'es', '"es" keeps Español');
clock += TYPEAHEAD_RESET_MS + 1;
model.keydown({ key: 'Р' });
assert.equal(model.activeOption.code, 'ru');
assert.deepEqual(
model.keydown({ key: 'z' }),
{ handled: true },
'an unmatched letter is still the list’s, not a global hotkey',
);
assert.equal(model.activeOption.code, 'ru');
assert.deepEqual(model.keydown({ key: 'c', metaKey: true }), {
handled: false,
});
});
test('keyboard model with search: letters, Space, Home and End belong to the field', () => {
const model = createLocalePickerModel({
locales: MANY,
nativeNames: NAMES,
current: 'en',
localName: createLocalNameReader('en'),
});
model.openList();
assert.deepEqual(model.keydown({ key: 'f' }), { handled: false });
assert.deepEqual(model.keydown({ key: ' ' }), { handled: false });
assert.deepEqual(model.keydown({ key: 'Home' }), { handled: false });
model.setQuery('SPAN');
assert.deepEqual(codes(model.visible), ['es']);
assert.equal(model.activeOption.code, 'es');
assert.deepEqual(model.keydown({ key: 'Enter' }), {
handled: true,
choose: 'es',
});
model.setQuery('e');
assert.equal(
model.activeOption.code,
'en',
'the current language stays active while it matches',
);
model.keydown({ key: 'ArrowDown' });
assert.notEqual(model.activeOption.code, 'en');
model.setQuery('nothing like it');
assert.equal(model.activeOption, null);
assert.deepEqual(
model.keydown({ key: 'Enter' }),
{ handled: true },
'nothing to choose',
);
model.openList();
assert.equal(model.query, '', 'reopening clears the search');
assert.equal(model.visible.length, MANY.length);
});
+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 // 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 { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { StyleManager } from './applicationShell.js'; import { StyleManager } from './applicationShell.js';
@@ -11,20 +11,9 @@ import {
setLocale, setLocale,
} from '../i18n/index.js'; } from '../i18n/index.js';
function fakeButton() { /** Wire the shell's language choice with the given shell state. No picker
const listeners = {}; * markup here: the picker's own behaviour is covered by localePicker.test. */
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. */
function renderSwitch({ calDirty = false, cockpit = false } = {}) { function renderSwitch({ calDirty = false, cockpit = false } = {}) {
const buttons = [];
const previous = { const previous = {
document: globalThis.document, document: globalThis.document,
location: globalThis.location, location: globalThis.location,
@@ -34,10 +23,7 @@ function renderSwitch({ calDirty = false, cockpit = false } = {}) {
const reloads = []; const reloads = [];
const prompts = []; const prompts = [];
let answer = true; let answer = true;
globalThis.document = { globalThis.document = { getElementById: () => null };
querySelector: () => ({ appendChild: (button) => buttons.push(button) }),
createElement: fakeButton,
};
globalThis.location = { globalThis.location = {
href: 'https://gev.example/#v=2', href: 'https://gev.example/#v=2',
assign() {}, assign() {},
@@ -56,12 +42,11 @@ function renderSwitch({ calDirty = false, cockpit = false } = {}) {
}, },
cockpitView: { active: cockpit }, cockpitView: { active: cockpit },
_confirmLocaleReload: StyleManager.prototype._confirmLocaleReload, _confirmLocaleReload: StyleManager.prototype._confirmLocaleReload,
_chooseLocale: StyleManager.prototype._chooseLocale,
}; };
StyleManager.prototype._initLocaleSelector.call(shell); StyleManager.prototype._initLocaleSelector.call(shell);
const button = (locale) =>
buttons.find((candidate) => candidate.dataset.locale === locale);
return { return {
button, choose: (locale) => shell._chooseLocale(locale),
reloads, reloads,
prompts, prompts,
answer: (value) => (answer = value), answer: (value) => (answer = value),
@@ -83,7 +68,7 @@ test.after(() => {
test('nothing to lose: the switch reloads without asking', () => { test('nothing to lose: the switch reloads without asking', () => {
const view = renderSwitch(); const view = renderSwitch();
try { try {
view.button('es').click(); view.choose('es');
assert.deepEqual(view.prompts, []); assert.deepEqual(view.prompts, []);
assert.equal(view.reloads.length, 1); assert.equal(view.reloads.length, 1);
} finally { } finally {
@@ -95,13 +80,13 @@ test('unsaved calibration or the open cockpit: the switch asks first', () => {
const view = renderSwitch({ calDirty: true, cockpit: true }); const view = renderSwitch({ calDirty: true, cockpit: true });
try { try {
view.answer(false); view.answer(false);
view.button('ru').click(); view.choose('ru');
assert.equal(view.prompts.length, 1); assert.equal(view.prompts.length, 1);
assert.match(view.prompts[0], /unsaved CCTV calibration/); assert.match(view.prompts[0], /unsaved CCTV calibration/);
assert.match(view.prompts[0], /the open cockpit/); assert.match(view.prompts[0], /the open cockpit/);
assert.equal(view.reloads.length, 0, 'declined: no reload'); assert.equal(view.reloads.length, 0, 'declined: no reload');
view.answer(true); view.answer(true);
view.button('ru').click(); view.choose('ru');
assert.equal(view.reloads.length, 1, 'confirmed: reload'); assert.equal(view.reloads.length, 1, 'confirmed: reload');
} finally { } finally {
view.restore(); view.restore();
@@ -116,7 +101,7 @@ test('modules register their own unsaved work', () => {
const view = renderSwitch(); const view = renderSwitch();
try { try {
view.answer(false); view.answer(false);
view.button('es').click(); view.choose('es');
assert.match(view.prompts[0], /drawings on the map/); assert.match(view.prompts[0], /drawings on the map/);
assert.equal(view.reloads.length, 0); assert.equal(view.reloads.length, 0);
} finally { } finally {
@@ -132,7 +117,7 @@ test('the prompt reads in the active language', () => {
const view = renderSwitch({ calDirty: true }); const view = renderSwitch({ calDirty: true });
try { try {
view.answer(false); view.answer(false);
view.button('en').click(); view.choose('en');
assert.equal(view.prompts.length, 1); assert.equal(view.prompts.length, 1);
assert.doesNotMatch(view.prompts[0], /Switching language/); assert.doesNotMatch(view.prompts[0], /Switching language/);
} finally { } finally {
@@ -140,3 +125,14 @@ test('the prompt reads in the active language', () => {
setLocale('en'); 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();
}
});
-51
View File
@@ -701,54 +701,3 @@ html:not([lang='en']) #gev-voice-control .gev-voice-help-detail {
rgba(232, 234, 237, 0.24) rgba(232, 234, 237, 0.24)
); );
} }
/* Language switch under the Visual Presets title: small mono chips, the
active language in accent like the other dock toggles. */
#command-dock #control-panel .dock-locale-switch {
display: flex;
justify-content: flex-end;
gap: 0.2rem;
margin-top: 0.22rem;
}
#command-dock .dock-locale-btn {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: 1.35rem;
height: 0.95rem;
margin: 0;
padding: 0 0.22rem;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.18rem;
font-family: var(--font-mono);
font-size: 0.46rem;
font-weight: 500;
letter-spacing: 0.06rem;
line-height: 1;
cursor: pointer;
transition:
color var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
}
#command-dock .dock-locale-btn:hover {
color: var(--text-primary);
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.06);
}
#command-dock .dock-locale-btn:focus-visible {
outline: 1px solid var(--text-primary);
outline-offset: 1px;
}
#command-dock .dock-locale-btn.active {
color: var(--accent);
border-color: rgba(0, 212, 255, 0.55);
background: var(--accent-dim);
}
+2
View File
@@ -174,6 +174,8 @@
.context-mode-button, .context-mode-button,
.map-stack-chip, .map-stack-chip,
.key-setup-chip, .key-setup-chip,
.locale-picker-button,
.locale-picker-popup,
.first-run-card .first-run-card
) { ) {
border-color: var(--glass-border); border-color: var(--glass-border);
+1
View File
@@ -3,6 +3,7 @@
body.ui-embed #clean-view-exit, body.ui-embed #clean-view-exit,
body.ui-embed #intel-hud, body.ui-embed #intel-hud,
body.ui-embed #key-setup-chip, body.ui-embed #key-setup-chip,
body.ui-embed #corner-actions,
body.ui-embed #key-setup, body.ui-embed #key-setup,
body.ui-embed #first-run-launcher, body.ui-embed #first-run-launcher,
body.ui-embed #command-dock, body.ui-embed #command-dock,
+299
View File
@@ -0,0 +1,299 @@
/* ============================================================
Bottom-right corner row: the POWER UP chip (provider-settings.css)
and, in the very corner, the interface language picker
(src/ui/localePicker.js). Logical properties throughout, so a
right-to-left page mirrors the row and the list.
============================================================ */
#corner-actions {
position: fixed;
inset-inline-end: 0.9rem;
inset-block-end: 0.9rem;
/* Above the dock (145), the cockpit HUD and panel stacks (146–147);
below the first-run launcher (175) and Provider Settings (190). */
z-index: 150;
display: flex;
align-items: stretch;
gap: 0.4rem;
pointer-events: none;
}
#corner-actions > * {
pointer-events: auto;
}
.locale-picker {
position: relative;
display: flex;
}
/* Same HUD frame as #key-setup-chip, so the two read as one group. */
.locale-picker-button {
display: flex;
align-items: center;
gap: 0.35rem;
margin: 0;
padding: 0.45rem 0.6rem;
color: rgba(225, 244, 248, 0.92);
font-family: var(--font-mono);
font-size: 0.6rem;
letter-spacing: 0.09rem;
line-height: 1;
background: linear-gradient(
145deg,
rgba(10, 27, 37, 0.92),
rgba(7, 13, 22, 0.9)
);
border: 1px solid rgba(54, 220, 255, 0.38);
border-radius: 0.42rem;
box-shadow:
0 0.4rem 1.6rem rgba(0, 0, 0, 0.5),
0 0 1.2rem rgba(0, 212, 255, 0.07);
backdrop-filter: blur(18px) saturate(1.2);
-webkit-backdrop-filter: blur(18px) saturate(1.2);
cursor: pointer;
transition:
border-color var(--transition-fast),
background var(--transition-fast),
transform var(--transition-fast);
}
.locale-picker-button:hover,
.locale-picker-button:focus-visible,
.locale-picker.is-open .locale-picker-button {
background: linear-gradient(
145deg,
rgba(14, 38, 51, 0.95),
rgba(9, 18, 28, 0.93)
);
border-color: rgba(54, 220, 255, 0.7);
outline: none;
}
.locale-picker-button:hover,
.locale-picker-button:focus-visible {
transform: translateY(-1px);
}
.locale-picker-globe {
flex: none;
width: 0.95rem;
height: 0.95rem;
color: #55dff5;
}
.locale-picker-caret {
flex: none;
width: 0.62rem;
height: 0.62rem;
margin-inline-start: -0.1rem;
opacity: 0.75;
transition: transform var(--transition-fast);
}
.locale-picker.is-open .locale-picker-caret {
transform: rotate(180deg);
}
/* Opens upward, aligned to the corner edge. */
.locale-picker-popup {
position: absolute;
inset-block-end: calc(100% + 0.4rem);
inset-inline-end: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
width: max-content;
min-width: 14rem;
max-width: calc(100vw - 1.8rem);
max-height: min(26rem, calc(100vh - 4.5rem));
max-height: min(26rem, calc(100dvh - 4.5rem));
padding: 0.45rem;
color: var(--text-primary);
background:
linear-gradient(145deg, rgba(10, 27, 37, 0.97), rgba(7, 13, 22, 0.95)),
rgba(7, 17, 25, 0.96);
border: 1px solid rgba(54, 220, 255, 0.42);
border-radius: 0.55rem;
box-shadow:
0 1rem 3rem rgba(0, 0, 0, 0.65),
0 0 2rem rgba(0, 212, 255, 0.08),
inset 0 0 0 1px rgba(255, 255, 255, 0.025);
backdrop-filter: blur(24px) saturate(1.25);
-webkit-backdrop-filter: blur(24px) saturate(1.25);
}
.locale-picker-popup[hidden] {
display: none;
}
.locale-picker-heading {
padding: 0.2rem 0.4rem 0.45rem;
color: rgba(85, 223, 245, 0.85);
font-family: var(--font-mono);
font-size: 0.55rem;
letter-spacing: 0.14rem;
text-transform: uppercase;
}
.locale-picker-search {
box-sizing: border-box;
width: 100%;
margin: 0 0 0.4rem;
padding: 0.45rem 0.55rem;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(54, 220, 255, 0.28);
border-radius: 0.35rem;
outline: none;
}
.locale-picker-search:focus {
border-color: rgba(54, 220, 255, 0.7);
}
.locale-picker-list {
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
outline: none;
overscroll-behavior: contain;
}
.locale-picker-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.45rem 0.5rem;
border: 1px solid transparent;
border-radius: 0.35rem;
cursor: pointer;
}
.locale-picker-option.is-active {
background: rgba(54, 220, 255, 0.09);
border-color: rgba(54, 220, 255, 0.28);
}
/* Keyboard focus lives on the list (aria-activedescendant): show it. */
.locale-picker-list:focus-visible .locale-picker-option.is-active,
.locale-picker-search:focus
~ .locale-picker-list
.locale-picker-option.is-active {
border-color: rgba(54, 220, 255, 0.75);
}
.locale-picker-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.locale-picker-native {
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 0.85rem;
line-height: 1.2;
}
.locale-picker-option[aria-selected='true'] .locale-picker-native {
color: #55dff5;
}
.locale-picker-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
color: rgba(199, 225, 231, 0.6);
font-family: var(--font-mono);
font-size: 0.55rem;
letter-spacing: 0.05rem;
}
.locale-picker-check {
flex: none;
width: 0.95rem;
height: 0.95rem;
margin-inline-start: auto;
color: #55dff5;
}
.locale-picker-empty {
margin: 0;
padding: 0.5rem;
color: rgba(199, 225, 231, 0.62);
font-family: var(--font-mono);
font-size: 0.6rem;
}
.locale-picker-empty[hidden] {
display: none;
}
/* The surfaces that hide the rest of the chrome hide the corner too. The
cockpit keeps the language picker (provider-settings.css hides the chip). */
body.ui-clean-view #corner-actions,
body.recording-mode #corner-actions,
body.scene-playback-mode #corner-actions {
display: none;
}
/* The cockpit's position readout shares this corner: it steps up to clear
the picker row (0.9rem inset + one ~2rem button + a gap). */
body.cockpit-mode .cockpit-position-readout {
bottom: max(clamp(22px, 4vh, 42px), 3.5rem);
}
/* Below this width the full chip label runs into the centred dock: the chip
keeps its bolt, and its count stays in its title and aria-label. */
@media (max-width: 1180px) {
#corner-actions #key-setup-chip [data-key-setup-chip-label] {
display: none;
}
}
@media (max-width: 767.98px) {
/* Phone: the corner beside the dock, clear of the attribution above it.
There is room for one button across, so the chip stacks on the globe. */
#corner-actions {
inset-inline-end: 10px;
inset-block-end: max(16px, env(safe-area-inset-bottom));
flex-direction: column;
}
#corner-actions #key-setup-chip {
justify-content: center;
padding-inline: 0.5rem;
}
/* Phone: the globe alone; the label stays in aria-label. */
.locale-picker-code,
.locale-picker-caret {
display: none;
}
.locale-picker-button {
padding-inline: 0.5rem;
}
/* The list becomes a bottom sheet across the screen. */
.locale-picker-popup {
position: fixed;
inset-inline: 16px;
inset-block-end: 16px;
width: auto;
min-width: 0;
max-width: none;
max-height: calc(100vh - 32px);
max-height: calc(100dvh - 32px);
padding: 0.6rem;
}
.locale-picker-option {
padding-block: 0.7rem;
}
}
+2 -11
View File
@@ -4,11 +4,9 @@
mono kickers. The chip retires once every key is configured. mono kickers. The chip retires once every key is configured.
============================================================ */ ============================================================ */
/* The chip sits in the bottom-right corner row (#corner-actions,
locale-picker.css), which owns the position. */
#key-setup-chip { #key-setup-chip {
position: fixed;
right: 0.9rem;
bottom: 0.9rem;
z-index: 60;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
@@ -389,13 +387,6 @@ body.scene-playback-mode #key-setup {
display: none; display: none;
} }
@media (max-width: 620px) {
/* Clear the dock. Desktop-first app; this is a courtesy, not a layout. */
#key-setup-chip {
bottom: 5.8rem;
}
}
/* Provider Settings additions: externally-managed badge + per-row remove. */ /* Provider Settings additions: externally-managed badge + per-row remove. */
.key-setup-external { .key-setup-external {
padding: 0.05rem 0.3rem; padding: 0.05rem 0.3rem;
-2
View File
@@ -10,8 +10,6 @@
<span class="panel-title" data-i18n-runtime><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span> <span class="panel-title" data-i18n-runtime><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
<span class="panel-divider"></span> <span class="panel-divider"></span>
</button> </button>
<!-- Language selector: buttons are rendered by applicationShell _initLocaleSelector(), one per shipped locale. -->
<div class="dock-locale-switch" role="group" data-i18n-aria-label aria-label="Language"></div>
<div id="control-panel-popover" class="dock-popover-content"> <div id="control-panel-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button> <button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
<div id="style-buttons" class="button-grid"> <div id="style-buttons" class="button-grid">
+10 -4
View File
@@ -2,10 +2,16 @@
both the chip and this dialog outright when /api/setup/status is not both the chip and this dialog outright when /api/setup/status is not
there to answer (prod builds, LAN visitors). Rows are rendered from there to answer (prod builds, LAN visitors). Rows are rendered from
that payload, so the key registry lives in src/keySetupCore.mjs alone. --> that payload, so the key registry lives in src/keySetupCore.mjs alone. -->
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup"> <!-- Bottom-right corner, one HUD row: the POWER UP chip (while keys are
<span class="material-symbols-outlined" aria-hidden="true">bolt</span> missing) and, in the very corner, the interface language picker that
<span data-key-setup-chip-label data-i18n>POWER UP</span> src/ui/localePicker.js renders into #locale-picker. -->
</button> <div id="corner-actions">
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup">
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
<span data-key-setup-chip-label data-i18n>POWER UP</span>
</button>
<div id="locale-picker"></div>
</div>
<aside id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" hidden> <aside id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" hidden>
<div class="key-setup-scanline" aria-hidden="true"></div> <div class="key-setup-scanline" aria-hidden="true"></div>
<header class="key-setup-header"> <header class="key-setup-header">
-1
View File
@@ -689,7 +689,6 @@ test('history time keeps the hour whatever the length of the localized date (K-6
} }
}); });
test('air-quality readout renders its band legend, Canada coverage and worst-first list', async () => { test('air-quality readout renders its band legend, Canada coverage and worst-first list', async () => {
const observedMs = Date.now() - 10 * 60_000; const observedMs = Date.now() - 10 * 60_000;
const reading = (stationId, value, band, name) => ({ const reading = (stationId, value, band, name) => ({
+1
View File
@@ -23,6 +23,7 @@
@import './src/ui/styles/voice-card.css'; @import './src/ui/styles/voice-card.css';
@import './src/ui/styles/first-run.css'; @import './src/ui/styles/first-run.css';
@import './src/ui/styles/provider-settings.css'; @import './src/ui/styles/provider-settings.css';
@import './src/ui/styles/locale-picker.css';
@import './src/ui/styles/weather.css'; @import './src/ui/styles/weather.css';
@import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/panel-surfaces.css';