`));
+ assert.ok(button, `${id} is missing`);
+ assert.match(button[0], /role="tab"/);
+ assert.match(button[0], /tabindex="0"/);
+ }
+
+ const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
+ assert.ok(sync, 'Context mode sync is missing');
+ assert.match(sync[1], /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/);
+ assert.match(sync[1], /button\.tabIndex = 0/);
+ assert.doesNotMatch(sync[1], /tabIndex\s*=\s*[^;]*\?\s*-1/);
+});
+
+test('Context transition state preserves focus and Tab availability until settle', () => {
+ const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
+ assert.ok(sync, 'Context mode sync is missing');
+
+ const attributes = () => new Map();
+ const makeButton = () => {
+ const attrs = attributes();
+ let disabled = false;
+ const button = {
+ attrs,
+ tabIndex: -1,
+ classList: { toggle() {} },
+ setAttribute(name, value) { attrs.set(name, String(value)); },
+ get disabled() { return disabled; },
+ set disabled(value) {
+ disabled = Boolean(value);
+ // Model the browser behavior that exposed this regression: native
+ // disabled drops focus immediately.
+ if (disabled && globalThis.document?.activeElement === button) {
+ globalThis.document.activeElement = null;
+ }
+ },
+ };
+ return button;
+ };
+ const contacts = makeButton();
+ const missions = makeButton();
+ const panel = { classList: { toggle() {} }, setAttribute() {} };
+ const priorDocument = globalThis.document;
+ globalThis.document = { activeElement: missions, getElementById: () => panel };
+ const owner = {
+ _contextMode: null,
+ _contextModeChanging: true,
+ _globalContextFlightsBtn: contacts,
+ _globalContextMissionsBtn: missions,
+ _contextModeStandby: {},
+ _contextFlightsView: {},
+ _contextMissionsView: {},
+ cockpitView: { syncEntry() {} },
+ _syncContactsDetection() {},
+ _scheduleRightPanelLayout() {},
+ };
+ try {
+ Function(sync[1]).call(owner);
+ assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
+ for (const button of [contacts, missions]) {
+ assert.equal(button.disabled, false);
+ assert.equal(button.tabIndex, 0);
+ assert.equal(button.attrs.get('aria-disabled'), 'true');
+ assert.equal(button.attrs.get('aria-busy'), 'true');
+ }
+
+ owner._contextModeChanging = false;
+ owner._contextMode = 'space-missions';
+ Function(sync[1]).call(owner);
+ assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
+ for (const button of [contacts, missions]) {
+ assert.equal(button.disabled, false);
+ assert.equal(button.tabIndex, 0);
+ assert.equal(button.attrs.get('aria-disabled'), 'false');
+ assert.equal(button.attrs.get('aria-busy'), 'false');
+ }
+ } finally {
+ globalThis.document = priorDocument;
+ }
+});
+
+test('Context activation and Clear All never native-disable tabs and guard repeated clicks', () => {
+ const init = ui.slice(
+ ui.indexOf('_initGlobalContextPanel() {'),
+ ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
+ );
+ const select = ui.slice(
+ ui.indexOf('async _selectContextMode('),
+ ui.indexOf('async _deactivateContextForLayerChange(', ui.indexOf('async _selectContextMode(')),
+ );
+ const clear = ui.slice(
+ ui.indexOf('clearSelectedLayers() {'),
+ ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
+ );
+ assert.equal((init.match(/if \(this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2);
+ assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
+ assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
+});
+
+test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
+ const init = ui.match(/_initGlobalContextPanel\(\) \{([\s\S]*?)\n this\._globalContextFlightsBtn\?\.addEventListener/);
+ assert.ok(init, 'Context panel initialization is missing');
+ assert.match(init[1], /event\.key === 'ArrowRight'/);
+ assert.match(init[1], /event\.key === 'ArrowLeft'/);
+ assert.match(init[1], /event\.key === 'Home'/);
+ assert.match(init[1], /event\.key === 'End'/);
+ assert.match(init[1], /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
+ assert.match(init[1], /contextTabs\[nextIndex\]\.click\(\)/);
+});
+
+test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
+ const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
+ const focusRule = rules.find(([, selector, body]) => (
+ selector.trim().endsWith('.context-mode-button:focus-visible')
+ && /outline:\s*2px solid var\(--text-primary\)/.test(body)
+ ));
+ assert.ok(focusRule, 'Context focus-visible rule must draw a two-pixel outline');
+ assert.match(focusRule[2], /outline-offset:\s*-3px/);
+
+ const activeRule = rules.find(([, selector]) => selector.trim().endsWith('.context-mode-button.active'));
+ assert.ok(activeRule, 'Context active-state rule is missing');
+ assert.doesNotMatch(activeRule[2], /outline:\s*none/);
+});
+
+test('Context async action buttons remain focused while busy', () => {
+ const init = ui.slice(
+ ui.indexOf('_initGlobalContextPanel() {'),
+ ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
+ );
+ const radio = ui.slice(
+ ui.indexOf('const toggleRadio = async (trigger) => {'),
+ ui.indexOf('this._contextRadioToggleBtn?.addEventListener', ui.indexOf('const toggleRadio = async (trigger) => {')),
+ );
+ const radioSync = ui.slice(
+ ui.indexOf('_renderRadioState(state) {'),
+ ui.indexOf('if (this._radioFilter)', ui.indexOf('_renderRadioState(state) {')),
+ );
+ const clear = ui.slice(
+ ui.indexOf('clearSelectedLayers() {'),
+ ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
+ );
+
+ assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
+ assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
+ assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
+ assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
+ for (const name of ['_radioEnableBtn', '_contextRadioMiniEnableBtn', '_cockpitRadioEnableBtn']) {
+ assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
+ }
+ assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
+ assert.match(clear, /_clearSelectedLayersBtn\.setAttribute\('aria-busy', 'true'\)/);
+});
diff --git a/src/creditKeyboard.js b/src/creditKeyboard.js
new file mode 100644
index 000000000..3c2a5931d
--- /dev/null
+++ b/src/creditKeyboard.js
@@ -0,0 +1,70 @@
+/**
+ * Adds the keyboard semantics omitted by Cesium's href-less attribution links.
+ * Cesium retains ownership of showing and hiding its required credit lightbox.
+ */
+export function configureCreditKeyboardAccess(root = document) {
+ const expand = root?.querySelector?.('#cesium-credits .cesium-credit-expand-link');
+ const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
+ const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
+ if (!expand || !lightbox || !close) return false;
+ const overlay = lightbox.parentElement;
+
+ if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
+ expand.setAttribute('role', 'button');
+ expand.setAttribute('tabindex', '0');
+ expand.setAttribute('aria-haspopup', 'dialog');
+ expand.setAttribute('aria-controls', lightbox.id);
+ expand.setAttribute('aria-expanded', 'false');
+ close.setAttribute('role', 'button');
+ close.setAttribute('tabindex', '0');
+ close.setAttribute('aria-label', 'Close data attribution');
+
+ if (expand.dataset.gevKeyboardReady === 'true') return true;
+ expand.dataset.gevKeyboardReady = 'true';
+
+ const installKeyboardActivation = (control) => {
+ let spacePressed = false;
+ control.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ if (!event.repeat) control.click();
+ return;
+ }
+ if (event.key !== ' ') return;
+ event.preventDefault();
+ if (!event.repeat) spacePressed = true;
+ });
+ control.addEventListener('keyup', (event) => {
+ if (event.key !== ' ') return;
+ event.preventDefault();
+ if (!spacePressed) return;
+ spacePressed = false;
+ control.click();
+ });
+ // A long Space hold may hand control to push-to-talk and move focus away.
+ // Cancelling the armed release prevents that eventual keyup from also
+ // activating the attribution control.
+ control.addEventListener('blur', () => { spacePressed = false; });
+ };
+ const closeAndRestoreFocus = () => {
+ expand.setAttribute('aria-expanded', 'false');
+ expand.focus();
+ };
+ installKeyboardActivation(expand);
+ installKeyboardActivation(close);
+ lightbox.addEventListener('keydown', (event) => {
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
+ event.preventDefault();
+ event.stopPropagation();
+ close.click();
+ });
+ expand.addEventListener('click', () => {
+ expand.setAttribute('aria-expanded', 'true');
+ close.focus();
+ });
+ close.addEventListener('click', closeAndRestoreFocus);
+ overlay?.addEventListener?.('click', (event) => {
+ if (!lightbox.contains(event.target)) closeAndRestoreFocus();
+ });
+ return true;
+}
diff --git a/src/creditKeyboard.test.mjs b/src/creditKeyboard.test.mjs
new file mode 100644
index 000000000..3662936aa
--- /dev/null
+++ b/src/creditKeyboard.test.mjs
@@ -0,0 +1,188 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { configureCreditKeyboardAccess } from './creditKeyboard.js';
+
+function fakeElement() {
+ const attributes = new Map();
+ const listeners = new Map();
+ return {
+ dataset: {},
+ focused: false,
+ id: '',
+ setAttribute(name, value) { attributes.set(name, String(value)); },
+ getAttribute(name) { return attributes.get(name) ?? null; },
+ addEventListener(type, listener) {
+ if (!listeners.has(type)) listeners.set(type, []);
+ listeners.get(type).push(listener);
+ },
+ dispatch(type, event = {}) {
+ const dispatched = {
+ currentTarget: this,
+ target: this,
+ defaultPrevented: false,
+ propagationStopped: false,
+ preventDefault() { this.defaultPrevented = true; },
+ stopPropagation() { this.propagationStopped = true; },
+ ...event,
+ };
+ for (const listener of listeners.get(type) || []) listener(dispatched);
+ return dispatched;
+ },
+ click() {
+ this.onclick?.();
+ this.dispatch('click');
+ },
+ focus() { this.focused = true; },
+ listenerCount(type) { return listeners.get(type)?.length || 0; },
+ };
+}
+
+function fixture() {
+ const expand = fakeElement();
+ const close = fakeElement();
+ const lightbox = fakeElement();
+ lightbox.querySelector = () => close;
+ lightbox.contains = (target) => target === lightbox || target === close;
+ const overlay = fakeElement();
+ lightbox.parentElement = overlay;
+ const root = {
+ querySelector(selector) {
+ if (selector === '#cesium-credits .cesium-credit-expand-link') return expand;
+ if (selector === '.cesium-credit-lightbox') return lightbox;
+ return null;
+ },
+ };
+ return { close, expand, lightbox, overlay, root };
+}
+
+test('Cesium Data attribution and close controls join the keyboard tab order', () => {
+ const f = fixture();
+ assert.equal(configureCreditKeyboardAccess(f.root), true);
+ assert.equal(f.expand.getAttribute('role'), 'button');
+ assert.equal(f.expand.getAttribute('tabindex'), '0');
+ assert.equal(f.expand.getAttribute('aria-haspopup'), 'dialog');
+ assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
+ assert.equal(f.close.getAttribute('role'), 'button');
+ assert.equal(f.close.getAttribute('tabindex'), '0');
+ assert.equal(f.close.getAttribute('aria-label'), 'Close data attribution');
+});
+
+test('Enter opens and closes attribution on initial keydown', () => {
+ const f = fixture();
+ let opened = 0;
+ let closed = 0;
+ f.expand.onclick = () => { opened++; };
+ f.close.onclick = () => { closed++; };
+ configureCreditKeyboardAccess(f.root);
+ const openEvent = f.expand.dispatch('keydown', { key: 'Enter' });
+ assert.equal(openEvent.defaultPrevented, true);
+ assert.equal(opened, 1);
+ assert.equal(f.expand.getAttribute('aria-expanded'), 'true');
+ assert.equal(f.close.focused, true);
+ const closeEvent = f.close.dispatch('keydown', { key: 'Enter' });
+ assert.equal(closeEvent.defaultPrevented, true);
+ assert.equal(closed, 1);
+ assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
+ assert.equal(f.expand.focused, true);
+});
+
+test('Space opens and closes attribution only on key release', () => {
+ const f = fixture();
+ let opened = 0;
+ let closed = 0;
+ f.expand.onclick = () => { opened++; };
+ f.close.onclick = () => { closed++; };
+ configureCreditKeyboardAccess(f.root);
+
+ const openDown = f.expand.dispatch('keydown', { key: ' ' });
+ assert.equal(openDown.defaultPrevented, true);
+ assert.equal(opened, 0);
+ assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
+ const openUp = f.expand.dispatch('keyup', { key: ' ' });
+ assert.equal(openUp.defaultPrevented, true);
+ assert.equal(opened, 1);
+ assert.equal(f.expand.getAttribute('aria-expanded'), 'true');
+ assert.equal(f.close.focused, true);
+
+ const closeDown = f.close.dispatch('keydown', { key: ' ' });
+ assert.equal(closeDown.defaultPrevented, true);
+ assert.equal(closed, 0);
+ const closeUp = f.close.dispatch('keyup', { key: ' ' });
+ assert.equal(closeUp.defaultPrevented, true);
+ assert.equal(closed, 1);
+ assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
+ assert.equal(f.expand.focused, true);
+});
+
+test('held activation keys do not repeat open or close actions', () => {
+ const f = fixture();
+ let opened = 0;
+ f.expand.onclick = () => { opened++; };
+ configureCreditKeyboardAccess(f.root);
+
+ f.expand.dispatch('keydown', { key: ' ' });
+ const repeated = f.expand.dispatch('keydown', { key: ' ', repeat: true });
+ assert.equal(repeated.defaultPrevented, true);
+ assert.equal(opened, 0);
+ f.expand.dispatch('keyup', { key: ' ' });
+ assert.equal(opened, 1);
+ f.expand.dispatch('keyup', { key: ' ' });
+ assert.equal(opened, 1);
+
+ const g = fixture();
+ let enterOpened = 0;
+ g.expand.onclick = () => { enterOpened++; };
+ configureCreditKeyboardAccess(g.root);
+ g.expand.dispatch('keydown', { key: 'Enter' });
+ g.expand.dispatch('keydown', { key: 'Enter', repeat: true });
+ assert.equal(enterOpened, 1);
+});
+
+test('losing focus during a Space hold cancels release activation', () => {
+ const f = fixture();
+ let opened = 0;
+ f.expand.onclick = () => { opened++; };
+ configureCreditKeyboardAccess(f.root);
+
+ f.expand.dispatch('keydown', { key: ' ' });
+ f.expand.dispatch('blur');
+ const release = f.expand.dispatch('keyup', { key: ' ' });
+ assert.equal(release.defaultPrevented, true);
+ assert.equal(opened, 0);
+ assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
+});
+
+test('Escape and backdrop dismissal close attribution and restore its disclosure', () => {
+ const f = fixture();
+ let closed = 0;
+ f.close.onclick = () => { closed++; };
+ configureCreditKeyboardAccess(f.root);
+ f.expand.click();
+ const escape = f.lightbox.dispatch('keydown', { key: 'Escape', target: f.close });
+ assert.equal(escape.defaultPrevented, true);
+ assert.equal(escape.propagationStopped, true);
+ assert.equal(closed, 1);
+ assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
+ assert.equal(f.expand.focused, true);
+
+ f.expand.focused = false;
+ f.expand.click();
+ f.overlay.dispatch('click', { target: f.overlay });
+ assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
+ assert.equal(f.expand.focused, true);
+});
+
+test('credit keyboard setup is idempotent and safely declines incomplete markup', () => {
+ const f = fixture();
+ assert.equal(configureCreditKeyboardAccess(f.root), true);
+ assert.equal(configureCreditKeyboardAccess(f.root), true);
+ assert.equal(f.expand.listenerCount('keydown'), 1);
+ assert.equal(f.expand.listenerCount('keyup'), 1);
+ assert.equal(f.expand.listenerCount('blur'), 1);
+ assert.equal(f.close.listenerCount('keydown'), 1);
+ assert.equal(f.close.listenerCount('keyup'), 1);
+ assert.equal(f.close.listenerCount('blur'), 1);
+ assert.equal(f.lightbox.listenerCount('keydown'), 1);
+ assert.equal(f.overlay.listenerCount('click'), 1);
+ assert.equal(configureCreditKeyboardAccess({ querySelector: () => null }), false);
+});
diff --git a/src/data/earthquakes.js b/src/data/earthquakes.js
index 18767adb2..cb4d3ac13 100644
--- a/src/data/earthquakes.js
+++ b/src/data/earthquakes.js
@@ -121,6 +121,35 @@ export function mapAnalystRecord(raw, index = 0) {
};
}
+/** Validate a complete feed before replacing the last good earthquake snapshot. */
+export function normalizeEarthquakeSnapshot(geojson) {
+ if (!Array.isArray(geojson?.features)) return null;
+ const rows = [];
+ const ids = new Set();
+ for (const [index, feature] of geojson.features.entries()) {
+ const coordinates = feature?.geometry?.coordinates;
+ const properties = feature?.properties;
+ if (!Array.isArray(coordinates) || coordinates.length < 2 || !properties
+ || typeof properties !== 'object' || Array.isArray(properties)
+ || (feature.geometry.type != null && feature.geometry.type !== 'Point')) return null;
+ const [lon, lat, depthKm] = coordinates;
+ const mag = properties.mag;
+ if (!Number.isFinite(lon) || Math.abs(lon) > 180
+ || !Number.isFinite(lat) || Math.abs(lat) > 90
+ || (depthKm != null && !Number.isFinite(depthKm))
+ || (mag != null && (!Number.isFinite(mag) || mag > 10))) return null;
+ // A missing magnitude cannot establish that this event meets M2.5+.
+ if (mag == null || mag < 2.5) continue;
+ const stableId = feature.id == null || feature.id === '' ? `event-${index + 1}` : String(feature.id);
+ if (ids.has(stableId)) return null;
+ ids.add(stableId);
+ rows.push({ stableId, usgsId: feature.id ?? null, lon, lat, depthKm: depthKm ?? null,
+ mag, place: typeof properties.place === 'string' ? properties.place : null,
+ time: Number.isFinite(properties.time) ? properties.time : null });
+ }
+ return rows;
+}
+
export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = {}) {
let _dataSource = null;
let _count = 0;
@@ -172,23 +201,17 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
}
const geojson = await response.json();
- if (!geojson || !Array.isArray(geojson.features)) {
+ const rows = normalizeEarthquakeSnapshot(geojson);
+ if (!rows) {
_lastError = 'Malformed USGS response';
return false;
}
- _dataSource.entities.removeAll();
+ const nextEntities = [];
let count = 0;
const overlayEntries = [];
- for (const feature of geojson.features) {
- const [lon, lat, depthKm] = feature.geometry.coordinates;
- const mag = feature.properties.mag;
- const place = feature.properties.place;
- const time = feature.properties.time;
-
- if (mag < 2.5) continue; // Skip micro-quakes
-
+ for (const { stableId, usgsId, lon, lat, depthKm, mag, place, time } of rows) {
count++;
const baseRadius = Math.pow(2, mag) * 1000;
const color = depthColor(depthKm || 0);
@@ -197,8 +220,7 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
const outlineAlpha = isSignificant ? 1.0 : 0.8;
const position = Cesium.Cartesian3.fromDegrees(lon, lat);
- const stableId = feature.id || `event-${count}`;
- _dataSource.entities.add({
+ nextEntities.push(new Cesium.Entity({
id: `earthquake:${stableId}`,
position,
ellipse: {
@@ -216,13 +238,13 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
},
properties: {
// Analyst seam (additive): the USGS event id (e.g. "us7000abcd").
- usgsId: feature.id ?? null,
+ usgsId,
mag,
place,
time,
depth: depthKm,
},
- });
+ }));
overlayEntries.push(createEarthquakeOverlayEntry({
id: String(stableId),
position,
@@ -231,6 +253,8 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
}));
}
+ _dataSource.entities.removeAll();
+ for (const entity of nextEntities) _dataSource.entities.add(entity);
if (_enabled) {
overlayHost.setEntries(
EARTHQUAKE_OVERLAY_SOURCE_ID,
diff --git a/src/data/earthquakes.test.mjs b/src/data/earthquakes.test.mjs
index 3e2e91fe1..8119a6dc7 100644
--- a/src/data/earthquakes.test.mjs
+++ b/src/data/earthquakes.test.mjs
@@ -338,3 +338,66 @@ test('earthquake refresh reports failure and clears it only after a successful r
layer.destroy(viewer);
}
});
+
+test('malformed earthquake refresh preserves entities, overlays, count and timestamp', async () => {
+ const originalFetch = globalThis.fetch;
+ const dataSources = [];
+ const publications = [];
+ const viewer = { dataSources: {
+ add(source) { dataSources.push(source); }, remove() { return true; },
+ } };
+ const layer = createEarthquakesLayer({ overlayHost: {
+ setEntries(...args) { publications.push(args); }, setVisible() {}, clearSource() {},
+ } });
+ const good = { id: 'good', geometry: { type: 'Point', coordinates: [10, 20, 5] },
+ properties: { mag: 4, time: 100, place: 'Fixture' } };
+ const respond = (features) => { globalThis.fetch = async () => ({ ok: true, json: async () => ({ features }) }); };
+ try {
+ layer.init(viewer);
+ layer.enable(viewer);
+ respond([good]);
+ assert.equal(await layer.update(viewer), true);
+ const entity = dataSources[0].entities.values[0];
+ const stats = layer.getStats();
+ for (const properties of ['invalid', 123, [], true]) {
+ const bad = { ...good, id: 'bad-properties', properties };
+ for (const features of [[bad], [good, bad]]) {
+ respond(features);
+ assert.equal(await layer.update(viewer), false);
+ assert.equal(dataSources[0].entities.values.length, 1);
+ assert.equal(dataSources[0].entities.values[0], entity);
+ assert.equal(layer.getStats().count, stats.count);
+ assert.equal(layer.getStats().lastUpdate, stats.lastUpdate);
+ assert.equal(layer.getStats().error, 'Malformed USGS response');
+ assert.equal(publications.length, 1);
+ }
+ }
+ for (const bad of [null, { ...good, geometry: null },
+ { ...good, geometry: { type: 'LineString', coordinates: [1, 2] } },
+ { ...good, geometry: { coordinates: [181, 20] } },
+ { ...good, geometry: { coordinates: [10, -91] } },
+ { ...good, geometry: { coordinates: [null, 20] } },
+ { ...good, properties: { mag: '4' } },
+ { ...good, properties: { mag: Infinity } }, good]) {
+ respond([good, bad]);
+ assert.equal(await layer.update(viewer), false);
+ assert.equal(dataSources[0].entities.values.length, 1);
+ assert.equal(dataSources[0].entities.values[0], entity);
+ assert.equal(layer.getStats().count, stats.count);
+ assert.equal(layer.getStats().lastUpdate, stats.lastUpdate);
+ assert.equal(layer.getStats().error, 'Malformed USGS response');
+ assert.equal(publications.length, 1);
+ }
+ respond([{ ...good, properties: { mag: null } }]);
+ assert.equal(await layer.update(viewer), true);
+ assert.equal(dataSources[0].entities.values.length, 0);
+ assert.equal(layer.getStats().error, null);
+ assert.equal(publications.at(-1)[1].length, 0);
+ respond([good]);
+ assert.equal(await layer.update(viewer), true);
+ assert.equal(dataSources[0].entities.values.length, 1);
+ } finally {
+ globalThis.fetch = originalFetch;
+ layer.destroy(viewer);
+ }
+});
diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs
new file mode 100644
index 000000000..5de363090
--- /dev/null
+++ b/src/data/firmsProxy.test.mjs
@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import test from 'node:test';
+import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
+
+const config = fs.readFileSync(new URL('../../vite.config.js', import.meta.url), 'utf8');
+const start = config.indexOf(' async function refreshUpstream(key) {');
+assert.notEqual(start, -1, 'FIRMS refresh function exists');
+const end = config.indexOf('\n }', start);
+assert.notEqual(end, -1, 'FIRMS refresh function closes');
+const refreshSource = config.slice(start, end + 4);
+const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT'];
+const NOW = Date.UTC(2026, 8, 11, 12);
+const recent = { acqDate: '2026-09-11', acqTime: '1100' };
+
+// Exercise the production refresh without opening a server or using a MAP_KEY.
+// Inject only its upstream, clock and filter dependencies; keep its aggregation
+// and source-status code intact, including failures while consuming records.
+function createRefresh(fetchSource, filter = filterTrailing24h) {
+ return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console',
+ `return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} });
+}
+
+test('FIRMS retains large sources in order and filters expired rows', async () => {
+ const header = 'latitude,longitude,acq_date,acq_time,confidence,frp\n';
+ const large = parseFirmsCsv(header + '1,2,2026-09-11,1100,n,4\n'.repeat(200_000));
+ const expired = { ...recent, acqDate: '2026-09-09' };
+ const last = { ...recent, marker: 'last' };
+ const calls = [];
+ let active = 0;
+ const refresh = createRefresh(async (key, source) => {
+ assert.equal(active++, 0, 'sources must be fetched sequentially');
+ calls.push(source);
+ await Promise.resolve();
+ active--;
+ return source === SOURCES[0] ? [...large, expired] : source === SOURCES[1] ? [last] : [];
+ });
+ const result = await refresh('fixture');
+ assert.deepEqual(calls, SOURCES);
+ assert.equal(result.fires.length, 200_001);
+ assert.equal(result.fires[0], large[0]);
+ assert.equal(result.fires[199_999], large.at(-1));
+ assert.equal(result.fires.at(-1), last);
+ assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
+ source, count: [200_000, 1, 0][index], ok: true,
+ })));
+});
+
+test('FIRMS keeps successful sources when another upstream fails', async () => {
+ const result = await createRefresh(async (key, source) => {
+ if (source === SOURCES[1]) throw new Error('upstream unavailable');
+ return [recent];
+ })('fixture');
+ assert.equal(result.fires.length, 2);
+ assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
+ source, count: index === 1 ? 0 : 1, ok: index !== 1,
+ })));
+});
+
+test('FIRMS reports one failure if consuming a source throws before append', async () => {
+ const failing = [];
+ const result = await createRefresh(async (key, source) => source === SOURCES[0] ? failing : [recent],
+ (records, now) => {
+ if (records !== failing) return filterTrailing24h(records, now);
+ // Fault injection for aggregation; ordinary parsed CSV returns an array.
+ return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } };
+ })('fixture');
+ assert.equal(result.fires.length, 2);
+ assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
+ source, count: index === 0 ? 0 : 1, ok: index !== 0,
+ })));
+});
+
+test('FIRMS distinguishes all-source failure from successful empty sources', async () => {
+ await assert.rejects(createRefresh(async () => { throw new Error('upstream unavailable'); })('fixture'),
+ /all FIRMS sources failed/);
+ const result = await createRefresh(async () => [])('fixture');
+ assert.deepEqual(result.fires, []);
+ assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
+});
diff --git a/src/data/manager.js b/src/data/manager.js
index 41635187a..7db25def4 100644
--- a/src/data/manager.js
+++ b/src/data/manager.js
@@ -2043,16 +2043,23 @@ export class DataLayerManager {
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
const toggle = document.createElement('button');
+ toggle.type = 'button';
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
this._syncToggleButton(toggle, layer);
toggle.addEventListener('click', async () => {
- toggle.disabled = true;
+ // Native `disabled` immediately evicts keyboard focus in Chromium. Keep
+ // the lifecycle control focusable while it is busy, and enforce the
+ // same single-flight interaction contract through ARIA instead.
+ if (toggle.getAttribute('aria-disabled') === 'true') return;
+ toggle.setAttribute('aria-disabled', 'true');
+ toggle.setAttribute('aria-busy', 'true');
try {
await this.setEnabled(layer.id, !this.isEnabled(layer.id), { origin: 'user' });
} catch (error) {
console.warn(`[Data] ${layer.id} toggle error:`, error);
} finally {
- toggle.disabled = false;
+ const current = this.getAll().find(({ id }) => id === layer.id);
+ if (current) this._syncToggleButton(toggle, current);
}
});
@@ -2266,7 +2273,12 @@ export class DataLayerManager {
button.dataset.feedState = transitioning
? layer.lifecycleState
: (uncertain ? 'uncertain' : feedState);
- button.disabled = transitioning;
+ // A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
+ // click guard above prevents repeat activation without the focus loss caused
+ // by native `disabled`.
+ button.disabled = false;
+ button.setAttribute('aria-disabled', String(transitioning));
+ button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs
index 267224567..60b7b3fdc 100644
--- a/src/data/manager.test.mjs
+++ b/src/data/manager.test.mjs
@@ -2757,6 +2757,7 @@ function makeControlElement() {
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
addEventListener(name, handler) { this.listeners[name] = handler; },
setAttribute(name, value) { this.attributes[name] = String(value); },
+ getAttribute(name) { return this.attributes[name] ?? null; },
closest(selector) {
const className = selector.slice(1);
return String(this.className).split(/\s+/).includes(className) ? this : null;
@@ -2783,6 +2784,163 @@ function makeControlElement() {
return element;
}
+test('keyboard focus survives Data Layer enabling and disabling transitions', async () => {
+ const originalDocument = globalThis.document;
+ globalThis.document = { createElement: makeControlElement, activeElement: null };
+ const mgr = new DataLayerManager({});
+ let releaseEnable;
+ let releaseDisable;
+ const calls = { enable: 0, disable: 0 };
+ mgr.register({
+ id: 'focus-layer',
+ name: 'Focus layer',
+ icon: '',
+ source: 'Local focus fixture',
+ updateInterval: -1,
+ init() {},
+ enable() {
+ calls.enable += 1;
+ return new Promise((resolve) => { releaseEnable = resolve; });
+ },
+ disable() {
+ calls.disable += 1;
+ return new Promise((resolve) => { releaseDisable = resolve; });
+ },
+ update() {},
+ destroy() {},
+ getStats() { return { count: 1, lastUpdate: Date.now() }; },
+ });
+ const container = makeControlElement();
+
+ const waitFor = async (predicate, label) => {
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ if (predicate()) return;
+ await Promise.resolve();
+ }
+ assert.fail(`timed out waiting for ${label}`);
+ };
+
+ try {
+ mgr.buildTogglePanel(container);
+ const row = container.querySelector('[data-layer-id="focus-layer"]');
+ const button = row.querySelector('.data-toggle-btn');
+ button.focus();
+
+ const enablingClick = button.listeners.click();
+ await waitFor(() => typeof releaseEnable === 'function', 'enable transition');
+ assert.equal(globalThis.document.activeElement, button);
+ assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
+ assert.equal(button.getAttribute('aria-disabled'), 'true');
+ assert.equal(button.getAttribute('aria-busy'), 'true');
+ assert.equal(button.textContent, 'ENABLING');
+ await button.listeners.click();
+ assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
+ assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
+
+ releaseEnable();
+ await enablingClick;
+ assert.equal(globalThis.document.activeElement, button);
+ assert.equal(button.disabled, false);
+ assert.equal(button.getAttribute('aria-disabled'), 'false');
+ assert.equal(button.getAttribute('aria-busy'), 'false');
+ assert.equal(mgr.isEnabled('focus-layer'), true);
+
+ const disablingClick = button.listeners.click();
+ await waitFor(() => typeof releaseDisable === 'function', 'disable transition');
+ assert.equal(globalThis.document.activeElement, button);
+ assert.equal(button.disabled, false, 'DISABLING never uses native disabled');
+ assert.equal(button.getAttribute('aria-disabled'), 'true');
+ assert.equal(button.getAttribute('aria-busy'), 'true');
+ assert.equal(button.textContent, 'DISABLING');
+ await button.listeners.click();
+ assert.equal(calls.disable, 1, 'repeat activation is inert while disabling');
+ assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
+
+ releaseDisable();
+ await disablingClick;
+ assert.equal(globalThis.document.activeElement, button);
+ assert.equal(button.disabled, false);
+ assert.equal(button.getAttribute('aria-disabled'), 'false');
+ assert.equal(button.getAttribute('aria-busy'), 'false');
+ assert.equal(mgr.isEnabled('focus-layer'), false);
+ } finally {
+ releaseEnable?.();
+ releaseDisable?.();
+ await mgr.destroyAll();
+ if (originalDocument === undefined) delete globalThis.document;
+ else globalThis.document = originalDocument;
+ }
+});
+
+test('a failed Data Layer transition clears busy state without losing keyboard focus', async () => {
+ const originalDocument = globalThis.document;
+ const originalWarn = console.warn;
+ globalThis.document = { createElement: makeControlElement, activeElement: null };
+ console.warn = () => {};
+ const mgr = new DataLayerManager({});
+ let rejectEnable;
+ let enableCalls = 0;
+ mgr.register({
+ id: 'focus-failure-layer',
+ name: 'Focus failure layer',
+ icon: '',
+ source: 'Local focus fixture',
+ updateInterval: -1,
+ init() {},
+ enable() {
+ enableCalls += 1;
+ if (enableCalls > 1) return true;
+ return new Promise((resolve, reject) => { rejectEnable = reject; });
+ },
+ disable() { return true; },
+ update() {},
+ destroy() {},
+ getStats() { return { count: 0, lastUpdate: null }; },
+ });
+ const container = makeControlElement();
+
+ try {
+ mgr.buildTogglePanel(container);
+ const button = container
+ .querySelector('[data-layer-id="focus-failure-layer"]')
+ .querySelector('.data-toggle-btn');
+ button.focus();
+
+ const failedClick = button.listeners.click();
+ for (let attempt = 0; attempt < 20 && typeof rejectEnable !== 'function'; attempt += 1) {
+ await Promise.resolve();
+ }
+ assert.equal(typeof rejectEnable, 'function');
+ assert.equal(globalThis.document.activeElement, button);
+ assert.equal(button.getAttribute('aria-disabled'), 'true');
+ assert.equal(button.getAttribute('aria-busy'), 'true');
+
+ rejectEnable(new Error('QA enable rejected'));
+ await failedClick;
+ assert.equal(globalThis.document.activeElement, button, 'failure settlement preserves focus');
+ assert.equal(button.disabled, false);
+ assert.equal(button.getAttribute('aria-disabled'), 'false');
+ assert.equal(button.getAttribute('aria-busy'), 'false');
+ assert.equal(button.textContent, 'OFF');
+ assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
+ enabled: false,
+ lifecycleState: 'disabled',
+ uncertain: false,
+ });
+
+ await button.listeners.click();
+ assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
+ assert.equal(globalThis.document.activeElement, button);
+ assert.equal(mgr.isEnabled('focus-failure-layer'), true);
+ } finally {
+ rejectEnable?.(new Error('test cleanup'));
+ console.warn = originalWarn;
+ await mgr.destroyAll();
+ if (originalDocument === undefined) delete globalThis.document;
+ else globalThis.document = originalDocument;
+ }
+});
+
/** Collect every node in a rendered subtree carrying `className`. */
function collectByClass(node, className) {
const found = [];
diff --git a/src/data/militaryAwareness.js b/src/data/militaryAwareness.js
index 329357871..ca100eeb5 100644
--- a/src/data/militaryAwareness.js
+++ b/src/data/militaryAwareness.js
@@ -982,6 +982,43 @@ function navigationState() {
};
}
+/** Stable identity for a delegated Contacts-panel button across live repaints. */
+export function awarenessPanelControlKey(element) {
+ const control = element?.closest?.(
+ 'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
+ );
+ if (!control) return null;
+ if (control.dataset.awarenessAction) return `action:${control.dataset.awarenessAction}`;
+ return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
+}
+
+/** Capture stable identity so a live repaint can restore only the same control. */
+export function captureAwarenessPanelFocus(panel, activeElement = document.activeElement) {
+ if (!panel?.contains?.(activeElement)) return null;
+ if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
+ return { key: 'continuation' };
+ }
+ const key = awarenessPanelControlKey(activeElement);
+ if (!key) return null;
+ return { key };
+}
+
+/** Restore the same control, or continue beyond the list when it is no longer rendered. */
+export function restoreAwarenessPanelFocus(panel, snapshot) {
+ if (!panel || !snapshot) return null;
+ const continuation = panel.querySelector('[data-awareness-focus-continuation]');
+ const controls = [...panel.querySelectorAll(
+ 'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
+ )].filter((control) => !control.disabled);
+ const retained = snapshot.key === 'continuation'
+ ? continuation
+ : controls.find((control) => awarenessPanelControlKey(control) === snapshot.key);
+ const target = retained
+ || continuation;
+ target?.focus?.({ preventScroll: true });
+ return target || null;
+}
+
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[char]);
}
@@ -993,11 +1030,13 @@ function renderResults() {
const markup = `${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
- Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.
`;
+ Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.
`;
panel.hidden = false;
if (state.panelMarkup !== markup) {
+ const focusSnapshot = captureAwarenessPanelFocus(panel);
panel.innerHTML = markup;
state.panelMarkup = markup;
+ restoreAwarenessPanelFocus(panel, focusSnapshot);
}
}
diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs
index 43e0c33cf..c16b9ae94 100644
--- a/src/data/militaryAwareness.test.mjs
+++ b/src/data/militaryAwareness.test.mjs
@@ -10,14 +10,17 @@ import militaryAwarenessLayer, {
awarenessRefreshIntervalMs,
awarenessRefreshRequired,
awarenessResultsAreLive,
+ awarenessPanelControlKey,
buildAwarenessContextSnapshot,
canNavigateAwarenessNext,
contactsWindowFromSnapshot,
contextTargetFlyToAllowed,
+ captureAwarenessPanelFocus,
summarizeInstallationViewport,
findCompatibleHistoryIndex,
AWARENESS_QUERY_LIMIT,
historySubjectSnapshot,
+ restoreAwarenessPanelFocus,
} from './militaryAwareness.js';
import flightsLayer, {
_setTrackedFlightRefreshStateForTest,
@@ -212,6 +215,96 @@ function installAwarenessRuntime({
};
}
+function awarenessControl(dataset, { disabled = false } = {}) {
+ return {
+ dataset,
+ disabled,
+ focusCalls: 0,
+ closest(selector) {
+ if (selector === '[data-awareness-focus-continuation]') {
+ return this.isContinuation ? this : null;
+ }
+ return selector.includes('button') && !this.isContinuation ? this : null;
+ },
+ matches(selector) {
+ return selector === '[data-awareness-focus-continuation]' && this.isContinuation;
+ },
+ focus(options) {
+ this.focusCalls += 1;
+ this.focusOptions = options;
+ },
+ };
+}
+
+function awarenessPanel(controls) {
+ const continuation = awarenessControl({});
+ continuation.isContinuation = true;
+ return {
+ controls,
+ continuation,
+ contains(element) { return controls.includes(element) || element === continuation; },
+ querySelectorAll() { return this.controls; },
+ querySelector(selector) {
+ return selector === '[data-awareness-focus-continuation]' ? continuation : null;
+ },
+ };
+}
+
+test('Contacts live repaint restores only stable identity and never retargets another contact', () => {
+ const previous = awarenessControl({ awarenessAction: 'previous' });
+ const focus = awarenessControl({ awarenessAction: 'focus' });
+ const next = awarenessControl({ awarenessAction: 'next' });
+ const alpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' });
+ const bravo = awarenessControl({ awarenessLayer: 'military', awarenessId: 'def456' });
+ const before = awarenessPanel([previous, focus, next, alpha, bravo]);
+ const alphaSnapshot = captureAwarenessPanelFocus(before, alpha);
+
+ assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' });
+ assert.equal(awarenessPanelControlKey(next), 'action:next');
+
+ const retainedAlpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' });
+ const retained = awarenessPanel([previous, focus, next, bravo, retainedAlpha]);
+ assert.equal(restoreAwarenessPanelFocus(retained, alphaSnapshot), retainedAlpha);
+ assert.equal(retainedAlpha.focusCalls, 1, 'identity wins even when live distance reorders rows');
+ assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true });
+
+ const forward = awarenessControl({ awarenessLayer: 'ais-live-vessels', awarenessId: '789' });
+ const departed = awarenessPanel([previous, focus, next, forward]);
+ assert.equal(restoreAwarenessPanelFocus(departed, alphaSnapshot), departed.continuation);
+ assert.equal(forward.focusCalls, 0, 'automatic paging must not transfer focus to another contact');
+ assert.equal(departed.continuation.focusCalls, 1);
+
+ const bravoSnapshot = captureAwarenessPanelFocus(before, bravo);
+ const noRemainingRow = awarenessPanel([previous, focus, next, alpha]);
+ assert.equal(
+ restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot),
+ noRemainingRow.continuation,
+ );
+ assert.equal(noRemainingRow.continuation.focusCalls, 1, 'the end sentinel continues Tab beyond the list');
+
+ const continuationSnapshot = captureAwarenessPanelFocus(departed, departed.continuation);
+ assert.deepEqual(continuationSnapshot, { key: 'continuation' });
+ const repaintedAgain = awarenessPanel([previous, focus, next, forward]);
+ assert.equal(
+ restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot),
+ repaintedAgain.continuation,
+ );
+ assert.equal(
+ repaintedAgain.continuation.focusCalls,
+ 1,
+ 'later live repaints keep focus on the continuation point until the user tabs onward',
+ );
+});
+
+test('Contacts continuation target is the visible explanatory note', () => {
+ const source = fs.readFileSync(new URL('./militaryAwareness.js', import.meta.url), 'utf8');
+ assert.match(
+ source,
+ /Open-source mapped\/observed context\./,
+ );
+ assert.doesNotMatch(source, /]*data-awareness-focus-continuation/);
+});
+
test('awareness disable settles every owned dependency release before resolving', async () => {
let releasing = false;
let releaseDependencies;
diff --git a/src/data/radioCountry.js b/src/data/radioCountry.js
index f72a50f4d..6b056ce10 100644
--- a/src/data/radioCountry.js
+++ b/src/data/radioCountry.js
@@ -55,6 +55,22 @@ for (const [name, code] of Object.entries({
brunei: 'BN',
'cape verde': 'CV',
'the netherlands': 'NL',
+ // Common English names and exonyms whose code is not reachable through the
+ // Intl.DisplayNames primary label, so they would otherwise fail closed
+ // (verified against ICU 77): TR resolves only as 'Türkiye', MM only as
+ // 'Myanmar (Burma)', AE only as 'United Arab Emirates', SZ as 'Eswatini',
+ // TL as 'Timor-Leste'. Each maps to exactly one country; ambiguous names
+ // (e.g. bare "Congo") are deliberately left to fail closed.
+ turkey: 'TR',
+ turkiye: 'TR',
+ myanmar: 'MM',
+ burma: 'MM',
+ uae: 'AE',
+ holland: 'NL',
+ swaziland: 'SZ',
+ 'east timor': 'TL',
+ 'cabo verde': 'CV',
+ vatican: 'VA',
})) COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
function canonicalCountryName(code) {
diff --git a/src/data/radioCountry.test.mjs b/src/data/radioCountry.test.mjs
index 70511de87..3270b46dc 100644
--- a/src/data/radioCountry.test.mjs
+++ b/src/data/radioCountry.test.mjs
@@ -18,6 +18,37 @@ test('Radio country normalization maps ISO codes and bounded common names', () =
}
});
+test('Radio country normalization resolves common English names/exonyms ICU misses', () => {
+ // Each of these fails closed against the Intl.DisplayNames primary label
+ // alone (e.g. TR is "Türkiye", MM is "Myanmar (Burma)", AE is "United Arab
+ // Emirates"), so a request like "play radio in Turkey" would return nothing.
+ for (const [input, code] of [
+ ['Turkey', 'TR'],
+ ['Turkiye', 'TR'],
+ ['Myanmar', 'MM'],
+ ['Burma', 'MM'],
+ ['UAE', 'AE'],
+ ['U.A.E.', 'AE'],
+ ['Holland', 'NL'],
+ ['Swaziland', 'SZ'],
+ ['East Timor', 'TL'],
+ ['Cabo Verde', 'CV'],
+ ['Vatican', 'VA'],
+ ]) {
+ const result = normalizeRadioCountryInput(input);
+ assert.equal(result.valid, true, input);
+ assert.equal(result.code, code, input);
+ }
+});
+
+test('Ambiguous country names still fail closed (no broadened selection)', () => {
+ // Two states share the name "Congo" (CD/CG), so a bare mention must not
+ // resolve to either — it stays low-confidence and fails closed.
+ for (const input of ['Congo', 'Korea']) {
+ assert.equal(normalizeRadioCountryInput(input).valid, false, input);
+ }
+});
+
test('Radio country normalization rejects malformed, non-ISO, and oversized values', () => {
for (const input of [
'ZZ',
diff --git a/src/data/rocketLaunches.js b/src/data/rocketLaunches.js
index bd9bf79c7..c52b9d20d 100644
--- a/src/data/rocketLaunches.js
+++ b/src/data/rocketLaunches.js
@@ -61,6 +61,7 @@ let _missionPanel = null;
let _missionRoster = null;
let _missionRosterHoverTimer = null;
let _hoveredRosterLaunchId = null;
+let _missionRosterPreviewOwnership = null;
let _missionHoverReticleImage = null;
let _replayVehicleOverlay = null;
let _replayVehicleOverlayText = '';
@@ -786,6 +787,115 @@ export function missionHoverPreviewRange(cameraHeight) {
);
}
+/**
+ * Coordinate pointer and keyboard ownership of the temporary roster preview.
+ * The most recent input owns the preview until it leaves, then any remaining
+ * input resumes ownership. This lets keyboard focus take over from a pointer
+ * resting on another row without losing that pointer preview on blur.
+ * @param {{preview: (index: number) => void, clear: () => void}} handlers
+ * @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void,
+ * focus: (index: number) => void, blur: (nextIndex?: number|null) => void,
+ * reset: () => void}}
+ */
+export function createMissionRosterPreviewOwnership({ preview, clear }) {
+ let pointerIndex = null;
+ let focusIndex = null;
+ let latestOwner = null;
+ let activeIndex = null;
+
+ const sync = () => {
+ const nextIndex = latestOwner === 'pointer'
+ ? pointerIndex ?? focusIndex
+ : focusIndex ?? pointerIndex;
+ if (nextIndex === activeIndex) return;
+ activeIndex = nextIndex;
+ if (Number.isInteger(nextIndex)) preview(nextIndex);
+ else clear();
+ };
+
+ return {
+ pointerEnter(index) {
+ pointerIndex = index;
+ latestOwner = 'pointer';
+ sync();
+ },
+ pointerLeave() {
+ pointerIndex = null;
+ if (latestOwner === 'pointer') latestOwner = focusIndex === null ? null : 'focus';
+ sync();
+ },
+ focus(index) {
+ focusIndex = index;
+ latestOwner = 'focus';
+ sync();
+ },
+ blur(nextIndex = null) {
+ focusIndex = Number.isInteger(nextIndex) ? nextIndex : null;
+ if (latestOwner === 'focus') latestOwner = focusIndex === null
+ ? (pointerIndex === null ? null : 'pointer')
+ : 'focus';
+ sync();
+ },
+ reset() {
+ pointerIndex = null;
+ focusIndex = null;
+ latestOwner = null;
+ activeIndex = null;
+ clear();
+ },
+ };
+}
+
+/**
+ * Bind native keyboard focus events for one rendered roster row.
+ * @param {Element} button Mission roster button.
+ * @param {number} index Source-array mission index.
+ * @param {{pointerEnter: (index: number) => void, pointerLeave: () => void,
+ * focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership
+ * @param {Element} roster Roster root used to reject focus targets outside the list.
+ */
+export function bindMissionRosterItemKeyboardPreview(button, index, ownership, roster) {
+ button.addEventListener('mouseenter', () => ownership.pointerEnter(index));
+ button.addEventListener('mouseleave', () => ownership.pointerLeave());
+ button.addEventListener('focus', () => ownership.focus(index));
+ button.addEventListener('blur', (event) => {
+ const nextButton = event.relatedTarget?.closest?.('[data-mission-roster-index]');
+ const nextIndex = nextButton && roster.contains(nextButton)
+ ? Number(nextButton.dataset.missionRosterIndex)
+ : null;
+ ownership.blur(nextIndex);
+ });
+}
+
+/** Capture the exact mission identity owned by keyboard focus before a refresh. */
+export function captureMissionRosterFocus(list, activeElement = globalThis.document?.activeElement) {
+ const button = activeElement?.closest?.('[data-mission-roster-id]');
+ if (!button || !list?.contains(button)) return null;
+ const launchId = button.dataset.missionRosterId;
+ return launchId ? { launchId } : null;
+}
+
+/** Restore focus by mission identity, or continue after the list if it departed. */
+export function restoreMissionRosterFocus(list, snapshot, continuation) {
+ if (!snapshot?.launchId) return 'none';
+ const button = Array.from(list?.querySelectorAll?.('[data-mission-roster-id]') || [])
+ .find((candidate) => candidate.dataset.missionRosterId === snapshot.launchId);
+ if (button) {
+ button.focus({ preventScroll: true });
+ return 'restored';
+ }
+ if (continuation?.focus) {
+ continuation.focus({ preventScroll: true });
+ return 'continued';
+ }
+ return 'none';
+}
+
+/** Resolve a pending preview against the current refresh, never a stale object. */
+export function resolveMissionRosterPreviewLaunch(launches, launchId) {
+ return (launches || []).find((candidate) => candidate.id === launchId) || null;
+}
+
/**
* Format a mission epoch for compact on-globe replay labels.
* @param {string|Date|null} launchTime ISO-8601 mission time or Date.
@@ -2106,13 +2216,15 @@ function normalizePayloadFlights(launch) {
const payload = flight.payload || flight;
return {
id: String(flight.id || payload.id || `payload-${index}`),
- name: payload.name || flight.name || 'Undisclosed payload',
+ name: payload.name || flight.name || 'Unnamed payload',
type: payload.type?.name || flight.type?.name || null,
manufacturer: payload.manufacturer?.name || null,
operator: payload.operator?.name || null,
destination: flight.destination || payload.destination || null,
amount: Number.isFinite(Number(flight.amount)) ? Number(flight.amount) : 1,
- massKg: Number.isFinite(Number(payload.mass)) ? Number(payload.mass) : null,
+ massKg: (typeof payload.mass === 'number' || (typeof payload.mass === 'string' && payload.mass.trim() !== ''))
+ && Number.isFinite(Number(payload.mass)) && Number(payload.mass) >= 0
+ ? Number(payload.mass) : null,
};
});
}
@@ -2288,7 +2400,7 @@ function renderMissionPanel() {
_missionPanel.querySelector('[data-mission-payloads]').innerHTML = missionTableRows(
payloadRows,
3,
- 'CLASSIFIED / MULTI-PAYLOAD',
+ 'PAYLOAD DATA UNAVAILABLE',
);
const stageRows = launch.recoveryStages.map((stage) => {
const endpoint = stage.endpoint;
@@ -2325,9 +2437,16 @@ function renderMissionRoster() {
const count = _missionRoster.querySelector('[data-mission-roster-count]');
if (count) count.textContent = `${_launches.length} / 30D`;
if (!list) return;
+ const focusSnapshot = captureMissionRosterFocus(list);
+ _missionRosterPreviewOwnership?.reset();
const entries = missionRosterEntries(_launches);
if (!entries.length) {
list.innerHTML = 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW
';
+ restoreMissionRosterFocus(
+ list,
+ focusSnapshot,
+ _missionRoster.querySelector('[data-mission-roster-focus-continuation]'),
+ );
return;
}
list.innerHTML = entries.map(({ launch, index }) => {
@@ -2335,8 +2454,22 @@ function renderMissionRoster() {
const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE';
const provider = launch.provider || 'UNSPECIFIED OPERATOR';
const label = shortMissionLabel(launch.name, 27).toUpperCase();
- return `${escapeMissionText(label)} ${escapeMissionText(provider)} · ${escapeMissionText(date)} › `;
+ return `${escapeMissionText(label)} ${escapeMissionText(provider)} · ${escapeMissionText(date)} › `;
}).join('');
+ list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
+ const index = Number(button.dataset.missionRosterIndex);
+ bindMissionRosterItemKeyboardPreview(
+ button,
+ index,
+ _missionRosterPreviewOwnership,
+ _missionRoster,
+ );
+ });
+ restoreMissionRosterFocus(
+ list,
+ focusSnapshot,
+ _missionRoster.querySelector('[data-mission-roster-focus-continuation]'),
+ );
}
function escapeMissionText(value) {
@@ -2382,7 +2515,7 @@ function selectMissionAt(index) {
focusMission(launch);
}
-function clearMissionRosterHover() {
+function clearMissionRosterPreviewState() {
if (_missionRosterHoverTimer) clearTimeout(_missionRosterHoverTimer);
_missionRosterHoverTimer = null;
const changed = _hoveredRosterLaunchId !== null;
@@ -2390,6 +2523,11 @@ function clearMissionRosterHover() {
if (changed) syncMissionOverlayEntries();
}
+function clearMissionRosterHover() {
+ if (_missionRosterPreviewOwnership) _missionRosterPreviewOwnership.reset();
+ else clearMissionRosterPreviewState();
+}
+
function previewMissionFromRoster(launch) {
if (!_viewer || !launch || _selectedLaunchId) return;
const range = missionHoverPreviewRange(_viewer.camera.positionCartographic?.height);
@@ -2412,7 +2550,9 @@ function scheduleMissionRosterPreview(index) {
syncMissionOverlayEntries();
_missionRosterHoverTimer = setTimeout(() => {
_missionRosterHoverTimer = null;
- if (_hoveredRosterLaunchId === launch.id) previewMissionFromRoster(launch);
+ if (_hoveredRosterLaunchId !== launch.id) return;
+ const currentLaunch = resolveMissionRosterPreviewLaunch(_launches, launch.id);
+ if (currentLaunch) previewMissionFromRoster(currentLaunch);
}, 140);
}
@@ -2497,6 +2637,10 @@ function createMissionPanel() {
if (!host) return;
_missionRoster = document.getElementById('space-mission-roster');
if (_missionRoster) {
+ _missionRosterPreviewOwnership = createMissionRosterPreviewOwnership({
+ preview: scheduleMissionRosterPreview,
+ clear: clearMissionRosterPreviewState,
+ });
_missionRoster.onclick = (event) => {
const button = event.target instanceof Element
? event.target.closest('[data-mission-roster-index]')
@@ -2504,23 +2648,6 @@ function createMissionPanel() {
if (!button) return;
selectMissionAt(Number(button.dataset.missionRosterIndex));
};
- _missionRoster.onmouseover = (event) => {
- const button = event.target instanceof Element
- ? event.target.closest('[data-mission-roster-index]')
- : null;
- if (!button || button.contains(event.relatedTarget)) return;
- scheduleMissionRosterPreview(Number(button.dataset.missionRosterIndex));
- };
- _missionRoster.onfocusin = (event) => {
- const button = event.target instanceof Element
- ? event.target.closest('[data-mission-roster-index]')
- : null;
- if (button) scheduleMissionRosterPreview(Number(button.dataset.missionRosterIndex));
- };
- _missionRoster.onmouseleave = clearMissionRosterHover;
- _missionRoster.onfocusout = (event) => {
- if (!_missionRoster.contains(event.relatedTarget)) clearMissionRosterHover();
- };
}
_missionPanel = document.createElement('aside');
_missionPanel.id = 'space-mission-panel';
@@ -3537,11 +3664,8 @@ const rocketLaunchesLayer = {
_missionPanel = null;
if (_missionRoster) {
_missionRoster.onclick = null;
- _missionRoster.onmouseover = null;
- _missionRoster.onfocusin = null;
- _missionRoster.onmouseleave = null;
- _missionRoster.onfocusout = null;
}
+ _missionRosterPreviewOwnership = null;
_missionRoster = null;
_viewer = null;
if (_dataSource) viewer.dataSources.remove(_dataSource, true);
diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs
index dd1ae4c3c..07b7666f6 100644
--- a/src/data/rocketLaunches.test.mjs
+++ b/src/data/rocketLaunches.test.mjs
@@ -6,9 +6,12 @@ import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
_setSelectedRocketMissionForTest,
approximateOrbitPath,
+ bindMissionRosterItemKeyboardPreview,
+ captureMissionRosterFocus,
buildMissionPaths,
cameraHeadingForPath,
compactLaunchSiteName,
+ createMissionRosterPreviewOwnership,
createRocketMissionElementOverlayEntry,
createRocketMissionMarkerOverlayEntry,
formatMissionEventTime,
@@ -41,6 +44,8 @@ import rocketLaunchesLayer, {
replayAscentDurationSeconds,
replayStartAfterPause,
replayVehicleScreenRotation,
+ resolveMissionRosterPreviewLaunch,
+ restoreMissionRosterFocus,
releaseAircraftTracking,
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY,
@@ -490,6 +495,150 @@ test('preserves globe scale for roster hover previews', () => {
assert.equal(missionHoverPreviewRange(Number.NaN), 5000000);
});
+test('keyboard focus previews roster missions and clears without selecting them', () => {
+ const calls = [];
+ const ownership = createMissionRosterPreviewOwnership({
+ preview: (index) => calls.push(['preview', index]),
+ clear: () => calls.push(['clear']),
+ });
+
+ ownership.focus(2);
+ ownership.blur(3);
+ ownership.blur();
+
+ assert.deepEqual(calls, [
+ ['preview', 2],
+ ['preview', 3],
+ ['clear'],
+ ]);
+});
+
+test('rendered mission row focus and blur drive the preview ownership controller', () => {
+ const listeners = new Map();
+ const calls = [];
+ const button = {
+ addEventListener(type, listener) {
+ listeners.set(type, listener);
+ },
+ };
+ const nextButton = {
+ dataset: { missionRosterIndex: '4' },
+ closest: () => nextButton,
+ };
+ const roster = { contains: (candidate) => candidate === nextButton };
+ const ownership = {
+ pointerEnter: (index) => calls.push(['pointer-enter', index]),
+ pointerLeave: () => calls.push(['pointer-leave']),
+ focus: (index) => calls.push(['focus', index]),
+ blur: (index) => calls.push(['blur', index]),
+ };
+
+ bindMissionRosterItemKeyboardPreview(button, 2, ownership, roster);
+ listeners.get('mouseenter')();
+ listeners.get('mouseleave')();
+ listeners.get('focus')();
+ listeners.get('blur')({ relatedTarget: nextButton });
+ listeners.get('blur')({ relatedTarget: null });
+
+ assert.deepEqual(calls, [
+ ['pointer-enter', 2],
+ ['pointer-leave'],
+ ['focus', 2],
+ ['blur', 4],
+ ['blur', null],
+ ]);
+});
+
+test('leaving a hovered row for an internal roster gap restores keyboard preview', () => {
+ const calls = [];
+ const ownership = createMissionRosterPreviewOwnership({
+ preview: (index) => calls.push(['preview', index]),
+ clear: () => calls.push(['clear']),
+ });
+
+ ownership.focus(1);
+ ownership.pointerEnter(0);
+ ownership.pointerLeave();
+
+ assert.deepEqual(calls, [
+ ['preview', 1],
+ ['preview', 0],
+ ['preview', 1],
+ ]);
+});
+
+test('mission roster refresh restores keyboard focus by stable mission identity after reorder', () => {
+ const focused = {
+ dataset: { missionRosterId: 'mission-b' },
+ closest: () => focused,
+ };
+ let restored = false;
+ const replacement = {
+ dataset: { missionRosterId: 'mission-b' },
+ focus: ({ preventScroll }) => { restored = preventScroll; },
+ };
+ const list = {
+ contains: (candidate) => candidate === focused,
+ querySelectorAll: () => [
+ { dataset: { missionRosterId: 'mission-c' } },
+ replacement,
+ { dataset: { missionRosterId: 'mission-a' } },
+ ],
+ };
+
+ const snapshot = captureMissionRosterFocus(list, focused);
+ assert.deepEqual(snapshot, { launchId: 'mission-b' });
+ assert.equal(restoreMissionRosterFocus(list, snapshot, null), 'restored');
+ assert.equal(restored, true);
+});
+
+test('mission roster refresh continues after the list when the focused mission departed', () => {
+ const focused = {
+ dataset: { missionRosterId: 'departed' },
+ closest: () => focused,
+ };
+ let continuationFocused = false;
+ const list = {
+ contains: (candidate) => candidate === focused,
+ querySelectorAll: () => [{ dataset: { missionRosterId: 'remaining' } }],
+ };
+ const continuation = {
+ focus: ({ preventScroll }) => { continuationFocused = preventScroll; },
+ };
+
+ const snapshot = captureMissionRosterFocus(list, focused);
+ assert.equal(restoreMissionRosterFocus(list, snapshot, continuation), 'continued');
+ assert.equal(continuationFocused, true);
+});
+
+test('pending mission preview resolves the current refreshed record by identity', () => {
+ const stale = { id: 'mission-a', name: 'Stale' };
+ const current = { id: 'mission-a', name: 'Current' };
+
+ assert.equal(resolveMissionRosterPreviewLaunch([current], stale.id), current);
+ assert.equal(resolveMissionRosterPreviewLaunch([], stale.id), null);
+});
+
+test('latest roster input owns preview and restores the remaining owner on leave', () => {
+ const calls = [];
+ const ownership = createMissionRosterPreviewOwnership({
+ preview: (index) => calls.push(['preview', index]),
+ clear: () => calls.push(['clear']),
+ });
+
+ ownership.pointerEnter(0);
+ ownership.focus(1);
+ ownership.blur();
+ ownership.pointerLeave();
+
+ assert.deepEqual(calls, [
+ ['preview', 0],
+ ['preview', 1],
+ ['preview', 0],
+ ['clear'],
+ ]);
+});
+
test('assigns distinct stable colors to mission operators', () => {
assert.equal(missionMarkerColor({ provider: 'NASA', name: 'Science Flight' }).toCssColorString(), 'rgb(255,159,67)');
assert.equal(missionMarkerColor({ provider: 'SpaceX', name: 'Starlink Group' }).toCssColorString(), 'rgb(76,201,240)');
@@ -1253,3 +1402,16 @@ test('disable reports a semantic failure while restoring the satellites dependen
/could not restore the satellites layer/,
);
});
+
+
+test('missing payload details stay unknown without inventing mass or classification', () => {
+ for (const [mass, expected] of [[null, null], [undefined, null], ['', null], [' ', null],
+ [false, null], [-1, null], ['invalid', null], [0, 0], ['125', 125]]) {
+ const [launch] = normalizeRocketLaunches([{ id: 'mass', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z',
+ payloads: [{ mass }] }], NOW);
+ assert.equal(launch.payloads[0].massKg, expected);
+ assert.equal(launch.payloads[0].name, 'Unnamed payload');
+ }
+ const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW);
+ assert.deepEqual(launch.payloads, []);
+});
diff --git a/src/devCctv.test.mjs b/src/devCctv.test.mjs
new file mode 100644
index 000000000..a333a4fe3
--- /dev/null
+++ b/src/devCctv.test.mjs
@@ -0,0 +1,80 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+
+const run = promisify(execFile);
+const bashTest = process.platform === 'win32' ? test.skip : test;
+
+async function launch(overrides = {}, dotenv = '') {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gev-cctv-launch-'));
+ try {
+ await fs.mkdir(path.join(root, 'scripts'));
+ await fs.mkdir(path.join(root, 'bin'));
+ await fs.mkdir(path.join(root, 'src', 'data'), { recursive: true });
+ await fs.copyFile(new URL('./data/cctv.js', import.meta.url), path.join(root, 'src', 'data', 'cctv.js'));
+ for (const name of ['dev-cctv.sh', 'dev-fresh.sh', 'read-dotenv-value.mjs']) {
+ await fs.copyFile(new URL(`../scripts/${name}`, import.meta.url), path.join(root, 'scripts', name));
+ }
+ await fs.copyFile(new URL('./main.js', import.meta.url), path.join(root, 'src', 'main.js'));
+ await fs.mkdir(path.join(root, 'node_modules'));
+ await fs.symlink(fileURLToPath(new URL('../node_modules/vite', import.meta.url)), path.join(root, 'node_modules', 'vite'), 'dir');
+ await fs.writeFile(path.join(root, '.env'), dotenv);
+ // Stub only external programs; both production launchers and dotenv parsing run.
+ for (const command of ['security', 'pkill', 'lsof']) {
+ await fs.writeFile(path.join(root, 'bin', command), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
+ }
+ await fs.writeFile(path.join(root, 'bin', 'npm'), `#!/usr/bin/env node
+const fs = require('node:fs');
+fs.writeFileSync(process.env.CCTV_TEST_CAPTURE, JSON.stringify({ args: process.argv.slice(2), env: process.env, cwd: process.cwd() }));
+`, { mode: 0o755 });
+ const capture = path.join(root, 'capture.json');
+ const result = await run('bash', [path.join(root, 'scripts', 'dev-cctv.sh')], {
+ cwd: os.tmpdir(),
+ env: { PATH: `${path.join(root, 'bin')}${path.delimiter}${process.env.PATH}`, CCTV_TEST_CAPTURE: capture, ...overrides },
+ timeout: 30_000,
+ });
+ return { ...JSON.parse(await fs.readFile(capture, 'utf8')), output: result.stdout + result.stderr, root };
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+}
+
+bashTest('CCTV preset starts keyless on localhost through the normal launcher', async () => {
+ const result = await launch();
+ assert.deepEqual(result.args, ['run', 'dev', '--', '--host', 'localhost', '--port', '4173', '--force']);
+ assert.equal(result.cwd, result.root);
+ assert.equal(result.env.CCTV_SOURCES_FILE, 'config/cctv_sources.austin.json');
+ assert.equal(result.env.CCTV_PREFER_AUSTIN, '1');
+ assert.equal(result.env.CCTV_AUSTIN_MAX_SOURCES, '36');
+ assert.equal(result.env.CCTV_MAX_SOURCES, '48');
+ assert.equal(result.env.GEV_LAUNCHER, 'dev-fresh');
+ assert.equal(result.env.GEV_KEY_SETUP_EXTERNAL_KEYS, '');
+ assert.equal(result.env.GOOGLE_MAPS_API_KEY, undefined);
+ assert.match(result.output, /Startup map: Esri World Imagery/);
+ assert.doesNotMatch(result.output, /!! WARNING/);
+});
+
+bashTest('CCTV preset preserves explicit LAN and source overrides with a warning', async () => {
+ const result = await launch({ HOST: '0.0.0.0', PORT: '4999', CCTV_SOURCES_FILE: 'config/custom.json', CCTV_PREFER_AUSTIN: '0', CCTV_AUSTIN_MAX_SOURCES: '5', CCTV_MAX_SOURCES: '9', CCTV_CALTRANS_DISTRICTS: '', CCTV_TFL_ENABLED: '0' });
+ assert.deepEqual(result.args.slice(-5), ['--host', '0.0.0.0', '--port', '4999', '--force']);
+ assert.equal(result.env.CCTV_SOURCES_FILE, 'config/custom.json');
+ assert.equal(result.env.CCTV_PREFER_AUSTIN, '0');
+ assert.equal(result.env.CCTV_AUSTIN_MAX_SOURCES, '5');
+ assert.equal(result.env.CCTV_MAX_SOURCES, '9');
+ assert.equal(result.env.CCTV_CALTRANS_DISTRICTS, '');
+ assert.equal(result.env.CCTV_TFL_ENABLED, '0');
+ assert.match(result.output, /!! WARNING: HOST=0\.0\.0\.0/);
+});
+
+bashTest('CCTV preset shares dotenv precedence and names-only credential provenance', async () => {
+ const result = await launch({ GOOGLE_MAPS_API_KEY: 'fixture-shell-maps' }, 'GOOGLE_MAPS_API_KEY=fixture-file-maps\nOPENAI_API_KEY=fixture-file-voice\n');
+ assert.equal(result.env.GOOGLE_MAPS_API_KEY, 'fixture-shell-maps');
+ assert.equal(result.env.OPENAI_API_KEY, 'fixture-file-voice');
+ assert.equal(result.env.GEV_KEY_SETUP_EXTERNAL_KEYS, 'GOOGLE_MAPS_API_KEY');
+ assert.doesNotMatch(result.output, /fixture-shell-maps|fixture-file-maps|fixture-file-voice/);
+});
diff --git a/src/hudA11y.test.mjs b/src/hudA11y.test.mjs
new file mode 100644
index 000000000..c2ef2e6dc
--- /dev/null
+++ b/src/hudA11y.test.mjs
@@ -0,0 +1,30 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
+const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
+
+// Focused markup guards; actual computed names are checked in Chromium.
+// Native labels and hidden inputs must not be treated as missing aria-labels.
+test('HUD sliders and location search have descriptive explicit names', () => {
+ for (const [id, name] of [
+ ['scope-feather-slider', 'Scope edge feather'],
+ ['bloom-intensity-slider', 'Bloom intensity'],
+ ['sharpen-intensity-slider', 'Sharpen intensity'],
+ ['location-search', 'Search location by name or coordinates'],
+ ]) {
+ const input = html.match(new RegExp(` ]*\\bid="${id}"[^>]*>`))?.[0];
+ assert.ok(input, `${id} exists`);
+ assert.ok(input.includes(`aria-label="${name}"`), `${id} has its descriptive name`);
+ }
+});
+
+test('the first-run checkbox keeps its native visible label', () => {
+ assert.match(html, /]*class="first-run-suppress"[^>]*>\s* \s*Don't show this again<\/span>\s*<\/label>/);
+});
+
+test('generated style sliders use the visible parameter label as their name', () => {
+ assert.match(ui, /label\.textContent\s*=\s*uMeta\.label;/);
+ assert.match(ui, /slider\.setAttribute\(['"]aria-label['"],\s*uMeta\.label\)/);
+});
diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs
new file mode 100644
index 000000000..3e6cfa541
--- /dev/null
+++ b/src/keyboardFocusStyles.test.mjs
@@ -0,0 +1,86 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+
+function ruleBody(selector) {
+ const start = css.indexOf(selector);
+ assert.ok(start >= 0, `${selector} rule exists`);
+ const open = css.indexOf('{', start);
+ const close = css.indexOf('}', open);
+ return css.slice(open + 1, close);
+}
+
+function ruleText(selector) {
+ const start = css.indexOf(selector);
+ assert.ok(start >= 0, `${selector} rule exists`);
+ const close = css.indexOf('}', css.indexOf('{', start));
+ return css.slice(start, close + 1);
+}
+
+function ruleBodyContaining(selector, declaration) {
+ let start = -1;
+ while ((start = css.indexOf(selector, start + 1)) >= 0) {
+ const open = css.indexOf('{', start);
+ const close = css.indexOf('}', open);
+ const body = css.slice(open + 1, close);
+ if (body.includes(declaration)) return body;
+ }
+ assert.fail(`${selector} rule containing ${declaration} exists`);
+}
+
+test('the global keyboard ring survives local active and outline-reset rules', () => {
+ const rule = ruleText(':where(\n button,');
+ assert.match(rule, /outline:\s*2px solid var\(--text-primary\) !important;/);
+ for (const selector of [
+ "[role='button']",
+ "[role='radio']",
+ "[role='slider']",
+ "[role='tab']",
+ '[tabindex]',
+ 'a[href]',
+ ]) assert.ok(rule.includes(selector), `${selector} receives the global ring`);
+});
+
+test('Location city, POI, search toggle and search field use an inset ring', () => {
+ const rule = ruleText('.location-pill:focus-visible,');
+ assert.match(rule, /\.poi-pill:focus-visible/);
+ assert.match(rule, /\.search-toggle-btn:focus-visible/);
+ assert.match(rule, /#location-search:focus-visible/);
+ assert.match(rule, /outline:\s*2px solid var\(--text-primary\)/);
+ assert.match(rule, /outline-offset:\s*-3px/);
+ for (const selector of ['.location-pill {', '.poi-pill {', '.search-toggle-btn {']) {
+ assert.doesNotMatch(ruleBody(selector), /transition:\s*all\b/);
+ }
+});
+
+test('Cockpit Display and Radio launcher glyphs have a complete inset ring', () => {
+ const body = ruleBody('.cockpit-utility-glyph:focus-visible');
+ assert.match(body, /outline:\s*2px solid var\(--text-primary\)/);
+ assert.match(body, /outline-offset:\s*-3px/);
+ assert.doesNotMatch(body, /outline:\s*none/);
+});
+
+test('focusable controls do not animate the global outline', () => {
+ for (const selector of [
+ '.panel-collapse-btn {',
+ '#top-center-actions button {',
+ '.data-toggle-chip {',
+ '.scene-btn {',
+ '.scene-shot-btn {',
+ ]) {
+ assert.doesNotMatch(
+ ruleBody(selector),
+ /transition:\s*all\b/,
+ `${selector} must leave the focus outline immediate`,
+ );
+ }
+});
+
+test('opening a dock popover makes its controls keyboard-reachable immediately', () => {
+ const base = ruleBodyContaining('#command-dock .dock-popover-content {', 'visibility: hidden');
+ assert.match(base, /visibility\s+0s\s+linear\s+180ms/);
+ const open = ruleText('#command-dock #location-bar:not(.collapsed) .dock-popover-content,');
+ assert.match(open, /transition-delay:\s*0s/);
+});
diff --git a/src/main.js b/src/main.js
index 84fbf12fe..0146a7f4c 100644
--- a/src/main.js
+++ b/src/main.js
@@ -17,6 +17,7 @@ import militaryAwarenessLayer from './data/militaryAwareness.js';
import localDataLayers from './data/localLayers.js';
import { LAYER_STATE_REGISTRY } from './data/layerState.js';
import { registerDataCredits } from './data/dataCredits.js';
+import { configureCreditKeyboardAccess } from './creditKeyboard.js';
import { SceneDirector } from './scenes/director.js';
import { initGevVoiceCommands } from './voice/gevRealtime.js';
import { MapStackController } from './mapStackController.js';
@@ -131,6 +132,7 @@ async function init() {
// expandable bottom-left credit lightbox (showOnScreen=false), so they never
// clutter the on-globe line. See docs/pre-ship-audit-2026-07-01.md H11.
registerDataCredits(viewer);
+ configureCreditKeyboardAccess(document);
// Hide Cesium's default globe — Google Photorealistic 3D Tiles provide their own
// globe at all LODs (street level → orbital). The default globe's 2D imagery
diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs
new file mode 100644
index 000000000..78e3e0fad
--- /dev/null
+++ b/src/mapSourceFocus.test.mjs
@@ -0,0 +1,387 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+// Exercise the installed event routes and central close method, without WebGL.
+const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
+const markup = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
+const locationMarkup = markup.slice(markup.indexOf(']*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/);
+const locationToggleAttributes = Object.fromEntries(
+ [...(locationToggleMarkup?.[1] || '').matchAll(/([\w-]+)="([^"]*)"/g)].map((match) => [match[1], match[2]]),
+);
+const initStart = source.indexOf(' _initAutoHoverPanel(');
+const initEnd = source.indexOf(' /**\n * Sets up drag-to-reposition', initStart);
+const escapeStart = source.indexOf(' _collapsePanelOnEscape(');
+const escapeEnd = source.indexOf(' _initCommandDockPins(', escapeStart);
+const closeStart = source.indexOf(' setPanelCollapsed(panelId, collapsed, {');
+const closeEnd = source.indexOf(' /**\n * Toggles "clean view"', closeStart);
+const syncStart = source.indexOf(' _syncPanelCollapseButton(panelEl) {');
+const syncEnd = source.indexOf(' /**\n * Converts a panel', syncStart);
+assert.ok(initStart >= 0 && initEnd > initStart && escapeStart >= 0 && escapeEnd > escapeStart
+ && closeStart >= 0 && closeEnd > closeStart);
+assert.ok(syncStart >= 0 && syncEnd > syncStart);
+
+function harness({ hidden = false, selected = true, noChips = false } = {}) {
+ let now = 0;
+ let nextTimer = 0;
+ let focusCalls = 0;
+ const timers = new Map();
+ const document = { activeElement: null };
+ const makeNode = (id, attributes = {}) => {
+ const listeners = new Map();
+ const classes = new Set();
+ return {
+ id, listeners, classes, hovered: false,
+ setAttribute(name, value) { attributes[name] = String(value); },
+ getAttribute(name) { return attributes[name] ?? null; },
+ classList: {
+ contains: (name) => classes.has(name),
+ remove: (name) => classes.delete(name),
+ toggle(name, value) { if (value) classes.add(name); else classes.delete(name); },
+ },
+ addEventListener(type, callback) {
+ if (!listeners.has(type)) listeners.set(type, []);
+ listeners.get(type).push(callback);
+ },
+ matches(selector) { return selector === ':hover' ? this.hovered : true; },
+ closest() { return this.isDisclosure ? this : null; },
+ };
+ };
+ const panel = makeNode('control-panel');
+ const location = makeNode('location-bar');
+ panel.classes.add('collapsed');
+ location.classes.add('collapsed');
+ const disclosure = makeNode('control-panel-toggle');
+ disclosure.isDisclosure = true;
+ const locationDisclosure = locationToggleMarkup ? makeNode('location-bar-toggle', { ...locationToggleAttributes }) : null;
+ if (locationDisclosure) locationDisclosure.isDisclosure = true;
+ const locationSearch = makeNode('location-search');
+ const first = makeNode('photoreal');
+ const active = makeNode('osm');
+ const elsewhere = makeNode('elsewhere');
+ const nodes = [panel, location, disclosure, locationDisclosure, locationSearch, first, active, elsewhere].filter(Boolean);
+ document.getElementById = (id) => nodes.find((node) => node.id === id);
+ panel.contains = (node) => [panel, disclosure, first, active].includes(node);
+ location.contains = (node) => Boolean(node) && [location, locationDisclosure, locationSearch].includes(node);
+ panel.querySelector = (selector) => {
+ if (selector.startsWith('[data-dock-toggle-target')) return disclosure;
+ if (selector.split(',').some((part) => part.trim() === '.panel-title')) return { textContent: 'VISUAL STYLES' };
+ if (noChips) return null;
+ if (selector === '.map-stack-chip.active') return selected ? active : null;
+ return selector === '.map-stack-chip' ? first : null;
+ };
+ location.querySelector = (selector) => {
+ if (selector === '[data-dock-toggle-target="location-bar"]') {
+ return locationDisclosure?.getAttribute('data-dock-toggle-target') === 'location-bar' ? locationDisclosure : null;
+ }
+ if (selector.split(',').some((part) => part.trim() === '.location-toolbar-label')) return { textContent: 'LOCATION' };
+ return null;
+ };
+ panel.querySelectorAll = location.querySelectorAll = () => [];
+ const emit = (node, type, values = {}) => {
+ assert.ok(node, 'the event target exists in the actual panel markup');
+ const event = {
+ target: node, defaultPrevented: false, propagationStopped: false,
+ preventDefault() { this.defaultPrevented = true; },
+ stopPropagation() { this.propagationStopped = true; },
+ ...values,
+ };
+ for (const callback of node.listeners.get(type) || []) callback(event);
+ return event;
+ };
+ const focus = (node) => {
+ const prior = document.activeElement;
+ if (prior === node) return;
+ document.activeElement = node;
+ for (const owner of [panel, location]) {
+ if (owner.contains(prior)) emit(owner, 'focusout', { target: prior, relatedTarget: node });
+ if (owner.contains(node)) emit(owner, 'focusin', { target: node, relatedTarget: prior });
+ }
+ };
+ disclosure.focus = () => focus(disclosure);
+ if (locationDisclosure) locationDisclosure.focus = () => focus(locationDisclosure);
+ for (const chip of [first, active]) {
+ chip.focus = () => { focusCalls += 1; if (!hidden) focus(chip); };
+ }
+ const window = {
+ setTimeout(callback, delay) { timers.set(++nextTimer, { callback, at: now + delay }); return nextTimer; },
+ };
+ const methods = new Function('document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame',
+ `return ({${source.slice(initStart, initEnd)},\n${source.slice(escapeStart, escapeEnd)},\n${source.slice(closeStart, closeEnd)},\n${source.slice(syncStart, syncEnd)}});`)(
+ document, window, (id) => timers.delete(id), { now: () => now }, () => {},
+ );
+ const saves = [];
+ const claims = [];
+ let shareSyncs = 0;
+ const manager = {
+ ...methods,
+ _savePanelCollapsedState(...args) { saves.push(args); },
+ _scheduleLeftPanelLayout() {}, _scheduleRightPanelLayout() {},
+ shareLinkManager: {
+ claimRestoreLane(...args) { claims.push(args); },
+ onPanelStateChange() { shareSyncs += 1; },
+ },
+ };
+ manager._initAutoHoverPanel('control-panel');
+ manager._initAutoHoverPanel('location-bar');
+ disclosure.focus();
+ const tick = () => {
+ const next = [...timers.entries()].sort((a, b) => a[1].at - b[1].at)[0];
+ if (!next) return;
+ timers.delete(next[0]); now = next[1].at; next[1].callback();
+ };
+ return {
+ manager, panel, location, disclosure, locationDisclosure, locationSearch,
+ first, active, elsewhere, document, timers, emit, focus, tick, saves, claims,
+ calls: () => focusCalls,
+ shareSyncs: () => shareSyncs,
+ show() { hidden = false; },
+ select(value) { selected = value; },
+ key(key = 'Enter', repeat = false) { return emit(disclosure, 'keydown', { key, repeat }); },
+ keyUp(key = ' ', repeat = false) {
+ const event = emit(disclosure, 'keyup', { key, repeat });
+ if (key === ' ' && !repeat && document.activeElement === disclosure && !event.defaultPrevented) emit(disclosure, 'click', { detail: 0 });
+ return event;
+ },
+ escape() { emit(panel, 'keydown', { key: 'Escape' }); },
+ locationKey(key = 'Enter', repeat = false) { return emit(locationDisclosure, 'keydown', { key, repeat }); },
+ locationKeyUp(key = ' ', repeat = false) {
+ const event = emit(locationDisclosure, 'keyup', { key, repeat });
+ if (key === ' ' && !repeat && document.activeElement === locationDisclosure && !event.defaultPrevented) {
+ emit(locationDisclosure, 'click', { detail: 0 });
+ }
+ return event;
+ },
+ locationEscape() { return emit(location, 'keydown', { key: 'Escape', target: document.activeElement }); },
+ click(detail = 1) { emit(disclosure, 'click', { detail }); },
+ drain() { for (let i = 0; i < 40 && timers.size; i += 1) tick(); },
+ };
+}
+
+test('Enter focuses selected OSM rather than the first chip', () => {
+ const h = harness(); h.key(); h.tick();
+ assert.equal(h.document.activeElement, h.active);
+ assert.equal(h.panel.classList.contains('collapsed'), false);
+ assert.equal(h.timers.size, 0);
+});
+test('Space waits for release before its synthesized click opens the tray', () => {
+ const h = harness();
+ const down = h.key(' ');
+ assert.equal(down.defaultPrevented, false);
+ assert.equal(down.propagationStopped, false);
+ assert.equal(h.panel.classList.contains('collapsed'), true);
+ assert.equal(h.timers.size, 0);
+ h.keyUp(' '); h.tick();
+ assert.equal(h.panel.classList.contains('collapsed'), false);
+ assert.equal(h.document.activeElement, h.active);
+});
+test('repeated keydown does not close an opening tray or replace its timer', () => {
+ const h = harness(); h.key(); const timer = [...h.timers.keys()][0]; h.key('Enter', true);
+ assert.equal([...h.timers.keys()][0], timer);
+ assert.equal(h.panel.classList.contains('collapsed'), false);
+});
+test('missing selection falls back to the first chip', () => {
+ const h = harness({ selected: false }); h.key(); h.tick();
+ assert.equal(h.document.activeElement, h.first);
+});
+test('delayed visibility retries until focus lands', () => {
+ const h = harness({ hidden: true }); h.key(); h.tick(); h.tick();
+ assert.equal(h.document.activeElement, h.disclosure);
+ assert.equal(h.timers.size, 1); h.show(); h.tick();
+ assert.equal(h.document.activeElement, h.active);
+ assert.equal(h.calls(), 3); assert.equal(h.timers.size, 0);
+});
+test('selection is read again when a delayed attempt can focus', () => {
+ const h = harness({ hidden: true, selected: false }); h.key(); h.tick();
+ h.select(true); h.show(); h.tick();
+ assert.equal(h.document.activeElement, h.active);
+});
+test('permanently hidden and missing targets have bounded work', () => {
+ for (const options of [{ hidden: true }, { noChips: true }]) {
+ const h = harness(options); h.key(); h.drain();
+ assert.equal(h.timers.size, 0);
+ assert.equal(h.calls(), options.noChips ? 0 : 25);
+ assert.equal(h.document.activeElement, h.disclosure);
+ }
+});
+test('focus departure cancels immediately even if focus returns before the timer', () => {
+ const h = harness(); h.key(); h.focus(h.elsewhere); h.focus(h.disclosure);
+ assert.equal(h.timers.size, 0); h.drain();
+ assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0);
+});
+test('deliberate focus movement inside the tray cancels pending retries', () => {
+ const h = harness({ hidden: true }); h.key(); h.tick(); h.focus(h.first);
+ assert.equal(h.timers.size, 0); h.show(); h.drain();
+ assert.equal(h.document.activeElement, h.first); assert.equal(h.calls(), 1);
+});
+test('Escape cancels immediately, closes the tray and restores disclosure focus', () => {
+ const h = harness({ hidden: true }); h.key(); h.tick(); h.escape();
+ assert.equal(h.timers.size, 0); assert.equal(h.document.activeElement, h.disclosure);
+ assert.equal(h.panel.classList.contains('collapsed'), true); h.show(); h.drain();
+ assert.equal(h.calls(), 1);
+});
+test('the central close seam cancels before both transition and already-closed return', () => {
+ for (const alreadyClosed of [false, true]) {
+ const h = harness(); h.key();
+ if (alreadyClosed) h.panel.classes.add('collapsed');
+ h.manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false });
+ assert.equal(h.timers.size, 0); h.drain(); assert.equal(h.calls(), 0);
+ }
+});
+test('opening the unpinned Location sibling cancels the Map Source request', () => {
+ const h = harness(); h.key(); h.manager.setPanelCollapsed('location-bar', false);
+ assert.equal(h.panel.classList.contains('collapsed'), true);
+ assert.equal(h.timers.size, 0); h.drain(); assert.equal(h.calls(), 0);
+});
+test('a pinned Map Source tray retains its own request when the sibling opens', () => {
+ const h = harness(); h.panel.classes.add('dock-pinned'); h.key();
+ h.manager.setPanelCollapsed('location-bar', false); h.tick();
+ assert.equal(h.panel.classList.contains('collapsed'), false);
+ assert.equal(h.document.activeElement, h.active);
+});
+test('pointer activation revokes a pending keyboard request without moving focus', () => {
+ const h = harness(); h.key(); h.emit(h.panel, 'pointerdown');
+ assert.equal(h.timers.size, 0); h.drain();
+ assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0);
+});
+test('close then pointer or programmatic reopen cannot inherit a keyboard request', () => {
+ for (const reopen of [(h) => h.click(1), (h) => h.manager.setPanelCollapsed('control-panel', false)]) {
+ const h = harness(); h.key(); const stale = [...h.timers.values()][0].callback;
+ h.escape(); reopen(h); stale(); h.drain();
+ assert.equal(h.panel.classList.contains('collapsed'), false);
+ assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0);
+ }
+});
+test('a stale callback cannot focus or clear the timer owned by a new keyboard opening', () => {
+ const h = harness(); h.key(); const stale = [...h.timers.values()][0].callback;
+ h.escape(); h.key(); const timer = [...h.timers.keys()][0]; stale();
+ assert.equal(h.calls(), 0); assert.equal([...h.timers.keys()][0], timer);
+ h.tick(); assert.equal(h.document.activeElement, h.active);
+});
+test('plain pointer opening does not request focus', () => {
+ const h = harness(); h.click(1); h.drain();
+ assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0);
+});
+test('pointer leave preserves established keyboard focus and the open tray', () => {
+ const h = harness(); h.key(); h.tick(); h.emit(h.panel, 'pointerleave', { pointerType: 'mouse' }); h.drain();
+ assert.equal(h.document.activeElement, h.active);
+ assert.equal(h.panel.classList.contains('collapsed'), false);
+});
+
+
+test('Location markup provides one named native disclosure linked to its popover', () => {
+ assert.ok(locationToggleMarkup, 'Location must expose a native button in the real markup');
+ assert.equal(locationToggleAttributes.type, 'button');
+ assert.equal(locationToggleAttributes['data-dock-toggle-target'], 'location-bar');
+ assert.equal(locationToggleAttributes['aria-controls'], 'location-bar-popover');
+ assert.equal(locationToggleAttributes['aria-expanded'], 'false');
+ assert.match(locationToggleAttributes['aria-label'], /expand.*location/i);
+ assert.match(locationToggleAttributes.class, /(?:^|\s)dock-tray-toggle(?:\s|$)/);
+ assert.match(locationToggleMarkup[2], /class="location-toolbar-label"/);
+ assert.doesNotMatch(locationToggleMarkup[2], //);
+});
+
+test('Location Enter stays immediate while Space waits for native key release', () => {
+ for (const key of ['Enter', ' ']) {
+ const h = harness(); h.focus(h.locationDisclosure); h.drain();
+ const event = h.locationKey(key);
+ assert.equal(event.defaultPrevented, key === 'Enter');
+ assert.equal(event.propagationStopped, key === 'Enter');
+ if (key === ' ') {
+ assert.equal(h.location.classList.contains('collapsed'), true);
+ h.locationKeyUp(key);
+ }
+ assert.equal(h.location.classList.contains('collapsed'), false);
+ assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'true');
+ assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION');
+ assert.equal(h.document.activeElement, h.locationDisclosure);
+ assert.equal(h.timers.size, 0);
+ assert.equal(h.calls(), 0);
+ const repeated = h.locationKey(key, true);
+ assert.equal(repeated.defaultPrevented, key === 'Enter');
+ assert.equal(repeated.propagationStopped, key === 'Enter');
+ assert.equal(h.location.classList.contains('collapsed'), false);
+ assert.equal(h.timers.size, 0);
+ const release = key === ' ' ? h.locationKeyUp(key, true) : h.emit(h.locationDisclosure, 'keyup', { key });
+ assert.equal(release.defaultPrevented, false, 'global voice release must remain reachable');
+ assert.equal(release.propagationStopped, false);
+ h.locationKey(key);
+ if (key === ' ') h.locationKeyUp(key);
+ assert.equal(h.location.classList.contains('collapsed'), true);
+ assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Expand LOCATION');
+ }
+});
+
+test('Location synthesized click and Escape retain focus and its accessible name', () => {
+ const h = harness(); h.focus(h.locationDisclosure); h.drain();
+ h.emit(h.locationDisclosure, 'click', { detail: 0 });
+ assert.equal(h.location.classList.contains('collapsed'), false);
+ assert.equal(h.calls(), 0);
+ assert.equal(h.timers.size, 0);
+ h.focus(h.locationSearch);
+ const escape = h.locationEscape();
+ assert.equal(escape.defaultPrevented, true);
+ assert.equal(h.location.classList.contains('collapsed'), true);
+ assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'false');
+ assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Expand LOCATION');
+ assert.equal(h.locationDisclosure.title, 'Expand LOCATION');
+ assert.equal(h.document.activeElement, h.locationDisclosure);
+ h.drain();
+ assert.equal(h.calls(), 0);
+});
+
+test('Location keyboard opening cancels the unpinned sibling and cannot revive its old callback', () => {
+ const h = harness({ hidden: true }); h.key(); h.tick();
+ const stale = [...h.timers.values()][0].callback;
+ h.focus(h.locationDisclosure); h.locationKey();
+ assert.equal(h.panel.classList.contains('collapsed'), true);
+ assert.equal(h.location.classList.contains('collapsed'), false);
+ const attemptsBefore = h.calls();
+ h.show(); stale(); h.drain();
+ assert.equal(h.calls(), attemptsBefore);
+ assert.equal(h.document.activeElement, h.locationDisclosure);
+});
+
+test('Location keyboard opening preserves a pinned sibling and a pinned Location survives sibling opening', () => {
+ const h = harness(); h.panel.classes.add('dock-pinned'); h.key(); h.tick();
+ h.focus(h.locationDisclosure); h.locationKey(); h.drain();
+ assert.equal(h.panel.classList.contains('collapsed'), false);
+ assert.equal(h.location.classList.contains('collapsed'), false);
+ assert.equal(h.document.activeElement, h.locationDisclosure);
+ h.location.classes.add('dock-pinned');
+ h.manager.setPanelCollapsed('control-panel', true);
+ h.focus(h.disclosure); h.key(); h.tick();
+ assert.equal(h.location.classList.contains('collapsed'), false);
+ assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'true');
+ assert.equal(h.document.activeElement, h.active);
+});
+
+test('Location restoration syncs its real name without claiming focus, collapsing its sibling or persisting', () => {
+ const h = harness(); h.key(); h.tick();
+ const saves = h.saves.length;
+ const claims = h.claims.length;
+ const shareSyncs = h.shareSyncs();
+ h.manager.setPanelCollapsed('location-bar', false, { restore: true, persist: false, syncShare: false });
+ assert.equal(h.panel.classList.contains('collapsed'), false);
+ assert.equal(h.location.classList.contains('collapsed'), false);
+ assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION');
+ assert.equal(h.document.activeElement, h.active);
+ assert.equal(h.saves.length, saves);
+ assert.equal(h.claims.length, claims);
+ assert.equal(h.shareSyncs(), shareSyncs);
+ h.locationDisclosure.setAttribute('aria-label', 'stale');
+ h.manager.setPanelCollapsed('location-bar', false, { restore: true, persist: false, syncShare: false });
+ assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION', 'same-state synchronization keeps the Location name');
+ assert.equal(h.timers.size, 0);
+});
+
+test('disposed UI cannot complete a pending handoff', () => {
+ const h = harness({ hidden: true }); h.key(); h.tick();
+ h.manager._disposed = true; h.show(); h.drain();
+ assert.equal(h.document.activeElement, h.disclosure);
+ assert.equal(h.calls(), 1); assert.equal(h.timers.size, 0);
+});
diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs
index 4472bd447..7f425fd12 100644
--- a/src/mapStackChips.test.mjs
+++ b/src/mapStackChips.test.mjs
@@ -344,7 +344,7 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
'the compact wing must expose a semantic keyboard disclosure',
);
assert.match(ui, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
- assert.match(ui, /map-stack-chip\.active, \.map-stack-chip/);
+ assert.match(ui, /querySelector\('\.map-stack-chip\.active'\)\s*\|\| panelEl\.querySelector\('\.map-stack-chip'\)/);
assert.match(
ui,
diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs
new file mode 100644
index 000000000..888267a75
--- /dev/null
+++ b/src/panelEscape.test.mjs
@@ -0,0 +1,169 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import test from 'node:test';
+
+const source = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
+
+function method(name, nextName) {
+ const start = source.indexOf(` ${name}(`);
+ const end = source.indexOf(`\n ${nextName}(`, start);
+ assert.ok(start >= 0 && end > start, `${name} source is available`);
+ return source.slice(start, end);
+}
+
+function panelFixture({ id = 'data-panel', nested = false, nestedCollapsed = false, onDisclosure = false } = {}) {
+ let collapsed = false;
+ let focused = false;
+ let blurred = false;
+ const disclosure = {
+ focus: () => { focused = true; },
+ blur: () => { blurred = true; focused = false; },
+ contains: (candidate) => candidate === disclosure,
+ };
+ const panel = {
+ id,
+ classList: { contains: (name) => name === 'collapsed' && collapsed },
+ contains: (target) => target?.panel === panel,
+ querySelector: () => disclosure,
+ };
+ const inner = nested ? { id: 'param-slider-panel' } : panel;
+ const target = onDisclosure ? disclosure : {
+ panel,
+ closest: (selector) => (
+ nested && nestedCollapsed && selector.includes(':not(.collapsed)') ? panel : inner
+ ),
+ };
+ if (onDisclosure) {
+ disclosure.panel = panel;
+ disclosure.closest = () => panel;
+ }
+ const document = { getElementById: (candidate) => candidate === id ? panel : null };
+ const manager = new Function('document', `return {${method('_collapsePanelOnEscape', '_initCommandDockPins')}};`)(document);
+ manager.setPanelCollapsed = (candidate, value, options) => {
+ assert.equal(candidate, id);
+ assert.equal(value, true);
+ assert.deepEqual(options, { explicit: true });
+ collapsed = true;
+ };
+ const event = {
+ key: 'Escape', target, defaultPrevented: false, propagationStopped: false,
+ preventDefault() { this.defaultPrevented = true; },
+ stopPropagation() { this.propagationStopped = true; },
+ };
+ return {
+ manager, panel, inner, target, event,
+ collapsed: () => collapsed,
+ focused: () => focused,
+ blurred: () => blurred,
+ };
+}
+
+test('Escape collapses an expanded panel and returns focus to its disclosure', () => {
+ const fixture = panelFixture();
+ assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true);
+ assert.equal(fixture.collapsed(), true);
+ assert.equal(fixture.focused(), true);
+ assert.equal(fixture.event.defaultPrevented, true);
+ assert.equal(fixture.event.propagationStopped, true);
+});
+
+test('Escape on an expanded panel disclosure closes it without retaining focus', () => {
+ const fixture = panelFixture({ onDisclosure: true });
+ assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true);
+ assert.equal(fixture.collapsed(), true);
+ assert.equal(fixture.focused(), false);
+ assert.equal(fixture.blurred(), true);
+ assert.equal(fixture.event.defaultPrevented, true);
+ assert.equal(fixture.event.propagationStopped, true);
+});
+
+test('non-Escape, handled, collapsed, and outside events do not collapse a panel', () => {
+ for (const mutate of [
+ (fixture) => { fixture.event.key = 'Enter'; },
+ (fixture) => { fixture.event.defaultPrevented = true; },
+ (fixture) => { fixture.manager.setPanelCollapsed('data-panel', true, { explicit: true }); },
+ (fixture) => { fixture.event.target = { panel: null, closest: () => null }; },
+ ]) {
+ const fixture = panelFixture();
+ mutate(fixture);
+ const wasCollapsed = fixture.collapsed();
+ assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), false);
+ assert.equal(fixture.collapsed(), wasCollapsed);
+ assert.equal(fixture.focused(), false);
+ }
+});
+
+test('an expanded nested panel owns Escape before its expanded parent', () => {
+ const inner = panelFixture({ id: 'param-slider-panel' });
+ assert.equal(inner.manager._collapsePanelOnEscape(inner.event, 'param-slider-panel'), true);
+
+ const outer = panelFixture({ id: 'pp-toggles', nested: true });
+ assert.equal(outer.manager._collapsePanelOnEscape(outer.event, 'pp-toggles'), false);
+ assert.equal(outer.collapsed(), false);
+ assert.equal(outer.focused(), false);
+});
+
+test('a collapsed nested panel does not block the next Escape from closing its parent', () => {
+ const outer = panelFixture({ id: 'pp-toggles', nested: true, nestedCollapsed: true });
+ assert.equal(outer.manager._collapsePanelOnEscape(outer.event, 'pp-toggles'), true);
+ assert.equal(outer.collapsed(), true);
+ assert.equal(outer.focused(), true);
+});
+
+test('Location Escape clears a hidden draft search before restoring disclosure focus', () => {
+ const fixture = panelFixture({ id: 'location-bar' });
+ const removed = [];
+ let blurred = false;
+ fixture.manager._locationSearch = {
+ classList: { remove: (...names) => removed.push(...names) },
+ value: 'focus cleanup',
+ blur: () => { blurred = true; },
+ };
+ assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'location-bar'), true);
+ assert.deepEqual(removed, ['expanded']);
+ assert.equal(fixture.manager._locationSearch.value, '');
+ assert.equal(blurred, true);
+ assert.equal(fixture.focused(), true);
+});
+
+test('panel chrome wires Escape for every declared collapse target', () => {
+ const init = method('_initPanelChrome', '_collapsePanelOnEscape');
+ assert.match(init, /for \(const targetId of targets\)[\s\S]*?addEventListener\('keydown'[\s\S]*?_collapsePanelOnEscape\(event, targetId\)/);
+ assert.match(source, /_initAutoHoverPanel[\s\S]*?if \(event\.key !== 'Escape'\) return;[\s\S]*?_collapsePanelOnEscape\(event, panelId\)[\s\S]*?clearOpen\(\);[\s\S]*?clearClose\(\);/);
+});
+
+test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', () => {
+ const onKeyDown = method('onKeyDown', 'enter');
+ assert.match(
+ onKeyDown,
+ /event\.target\?\.closest\?\.\('\.cesium-credit-lightbox'\)[\s\S]*?return;/,
+ 'the focused attribution lightbox keeps ownership of Escape before Cockpit',
+ );
+ assert.match(
+ onKeyDown,
+ /this\.context\?\.contains\(event\.target\)[\s\S]*?setContextCollapsed\(true\)[\s\S]*?event\.target === this\.contextToggle[\s\S]*?contextToggle\?\.blur[\s\S]*?contextToggle\?\.focus/,
+ );
+ assert.match(
+ onKeyDown,
+ /this\.signalStream\?\.contains\(event\.target\)[\s\S]*?setSignalCollapsed\(true, \{ user: true \}\)[\s\S]*?event\.target === this\.signalToggle[\s\S]*?signalToggle\?\.blur[\s\S]*?signalToggle\?\.focus/,
+ );
+ assert.ok(onKeyDown.indexOf('setContextCollapsed(true)') < onKeyDown.indexOf('this.exit()'));
+ assert.ok(onKeyDown.indexOf('setSignalCollapsed(true') < onKeyDown.indexOf('this.exit()'));
+});
+
+test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => {
+ assert.match(
+ source,
+ /const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/,
+ );
+ assert.match(
+ source,
+ /const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/,
+ 'Cockpit utility disclosures clear their own focus when Escape closes them',
+ );
+ assert.match(
+ source,
+ /_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/,
+ 'compact Radio disclosure clears its own focus when Escape closes it',
+ );
+});
diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs
new file mode 100644
index 000000000..8b2e5d0ff
--- /dev/null
+++ b/src/proxyErrorResponses.test.mjs
@@ -0,0 +1,170 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+
+const source = readFileSync(new URL('../vite.config.js', import.meta.url), 'utf8');
+const detail = 'fixture-secret-token /internal/example ';
+
+// Execute the production middleware with isolated upstreams and cache storage.
+// Top-level function closing braces start in column zero in this module.
+function extract(name) {
+ const start = source.search(new RegExp(`(?:export )?(?:async )?function ${name}\\(`));
+ assert.ok(start >= 0, `${name} must exist`);
+ const end = source.indexOf('\n}', start);
+ return source.slice(start, end + 2).replace(/^export /, '');
+}
+
+function fixture(name, overrides = {}, preview = false) {
+ const logs = [];
+ const deps = {
+ path, process: { cwd: () => '/fixture', env: {} },
+ fsp: {
+ readFile: async () => { throw new Error('cache absent'); },
+ stat: async () => { throw new Error('cache absent'); },
+ mkdir: async () => {}, writeFile: async () => {},
+ },
+ fetch: async () => { throw new Error(detail); },
+ console: { warn: (...args) => logs.push(args.join(' ')), error: (...args) => logs.push(args.join(' ')) },
+ setInterval: () => ({ unref() {} }),
+ LL2_CACHE_TTL_MS: 15 * 60_000,
+ parseTerrainPoints: () => [[1, 2]],
+ resolveTerrainHeightRequest: async () => { throw new Error(detail); },
+ ...overrides,
+ };
+ const helpers = ['readResponseTextCapped', 'coalesceProxyRequest', 'launchLibraryRequestHeaders'].map(extract).join('\n');
+ const plugin = new Function(...Object.keys(deps), `${helpers}\n${extract(name)}\nreturn ${name}();`)(...Object.values(deps));
+ let middleware;
+ plugin[preview ? 'configurePreviewServer' : 'configureServer']({ middlewares: { use(_route, handler) { middleware = handler; } } });
+ return {
+ logs,
+ async request(url = '/', method = 'GET') {
+ const response = { headersSent: false, writeHead(status, headers) { Object.assign(this, { status, headers, headersSent: true }); }, end(body) { this.body = body; } };
+ await middleware({ url, method }, response);
+ assert.doesNotMatch(response.body, /fixture-secret-token|internal\/example|/);
+ assert.doesNotMatch(logs.join('\n'), /fixture-secret-token|internal\/example|/);
+ return response;
+ },
+ };
+}
+
+for (const status of [401, 429, 500]) {
+ for (const preview of [false, true]) {
+ test(`Launch Library ${status} stays generic in ${preview ? 'preview' : 'development'}`, async () => {
+ const app = fixture('rocketLaunchesProxy', { fetch: async () => new Response(detail.repeat(1000), { status }) }, preview);
+ const res = await app.request();
+ assert.equal(res.status, status);
+ assert.deepEqual(JSON.parse(res.body), { error: 'Launch Library 2 unavailable' });
+ assert.equal(res.headers['Cache-Control'], 'no-store');
+ assert.equal(res.headers['X-GEV-Cache'], 'NONE');
+ assert.equal(app.logs.length, 1);
+ assert.match(app.logs[0], new RegExp(`HTTP ${status}`));
+ assert.ok(app.logs[0].length < 100);
+ });
+ }
+}
+
+for (const [label, fetch] of [
+ ['network error', async () => { throw new Error(detail); }],
+ ['malformed JSON', async () => new Response(detail)],
+ ['invalid feed', async () => new Response('{}')],
+ ['oversized response', async () => new Response(detail, { headers: { 'content-length': String(13 * 1024 * 1024) } })],
+]) {
+ test(`Launch Library ${label} returns 502`, async () => {
+ const res = await fixture('rocketLaunchesProxy', { fetch }).request();
+ assert.equal(res.status, 502);
+ assert.deepEqual(JSON.parse(res.body), { error: 'Launch Library 2 unavailable' });
+ });
+}
+
+test('Launch Library retains single-flight, fresh cache, stale fallback, and method guard', async () => {
+ let now = Date.now();
+ let calls = 0;
+ let release;
+ const gate = new Promise(resolve => { release = resolve; });
+ class Clock extends Date { static now() { return now; } }
+ const app = fixture('rocketLaunchesProxy', {
+ Date: Clock,
+ fetch: async () => { calls += 1; await gate; if (calls > 1) throw new Error(detail); return new Response('{"results":[]}'); },
+ });
+ assert.equal((await app.request('/', 'POST')).status, 405);
+ const first = app.request();
+ const second = app.request();
+ release();
+ const pair = await Promise.all([first, second]);
+ assert.deepEqual(pair.map(res => res.headers['X-GEV-Cache']).sort(), ['INFLIGHT', 'MISS']);
+ assert.equal(calls, 1);
+ assert.equal((await app.request()).headers['X-GEV-Cache'], 'HIT');
+ now += 16 * 60_000;
+ const stale = await app.request();
+ assert.equal(stale.status, 200);
+ assert.equal(stale.body, '{"results":[]}');
+ assert.equal(stale.headers['X-GEV-Cache'], 'STALE-ERROR');
+ assert.equal(calls, 2);
+});
+
+test('CelesTrak unexpected failures hide details', async () => {
+ const app = fixture('celestrakProxy', { Date: { now() { throw new Error(detail); } } });
+ const res = await app.request('/active');
+ assert.equal(res.status, 500);
+ assert.equal(res.body, 'celestrak proxy error');
+ assert.equal(res.headers['x-tle-cache'], 'ERROR');
+});
+
+test('CelesTrak retains invalid-group and unavailable responses', async () => {
+ const app = fixture('celestrakProxy');
+ assert.equal((await app.request('/../')).status, 400);
+ const res = await app.request('/active');
+ assert.equal(res.status, 502);
+ assert.equal(res.headers['x-tle-cache'], 'NONE');
+});
+
+test('CelesTrak retains fresh and stale TLE caches', async () => {
+ let now = Date.now();
+ let calls = 0;
+ const app = fixture('celestrakProxy', {
+ Date: { now: () => now },
+ fetch: async () => { if (++calls > 1) throw new Error(detail); return new Response('1 valid-fixture-TLE'); },
+ });
+ assert.equal((await app.request('/active')).headers['x-tle-cache'], 'MISS');
+ assert.equal((await app.request('/active')).headers['x-tle-cache'], 'HIT');
+ now += 7 * 3600_000;
+ const stale = await app.request('/active');
+ assert.equal(stale.status, 200);
+ assert.equal(stale.body, '1 valid-fixture-TLE');
+ assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR');
+});
+
+test('terrain unexpected failures hide details', async () => {
+ const res = await fixture('terrainHeightsProxy').request('/?points=1,2');
+ assert.equal(res.status, 500);
+ assert.deepEqual(JSON.parse(res.body), { error: 'terrain heights proxy error' });
+});
+
+test('terrain validation and resolver outcomes remain intact', async () => {
+ const invalid = await fixture('terrainHeightsProxy', { parseTerrainPoints: () => null }).request();
+ assert.equal(invalid.status, 400);
+ const body = { results: [{ height: 12 }] };
+ const app = fixture('terrainHeightsProxy', { resolveTerrainHeightRequest: async () => ({ status: 200, body, upstreamError: new Error(detail) }) });
+ const res = await app.request('/?points=1,2');
+ assert.equal(res.status, 200);
+ assert.deepEqual(JSON.parse(res.body), body);
+ assert.equal(app.logs.length, 1);
+});
+
+test('ADSBDB unexpected failures hide details', async () => {
+ const badUrl = { toString() { throw new Error(detail); } };
+ const res = await fixture('adsbdbProxy').request(badUrl);
+ assert.equal(res.status, 500);
+ assert.deepEqual(JSON.parse(res.body), { error: 'adsbdb proxy error' });
+});
+
+test('ADSBDB retains validation and missing-aircraft semantics', async () => {
+ const app = fixture('adsbdbProxy');
+ assert.equal((await app.request('/route/!')).status, 400);
+ assert.equal((await app.request('/type/nope')).status, 400);
+ assert.equal((await app.request('/unknown')).status, 404);
+ const absent = await app.request('/type/abcdef');
+ assert.equal(absent.status, 200);
+ assert.deepEqual(JSON.parse(absent.body), { found: false });
+});
diff --git a/src/ui.js b/src/ui.js
index 668e752d4..b2840fff5 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -1016,8 +1016,34 @@ class CockpitViewController {
onKeyDown(event) {
if (event.repeat || event.isComposing) return;
if (event.key === 'Escape' && this.active) {
+ // The credit lightbox owns Escape while its Close control or links hold
+ // focus. Its target handler closes the overlay and restores attribution
+ // focus; Cockpit must stay active behind it.
+ if (event.target?.closest?.('.cesium-credit-lightbox')) return;
if (document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')) return;
if (document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')) return;
+ if (this.context?.contains(event.target) && !this.contextCollapsed) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ this.setContextCollapsed(true);
+ if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) {
+ this.contextToggle?.blur?.();
+ } else {
+ this.contextToggle?.focus({ preventScroll: true });
+ }
+ return;
+ }
+ if (this.signalStream?.contains(event.target) && !this.signalCollapsed) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ this.setSignalCollapsed(true, { user: true });
+ if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) {
+ this.signalToggle?.blur?.();
+ } else {
+ this.signalToggle?.focus({ preventScroll: true });
+ }
+ return;
+ }
event.preventDefault();
event.stopImmediatePropagation();
this.exit();
@@ -1851,40 +1877,89 @@ class CockpitViewController {
renderCockpitSignals() {
if (!this.signalList) return;
- this.signalList.replaceChildren(...this.signalItems.map((item) => {
- const entry = document.createElement('li');
- entry.className = item.tone;
- const time = document.createElement('time');
- time.textContent = new Date(item.timestamp).toISOString().slice(11, 19) + 'Z';
- const body = document.createElement('div');
- const heading = item.target ? document.createElement('button') : document.createElement('strong');
+ const existing = [...this.signalList.children];
+ const focusedEntry = existing.find((entry) => entry.contains(document.activeElement));
+ const entriesByKey = new Map();
+ for (const entry of existing) {
+ const key = entry.dataset.signalKey;
+ if (!entriesByKey.has(key)) entriesByKey.set(key, []);
+ entriesByKey.get(key).push(entry);
+ }
+ const setText = (element, value) => {
+ if (element.textContent !== value) element.textContent = value;
+ };
+ const entries = this.signalItems.map((item) => {
+ // Identity includes the action target: a reused status key must never
+ // silently turn a focused flight button into a different selection.
+ const key = JSON.stringify([
+ item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''),
+ ]);
+ let entry = entriesByKey.get(key)?.shift();
+ if (!entry) {
+ entry = document.createElement('li');
+ entry.dataset.signalKey = key;
+ const time = document.createElement('time');
+ const body = document.createElement('div');
+ const heading = document.createElement(item.target ? 'button' : 'strong');
+ if (item.target) {
+ heading.type = 'button';
+ heading.className = 'cockpit-signal-target';
+ const label = document.createElement('span');
+ label.className = 'cockpit-signal-target-label';
+ const rule = document.createElement('span');
+ rule.className = 'cockpit-signal-target-rule';
+ rule.setAttribute('aria-hidden', 'true');
+ const chevron = document.createElement('span');
+ chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron';
+ chevron.setAttribute('aria-hidden', 'true');
+ chevron.textContent = 'chevron_right';
+ heading.append(label, rule, chevron);
+ }
+ body.append(heading, document.createElement('span'));
+ entry.append(time, body);
+ }
+ const [time, body] = entry.children;
+ const [heading, copy] = body.children;
+ const className = item.target ? `${item.tone} actionable` : item.tone;
+ if (entry.className !== className) entry.className = className;
+ setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z');
if (item.target) {
- heading.type = 'button';
- heading.className = 'cockpit-signal-target';
heading.dataset.signalLayer = item.target.layerId;
heading.dataset.signalId = item.target.id;
- heading.setAttribute('aria-label', `Select flight ${item.title}`);
- const label = document.createElement('span');
- label.className = 'cockpit-signal-target-label';
- label.textContent = item.title;
- const rule = document.createElement('span');
- rule.className = 'cockpit-signal-target-rule';
- rule.setAttribute('aria-hidden', 'true');
- const chevron = document.createElement('span');
- chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron';
- chevron.setAttribute('aria-hidden', 'true');
- chevron.textContent = 'chevron_right';
- heading.append(label, rule, chevron);
- entry.classList.add('actionable');
+ const label = `Select flight ${item.title}`;
+ if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label);
+ setText(heading.children[0], item.title);
} else {
- heading.textContent = item.title;
+ setText(heading, item.title);
}
- const copy = document.createElement('span');
- copy.textContent = item.detail;
- body.append(heading, copy);
- entry.append(time, body);
+ setText(copy, item.detail);
return entry;
- }));
+ });
+ const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null;
+ if (focusedEntry && !retainedFocus) {
+ // A departed contact cannot remain selectable. Continue from the stable
+ // briefing footer instead of dropping keyboard traversal to the page top.
+ (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true });
+ }
+ for (const entry of existing) if (!entries.includes(entry)) entry.remove();
+
+ // Ordinary insertBefore moves disconnect an element and lose its focus.
+ // Reorder the other rows around the focused row, which stays connected.
+ const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1;
+ if (retainedFocus) {
+ let anchor = retainedFocus;
+ for (let index = focusIndex - 1; index >= 0; index -= 1) {
+ const entry = entries[index];
+ if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor);
+ anchor = entry;
+ }
+ }
+ let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild;
+ for (let index = focusIndex + 1; index < entries.length; index += 1) {
+ const entry = entries[index];
+ if (entry === anchor) anchor = anchor.nextElementSibling;
+ else this.signalList.insertBefore(entry, anchor);
+ }
this.scheduleContextLayout();
}
@@ -3946,6 +4021,10 @@ export class StyleManager {
});
for (const targetId of targets) {
+ const panelEl = document.getElementById(targetId);
+ panelEl?.addEventListener('keydown', (event) => {
+ this._collapsePanelOnEscape(event, targetId);
+ });
this._restorePanelCollapsedState(targetId, {
allowStored: !this._initialShareState,
});
@@ -3961,6 +4040,46 @@ export class StyleManager {
this._maybeNotifyLayoutReset();
}
+ /**
+ * Collapses the nearest expanded panel that owns keyboard focus on Escape.
+ * Nested panels consume the event first, so one key closes one level and
+ * returns focus to that level's disclosure. If Escape was pressed on the
+ * disclosure itself, remove focus after closing so the collapsed button does
+ * not keep a stale keyboard ring.
+ * @param {KeyboardEvent} event - Candidate Escape key event.
+ * @param {string} panelId - Collapsible panel containing the listener.
+ * @returns {boolean} Whether this panel handled the key.
+ */
+ _collapsePanelOnEscape(event, panelId) {
+ if (event.key !== 'Escape' || event.defaultPrevented) return false;
+ const panelEl = document.getElementById(panelId);
+ if (!panelEl || panelEl.classList.contains('collapsed') || !panelEl.contains(event.target)) {
+ return false;
+ }
+ const focusedPanel = event.target?.closest?.(
+ '.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)',
+ );
+ if (focusedPanel && focusedPanel !== panelEl) return false;
+ event.preventDefault();
+ event.stopPropagation();
+ if (panelId === 'location-bar' && this._locationSearch) {
+ // The document-level Escape cleanup cannot run after this panel consumes
+ // the event. Mirror that cleanup here so reopening Location never reveals
+ // a hidden draft query or expanded search field.
+ this._locationSearch.classList.remove('expanded');
+ this._locationSearch.value = '';
+ this._locationSearch.blur();
+ }
+ this.setPanelCollapsed(panelId, true, { explicit: true });
+ const disclosure = panelEl.querySelector(`[data-dock-toggle-target="${panelId}"]`)
+ || panelEl.querySelector(`[data-collapse-target="${panelId}"]`);
+ const escapedFromDisclosure = event.target === disclosure
+ || disclosure?.contains?.(event.target);
+ if (escapedFromDisclosure) disclosure?.blur?.();
+ else disclosure?.focus?.({ preventScroll: true });
+ return true;
+ }
+
/**
* Allows either command-dock tray to remain open until explicitly unpinned.
* Both trays may be pinned; transient and error trays stack above them.
@@ -4107,6 +4226,17 @@ export class StyleManager {
let closeTimer = null;
let lastWheelTime = 0;
let disclosureFocusTimer = null;
+ let focusRequest = 0;
+
+ const cancelMapSourceFocus = () => {
+ clearTimeout(disclosureFocusTimer);
+ disclosureFocusTimer = null;
+ focusRequest += 1;
+ };
+ if (panelId === 'control-panel') {
+ this._cancelMapSourceFocus?.();
+ this._cancelMapSourceFocus = cancelMapSourceFocus;
+ }
const clearOpen = () => {
if (!openTimer) return;
@@ -4190,24 +4320,52 @@ export class StyleManager {
});
panelEl.addEventListener('pointerdown', () => {
+ cancelMapSourceFocus();
clearOpen();
clearClose();
});
const focusMapSource = () => {
- if (panelId !== 'control-panel') return;
- panelEl.querySelector('.map-stack-chip.active, .map-stack-chip')?.focus?.({ preventScroll: true });
+ if (panelId !== 'control-panel') return false;
+ const chip = panelEl.querySelector('.map-stack-chip.active')
+ || panelEl.querySelector('.map-stack-chip');
+ if (!chip?.focus) return false;
+ chip.focus({ preventScroll: true });
+ // .focus() on a still-hidden element is a SILENT no-op, so the caller
+ // has to check whether focus actually landed rather than assume it did.
+ return document.activeElement === chip;
};
+ // The tray opens behind a 180ms `visibility` transition (.dock-popover-content
+ // in style.css), and a chip inside it cannot take focus until that lands.
+ // A single fixed delay therefore races the transition: when the machine is
+ // slow enough that the fade has not finished by the time the timer fires,
+ // focus() silently does nothing and the keyboard user is stranded on the
+ // disclosure with an open tray they cannot reach (#54). Retry on a short
+ // cadence until focus actually lands, bounded so a permanently hidden tray
+ // cannot spin.
const scheduleMapSourceFocus = () => {
- clearTimeout(disclosureFocusTimer);
- disclosureFocusTimer = window.setTimeout(() => {
+ cancelMapSourceFocus();
+ if (panelId !== 'control-panel') return;
+ const request = focusRequest;
+ let attempts = 0;
+ const attemptFocus = () => {
+ if (request !== focusRequest) return;
disclosureFocusTimer = null;
- if (!panelEl.classList.contains('collapsed')) focusMapSource();
- }, 240);
+ if (this._disposed || panelEl.classList.contains('collapsed')) return;
+ // A Tab or click elsewhere owns focus now. A delayed transition must
+ // not pull the keyboard back into a tray the user has already left.
+ if (document.activeElement !== disclosure) return;
+ if (focusMapSource()) return;
+ if (request !== focusRequest) return;
+ if (++attempts > 24) return; // ~720ms past the first try, then give up
+ disclosureFocusTimer = window.setTimeout(attemptFocus, 30);
+ };
+ disclosureFocusTimer = window.setTimeout(attemptFocus, 240);
};
const toggleDisclosure = ({ focusSource = false } = {}) => {
+ cancelMapSourceFocus();
clearOpen();
clearClose();
const shouldOpen = panelEl.classList.contains('collapsed');
@@ -4224,10 +4382,9 @@ export class StyleManager {
toggleDisclosure({ focusSource: event.detail === 0 });
});
disclosure?.addEventListener('keydown', (event) => {
- if (event.key !== 'Enter' && event.key !== ' ') return;
- // The application-level Space shortcut must not steal activation from a
- // focused disclosure. One non-repeating keydown is enough; no keyup latch
- // is retained after focus moves into the tray.
+ if (event.key !== 'Enter') return;
+ // Preserve immediate Enter activation while leaving Space to the native
+ // button path, which emits its synthesized click only after key release.
event.preventDefault();
event.stopPropagation();
if (event.repeat) return;
@@ -4236,16 +4393,16 @@ export class StyleManager {
panelEl.addEventListener('focusin', () => clearClose());
panelEl.addEventListener('focusout', (event) => {
+ cancelMapSourceFocus();
if (panelEl.contains(event.relatedTarget)) return;
scheduleClose();
});
panelEl.addEventListener('keydown', (event) => {
- if (event.key !== 'Escape' || panelEl.classList.contains('collapsed')) return;
- event.preventDefault();
+ if (event.key !== 'Escape') return;
+ if (!event.defaultPrevented) this._collapsePanelOnEscape(event, panelId);
+ cancelMapSourceFocus();
clearOpen();
clearClose();
- this.setPanelCollapsed(panelId, true, { explicit: true });
- disclosure?.focus?.({ preventScroll: true });
});
}
@@ -4615,6 +4772,7 @@ export class StyleManager {
contextTabs[nextIndex].click();
}));
this._globalContextFlightsBtn?.addEventListener('click', () => {
+ if (this._contextModeChanging || this._clearSelectedLayersPromise) return;
const nextMode = this._contextMode === 'flights' ? null : 'flights';
this._claimContextVisualAuthority();
void this._runUserFacingContextAction(
@@ -4632,6 +4790,7 @@ export class StyleManager {
});
});
this._globalContextMissionsBtn?.addEventListener('click', () => {
+ if (this._contextModeChanging || this._clearSelectedLayersPromise) return;
const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions';
this._claimContextVisualAuthority();
void this._runUserFacingContextAction(
@@ -4651,7 +4810,9 @@ export class StyleManager {
this._installationsSearchBtn?.addEventListener('click', () => {
if (!this._dataManager?.layers?.has('military-installations')) return;
const button = this._installationsSearchBtn;
- button.disabled = true;
+ if (button.getAttribute('aria-busy') === 'true') return;
+ button.setAttribute('aria-disabled', 'true');
+ button.setAttribute('aria-busy', 'true');
void this._runUserFacingContextAction(async (notificationToken) => {
const enabled = await this._dataManager.setEnabled('military-installations', true, {
origin: 'user',
@@ -4666,7 +4827,8 @@ export class StyleManager {
: 'Nearby installations refreshed'));
return true;
}, 'Nearby installations could not be refreshed; try again').finally(() => {
- button.disabled = false;
+ button.setAttribute('aria-disabled', 'false');
+ button.setAttribute('aria-busy', 'false');
});
});
}
@@ -4881,8 +5043,7 @@ export class StyleManager {
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
this._contextModeChanging = true;
- this._globalContextFlightsBtn && (this._globalContextFlightsBtn.disabled = true);
- this._globalContextMissionsBtn && (this._globalContextMissionsBtn.disabled = true);
+ this._syncContextModeButtons();
try {
if (mode !== 'flights' && this.cockpitView?.active) {
this.cockpitView.exit({ restoreTracking: false });
@@ -5036,8 +5197,6 @@ export class StyleManager {
} finally {
if (isCurrent()) {
this._contextModeChanging = false;
- this._globalContextFlightsBtn && (this._globalContextFlightsBtn.disabled = false);
- this._globalContextMissionsBtn && (this._globalContextMissionsBtn.disabled = false);
this._syncContextModeButtons();
}
}
@@ -5284,8 +5443,18 @@ export class StyleManager {
this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive));
this._globalContextMissionsBtn?.classList.toggle('active', missionsActive);
this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive));
- if (this._globalContextFlightsBtn) this._globalContextFlightsBtn.tabIndex = missionsActive ? -1 : 0;
- if (this._globalContextMissionsBtn) this._globalContextMissionsBtn.tabIndex = missionsActive ? 0 : -1;
+ const transitionBusy = Boolean(this._contextModeChanging);
+ // Both Context choices stay in the ordinary Tab sequence. Arrow keys still
+ // provide tablist navigation, but must not be the only way to reach Space
+ // Missions from the keyboard. Semantic busy state keeps them perceivable
+ // while synchronous click guards prevent a second transition.
+ for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) {
+ if (!button) continue;
+ button.disabled = false;
+ button.tabIndex = 0;
+ button.setAttribute('aria-disabled', String(transitionBusy));
+ button.setAttribute('aria-busy', String(transitionBusy));
+ }
if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive;
if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive;
if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive;
@@ -5532,9 +5701,11 @@ export class StyleManager {
};
const toggleRadio = async (trigger) => {
if (!this._dataManager?.layers?.has('radio')) return;
+ if (trigger.getAttribute('aria-busy') === 'true') return;
const enabling = !this._dataManager.isEnabled('radio');
const revealAfterEnable = enabling && trigger === this._radioEnableBtn;
- trigger.disabled = true;
+ trigger.setAttribute('aria-disabled', 'true');
+ trigger.setAttribute('aria-busy', 'true');
try {
const toggled = await this._runUserFacingContextAction(
(notificationToken) => this._dataManager.setEnabled('radio', enabling, {
@@ -5550,7 +5721,8 @@ export class StyleManager {
}
if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger);
} finally {
- trigger.disabled = false;
+ trigger.setAttribute('aria-disabled', 'false');
+ trigger.setAttribute('aria-busy', 'false');
if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true });
}
};
@@ -5602,16 +5774,25 @@ export class StyleManager {
// first-run launcher — one key, two actions. Matches the cockpit
// disclosure handler directly below.
event.stopImmediatePropagation();
- setRadioDisclosure(false, { returnFocus: true });
+ const escapedFromDisclosure = event.target === this._contextRadioToggleBtn
+ || this._contextRadioToggleBtn?.contains?.(event.target);
+ setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
+ if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
}, { capture: true, signal: this._radioTunerAbort.signal });
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true';
const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true';
if (!displayOpen && !radioOpen) return;
+ const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)');
+ if (nestedPanel) return;
event.preventDefault();
event.stopImmediatePropagation();
- setCockpitDisclosure(displayOpen ? 'display' : 'radio', false, { returnFocus: true });
+ const kind = displayOpen ? 'display' : 'radio';
+ const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn;
+ const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target);
+ setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure });
+ if (escapedFromDisclosure) disclosure?.blur?.();
}, { capture: true, signal: this._radioTunerAbort.signal });
window.addEventListener('gev:cockpit-mode-changed', (event) => {
if (event?.detail?.active) return;
@@ -5905,7 +6086,9 @@ export class StyleManager {
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
- this._radioEnableBtn.disabled = transitioning;
+ this._radioEnableBtn.disabled = false;
+ this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
+ this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._contextRadioMiniEnableBtn) {
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
@@ -5917,7 +6100,9 @@ export class StyleManager {
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
- this._contextRadioMiniEnableBtn.disabled = transitioning;
+ this._contextRadioMiniEnableBtn.disabled = false;
+ this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning));
+ this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._cockpitRadioEnableBtn) {
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
@@ -5929,7 +6114,9 @@ export class StyleManager {
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
- this._cockpitRadioEnableBtn.disabled = transitioning;
+ this._cockpitRadioEnableBtn.disabled = false;
+ this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning));
+ this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._radioFilter) {
@@ -7433,7 +7620,7 @@ export class StyleManager {
});
const dockToggle = panelEl.querySelector(`[data-dock-toggle-target="${panelEl.id}"]`);
if (dockToggle) {
- const panelName = panelEl.querySelector('.panel-title')?.textContent?.trim() || 'panel';
+ const panelName = panelEl.querySelector('.panel-title, .location-toolbar-label')?.textContent?.trim() || 'panel';
const action = collapsed ? 'Expand' : 'Collapse';
dockToggle.setAttribute('aria-expanded', String(!collapsed));
dockToggle.setAttribute('aria-label', `${action} ${panelName}`);
@@ -7677,6 +7864,7 @@ export class StyleManager {
persist = true,
syncShare = true,
} = {}) {
+ if (panelId === 'control-panel' && collapsed) this._cancelMapSourceFocus?.();
const panelEl = document.getElementById(panelId);
if (!panelEl) return;
if (explicit && !restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
@@ -8930,6 +9118,7 @@ export class StyleManager {
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'param-slider';
+ slider.setAttribute('aria-label', uMeta.label);
slider.min = uMeta.min;
slider.max = uMeta.max;
slider.step = uMeta.max <= 1 ? '0.01' : '0.1';
@@ -9650,9 +9839,8 @@ export class StyleManager {
this._preservePanelStateDuringLayerClear = true;
this._syncContextModeButtons();
this._userFacingContextNotificationTokens.add(notificationToken);
- this._globalContextFlightsBtn && (this._globalContextFlightsBtn.disabled = true);
- this._globalContextMissionsBtn && (this._globalContextMissionsBtn.disabled = true);
- this._clearSelectedLayersBtn.disabled = true;
+ this._clearSelectedLayersBtn.setAttribute('aria-disabled', 'true');
+ this._clearSelectedLayersBtn.setAttribute('aria-busy', 'true');
this._clearSelectedLayersBtn.setAttribute('aria-label', 'Clearing selected data layers');
const managerOperation = this._dataManager.clearSelectedLayers({
@@ -9684,10 +9872,9 @@ export class StyleManager {
if (generation === this._contextModeGeneration) {
this._contextModeChanging = false;
this._syncContextModeButtons();
- this._globalContextFlightsBtn && (this._globalContextFlightsBtn.disabled = false);
- this._globalContextMissionsBtn && (this._globalContextMissionsBtn.disabled = false);
}
- this._clearSelectedLayersBtn.disabled = false;
+ this._clearSelectedLayersBtn.setAttribute('aria-disabled', 'false');
+ this._clearSelectedLayersBtn.setAttribute('aria-busy', 'false');
this._clearSelectedLayersBtn.setAttribute('aria-label', 'Clear selected data layers');
this._preservePanelStateDuringLayerClear = false;
this._clearSelectedLayersManagerPromise = null;
@@ -10123,6 +10310,7 @@ export class StyleManager {
this._globalStatusNotice = null;
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
this._disposed = true;
+ this._cancelMapSourceFocus?.();
// Revoke persistence/hash authority before teardown can emit manager changes.
this._layerStateCoordinator?.destroy();
this._layerStateCoordinator = null;
diff --git a/src/voice/gevRealtime.js b/src/voice/gevRealtime.js
index ab566fec1..45ebed526 100644
--- a/src/voice/gevRealtime.js
+++ b/src/voice/gevRealtime.js
@@ -20,6 +20,7 @@ const STATUS = {
error: 'ERROR',
};
const CALL_DEDUPE_MS = 2500;
+export const PUSH_TO_TALK_HOLD_DELAY_MS = 500;
// WebRTC 'disconnected' is frequently momentary (a brief network blip that ICE
// recovers on its own). Give it this long to return to 'connected' before we
// treat it as a real drop (H8).
@@ -307,6 +308,11 @@ export class GevRealtimeController {
this.pushToTalkMode = false;
this.pushToTalkKeyHeld = false;
this.spaceKeyHeld = false;
+ this.pushToTalkHoldTimer = null;
+ this.pushToTalkHoldGeneration = 0;
+ this.pushToTalkHoldFocusOwner = null;
+ this.pushToTalkHoldControl = null;
+ this.pushToTalkHoldPreservesNative = false;
this.shortcutKeyDownHandler = null;
this.shortcutKeyUpHandler = null;
this.shortcutBlurHandler = null;
@@ -568,65 +574,122 @@ export class GevRealtimeController {
}
}
- /**
- * Registers hold-Space push-to-talk without hijacking typing or modified shortcuts.
- * @returns {void}
- */
+ /** Registers delayed hold-Space push-to-talk while preserving short control taps. */
bindPushToTalkShortcut() {
if (this.shortcutKeyDownHandler) return;
this.shortcutKeyDownHandler = (event) => {
if (!shouldHandlePushToTalkKeyDown(event)) return;
if (event.repeat) {
- if (this.spaceKeyHeld) event.preventDefault();
+ if (this.spaceKeyHeld && !this.pushToTalkHoldPreservesNative) event.preventDefault();
+ if (this.pushToTalkKeyHeld) event.preventDefault();
return;
}
this.spaceKeyHeld = true;
- // Space must not generate the focused mic button's native click on keyup.
- event.preventDefault();
- this.pauseRadioForVoice();
- if (this.pushToTalkKeyHeld) return;
+ this.pushToTalkHoldFocusOwner = document.activeElement;
+ this.pushToTalkHoldControl = isInteractiveSpaceTarget(document.activeElement)
+ ? document.activeElement
+ : (isInteractiveSpaceTarget(event.target)
+ ? event.target.closest?.(SPACE_INTERACTIVE_SELECTOR) || event.target
+ : null);
+ this.pushToTalkHoldPreservesNative = Boolean(this.pushToTalkHoldControl);
+ // Background Space is reserved immediately to avoid scrolling. A focused
+ // control keeps its native keydown and release unless the hold is claimed.
+ if (!this.pushToTalkHoldPreservesNative) event.preventDefault();
// A click-started session is intentionally open-mic. Space only claims an
// idle session (or a session it already started) so releasing the key can
// never surprise the user by muting a click-started conversation.
if (this.isActive() && !this.pushToTalkMode) return;
- this.pushToTalkKeyHeld = true;
- this.ui.root.dataset.pushToTalk = 'held';
- if (this.isActive()) {
- this.setMicrophoneEnabled(true);
- if (this.status === 'listening') this.setStatus('listening', 'Release Space to send');
- } else {
- this.start({ pushToTalk: true });
- }
+ this.cancelPushToTalkHold();
+ const holdGeneration = ++this.pushToTalkHoldGeneration;
+ this.pushToTalkHoldTimer = setTimeout(() => {
+ this.pushToTalkHoldTimer = null;
+ if (holdGeneration !== this.pushToTalkHoldGeneration) return;
+ if (!this.spaceKeyHeld || this.pushToTalkKeyHeld) return;
+ // A pointer or Tab focus change during the delay cancels the original
+ // gesture rather than letting voice claim a key held for another owner.
+ if (document.activeElement !== this.pushToTalkHoldFocusOwner) return;
+ if (
+ document.visibilityState === 'hidden'
+ || (typeof document.hasFocus === 'function' && !document.hasFocus())
+ ) return;
+ if (this.isActive() && !this.pushToTalkMode) return;
+ // Blur before voice starts. This removes the focused state and ensures
+ // the eventual Space release cannot activate the old control.
+ if (
+ this.pushToTalkHoldControl
+ && document.activeElement === this.pushToTalkHoldControl
+ && typeof this.pushToTalkHoldControl.blur === 'function'
+ ) {
+ this.pushToTalkHoldControl.blur();
+ }
+ this.pauseRadioForVoice();
+ this.pushToTalkKeyHeld = true;
+ if (this.isActive()) {
+ this.ui.root.dataset.pushToTalk = 'held';
+ this.setMicrophoneEnabled(true);
+ if (this.status === 'listening') this.setStatus('listening', 'Release Space to send');
+ } else {
+ this.start({ pushToTalk: true });
+ // start() performs a controlled stop() before connecting. Restore the
+ // marker it clears so the UI reflects this still-held gesture.
+ if (this.pushToTalkKeyHeld) this.ui.root.dataset.pushToTalk = 'held';
+ }
+ }, PUSH_TO_TALK_HOLD_DELAY_MS);
};
this.shortcutKeyUpHandler = (event) => {
if (!isPushToTalkKey(event)) return;
const wasHoldingSpace = this.spaceKeyHeld;
+ const preservedNativeActivation = this.pushToTalkHoldPreservesNative;
this.spaceKeyHeld = false;
+ this.cancelPushToTalkHold();
if (!this.pushToTalkKeyHeld) {
- if (wasHoldingSpace) event.preventDefault();
+ if (wasHoldingSpace && !preservedNativeActivation) event.preventDefault();
+ this.resetPushToTalkGesture();
return;
}
event.preventDefault();
this.releasePushToTalkKey();
+ this.resetPushToTalkGesture();
};
this.shortcutBlurHandler = () => {
this.spaceKeyHeld = false;
+ this.cancelPushToTalkHold();
this.releasePushToTalkKey();
+ this.resetPushToTalkGesture();
};
this.shortcutVisibilityHandler = () => {
if (document.visibilityState === 'hidden') this.shortcutBlurHandler();
};
- document.addEventListener('keydown', this.shortcutKeyDownHandler);
- document.addEventListener('keyup', this.shortcutKeyUpHandler);
+ // Observe before custom controls call preventDefault for their own Space
+ // behavior. This lets the same physical hold cross the 500ms voice
+ // threshold while short taps still reach the control unchanged.
+ document.addEventListener('keydown', this.shortcutKeyDownHandler, true);
+ document.addEventListener('keyup', this.shortcutKeyUpHandler, true);
window.addEventListener('blur', this.shortcutBlurHandler);
document.addEventListener('visibilitychange', this.shortcutVisibilityHandler);
}
+ /** Cancels an unclaimed Space hold before it starts voice. */
+ cancelPushToTalkHold() {
+ this.pushToTalkHoldGeneration++;
+ if (this.pushToTalkHoldTimer === null) return;
+ clearTimeout(this.pushToTalkHoldTimer);
+ this.pushToTalkHoldTimer = null;
+ }
+
+ /** Clears the owner metadata for the current physical Space gesture. */
+ resetPushToTalkGesture() {
+ this.pushToTalkHoldFocusOwner = null;
+ this.pushToTalkHoldControl = null;
+ this.pushToTalkHoldPreservesNative = false;
+ }
+
/**
* Mutes a keyboard-started microphone while leaving WebRTC alive for the reply.
* @returns {void}
*/
releasePushToTalkKey() {
+ this.cancelPushToTalkHold();
if (!this.pushToTalkKeyHeld) return;
this.pushToTalkKeyHeld = false;
delete this.ui.root.dataset.pushToTalk;
@@ -773,6 +836,7 @@ export class GevRealtimeController {
// releases its own resources instead of promoting them onto a stopped
// controller (H7).
this.startEpoch++;
+ this.cancelPushToTalkHold();
this.radioHandoffEpoch++;
for (const controller of this.activeToolAbortControllers) controller.abort();
this.activeToolAbortControllers.clear();
@@ -844,6 +908,7 @@ export class GevRealtimeController {
this.pushToTalkMode = false;
this.pushToTalkKeyHeld = false;
this.spaceKeyHeld = false;
+ this.resetPushToTalkGesture();
if (this.ui?.root) {
delete this.ui.root.dataset.pushToTalk;
delete this.ui.root.dataset.microphone;
@@ -857,8 +922,8 @@ export class GevRealtimeController {
this.tierHandler = null;
}
if (removeUi) {
- if (this.shortcutKeyDownHandler) document.removeEventListener('keydown', this.shortcutKeyDownHandler);
- if (this.shortcutKeyUpHandler) document.removeEventListener('keyup', this.shortcutKeyUpHandler);
+ if (this.shortcutKeyDownHandler) document.removeEventListener('keydown', this.shortcutKeyDownHandler, true);
+ if (this.shortcutKeyUpHandler) document.removeEventListener('keyup', this.shortcutKeyUpHandler, true);
if (this.shortcutBlurHandler) window.removeEventListener('blur', this.shortcutBlurHandler);
if (this.shortcutVisibilityHandler) {
document.removeEventListener('visibilitychange', this.shortcutVisibilityHandler);
@@ -2454,18 +2519,77 @@ export function isPushToTalkKey(event) {
return event?.code === 'Space' || event?.key === ' ';
}
+const SPACE_INTERACTIVE_SELECTOR = [
+ 'button',
+ 'input',
+ 'textarea',
+ 'select',
+ 'option',
+ 'a[href]',
+ 'summary',
+ 'audio[controls]',
+ 'video[controls]',
+ '[contenteditable]',
+ '[tabindex]',
+ '[role="button"]',
+ '[role="checkbox"]',
+ '[role="combobox"]',
+ '[role="link"]',
+ '[role="listbox"]',
+ '[role="menuitem"]',
+ '[role="menuitemcheckbox"]',
+ '[role="menuitemradio"]',
+ '[role="option"]',
+ '[role="radio"]',
+ '[role="searchbox"]',
+ '[role="slider"]',
+ '[role="spinbutton"]',
+ '[role="switch"]',
+ '[role="tab"]',
+ '[role="textbox"]',
+ '[role="treeitem"]',
+].join(', ');
+
/**
- * Protects text entry and modified shortcuts from the global push-to-talk key.
- * @param {KeyboardEvent|object|null} event
- * @returns {boolean}
+ * Returns whether Space began on the map surface reserved for push-to-talk.
+ * Cesium gives its canvas `tabindex="0"` for camera input, which otherwise
+ * makes the generic focus guard treat a map click like a button activation.
*/
+export function isPushToTalkSurface(target) {
+ if (target?.tagName?.toUpperCase?.() !== 'CANVAS') return false;
+ if (target.id === 'world-overlay-canvas') return true;
+ return Boolean(target.closest?.('#cesiumContainer'));
+}
+
+/** Returns whether a focused target owns Space for UI interaction. */
+export function isInteractiveSpaceTarget(target) {
+ if (!target) return false;
+ if (isPushToTalkSurface(target)) return false;
+ if (target.isContentEditable) return true;
+ return Boolean(target.closest?.(SPACE_INTERACTIVE_SELECTOR));
+}
+
+/** Returns whether Space belongs to a text-entry surface. */
+export function isEditingSpaceTarget(target) {
+ if (!target) return false;
+ if (target.isContentEditable || target.closest?.('[contenteditable]')) return true;
+ if (target.closest?.('textarea, [role="textbox"], [role="searchbox"], [role="spinbutton"]')) {
+ return true;
+ }
+ const input = target.closest?.('input');
+ if (!input) return false;
+ const type = String(input.type || 'text').toLowerCase();
+ return [
+ 'text', 'search', 'email', 'url', 'tel', 'password', 'number',
+ 'date', 'datetime-local', 'month', 'week', 'time',
+ ].includes(type);
+}
+
+/** Protects text entry and modified shortcuts from push-to-talk arbitration. */
export function shouldHandlePushToTalkKeyDown(event) {
if (!isPushToTalkKey(event) || event.defaultPrevented) return false;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false;
- const target = event.target;
- if (target?.isContentEditable) return false;
- const editingControl = target?.closest?.('input, textarea, select, [contenteditable], [role="textbox"]');
- return !editingControl;
+ return !isEditingSpaceTarget(event.target);
}
/**
@@ -2510,7 +2634,7 @@ export function resolveVoiceVisualizerSpeaker(currentSpeaker, nextSpeaker, keepC
export function resolveVoiceControlHint(pushToTalkMode, pushToTalkKeyHeld) {
return pushToTalkMode && pushToTalkKeyHeld
? 'Release Space to send'
- : 'Hold Space to speak · click mic to toggle voice';
+ : 'Hold Space to speak · tap Space to activate focused controls';
}
/**
@@ -2559,7 +2683,7 @@ function createVoiceControl({ reset = false } = {}) {
~$0.00
-
+
ON/OFF
@@ -2571,7 +2695,7 @@ function createVoiceControl({ reset = false } = {}) {
VOICE CONTROL
- Hold Space to speak · click mic to toggle voice
+ Hold Space to speak · tap Space to activate focused controls