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`) →
`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
their own language. The language switch in the Visual Presets dock stores the
choice, removes `?lang=` (keeps it only where storage is blocked) and reloads,
their own language. The language picker in the bottom-right corner (a globe
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
(unsaved CCTV calibration, the open cockpit, Draw marks on the board,
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 />
```
`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
@@ -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.
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`.
+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.
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
+2
View File
@@ -635,6 +635,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",
@@ -2074,6 +2075,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",
+2 -1
View File
@@ -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/<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).
*/
export const LOCALES = Object.freeze(['en', 'es', 'ru']);
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -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": "Обычный",
+11 -6
View File
@@ -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;
+2 -3
View File
@@ -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;
+28 -27
View File
@@ -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();
+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
// 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();
}
});
-51
View File
@@ -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);
}
+2
View File
@@ -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);
+1
View File
@@ -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,
+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.
============================================================ */
/* 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;
-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-divider"></span>
</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">
<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">
+10 -4
View File
@@ -2,10 +2,16 @@
both the chip and this dialog outright when /api/setup/status is not
there to answer (prod builds, LAN visitors). Rows are rendered from
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">
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
<span data-key-setup-chip-label data-i18n>POWER UP</span>
</button>
<!-- Bottom-right corner, one HUD row: the POWER UP chip (while keys are
missing) and, in the very corner, the interface language picker that
src/ui/localePicker.js renders into #locale-picker. -->
<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>
<div class="key-setup-scanline" aria-hidden="true"></div>
<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 () => {
const observedMs = Date.now() - 10 * 60_000;
const reading = (stationId, value, band, name) => ({
+1
View File
@@ -23,6 +23,7 @@
@import './src/ui/styles/voice-card.css';
@import './src/ui/styles/first-run.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/panel-surfaces.css';