.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.
-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
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 08760e422..8c8dd13ba 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -624,6 +624,7 @@
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
+ "src/ui/localePicker.js",
"src/ui/cyberSonarControls.js",
"src/ui/locationNavigation.js",
"src/ui/cockpitCoordinator.js",
@@ -2063,6 +2064,7 @@
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
+ "src/ui/localePicker.js",
"src/ui/cyberSonarControls.js",
"src/ui/locationNavigation.js",
"src/ui/cockpitCoordinator.js",
diff --git a/src/i18n/locale.js b/src/i18n/locale.js
index 50cf061ce..f943d44ff 100644
--- a/src/i18n/locale.js
+++ b/src/i18n/locale.js
@@ -8,7 +8,8 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
export const DEFAULT_LOCALE = 'en';
/**
- * Shipped locales, in selector order. Adding one: locales/.json, an
+ * Shipped locales. The picker (src/ui/localePicker.js) lists the active one
+ * first and the rest by LOCALE_NAMES. Adding one: locales/.json, an
* import in index.js, and an entry here (see docs/TRANSLATORS.md).
*/
export const LOCALES = Object.freeze(['en', 'es', 'ru']);
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index d9d0315e2..a06512624 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -22,7 +22,11 @@
"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",
"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 the keyless globe...": "Cargando el globo sin clave...",
"Initializing systems...": "Inicializando sistemas...",
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index ab61f56cc..3e8e1f152 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -688,7 +688,11 @@
"Keep location tray open": "Держать панель места открытой",
"Search any location": "Поиск любого места",
"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 any location...": "Поиск любого места...",
"Normal": "Обычный",
diff --git a/src/keySetup.js b/src/keySetup.js
index dd43492d6..177937126 100644
--- a/src/keySetup.js
+++ b/src/keySetup.js
@@ -345,17 +345,22 @@ function buildRow(documentRef, key) {
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 toolbar = documentRef?.getElementById?.('top-center-actions');
const Observer = documentRef?.defaultView?.MutationObserver;
- if (!theme || !toolbar || !Observer) return () => {};
+ const home = chip?.parentNode;
+ if (!theme || !toolbar || !Observer || !home) return () => {};
const sync = () => {
if (theme.dataset.uiTheme === 'cyber') {
if (chip.parentNode !== toolbar) toolbar.append(chip);
- } else if (chip.parentNode !== root.parentNode) {
- root.before(chip);
+ } else if (chip.parentNode !== home) {
+ home.prepend(chip);
}
};
const observer = new Observer(sync);
@@ -415,7 +420,7 @@ export async function initKeySetup({
}
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 closeButton = root.querySelector('[data-key-setup-close]');
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
diff --git a/src/keySetup.test.mjs b/src/keySetup.test.mjs
index 4cdba1765..b3662bedf 100644
--- a/src/keySetup.test.mjs
+++ b/src/keySetup.test.mjs
@@ -9,9 +9,8 @@ import {
} from './keySetup.js';
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 root = { parentNode: home, before: (node) => { node.parentNode = home; } };
let moves = 0;
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
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,
getElementById: () => toolbar,
defaultView: { MutationObserver: Observer },
- }, chip, root);
+ }, chip);
assert.equal(chip.parentNode, home);
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
theme.dataset.uiTheme = variant;
diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js
index 4af8a9d95..56fbc7f43 100644
--- a/src/ui/applicationShell.js
+++ b/src/ui/applicationShell.js
@@ -45,7 +45,7 @@ import {
registerUnsavedWork,
t,
} 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.
@@ -715,17 +715,13 @@ export class StyleManager extends ShellFacade {
}
/**
- * Renders the command-dock locale switch: one button per shipped locale,
- * labelled with its code and named in its own language. A click stores the
- * choice and reloads, so nothing re-renders live; it asks first when the
- * reload would lose work (see _confirmLocaleReload).
+ * Mounts the interface language picker in the bottom-right corner
+ * (src/ui/localePicker.js). Choosing another language stores it and
+ * reloads, so nothing re-renders live; it asks first when the reload would
+ * lose work (see _chooseLocale).
* @returns {void}
*/
_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
// their own (registerUnsavedWork in src/i18n/index.js).
this._unsavedWorkChecks = [
@@ -740,24 +736,27 @@ export class StyleManager extends ShellFacade {
this.cockpitView?.active ? msg('the open cockpit') : null,
),
];
- const active = getLocale();
- for (const locale of LOCALES) {
- const button = document.createElement('button');
- button.type = 'button';
- button.className = 'dock-locale-btn';
- button.dataset.locale = locale;
- button.textContent = locale.toUpperCase();
- button.setAttribute('aria-label', LOCALE_NAMES[locale]);
- button.lang = locale;
- const isActive = locale === active;
- button.classList.toggle('active', isActive);
- button.setAttribute('aria-pressed', String(isActive));
- button.addEventListener('click', () => {
- if (!this._confirmLocaleReload()) return;
- persistLocaleAndReload(button.dataset.locale);
- });
- container.appendChild(button);
- }
+ const root = globalThis.document?.getElementById?.('locale-picker');
+ if (!root) return;
+ this._localePicker = mountLocalePicker({
+ root,
+ current: getLocale(),
+ onSelect: (locale) => this._chooseLocale(locale),
+ });
+ }
+
+ /**
+ * Switch to `locale`: nothing for the active language, otherwise store it
+ * and reload once _confirmLocaleReload agrees.
+ * @param {string} locale
+ * @returns {'unchanged'|'declined'|'switched'}
+ */
+ _chooseLocale(locale) {
+ return applyLocaleChoice(locale, {
+ current: getLocale(),
+ confirmSwitch: () => this._confirmLocaleReload(),
+ switchLocale: (next) => persistLocaleAndReload(next),
+ });
}
/**
@@ -1759,6 +1758,8 @@ export class StyleManager extends ShellFacade {
this._lifetime.destroy();
for (const unregister of this._unsavedWorkChecks?.splice(0) ?? [])
unregister();
+ this._localePicker?.destroy();
+ this._localePicker = null;
this._recording.destroy();
this._panelChrome.destroy();
this._feedback.destroy();
diff --git a/src/ui/localePicker.js b/src/ui/localePicker.js
new file mode 100644
index 000000000..7de5103aa
--- /dev/null
+++ b/src/ui/localePicker.js
@@ -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} 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} [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} [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 = '';
+ },
+ };
+}
diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs
new file mode 100644
index 000000000..7c7003eec
--- /dev/null
+++ b/src/ui/localePicker.test.mjs
@@ -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);
+});
diff --git a/src/ui/localeSwitchReload.test.mjs b/src/ui/localeSwitchReload.test.mjs
index 04121dd11..0cd2da5cc 100644
--- a/src/ui/localeSwitchReload.test.mjs
+++ b/src/ui/localeSwitchReload.test.mjs
@@ -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();
+ }
+});
diff --git a/src/ui/styles/command-dock-sliding.css b/src/ui/styles/command-dock-sliding.css
index 993fe7df5..ee64b9a8c 100644
--- a/src/ui/styles/command-dock-sliding.css
+++ b/src/ui/styles/command-dock-sliding.css
@@ -701,54 +701,3 @@ html:not([lang='en']) #gev-voice-control .gev-voice-help-detail {
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);
-}
diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css
index c8f5b139f..7d52adf0d 100644
--- a/src/ui/styles/cyber.css
+++ b/src/ui/styles/cyber.css
@@ -174,6 +174,8 @@
.context-mode-button,
.map-stack-chip,
.key-setup-chip,
+ .locale-picker-button,
+ .locale-picker-popup,
.first-run-card
) {
border-color: var(--glass-border);
diff --git a/src/ui/styles/embed.css b/src/ui/styles/embed.css
index 3567f6a50..5528a76da 100644
--- a/src/ui/styles/embed.css
+++ b/src/ui/styles/embed.css
@@ -3,6 +3,7 @@
body.ui-embed #clean-view-exit,
body.ui-embed #intel-hud,
body.ui-embed #key-setup-chip,
+body.ui-embed #corner-actions,
body.ui-embed #key-setup,
body.ui-embed #first-run-launcher,
body.ui-embed #command-dock,
diff --git a/src/ui/styles/locale-picker.css b/src/ui/styles/locale-picker.css
new file mode 100644
index 000000000..6f5d994b3
--- /dev/null
+++ b/src/ui/styles/locale-picker.css
@@ -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;
+ }
+}
diff --git a/src/ui/styles/provider-settings.css b/src/ui/styles/provider-settings.css
index 7563a886d..da29da9ae 100644
--- a/src/ui/styles/provider-settings.css
+++ b/src/ui/styles/provider-settings.css
@@ -4,11 +4,9 @@
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 {
- position: fixed;
- right: 0.9rem;
- bottom: 0.9rem;
- z-index: 60;
display: flex;
align-items: center;
gap: 0.4rem;
@@ -389,13 +387,6 @@ body.scene-playback-mode #key-setup {
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. */
.key-setup-external {
padding: 0.05rem 0.3rem;
diff --git a/src/ui/templates/command-dock.html b/src/ui/templates/command-dock.html
index 4c46ed6a6..e6e2735ea 100644
--- a/src/ui/templates/command-dock.html
+++ b/src/ui/templates/command-dock.html
@@ -10,8 +10,6 @@
VISUAL PRESETS
-
-