Merge Core main into german/hal-644-ar: Arabic on the Vue UI

Main moved the UI to Vue views and Pinia stores, so the Arabic changes to
the old DOM controllers now live in their views: the rail legend range
comes from formatScaleRange (keeping units a source supplies as given),
the voice timeout options are worded by Intl unit formatting in
VoiceControl, and the scene editor's JSON fields stay left to right in
SceneDialog. Main's CCTV status line, launch panel and calibration values
keep the Arabic isolates and Western-digit formatting.

isolate() and keepCodesWithValues() read the locale through getLocale(),
which follows the page's vue-i18n instance, and the Intl formatters take
intlLocale(getLocale()). A language switch now applies in place, and
applyDocumentLanguage sets <html dir> on every switch, so the page mirrors
without a reload.

The icon rails and their tips are placed with logical properties, so they
stay on the outer side of the mirrored stacks; Cyber's new stack offsets,
padding and edge lines are logical too, and rtl.css moves Cyber's right
stack to its new rail clearance. Main removed the Context mini radio, so its
right-to-left rules and test go.

The Arabic catalog gains main's new strings and drops the ones main no
longer uses; the other catalogs keep main's new keys and the branch's
landing labels.
This commit is contained in:
Sameh Khamis committed 2026-11-01 20:15:02 -08:00
commit bc5d038afd
630 files changed
+41141 -20956

No files matched your search

+54 -64
View File
@@ -33,9 +33,6 @@ import { t } from '../i18n/index.js';
const ROOT_ID = 'world-overlay-root';
const CANVAS_ID = 'world-overlay-canvas';
const DETECTION_SURFACE_ID = 'world-overlay-detection-surface';
const ACCESSIBILITY_ROOT_ID = 'world-overlay-actions';
const ACCESSIBILITY_LIST_ID = 'world-overlay-action-list';
const ACCESSIBILITY_STATUS_ID = 'world-overlay-status';
const PAINT_TARGET_SHARED = 'shared';
const PAINT_TARGET_DETECTION = 'detection';
const OCCLUDER_PADDING_PX = 6;
@@ -114,7 +111,6 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
'.hud-top-right',
'.hud-bottom-left',
'.hud-bottom-right',
'.hud-top-bar',
'.hud-bottom-bar',
'#space-mission-panel',
'#space-mission-panel-host',
@@ -157,12 +153,16 @@ let _root = null;
let _canvas = null;
/** @type {CanvasRenderingContext2D|null} */
let _ctx = null;
/** @type {HTMLElement|null} Focusable mirror of painted actionable cards. */
let _accessibilityRoot = null;
/** @type {HTMLElement|null} */
let _accessibilityList = null;
/** @type {HTMLElement|null} */
let _accessibilityStatus = null;
/**
* The focusable mirror of painted actionable cards, as its view renders it:
* `{owner, items, status, announced}`, or null without a host. `announced`
* counts accepted focuses, so a repeated announcement is spoken again. Subscribers (the
* application shell, which publishes it to a store) receive every change;
* the module itself needs no Vue, for the portable transit entry.
* @type {?{owner: object, items: object[], status: string, announced: number}}
*/
let _actions = null;
const _actionsListeners = new Set();
let _accessibilitySignature = '';
const _accessibleActivatorByKey = new Map();
/** @type {HTMLCanvasElement|null} Host-owned detection blend-isolation surface. */
@@ -1260,30 +1260,10 @@ function ensureOverlayDom() {
_root.setAttribute('aria-hidden', 'true');
_canvas.setAttribute('aria-hidden', 'true');
_detectionSurface.setAttribute('aria-hidden', 'true');
_accessibilityRoot = document.getElementById(ACCESSIBILITY_ROOT_ID);
if (!_accessibilityRoot) {
_accessibilityRoot = document.createElement('div');
_accessibilityRoot.id = ACCESSIBILITY_ROOT_ID;
_accessibilityRoot.className = 'world-overlay-accessibility';
_accessibilityRoot.setAttribute('role', 'region');
_accessibilityRoot.setAttribute('aria-label', t('Visible map targets'));
document.body.appendChild(_accessibilityRoot);
}
_accessibilityList = document.getElementById(ACCESSIBILITY_LIST_ID);
if (!_accessibilityList) {
_accessibilityList = document.createElement('div');
_accessibilityList.id = ACCESSIBILITY_LIST_ID;
_accessibilityRoot.appendChild(_accessibilityList);
}
_accessibilityStatus = document.getElementById(ACCESSIBILITY_STATUS_ID);
if (!_accessibilityStatus) {
_accessibilityStatus = document.createElement('div');
_accessibilityStatus.id = ACCESSIBILITY_STATUS_ID;
_accessibilityStatus.setAttribute('role', 'status');
_accessibilityStatus.setAttribute('aria-live', 'polite');
_accessibilityStatus.setAttribute('aria-atomic', 'true');
_accessibilityRoot.appendChild(_accessibilityStatus);
}
// Each host owns its action list; a click from an earlier host's list is
// ignored.
if (!_actions)
presentActions({ owner: {}, items: [], status: '', announced: 0 });
_ctx = _canvas.getContext('2d', { alpha: true, desynchronized: true });
_detectionCtx = _detectionSurface.getContext('2d', {
alpha: true,
@@ -2372,9 +2352,46 @@ function publishPaintRect(item) {
if (record.entry.interactive) _hitRects[_hitRectCount++] = rect;
}
/** Publish the action list to every subscriber. */
function presentActions(next) {
_actions = next && Object.freeze(next);
for (const listener of _actionsListeners) listener(_actions);
}
/**
* Receive the visible map target list now and after every change.
* @param {(view: ?object) => void} listener
* @returns {() => void} Unsubscribe.
*/
export function subscribeWorldOverlayActions(listener) {
_actionsListeners.add(listener);
listener(_actions);
return () => _actionsListeners.delete(listener);
}
/**
* Focus a listed target, as its button does, announcing an accepted focus.
* @param {object} owner The `owner` of the list the button belongs to.
* @param {string} key The item's key.
*/
export function activateWorldOverlayAction(owner, key) {
if (!_actions || owner !== _actions.owner) return;
const item = _actions.items.find((candidate) => candidate.key === key);
const activate = _accessibleActivatorByKey.get(key);
if (!item || !activate) return;
const accepted = activate();
if (accepted !== false && _actions?.owner === owner) {
presentActions({
..._actions,
status: t('Focusing {label}', { label: item.label }),
announced: _actions.announced + 1,
});
}
}
/** Keep a stable, bounded accessible mirror of currently painted actions. */
function syncAccessibleActions() {
if (!_accessibilityList) return;
if (!_actions) return;
_accessibleActivatorByKey.clear();
const items = [];
for (let i = _hitRectCount - 1; i >= 0; i--) {
@@ -2398,31 +2415,7 @@ function syncAccessibleActions() {
.join('\u0001');
if (signature === _accessibilitySignature) return;
_accessibilitySignature = signature;
if (typeof _accessibilityList.replaceChildren === 'function') {
_accessibilityList.replaceChildren();
} else {
while (_accessibilityList.firstChild)
_accessibilityList.removeChild(_accessibilityList.firstChild);
}
for (const item of items) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = item.label;
button.setAttribute('aria-label', item.label);
button.setAttribute('aria-pressed', String(item.selected));
button.dataset.overlayActionKey = item.key;
button.addEventListener?.('click', () => {
const activate = _accessibleActivatorByKey.get(item.key);
if (!activate) return;
const accepted = activate();
if (accepted !== false && _accessibilityStatus) {
_accessibilityStatus.textContent = t('Focusing {label}', {
label: item.label,
});
}
});
_accessibilityList.appendChild(button);
}
presentActions({ ..._actions, items });
}
let _detectionSurfacePrepared = false;
@@ -2740,7 +2733,6 @@ export function destroyWorldOverlay() {
if (_occluderRefreshTimer != null) clearTimeout(_occluderRefreshTimer);
_occluderRefreshTimer = null;
_root?.remove?.();
_accessibilityRoot?.remove?.();
// The detection surface is not a child of `_root` (it lives in the Cesium
// container so its `screen` blend reaches the scene), so it has to be torn
// down explicitly rather than by the root's removal.
@@ -2788,9 +2780,7 @@ export function destroyWorldOverlay() {
_root = null;
_canvas = null;
_ctx = null;
_accessibilityRoot = null;
_accessibilityList = null;
_accessibilityStatus = null;
if (_actions) presentActions(null);
_detectionSurface = null;
_detectionCtx = null;
_occluder = null;
+32 -14
View File
@@ -1,5 +1,5 @@
import { createNamedMarkers } from '../layers/installations/namedMarkers.js';
import { expandApplicationHtml } from '../../build/application-html.js';
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -21,7 +21,9 @@ import {
getOverlayPaintRect,
getWorldOverlayDiagnostics,
hitTestWorldOverlay,
activateWorldOverlayAction,
initWorldOverlay,
subscribeWorldOverlayActions,
isOverlayPointVisible,
normalizeOverlayEntry,
paintLaneForOverlayEntry,
@@ -447,7 +449,7 @@ test('destroying before the first init leaves pre-init buffering intact', () =>
env.cleanup();
});
test('lifecycle is idempotent and teardown removes listeners, observers, and DOM', () => {
test('lifecycle is idempotent and teardown removes listeners, observers, and DOM', async () => {
const env = installMockEnvironment();
assert.equal(env.document.getElementById('world-overlay-detection-surface'), null);
initWorldOverlay(env.viewer);
@@ -465,7 +467,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM
assert.deepEqual(root.children, [canvas],
'the overlay root carries only the shared card canvas');
assert.doesNotMatch(
expandApplicationHtml(readFileSync(new URL('../../index.html', import.meta.url), 'utf8')),
await renderApplicationHtml(readFileSync(new URL('../../index.html', import.meta.url), 'utf8')),
/world-overlay-detection-surface/,
'the surface is runtime host-owned, not static markup',
);
@@ -1019,7 +1021,14 @@ test('accessible actions announce only accepted focus and expose selected state'
const env = installMockEnvironment();
let acceptedActivations = 0;
let staleActivations = 0;
let actions = null;
const unsubscribe = subscribeWorldOverlayActions((view) => {
actions = view;
});
initWorldOverlay(env.viewer);
assert.deepEqual(actions.items, []);
assert.equal(actions.status, '');
assert.equal(actions.announced, 0);
setOverlayEntries('accessible-actions', [selectedEntry('vessel:123', {
interactive: true,
accessibilityLabel: 'Focus vessel TEST, MMSI 123',
@@ -1030,15 +1039,20 @@ test('accessible actions announce only accepted focus and expose selected state'
})]);
env.postRender.raise();
const list = env.document.getElementById('world-overlay-action-list');
const status = env.document.getElementById('world-overlay-status');
assert.equal(list.children.length, 1);
assert.equal(list.children[0]['aria-pressed'], 'true');
list.children[0].click();
assert.equal(actions.items.length, 1);
const [item] = actions.items;
assert.equal(item.label, 'Focus vessel TEST, MMSI 123');
assert.equal(item.selected, true);
const owner = actions.owner;
activateWorldOverlayAction({}, item.key);
assert.equal(acceptedActivations, 0, 'a list from another host cannot activate');
activateWorldOverlayAction(owner, item.key);
assert.equal(acceptedActivations, 1);
assert.equal(status.textContent, 'Focusing Focus vessel TEST, MMSI 123');
assert.equal(actions.status, 'Focusing Focus vessel TEST, MMSI 123');
assert.equal(actions.announced, 1);
activateWorldOverlayAction(owner, item.key);
assert.equal(actions.announced, 2, 'a repeated focus is a new announcement');
status.textContent = 'Focus unchanged';
setOverlayEntries('accessible-actions', [selectedEntry('vessel:123', {
selected: false,
interactive: true,
@@ -1049,12 +1063,16 @@ test('accessible actions announce only accepted focus and expose selected state'
},
})]);
env.postRender.raise();
assert.equal(list.children.length, 1);
assert.equal(list.children[0]['aria-pressed'], 'false');
list.children[0].click();
assert.equal(actions.items.length, 1);
assert.equal(actions.items[0].selected, false);
assert.equal(actions.status, 'Focusing Focus vessel TEST, MMSI 123', 'the list keeps its last announcement');
activateWorldOverlayAction(owner, actions.items[0].key);
assert.equal(staleActivations, 1);
assert.equal(status.textContent, 'Focus unchanged');
assert.equal(actions.status, 'Focusing Focus vessel TEST, MMSI 123', 'a refused focus announces nothing');
assert.equal(actions.announced, 2);
env.cleanup();
assert.equal(actions, null, 'teardown withdraws the list');
unsubscribe();
});
test('viewport and horizon culling obey explicit policy inputs', () => {
@@ -0,0 +1,122 @@
// src/overlays/worldOverlayActionsView.test.mjs — the scene chrome's visible
// map target list renders what the world overlay presents and activates the
// target of the list each button belongs to.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useWorldOverlayActionsStore } from '../ui/stores/worldOverlayActions.js';
import SceneChromeView from '../ui/views/SceneChromeView.vue';
function mount(t) {
document.body.replaceChildren();
const pinia = createPinia();
const store = useWorldOverlayActionsStore(pinia);
const app = createApp(SceneChromeView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const $ = (selector) => document.querySelector(selector);
return { store, $ };
}
test('the target list renders each painted action and the last announcement', async (t) => {
const { store, $ } = mount(t);
const region = $('#world-overlay-actions');
assert.equal(region.getAttribute('role'), 'region');
assert.equal(region.className, 'world-overlay-accessibility');
assert.equal($('#world-overlay-action-list').children.length, 0);
assert.equal($('#world-overlay-status').textContent, '');
const owner = {};
store.publish({
owner,
items: [
{ key: 'vessel\u0000123', label: 'Focus vessel TEST', selected: true },
{ key: 'fire\u000042', label: 'Focus fire 42', selected: false },
],
status: 'Focusing Focus vessel TEST',
announced: 1,
});
await nextTick();
const buttons = [...$('#world-overlay-action-list').children];
assert.deepEqual(
buttons.map((button) => [
button.type,
button.textContent,
button.getAttribute('aria-label'),
button.getAttribute('aria-pressed'),
button.dataset.overlayActionKey,
]),
[
[
'button',
'Focus vessel TEST',
'Focus vessel TEST',
'true',
'vessel\u0000123',
],
['button', 'Focus fire 42', 'Focus fire 42', 'false', 'fire\u000042'],
],
);
assert.equal(
$('#world-overlay-status').textContent,
'Focusing Focus vessel TEST',
);
});
test('a button activates its own list’s target', async (t) => {
const { store, $ } = mount(t);
const calls = [];
store.connect({ activate: (owner, key) => calls.push([owner, key]) });
const owner = {};
store.publish({
owner,
items: [{ key: 'a', label: 'Focus A', selected: false }],
status: '',
});
await nextTick();
$('#world-overlay-action-list button').click();
assert.equal(calls.length, 1);
assert.ok(calls[0][0] === owner);
assert.equal(calls[0][1], 'a');
store.publish(null);
await nextTick();
assert.equal($('#world-overlay-action-list').children.length, 0);
assert.ok($('#world-overlay-actions'), 'the region itself stays');
});
test('a repeated focus rewrites the status so it is announced again', async (t) => {
const { store, $ } = mount(t);
const owner = {};
const list = (announced) => ({
owner,
items: [{ key: 'a', label: 'Focus A', selected: false }],
status: 'Focusing Focus A',
announced,
});
store.publish(list(1));
await nextTick();
const status = $('#world-overlay-status');
assert.equal(status.textContent, 'Focusing Focus A');
let writes = 0;
new MutationObserver((records) => (writes += records.length)).observe(
status,
{
childList: true,
characterData: true,
subtree: true,
},
);
store.publish({
...list(1),
items: [{ key: 'a', label: 'Focus A', selected: true }],
});
await nextTick();
await new Promise((resolve) => setTimeout(resolve));
assert.equal(writes, 0, 'a list change alone announces nothing');
store.publish(list(2));
await nextTick();
await new Promise((resolve) => setTimeout(resolve));
assert.ok(writes > 0, 'the second focus of the same target is written again');
assert.equal(status.textContent, 'Focusing Focus A');
});