diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d40619c8..931c492b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,49 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
+- Keyboard focus rings now survive active/selected button styles across the
+ interface. Visual Styles, Location cities and points of interest, search,
+ Context/mission actions, Cockpit utilities, and sliders retain a distinct
+ focus indicator.
+- A short Space press activates a focused control only on key release. Holding
+ Space for 500 ms blurs that control before push-to-talk starts, and release is
+ then consumed so it cannot also activate the old control. The same hold works
+ from the map or page background; text-entry controls remain protected.
+- The Location disclosure is reachable with Tab and shows keyboard focus;
+ its city, point-of-interest, and search controls do too. Escape from inside
+ the tray returns focus to its disclosure and discards any unfinished search;
+ Escape on the disclosure itself closes the tray and clears that focus.
+- Data Layers ON/OFF buttons show a keyboard focus ring independently of
+ their enabled and feed-status colors.
+- Display buttons, layout selectors, mode buttons, and sliders show a visible
+ keyboard focus ring, including the controls used in Cockpit Display. Enabled
+ CCTV camera dropdowns also show keyboard focus.
+- Context tabs keep a distinct keyboard ring when selected. Their existing
+ Left/Right arrow navigation continues to switch Contacts and Space Missions,
+ and both choices remain reachable through ordinary Tab navigation.
+- Tabbing through the Space Missions roster now drives the same temporary globe
+ rotation and mission-marker highlight as pointer hover, without selecting the
+ mission. Keyboard and pointer previews no longer cancel each other.
+- Radio power controls, Search Nearby Sites, and Clear Selected Layers retain
+ keyboard focus while their async work is busy. They expose that busy state to
+ assistive technology and ignore repeated activation until the work settles.
+- Live Contacts results retain keyboard focus by contact identity when counts,
+ distance order, or pages refresh. If a focused contact departs or rotates off
+ the visible page, focus moves to the named explanatory note at the end of the
+ list and survives later refreshes there, so the next Tab proceeds beyond the
+ list instead of restarting at Contacts or silently selecting another contact.
+- Cockpit Live Signals retains keyboard focus during live updates and contact
+ reordering, allowing Tab to continue to Display and Radio. If the focused
+ contact leaves the list, focus moves to the current briefing tab.
+- Cockpit-only Display and Radio launchers show complete inset focus rings.
+- Escape collapses the nearest expanded panel containing keyboard focus and
+ returns focus to that panel's disclosure when closing from its contents.
+ Escape on the disclosure itself closes without leaving the collapsed control
+ focused. Cockpit Contact and Live Signals panels follow the same nesting rule.
+- Cesium's bottom-left Data attribution control and lightbox Close control are
+ in the Tab order and support Enter and Space. Close, Escape, and backdrop
+ dismissal restore focus and synchronize the disclosure state.
+
- CCTV testing uses the normal launcher for keyless startup, credential loading,
localhost binding, and explicit LAN-exposure warnings while retaining its
smaller source-pack limits.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 252bed655..640dfe805 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,5 +1,64 @@
# God's Eye View Current State
+## Keyboard interaction and focus
+
+- Enter on the map-source disclosure opens immediately. A short Space press
+ opens it on key release. Either route focuses the selected source once visible;
+ a bounded retry handles delayed opening transitions, and closing the tray or
+ moving focus elsewhere cancels the handoff.
+- Keyboard focus rings are a global interface state and stay visible when a
+ button is active or selected. Visual Styles, Location suboptions, Context and
+ mission actions, Cockpit utilities, native fields, and sliders use the same
+ visible-ring contract.
+- A short Space press on a focused control keeps its native key-release action.
+ If it remains held for 500 ms, focus is checked again, the control is blurred,
+ and push-to-talk starts; releasing a claimed hold cannot activate the old
+ control. The same hold works on the map and page background. Text-entry
+ controls remain protected.
+- The Location disclosure is reachable with Tab or Shift+Tab and shows a
+ keyboard focus ring. Its city, point-of-interest, search-toggle, and search
+ field controls show inset rings, including selected items. Enter toggles its
+ tray immediately, while Space does so on release; either route makes the revealed controls immediately reachable by Tab;
+ Escape closes it and clears any unfinished search. Escape from a tray control
+ returns focus to the disclosure; Escape on the disclosure clears focus after closing.
+- Data Layers ON/OFF buttons show a visible keyboard focus ring without
+ changing their enabled state or feed-status presentation.
+- Display controls and shader-parameter sliders show keyboard focus in both
+ the map panel and Cockpit Display. Arrow keys retain native range adjustment.
+ The enabled CCTV camera dropdown shows keyboard focus as well.
+- Tab reaches both Contacts and Space Missions in sequence in every Context
+ state. Left/Right arrows also switch them; the focused tab has a distinct ring
+ even when selected.
+- Keyboard focus on a Space Missions roster item uses the same temporary globe
+ rotation and mission-marker highlight as pointer hover. Focus alone does not
+ select the mission; Enter or Space performs selection. Keyboard and pointer
+ preview ownership remain independent when the pointer is parked over the list.
+- Radio power controls in the full, compact, and Cockpit surfaces, Search Nearby
+ Sites, and Clear Selected Layers remain focusable while lifecycle work is in
+ progress. They announce busy/disabled semantics and ignore repeat activation
+ until the operation settles, so async work cannot drop the keyboard ring.
+- Normal-mode Contacts results preserve the focused action or contact by stable
+ identity while live counts, distance order, and pages refresh. If that contact
+ departs or rotates off the visible page, focus moves to a stable continuation
+ point on the named explanatory note at the end of the list and remains there
+ through later repaints. The following Tab proceeds beyond the results instead
+ of restarting at Contacts or silently focusing another contact.
+- Cockpit Live Signals updates existing contact buttons without replacing or
+ disconnecting the focused one. Tab can continue through the briefing footer
+ to Display and Radio. If the focused contact leaves the list, focus moves
+ once to the current briefing tab; later refreshes do not reclaim it.
+- Cockpit-only Display and Radio launcher icons show a complete inset keyboard
+ ring in their collapsed and expanded states.
+- Escape collapses the nearest expanded panel containing focus before any
+ containing panel acts. Closing from panel content returns focus to that panel's
+ disclosure; closing from the disclosure itself clears focus so the collapsed
+ button does not retain its ring. This includes standard panels, nested
+ Parameters, Cockpit Contact and Live Signals, and Cockpit Display/Radio utilities.
+- The required bottom-left Data attribution control is a named popup button in
+ the Tab order. Enter opens immediately and Space opens on release, then the
+ credit lightbox focuses Close;
+ Close, Escape, or backdrop dismissal restores focus to Data attribution.
+
## CCTV launcher and proxy failure responses
`scripts/dev-cctv.sh` delegates startup to `scripts/dev-fresh.sh`. It retains
@@ -793,7 +852,7 @@ This is the current runtime/source-of-truth snapshot for the project.
> corridor. Layer toggles stay live from there, and collapsing returns the
> plain launcher. The map-only Clear, Share, and Reset Globe actions are hidden
> for the duration of Cockpit, both as a group and as individual controls.
-> It uses the `radar` symbol and provides roving keyboard tab navigation. Its action row
+> It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row
> places the single Cockpit entry before Search Nearby Sites. Cockpit removes
> the duplicate floating map entry and topline exit; the bottom-center
> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape`/`C` own exit, with entry/exit focus
diff --git a/index.html b/index.html
index 900620de0..87fb168bc 100644
--- a/index.html
+++ b/index.html
@@ -548,14 +548,16 @@
- LOCATION
+
+ LOCATION
+
+
π Location: --
Landmark: --
-
+
@@ -727,7 +729,7 @@
radar
CONTACTS
-
+
rocket_launch
SPACE MISSIONS
@@ -765,6 +767,7 @@
LOADING 30-DAY MISSION INDEX
+ TAB PREVIEWS Β· ENTER / SPACE SELECTS
diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs
index e174b66d8..ffb3b0851 100644
--- a/scripts/qa-cockpit-utility.mjs
+++ b/scripts/qa-cockpit-utility.mjs
@@ -20,7 +20,13 @@ const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candid
const browser = await puppeteer.launch({
headless: headful ? false : 'new',
...(executablePath ? { executablePath } : {}),
- args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
+ // Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
+ args: [
+ ...(process.platform === 'darwin'
+ ? ['--use-angle=metal', '--enable-gpu']
+ : ['--use-gl=angle', '--use-angle=swiftshader']),
+ '--no-sandbox',
+ ],
});
const page = await browser.newPage();
const failures = [];
@@ -54,8 +60,22 @@ const check = (name, passed, detail) => {
try {
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.setRequestInterception(true);
- page.on('request', (request) => {
+ const routeQaRequest = (request) => {
const url = new URL(request.url());
+ // This harness verifies UI/lifecycle behavior, not DEM accuracy. Keep an
+ // unrelated upstream terrain outage out of the rendered interaction gate.
+ if (url.origin === new URL(appUrl).origin && url.pathname === '/api/terrain/heights') {
+ const points = (url.searchParams.get('points') || '').split(';').filter(Boolean);
+ request.respond({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ results: points.map((point) => {
+ const [lon, lat] = point.split(',').map(Number);
+ return { lon, lat, elevation: 0, geoid: 0, ellipsoid: 0 };
+ }) }),
+ });
+ return;
+ }
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') {
request.respond({
status: 200,
@@ -111,7 +131,8 @@ try {
return;
}
request.continue();
- });
+ };
+ page.on('request', routeQaRequest);
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
await page.waitForFunction(
@@ -768,146 +789,189 @@ try {
// was created to fix.
// Holding the network reproduces the field exactly and the panel tracks it,
// which is what gives these assertions teeth.
- const deferredInstallationReadiness = await page.evaluate(async () => {
- const { styleManager, dataManager } = window.__godsEyeView;
- const entry = dataManager.layers.get('military-installations');
- if (!entry?.module) return { exercised: false, reason: 'military-installations missing' };
- const settleWithin = (promise, ms) => Promise.race([
- promise.then(
- (value) => ({ settled: true, value }),
- (error) => ({ settled: true, value: `error: ${String(error?.message || error)}` }),
- ),
- new Promise((resolve) => { setTimeout(() => resolve({ settled: false, value: null }), ms); }),
- ]);
- const row = () => [...document.querySelectorAll('#military-awareness-panel .military-awareness-row')]
- .find((candidate) => candidate.querySelector('strong')?.textContent?.trim() === 'Mapped installations');
- const installationCount = () => row()?.querySelector('b')?.textContent?.trim() || null;
- /** The REASON text, which is what tracks availability: the cohort engine
- * answers 'feed unavailable' / 'feed stale' when it refuses to count. */
- const installationReason = () => row()?.querySelector('small')?.textContent?.trim() || null;
-
- const realFetch = window.fetch;
- let released = false;
- let requestSeen = false;
- let transition = null;
- try {
- await styleManager._selectContextMode(null);
- await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' });
-
- // Hold the first Overpass request open, honouring the module's own
- // AbortSignal so its camera-settle abort/refetch behaves as it does live.
- window.fetch = (input, init) => {
- const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url;
- let url = null;
- try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); }
- if (url.pathname !== '/api/military-installations') return realFetch(input, init);
- requestSeen = true;
- const signal = init?.signal;
- return new Promise((resolve, reject) => {
- let done = false;
- const fail = () => {
- if (done) return;
- done = true;
- const error = new Error('aborted');
- error.name = 'AbortError';
- reject(error);
- };
- if (signal) {
- if (signal.aborted) return fail();
- signal.addEventListener('abort', fail, { once: true });
- }
- const tick = () => {
- if (done) return;
- if (released) {
- done = true;
- resolve(new Response(
- JSON.stringify({ elements: [], retrievedAt: new Date().toISOString() }),
- { status: 200, headers: { 'Content-Type': 'application/json' } },
- ));
- return;
- }
- setTimeout(tick, 100);
- };
- tick();
+ // Earlier scenarios have already loaded installations. Use a fresh page so
+ // a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch
+ // assertion when camera motion supersedes the manager's initial update.
+ const readinessPage = await browser.newPage();
+ let deferredInstallationReadiness;
+ try {
+ await readinessPage.setViewport({ width: 1440, height: 900 });
+ readinessPage.on('pageerror', (error) => consoleErrors.push(error.message));
+ await readinessPage.setRequestInterception(true);
+ readinessPage.on('request', (request) => {
+ const url = new URL(request.url());
+ if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky') {
+ const now = Math.floor(Date.now() / 1000);
+ request.respond({
+ status: 200, contentType: 'application/json',
+ body: JSON.stringify({ time: now, states: [
+ ['aaa051', 'QA051', 'Synthetic', now, now, -97.7431, 30.2672,
+ 9000, false, 230, 90, 0, null, 9000, null, false, 0],
+ ] }),
});
- };
-
- transition = styleManager._selectContextMode('flights');
- // The activation promise stays PENDING while the first fetch is out, and
- // the Contacts panel comes up behind it β that is what a deferred
- // dependency is for. Verified live on :4272: the panel renders and reads
- // a non-numeric count for the whole of a 17 s first fetch.
- const started = await settleWithin((async () => {
- for (let i = 0; i < 100 && !(requestSeen && row()); i++) {
- await new Promise((resolve) => setTimeout(resolve, 100));
- }
- return requestSeen && Boolean(row());
- })(), 12_000);
- styleManager.cockpitView.syncEntry();
- const pendingLifecycle = dataManager.getLayerLifecycleState('military-installations');
- const pendingCount = installationCount();
- const pendingReason = installationReason();
-
- // THE WINDOW: first request outstanding, panel live. Sampled more than
- // once β the field report was a panel sitting on a fabricated 0 for ~13 s,
- // so a single lucky read is not evidence.
- const fetchingCounts = [];
- const fetchingReasons = [];
- for (let i = 0; i < 6; i++) {
- fetchingCounts.push(installationCount());
- fetchingReasons.push(installationReason());
- await new Promise((resolve) => setTimeout(resolve, 250));
+ return;
}
-
- released = true;
- const contacts = await settleWithin(transition, 20_000);
- styleManager.cockpitView.syncEntry();
- // Measured AFTER activation settles, not during it. The sibling pin above
- // ("Contacts blocks early Cockpit entry, then adopts the already tracked
- // flight after settlement") establishes that Contacts HIDES the entry
- // while the transition is changing β that is the designed contract, and
- // asserting the opposite (as this scenario used to) could never pass.
- // What matters is that a slow dependency does not lock it away for good.
- const cockpitAvailableAfterActivation = !document.getElementById('cockpit-entry')?.hidden;
- const installed = await settleWithin((async () => {
- while (dataManager.getLayerLifecycleState('military-installations')?.lifecycleState !== 'enabled') {
- await new Promise((resolve) => setTimeout(resolve, 50));
- }
- await new Promise((resolve) => setTimeout(resolve, 200));
- return true;
- })(), 20_000);
- const answered = await settleWithin((async () => {
- for (let i = 0; i < 60 && !/^\d+$/.test(String(installationCount())); i++) {
- await new Promise((resolve) => setTimeout(resolve, 150));
- }
- return true;
- })(), 12_000);
-
- return {
- exercised: true,
- started,
- contacts,
- pendingLifecycle,
- pendingCount,
- pendingReason,
- cockpitAvailableAfterActivation,
- installed,
- settledLifecycle: dataManager.getLayerLifecycleState('military-installations'),
- fetchingCounts,
- fetchingReasons,
- answered,
- settledCount: installationCount(),
- settledReason: installationReason(),
- };
- } finally {
- released = true;
- window.fetch = realFetch;
- if (transition) await settleWithin(transition, 20_000);
- if (styleManager._contextMode !== 'flights') {
- await settleWithin(styleManager._selectContextMode('flights'), 15_000);
+ routeQaRequest(request);
+ });
+ const readinessUrl = new URL(appUrl);
+ readinessUrl.searchParams.set('welcome', '0');
+ await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' });
+ await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager
+ && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 });
+ await readinessPage.evaluate(async () => {
+ const manager = window.__godsEyeView.dataManager;
+ await manager.setEnabled('flights', true, { origin: 'user' });
+ if (!manager.layers.get('flights').module.trackById('aaa051')) {
+ throw new Error('First-fetch fixture aircraft was not tracked');
}
- }
- });
+ if (manager.layers.get('military-installations').module.getStats().lastUpdate != null) {
+ throw new Error('First-fetch fixture already has an installation snapshot');
+ }
+ });
+ deferredInstallationReadiness = await readinessPage.evaluate(async () => {
+ const { styleManager, dataManager } = window.__godsEyeView;
+ const entry = dataManager.layers.get('military-installations');
+ if (!entry?.module) return { exercised: false, reason: 'military-installations missing' };
+ const settleWithin = (promise, ms) => Promise.race([
+ promise.then(
+ (value) => ({ settled: true, value }),
+ (error) => ({ settled: true, value: `error: ${String(error?.message || error)}` }),
+ ),
+ new Promise((resolve) => { setTimeout(() => resolve({ settled: false, value: null }), ms); }),
+ ]);
+ const row = () => [...document.querySelectorAll('#military-awareness-panel .military-awareness-row')]
+ .find((candidate) => candidate.querySelector('strong')?.textContent?.trim() === 'Mapped installations');
+ const installationCount = () => row()?.querySelector('b')?.textContent?.trim() || null;
+ /** The REASON text, which is what tracks availability: the cohort engine
+ * answers 'feed unavailable' / 'feed stale' when it refuses to count. */
+ const installationReason = () => row()?.querySelector('small')?.textContent?.trim() || null;
+
+ const realFetch = window.fetch;
+ let released = false;
+ let requestSeen = false;
+ let transition = null;
+ try {
+ await styleManager._selectContextMode(null);
+ await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' });
+
+ // Hold the first Overpass request open, honouring the module's own
+ // AbortSignal so its camera-settle abort/refetch behaves as it does live.
+ window.fetch = (input, init) => {
+ const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url;
+ let url = null;
+ try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); }
+ if (url.pathname !== '/api/military-installations') return realFetch(input, init);
+ requestSeen = true;
+ const signal = init?.signal;
+ return new Promise((resolve, reject) => {
+ let done = false;
+ const fail = () => {
+ if (done) return;
+ done = true;
+ const error = new Error('aborted');
+ error.name = 'AbortError';
+ reject(error);
+ };
+ if (signal) {
+ if (signal.aborted) return fail();
+ signal.addEventListener('abort', fail, { once: true });
+ }
+ const tick = () => {
+ if (done) return;
+ if (released) {
+ done = true;
+ resolve(new Response(
+ JSON.stringify({ elements: [], retrievedAt: new Date().toISOString() }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ));
+ return;
+ }
+ setTimeout(tick, 100);
+ };
+ tick();
+ });
+ };
+
+ transition = styleManager._selectContextMode('flights');
+ // The activation promise stays PENDING while the first fetch is out, and
+ // the Contacts panel comes up behind it β that is what a deferred
+ // dependency is for. Verified live on :4272: the panel renders and reads
+ // a non-numeric count for the whole of a 17 s first fetch.
+ const started = await settleWithin((async () => {
+ for (let i = 0; i < 100 && !(requestSeen && row()); i++) {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ return requestSeen && Boolean(row());
+ })(), 12_000);
+ styleManager.cockpitView.syncEntry();
+ const pendingLifecycle = dataManager.getLayerLifecycleState('military-installations');
+ const pendingCount = installationCount();
+ const pendingReason = installationReason();
+
+ // THE WINDOW: first request outstanding, panel live. Sampled more than
+ // once β the field report was a panel sitting on a fabricated 0 for ~13 s,
+ // so a single lucky read is not evidence.
+ const fetchingCounts = [];
+ const fetchingReasons = [];
+ for (let i = 0; i < 6; i++) {
+ fetchingCounts.push(installationCount());
+ fetchingReasons.push(installationReason());
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+
+ released = true;
+ const contacts = await settleWithin(transition, 20_000);
+ styleManager.cockpitView.syncEntry();
+ // Measured AFTER activation settles, not during it. The sibling pin above
+ // ("Contacts blocks early Cockpit entry, then adopts the already tracked
+ // flight after settlement") establishes that Contacts HIDES the entry
+ // while the transition is changing β that is the designed contract, and
+ // asserting the opposite (as this scenario used to) could never pass.
+ // What matters is that a slow dependency does not lock it away for good.
+ const cockpitAvailableAfterActivation = !document.getElementById('cockpit-entry')?.hidden;
+ const installed = await settleWithin((async () => {
+ while (dataManager.getLayerLifecycleState('military-installations')?.lifecycleState !== 'enabled') {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ return true;
+ })(), 20_000);
+ const answered = await settleWithin((async () => {
+ for (let i = 0; i < 60 && !/^\d+$/.test(String(installationCount())); i++) {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ }
+ return true;
+ })(), 12_000);
+
+ return {
+ exercised: true,
+ started,
+ contacts,
+ pendingLifecycle,
+ pendingCount,
+ pendingReason,
+ cockpitAvailableAfterActivation,
+ installed,
+ settledLifecycle: dataManager.getLayerLifecycleState('military-installations'),
+ fetchingCounts,
+ fetchingReasons,
+ answered,
+ settledCount: installationCount(),
+ settledReason: installationReason(),
+ };
+ } finally {
+ released = true;
+ window.fetch = realFetch;
+ if (transition) await settleWithin(transition, 20_000);
+ if (styleManager._contextMode !== 'flights') {
+ await settleWithin(styleManager._selectContextMode('flights'), 15_000);
+ }
+ }
+ });
+ } finally {
+ await readinessPage.close();
+ await page.bringToFront();
+ }
// The panel has two truthful ways to say "nothing has answered yet" β the
// cohort count renders `?`, and an UNKNOWN relationship renders as that word.
// Which one is on screen depends on which panel surface is mounted, and the
@@ -916,6 +980,11 @@ try {
// assert numeric-vs-not, which is exactly the field report.
const readsUnknown = (value) => typeof value === 'string' && value.length > 0 && !/^\d/.test(value);
const readsNumber = (value) => typeof value === 'string' && /^\d+$/.test(value);
+ // Loading has explicit copy since the installations reliability update.
+ // Keep the numeric/count gates below: wording alone never proves readiness.
+ const readsPendingReason = (value) => (
+ /unavailable|stale|Mapped sites not loaded|Fetching mapped sitesβ¦/i.test(String(value))
+ );
check(
'mapped installations load behind Contacts without a false all-clear or a locked Cockpit',
deferredInstallationReadiness.exercised
@@ -924,7 +993,7 @@ try {
&& deferredInstallationReadiness.contacts.value === true
&& deferredInstallationReadiness.pendingLifecycle?.lifecycleState === 'enabling'
&& readsUnknown(deferredInstallationReadiness.pendingCount)
- && /unavailable|stale/i.test(String(deferredInstallationReadiness.pendingReason))
+ && readsPendingReason(deferredInstallationReadiness.pendingReason)
&& deferredInstallationReadiness.cockpitAvailableAfterActivation
&& deferredInstallationReadiness.installed?.settled,
JSON.stringify(deferredInstallationReadiness),
@@ -937,10 +1006,11 @@ try {
&& deferredInstallationReadiness.fetchingCounts.length > 0
&& deferredInstallationReadiness.fetchingCounts.every(readsUnknown)
&& Array.isArray(deferredInstallationReadiness.fetchingReasons)
- && deferredInstallationReadiness.fetchingReasons.every((r) => /unavailable|stale/i.test(String(r))),
+ && deferredInstallationReadiness.fetchingReasons.every(readsPendingReason),
JSON.stringify({
settledLifecycle: deferredInstallationReadiness.settledLifecycle,
fetchingCounts: deferredInstallationReadiness.fetchingCounts,
+ fetchingReasons: deferredInstallationReadiness.fetchingReasons,
}),
);
check(
@@ -948,7 +1018,7 @@ try {
deferredInstallationReadiness.exercised
&& deferredInstallationReadiness.answered?.settled
&& readsNumber(deferredInstallationReadiness.settledCount)
- && !/unavailable|stale/i.test(String(deferredInstallationReadiness.settledReason)),
+ && !readsPendingReason(deferredInstallationReadiness.settledReason),
JSON.stringify({
answered: deferredInstallationReadiness.answered,
settledCount: deferredInstallationReadiness.settledCount,
@@ -1000,7 +1070,12 @@ try {
);
const zoomedRange = cameraRange();
const refocused = awareness?.focusCurrent?.() === true;
- await new Promise((resolve) => setTimeout(resolve, 180));
+ // The follow frame commits in Cesium preUpdate. A fixed 180ms delay can
+ // sample before that frame on a busy software renderer.
+ const frameDeadline = performance.now() + 5000;
+ while (cameraRange() >= 50_000 && performance.now() < frameDeadline) {
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ }
const focusedRange = cameraRange();
const after = awareness?.getContextSnapshot?.()?.subject || null;
return {
@@ -1339,12 +1414,25 @@ try {
const { styleManager, dataManager, viewer } = window.__godsEyeView;
const awareness = dataManager.layers.get('military-awareness')?.module;
const entity = viewer.trackedEntity || styleManager.cockpitView.trackedEntity;
+ const nextFrame = () => new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ remove();
+ reject(new Error('Cockpit camera ownership check did not render a frame'));
+ }, 5000);
+ const remove = viewer.scene.postRender.addEventListener(() => {
+ remove();
+ clearTimeout(timer);
+ resolve();
+ });
+ });
+ // Let revoked listeners retire before comparing ownership across exit.
+ await nextFrame();
const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners;
const exited = styleManager.cockpitView.exit() === true;
- await new Promise((resolve) => setTimeout(resolve, 80));
+ await nextFrame();
const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners;
const refocused = awareness?.focusCurrent?.() === true;
- await new Promise((resolve) => setTimeout(resolve, 80));
+ await nextFrame();
return {
exited,
refocused,
@@ -1907,6 +1995,58 @@ try {
&& contextTransition.label === 'Collapse Contact panel',
JSON.stringify(contextTransition),
);
+ // Use the real connected DOM to catch focus loss caused by moving live rows.
+ const signalFocus = await page.evaluate(() => {
+ const cockpit = window.__godsEyeView.styleManager.cockpitView;
+ const originalItems = cockpit.signalItems;
+ const wasCollapsed = cockpit.signalCollapsed;
+ cockpit.setSignalCollapsed(false);
+ const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
+ title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
+ try {
+ cockpit.signalItems = [item('focus-a'), item('focus-b')];
+ cockpit.renderCockpitSignals();
+ const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
+ button.focus();
+ const acquired = document.activeElement === button;
+ cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')];
+ cockpit.renderCockpitSignals();
+ const retained = document.activeElement === button && button.isConnected;
+ const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
+ cockpit.signalItems = [item('focus-b')];
+ cockpit.renderCockpitSignals();
+ const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
+ const continued = document.activeElement === continuation;
+ cockpit.signalItems = [item('focus-a'), item('focus-b')];
+ cockpit.renderCockpitSignals();
+ return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
+ } finally {
+ cockpit.signalItems = originalItems;
+ cockpit.renderCockpitSignals();
+ cockpit.setSignalCollapsed(wasCollapsed);
+ }
+ });
+ check('Cockpit live reorder retains the same connected focus owner',
+ signalFocus.acquired && signalFocus.retained
+ && JSON.stringify(signalFocus.order) === JSON.stringify(['focus-c', 'focus-b', 'focus-a']),
+ JSON.stringify(signalFocus));
+ check('a departed Cockpit contact continues at the footer without later reclaiming focus',
+ signalFocus.continued && signalFocus.notReclaimed, JSON.stringify(signalFocus));
+
+ // Real keyboard events also verify the nested lightbox does not exit Cockpit.
+ await page.focus('#cesium-credits .cesium-credit-expand-link');
+ await page.keyboard.press('Enter');
+ check('keyboard attribution opening focuses Close inside Cockpit', await page.evaluate(() => (
+ document.activeElement?.classList.contains('cesium-credit-lightbox-close')
+ && document.querySelector('.cesium-credit-expand-link').getAttribute('aria-expanded') === 'true'
+ )));
+ await page.screenshot({ path: path.join(shotsDir, 'keyboard-attribution.png') });
+ await page.keyboard.press('Escape');
+ check('attribution Escape restores focus and keeps Cockpit active', await page.evaluate(() => (
+ document.activeElement?.classList.contains('cesium-credit-expand-link')
+ && document.activeElement.getAttribute('aria-expanded') === 'false'
+ && window.__godsEyeView.styleManager.cockpitView.active
+ )));
await page.screenshot({ path: path.join(shotsDir, 'restored-desktop.png') });
check(
'restored screenshot remains in a real Cockpit session',
diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs
index 9a23d2927..900292ac2 100644
--- a/scripts/qa-map-source-tray.mjs
+++ b/scripts/qa-map-source-tray.mjs
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const shotsDir = path.join(repoRoot, 'qa-shots', 'map-source-tray');
+const shotsDir = process.env.QA_SHOTS_DIR || path.join(repoRoot, 'qa-shots', 'map-source-tray');
const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173';
const headful = process.argv.includes('--headful');
// The no-ion-token contract is a real shipped state that a normal keyed
@@ -91,6 +91,143 @@ const trayMetrics = () => page.evaluate(() => {
};
});
+// Observe rendered focus, including clipping by the scrolling style row. A
+// focus-visible match alone does not prove that the keyboard user sees a ring.
+const focusMetrics = (selector = '.style-btn') => page.evaluate((match) => {
+ const button = document.activeElement;
+ if (!button?.matches(match)) return { focusedStyle: null, focusedId: null };
+ const rect = button.getBoundingClientRect();
+ const css = getComputedStyle(button);
+ const outlineWidth = parseFloat(css.outlineWidth) || 0;
+ const outlineOffset = parseFloat(css.outlineOffset) || 0;
+ const outset = Math.max(0, outlineWidth + outlineOffset);
+ const ring = {
+ left: rect.left - outset, right: rect.right + outset,
+ top: rect.top - outset, bottom: rect.bottom + outset,
+ };
+ const clips = [];
+ for (let parent = button.parentElement; parent; parent = parent.parentElement) {
+ const parentCss = getComputedStyle(parent);
+ const parentRect = parent.getBoundingClientRect();
+ const left = parentRect.left + parent.clientLeft;
+ const top = parentRect.top + parent.clientTop;
+ if (/auto|scroll|hidden|clip/.test(parentCss.overflowX)
+ && (ring.left < left - 1 || ring.right > left + parent.clientWidth + 1)) {
+ clips.push(`${parent.id || parent.className}:horizontal`);
+ }
+ if (/auto|scroll|hidden|clip/.test(parentCss.overflowY)
+ && (ring.top < top - 1 || ring.bottom > top + parent.clientHeight + 1)) {
+ clips.push(`${parent.id || parent.className}:vertical`);
+ }
+ }
+ const center = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
+ return {
+ focusedStyle: button.dataset.style, focusedId: button.id || null, tag: button.tagName, type: button.type,
+ layerId: button.closest('[data-layer-id]')?.dataset.layerId || null,
+ text: button.textContent.trim(), ariaLabel: button.getAttribute('aria-label'),
+ controls: button.getAttribute('aria-controls'), expanded: button.getAttribute('aria-expanded'),
+ selectedStyle: window.__godsEyeView.styleManager.activeStyle,
+ selectedMap: window.__godsEyeView.styleManager.mapStackController.getActiveId(),
+ focusVisible: button.matches(':focus-visible'),
+ selected: button.classList.contains('active'),
+ outlineStyle: css.outlineStyle, outlineWidth, outlineOffset, outlineColor: css.outlineColor,
+ visible: rect.width > 0 && rect.height > 0 && css.visibility === 'visible'
+ && (center === button || button.contains(center)),
+ insideViewport: ring.left >= 0 && ring.right <= innerWidth
+ && ring.top >= 0 && ring.bottom <= innerHeight,
+ clips,
+ ring,
+ viewport: { width: innerWidth, height: innerHeight },
+ rowScrollLeft: document.getElementById('style-buttons').scrollLeft,
+ };
+}, selector);
+const styleFocusMetrics = () => focusMetrics('.style-btn');
+const hasVisibleControlFocus = (state) => state.focusVisible && state.visible && state.insideViewport && state.clips.length === 0
+ && state.outlineStyle !== 'none' && state.outlineStyle !== 'hidden' && state.outlineWidth >= 2
+ && state.outlineColor !== 'transparent' && state.outlineColor !== 'rgba(0, 0, 0, 0)';
+const hasVisibleStyleFocus = (state, style) => state.focusedStyle === style && hasVisibleControlFocus(state);
+const pressTabs = async (count, backwards = false) => {
+ if (backwards) await page.keyboard.down('Shift');
+ try {
+ for (let index = 0; index < count; index += 1) await page.keyboard.press('Tab');
+ } finally {
+ if (backwards) await page.keyboard.up('Shift');
+ }
+ // The row scrolls smoothly when native Tab brings an edge button into view.
+ await new Promise((resolve) => setTimeout(resolve, 250));
+};
+const checkResponsiveStyleFocus = async (width) => {
+ // The disclosure is only the starting boundary. Every style target below is
+ // reached by actual Tab events, never by focusing the style under test.
+ const selectedMapBefore = await page.evaluate(() => (
+ window.__godsEyeView.styleManager.mapStackController.getActiveId()
+ ));
+ await page.focus('#control-panel-toggle');
+ await pressTabs(2); // disclosure -> pin -> Normal
+ for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) {
+ if (index) await pressTabs(3);
+ const state = await styleFocusMetrics();
+ check(`${width} px ${style} keyboard ring is visible and unclipped`,
+ hasVisibleStyleFocus(state, style) && state.selectedStyle === 'normal'
+ && state.selectedMap === selectedMapBefore, JSON.stringify(state));
+ await page.screenshot({ path: path.join(shotsDir, `${width}-style-${style}-focus.png`) });
+ }
+};
+
+// Start from an explicit boundary, then use real Tab events for every target.
+// No layer or location action is activated merely to establish keyboard focus.
+const tabTo = async (selector, { backwards = false, limit = 160 } = {}) => {
+ if (backwards) await page.keyboard.down('Shift');
+ try {
+ for (let step = 1; step <= limit; step += 1) {
+ await page.keyboard.press('Tab');
+ if (await page.evaluate((match) => document.activeElement?.matches(match), selector)) {
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ return { reached: true, steps: step };
+ }
+ }
+ return { reached: false, steps: limit };
+ } finally {
+ if (backwards) await page.keyboard.up('Shift');
+ }
+};
+const locationState = async () => ({
+ ...await focusMetrics('#location-bar-toggle'),
+ ...await page.evaluate(() => {
+ const panel = document.getElementById('location-bar');
+ const toggle = document.getElementById('location-bar-toggle');
+ const voice = window.__godsEyeView.voiceCommands;
+ return {
+ locationExpanded: toggle?.getAttribute('aria-expanded'),
+ locationLabel: toggle?.getAttribute('aria-label'),
+ popoverId: panel.querySelector('.dock-popover-content')?.id,
+ pinned: panel.classList.contains('dock-pinned'),
+ searchExpanded: document.getElementById('location-search').classList.contains('expanded'),
+ searchValue: document.getElementById('location-search').value,
+ transitions: [...(window.__qaLocationFocus?.transitions || [])],
+ voice: { status: voice.status, epoch: voice.startEpoch, held: voice.spaceKeyHeld, pushToTalk: voice.pushToTalkKeyHeld },
+ };
+ }),
+});
+const resetLocationTransitions = () => page.evaluate(() => { window.__qaLocationFocus.transitions = []; });
+const layerFocusState = async (id) => ({
+ ...await focusMetrics('.data-toggle-btn'),
+ ...await page.evaluate((layerId) => {
+ const manager = window.__godsEyeView.dataManager;
+ const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
+ const list = document.getElementById('data-toggles');
+ return {
+ lifecycle: manager.getLayerLifecycleState(layerId),
+ feedState: button?.dataset.feedState, label: button?.textContent.trim(),
+ buttonActive: button?.classList.contains('active'),
+ disabled: button?.disabled,
+ ariaDisabled: button?.getAttribute('aria-disabled'),
+ ariaBusy: button?.getAttribute('aria-busy'),
+ listScrollTop: list.scrollTop, listClientHeight: list.clientHeight, listScrollHeight: list.scrollHeight,
+ };
+ }, id),
+});
+
try {
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
await page.setRequestInterception(true);
@@ -150,6 +287,12 @@ try {
const styleManager = window.__godsEyeView.styleManager;
const controller = styleManager.mapStackController;
await styleManager._setMapStack('esri-imagery', { syncShare: false });
+ // Cesium updates on-screen credits on a rendered frame, after setStack
+ // resolves. Observe that boundary before checking the visible source.
+ const creditDeadline = performance.now() + 5000;
+ while (!document.body.innerText.includes('Powered by Esri') && performance.now() < creditDeadline) {
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ }
const provider = controller._activeImageryProvider;
const before = {
activeId: controller.getActiveId(),
@@ -229,7 +372,14 @@ try {
wantExpanded, wantFocus,
).catch(() => {});
- const keyboardSource = await page.evaluate(() => window.__godsEyeView.styleManager.mapStackController.getActiveId());
+ await page.evaluate(() => window.__godsEyeView.styleManager._setMapStack('osm', { syncShare: false }));
+ const keyboardSource = await page.evaluate(() => (
+ window.__godsEyeView.styleManager.mapStackController.getActiveId()
+ ));
+ check('keyboard checks start on selected OSM as the last tile',
+ keyboardSource === 'osm' && await page.evaluate(() => (
+ document.querySelector('#map-stack-chips .map-stack-chip:last-child')?.dataset.stackId === 'osm'
+ )));
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await waitTray('true', keyboardSource);
@@ -328,11 +478,221 @@ try {
check('Tab away during the opening transition revokes delayed focus', departure && focusRetained,
JSON.stringify({ departure, focusRetained }));
- await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('control-panel', true));
+ await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('control-panel', true, { explicit: true }));
+ await page.focus('#control-panel-toggle');
+ await page.keyboard.press('Enter');
+ await page.keyboard.press('Escape');
+ await new Promise((resolve) => setTimeout(resolve, 1100));
+ check('Escape cancels the pending opening handoff', await page.evaluate(() => (
+ document.activeElement === document.body
+ && document.getElementById('control-panel').classList.contains('collapsed')
+ )));
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await waitTray('true', keyboardSource);
+ await page.keyboard.press('Escape');
+ await page.focus('#control-panel-toggle');
+ await page.keyboard.press('Enter');
+ await page.keyboard.down('Shift');
+ await page.keyboard.press('Tab');
+ await page.keyboard.up('Shift');
+ const leftBeforeReturn = await page.evaluate(() => document.activeElement?.id !== 'control-panel-toggle');
+ await page.focus('#control-panel-toggle');
+ await new Promise((resolve) => setTimeout(resolve, 1100));
+ check('returning to the disclosure does not revive a cancelled keyboard opening',
+ leftBeforeReturn && await page.evaluate(() => document.activeElement?.id === 'control-panel-toggle'));
+
+ await page.keyboard.press('Escape');
+ await page.focus('#control-panel-toggle');
+ await page.keyboard.press('Enter');
+ await page.keyboard.press('Escape');
+ await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('control-panel', false));
+ await new Promise((resolve) => setTimeout(resolve, 1100));
+ check('programmatic reopening cannot inherit a cancelled keyboard handoff', await page.evaluate(() => (
+ document.activeElement === document.body
+ && document.getElementById('control-panel-toggle').getAttribute('aria-expanded') === 'true'
+ )));
+ await page.focus('#control-panel-toggle');
+ await page.keyboard.press('Escape');
+ await page.focus('#control-panel-toggle');
+ await page.keyboard.press('Enter');
+ await waitTray('true', keyboardSource);
+
+ // Visual Styles precede the five Map Source tiles. Keep the existing opening
+ // handoff to selected OSM, then use the user's real Shift+Tab path into styles.
+ await pressTabs(5, true);
+ const snowFocus = await styleFocusMetrics();
+ check('Shift+Tab reaches Snow with a visible ring while Normal and OSM stay selected',
+ hasVisibleStyleFocus(snowFocus, 'snow') && !snowFocus.selected
+ && snowFocus.selectedStyle === 'normal' && snowFocus.selectedMap === 'osm',
+ JSON.stringify(snowFocus));
+ await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') });
+ await pressTabs(3, true);
+ const middleFocus = await styleFocusMetrics();
+ check('Shift+Tab traverses the middle FLIR style without activating it',
+ hasVisibleStyleFocus(middleFocus, 'thermal') && !middleFocus.selected
+ && middleFocus.selectedStyle === 'normal' && middleFocus.selectedMap === 'osm',
+ JSON.stringify(middleFocus));
+ await pressTabs(2, true);
+ await page.keyboard.press('Enter');
+ const enterStyle = await styleFocusMetrics();
+ check('Enter activates the focused CRT style and preserves selected OSM',
+ enterStyle.focusedStyle === 'retro' && enterStyle.selectedStyle === 'retro'
+ && enterStyle.selected && enterStyle.selectedMap === 'osm', JSON.stringify(enterStyle));
+ await pressTabs(1, true);
+ const normalFocus = await styleFocusMetrics();
+ check('the first Normal style has a focus ring independent from selected CRT',
+ hasVisibleStyleFocus(normalFocus, 'normal') && !normalFocus.selected
+ && normalFocus.selectedStyle === 'retro' && normalFocus.selectedMap === 'osm',
+ JSON.stringify(normalFocus));
+
+ // Count real native clicks and calls into the unchanged style implementation.
+ // Stub only the provider start seam so timing and focus arbitration can be
+ // exercised without opening a live microphone session during browser QA.
+ await page.evaluate(() => {
+ const manager = window.__godsEyeView.styleManager;
+ const voice = window.__godsEyeView.voiceCommands;
+ const grid = document.getElementById('style-buttons');
+ const root = document.getElementById('gev-voice-control');
+ const originalSetStyle = manager.setStyle;
+ const originalVoiceStart = voice.start;
+ const probe = { events: [], activations: [], voiceMutations: [], voiceStarts: [] };
+ const voiceState = () => ({
+ present: Boolean(voice && root), status: voice?.status, startEpoch: voice?.startEpoch,
+ spaceKeyHeld: voice?.spaceKeyHeld, pushToTalkKeyHeld: voice?.pushToTalkKeyHeld,
+ pushToTalkMode: voice?.pushToTalkMode, radioVoiceDucked: voice?.radioVoiceDucked,
+ visibleStatus: root?.dataset.status, visibleHold: root?.dataset.pushToTalk || null,
+ });
+ const record = (event) => probe.events.push({
+ type: event.type, code: event.code || null, repeat: event.repeat || false,
+ trusted: event.isTrusted, detail: event.detail ?? null,
+ style: event.target.closest('.style-btn')?.dataset.style || null,
+ });
+ for (const type of ['keydown', 'keyup', 'click']) grid.addEventListener(type, record, true);
+ manager.setStyle = function (...args) {
+ probe.activations.push(args[0]);
+ return originalSetStyle.apply(this, args);
+ };
+ voice.start = async function (options) {
+ probe.voiceStarts.push({
+ options: { ...options },
+ focusedStyle: document.activeElement?.dataset.style || null,
+ activeTag: document.activeElement?.tagName || null,
+ });
+ };
+ const observer = new MutationObserver((records) => {
+ for (const record of records) probe.voiceMutations.push({
+ attribute: record.attributeName, oldValue: record.oldValue,
+ value: root.getAttribute(record.attributeName),
+ });
+ });
+ if (root) observer.observe(root, {
+ attributes: true, attributeOldValue: true, attributeFilter: ['data-status', 'data-push-to-talk'],
+ });
+ probe.voiceBefore = voiceState();
+ probe.snapshot = () => ({
+ events: [...probe.events], activations: [...probe.activations], voiceMutations: [...probe.voiceMutations],
+ voiceStarts: [...probe.voiceStarts],
+ voiceBefore: probe.voiceBefore, voiceNow: voiceState(),
+ selectedStyle: manager.activeStyle, selectedMap: manager.mapStackController.getActiveId(),
+ focusedStyle: document.activeElement?.dataset.style || null,
+ });
+ probe.reset = () => {
+ probe.events.length = 0;
+ probe.activations.length = 0;
+ probe.voiceMutations.length = 0;
+ probe.voiceStarts.length = 0;
+ probe.voiceBefore = voiceState();
+ };
+ probe.restore = () => {
+ manager.setStyle = originalSetStyle;
+ voice.start = originalVoiceStart;
+ observer.disconnect();
+ for (const type of ['keydown', 'keyup', 'click']) grid.removeEventListener(type, record, true);
+ };
+ window.__qaStyleKeyProbe = probe;
+ });
+ let shortSpaceDown;
+ let shortSpaceReleased;
+ let longSpaceDown;
+ let longSpaceHeld;
+ let longSpaceReleased;
+ let styleSpaceIsDown = false;
+ try {
+ await page.keyboard.down('Space');
+ styleSpaceIsDown = true;
+ shortSpaceDown = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ await page.keyboard.up('Space');
+ styleSpaceIsDown = false;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ shortSpaceReleased = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
+
+ await page.evaluate(() => {
+ const manager = window.__godsEyeView.styleManager;
+ manager.setStyle('retro');
+ window.__qaStyleKeyProbe.reset();
+ document.querySelector('.style-btn[data-style="normal"]')?.focus();
+ });
+ await page.keyboard.down('Space');
+ styleSpaceIsDown = true;
+ longSpaceDown = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ await page.keyboard.down('Space'); // exercise repeat without resetting the hold deadline
+ await new Promise((resolve) => setTimeout(resolve, 400));
+ longSpaceHeld = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
+ await page.keyboard.up('Space');
+ styleSpaceIsDown = false;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ longSpaceReleased = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
+ } finally {
+ if (styleSpaceIsDown) await page.keyboard.up('Space');
+ await page.evaluate(() => {
+ window.__qaStyleKeyProbe?.restore();
+ delete window.__qaStyleKeyProbe;
+ window.__godsEyeView.styleManager.setStyle('normal');
+ });
+ }
+ const voiceUntouched = (state) => state.voiceBefore.present
+ && state.voiceStarts.length === 0 && JSON.stringify(state.voiceBefore) === JSON.stringify(state.voiceNow)
+ && state.voiceMutations.length === 0;
+ check('Space down on focused Normal does not activate the style or start voice',
+ shortSpaceDown.focusedStyle === 'normal' && shortSpaceDown.selectedStyle === 'retro'
+ && shortSpaceDown.selectedMap === 'osm' && shortSpaceDown.activations.length === 0
+ && shortSpaceDown.events.every((event) => event.type !== 'click')
+ && shortSpaceDown.voiceStarts.length === 0 && shortSpaceDown.voiceMutations.length === 0
+ && shortSpaceDown.voiceNow.status === shortSpaceDown.voiceBefore.status
+ && shortSpaceDown.voiceNow.startEpoch === shortSpaceDown.voiceBefore.startEpoch
+ && shortSpaceDown.voiceNow.spaceKeyHeld === true
+ && shortSpaceDown.voiceNow.pushToTalkKeyHeld === false,
+ JSON.stringify(shortSpaceDown));
+ const releasedClicks = shortSpaceReleased.events.filter((event) => event.type === 'click');
+ check('short Space activates Normal once on trusted key release and preserves OSM',
+ shortSpaceReleased.focusedStyle === 'normal' && shortSpaceReleased.selectedStyle === 'normal'
+ && shortSpaceReleased.selectedMap === 'osm'
+ && JSON.stringify(shortSpaceReleased.activations) === JSON.stringify(['normal'])
+ && releasedClicks.length === 1 && releasedClicks[0].trusted && releasedClicks[0].detail === 0
+ && shortSpaceReleased.events.some((event) => event.type === 'keyup' && event.trusted)
+ && voiceUntouched(shortSpaceReleased), JSON.stringify(shortSpaceReleased));
+ check('long Space blurs the focused style before requesting push-to-talk',
+ longSpaceDown.focusedStyle === 'normal' && longSpaceDown.selectedStyle === 'retro'
+ && longSpaceDown.activations.length === 0 && longSpaceDown.voiceStarts.length === 0
+ && longSpaceHeld.focusedStyle === null && longSpaceHeld.selectedStyle === 'retro'
+ && longSpaceHeld.activations.length === 0
+ && longSpaceHeld.events.some((event) => event.type === 'keydown' && event.repeat && event.trusted)
+ && longSpaceHeld.events.every((event) => event.type !== 'click')
+ && longSpaceHeld.voiceStarts.length === 1
+ && longSpaceHeld.voiceStarts[0].options.pushToTalk === true
+ && longSpaceHeld.voiceStarts[0].focusedStyle === null,
+ JSON.stringify({ down: longSpaceDown, held: longSpaceHeld }));
+ check('releasing a claimed long Space hold does not activate the blurred style',
+ longSpaceReleased.focusedStyle === null && longSpaceReleased.selectedStyle === 'retro'
+ && longSpaceReleased.selectedMap === 'osm' && longSpaceReleased.activations.length === 0
+ && longSpaceReleased.voiceStarts.length === 1
+ && longSpaceReleased.events.every((event) => event.type !== 'click'),
+ JSON.stringify(longSpaceReleased));
+
if (forceKeyless) {
await page.evaluate(async () => {
const styleManager = window.__godsEyeView.styleManager;
@@ -362,12 +722,29 @@ try {
const activeBeforeIonAttempt = await page.evaluate(() => (
window.__godsEyeView.styleManager.mapStackController.getActiveId()
));
+ // The long-Space test above deliberately blurs its style button. On a slower
+ // keyless rebuild that can give the tray's pending auto-close enough time to
+ // hide its chips before page.focus() runs. Focus the disclosure first to
+ // clear that close timer, then ensure the tray is visibly open so this check
+ // exercises the unavailable tile rather than a hidden element.
+ await page.focus('#control-panel-toggle');
+ await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed(
+ 'control-panel', false, { persist: false, syncShare: false },
+ ));
+ await waitTray('true', null);
await page.focus('[data-stack-id="bing-aerial"]');
const ionAvailable = await page.$eval(
'[data-stack-id="bing-aerial"]',
(chip) => chip.getAttribute('aria-disabled') !== 'true',
);
- await page.click('[data-stack-id="bing-aerial"]');
+ const ionFocusedBeforeActivation = await page.$eval(
+ '[data-stack-id="bing-aerial"]',
+ (chip) => document.activeElement === chip,
+ );
+ // Exercise the keyboard path. Unavailable tiles intentionally reject pointer
+ // hit testing while remaining focusable so their missing-key explanation is
+ // available to keyboard and assistive-technology users.
+ await page.keyboard.press('Space');
if (ionAvailable) {
// Cesium creates the imagery provider asynchronously. Wait for controller
// truth instead of assuming a keyed switch can settle in one animation.
@@ -393,11 +770,12 @@ try {
.map((candidate) => candidate.dataset.stackId),
};
});
+ ionSource.focusedBeforeActivation = ionFocusedBeforeActivation;
if (forceKeyless || ionSource.ariaDisabled === 'true') {
check(
'key-required sources stay focusable, explained, and inert when no ion token is configured',
ionSource.ariaDisabled === 'true'
- && ionSource.focused
+ && ionSource.focusedBeforeActivation
// #143 names the missing key: "Needs CESIUM_ION_TOKEN β add it in Provider Settings".
&& /needs [A-Z_]+.*provider settings/i.test(ionSource.ariaLabel)
&& ionSource.activeId === activeBeforeIonAttempt
@@ -709,8 +1087,14 @@ try {
await setControlPanelPinned(false);
await page.evaluate(() => window.__godsEyeView.styleManager
._setMapStack('photoreal', { syncShare: false }));
- // Hand the tray back OPEN and unpinned β the responsive block below starts by
- // clicking the pin control, which is only hittable while the tray is showing.
+ // Approach the disclosure before opening: the previous mouse-away case
+ // intentionally leaves a pending close, so an unattended programmatic reopen
+ // can disappear while the next real pointer click is being dispatched.
+ const pinApproach = await page.$eval('#control-panel-toggle', (toggle) => {
+ const rect = toggle.getBoundingClientRect();
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
+ });
+ await page.mouse.move(pinApproach.x, pinApproach.y);
await page.evaluate(() => window.__godsEyeView.styleManager
.setPanelCollapsed('control-panel', false, { explicit: true }));
await new Promise((resolve) => setTimeout(resolve, 240));
@@ -736,6 +1120,8 @@ try {
JSON.stringify(desktop),
);
+ await checkResponsiveStyleFocus(1000);
+
await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 });
await new Promise((resolve) => setTimeout(resolve, 180));
const at620 = await trayMetrics();
@@ -749,6 +1135,8 @@ try {
JSON.stringify(at620),
);
+ await checkResponsiveStyleFocus(620);
+
await page.setViewport({ width: 480, height: 900, deviceScaleFactor: 1 });
await new Promise((resolve) => setTimeout(resolve, 180));
const at480 = await trayMetrics();
@@ -767,6 +1155,7 @@ try {
JSON.stringify(at480),
);
await page.screenshot({ path: path.join(shotsDir, '480-open.png') });
+ await checkResponsiveStyleFocus(480);
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
await page.click('#control-panel-toggle');
@@ -844,6 +1233,382 @@ try {
await page.screenshot({ path: path.join(shotsDir, `legacy-${legacyId}.png`) });
}
+ // Location is a native disclosure: Enter is immediate, while Space waits for
+ // key release. A repeated keydown must not toggle or claim voice early.
+ await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
+ await page.mouse.move(20, 20);
+ await page.evaluate(() => {
+ const manager = window.__godsEyeView.styleManager;
+ for (const id of ['control-panel', 'location-bar']) {
+ manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false });
+ manager.setPanelCollapsed(id, true, { persist: false, syncShare: false });
+ }
+ const original = manager.setPanelCollapsed;
+ const probe = { transitions: [], restore: () => { manager.setPanelCollapsed = original; } };
+ manager.setPanelCollapsed = function (id, ...args) {
+ const before = document.getElementById(id)?.classList.contains('collapsed');
+ const result = original.call(this, id, ...args);
+ const after = document.getElementById(id)?.classList.contains('collapsed');
+ if (id === 'location-bar' && before !== after) probe.transitions.push({ before, after });
+ return result;
+ };
+ window.__qaLocationFocus = probe;
+ });
+ try {
+ await page.focus('#gev-voice-button');
+ const locationTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 8 });
+ const locationClosed = await locationState();
+ check('Location: native disclosure semantics and closed Tab focus without opening',
+ locationTab.reached && hasVisibleControlFocus(locationClosed)
+ && locationClosed.tag === 'BUTTON' && locationClosed.type === 'button'
+ && locationClosed.controls === 'location-bar-popover' && locationClosed.popoverId === 'location-bar-popover'
+ && locationClosed.locationExpanded === 'false' && /^Expand Location$/i.test(locationClosed.locationLabel)
+ && locationClosed.transitions.length === 0, JSON.stringify({ locationTab, locationClosed }));
+
+ await resetLocationTransitions();
+ await page.keyboard.press('Enter');
+ const locationEnter = await locationState();
+ check('Location: Enter opens once with visible focus and accurate name',
+ hasVisibleControlFocus(locationEnter) && locationEnter.locationExpanded === 'true'
+ && /^Collapse Location$/i.test(locationEnter.locationLabel) && locationEnter.transitions.length === 1,
+ JSON.stringify(locationEnter));
+ await page.keyboard.press('Escape');
+ const locationEscape = await locationState();
+ check('Location: Escape on the disclosure closes without retaining focus',
+ locationEscape.focusedId === null && locationEscape.locationExpanded === 'false'
+ && /^Expand Location$/i.test(locationEscape.locationLabel), JSON.stringify(locationEscape));
+
+ await page.focus('#location-bar-toggle');
+ await resetLocationTransitions();
+ const voiceBeforeLocationSpace = locationEscape.voice;
+ let locationSpaceHeld = false;
+ let locationSpaceDown;
+ let locationSpaceRepeat;
+ try {
+ await page.keyboard.down('Space');
+ locationSpaceHeld = true;
+ locationSpaceDown = await locationState();
+ await new Promise((resolve) => setTimeout(resolve, 320));
+ await page.keyboard.down('Space');
+ await page.keyboard.down('Space');
+ locationSpaceRepeat = await locationState();
+ await page.keyboard.up('Space');
+ locationSpaceHeld = false;
+ } finally {
+ if (locationSpaceHeld) await page.keyboard.up('Space');
+ }
+ const locationSpaceUp = await locationState();
+ check('Location: short repeated Space toggles once on release without starting voice',
+ [locationSpaceDown, locationSpaceRepeat].every((state) => (
+ state.focusedId === 'location-bar-toggle' && state.locationExpanded === 'false'
+ && state.transitions.length === 0 && state.voice.status === voiceBeforeLocationSpace.status
+ && state.voice.epoch === voiceBeforeLocationSpace.epoch
+ && state.voice.held === true && state.voice.pushToTalk === false
+ ))
+ && locationSpaceUp.focusedId === 'location-bar-toggle'
+ && locationSpaceUp.locationExpanded === 'true' && locationSpaceUp.transitions.length === 1
+ && JSON.stringify(locationSpaceUp.voice) === JSON.stringify(voiceBeforeLocationSpace),
+ JSON.stringify({ down: locationSpaceDown, repeat: locationSpaceRepeat, up: locationSpaceUp }));
+
+ const searchTab = await tabTo('#search-toggle', { limit: 30 });
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('focus cleanup'); // no submission or navigation
+ const typedSearch = await page.$eval('#location-search', (input) => ({
+ focused: document.activeElement === input, expanded: input.classList.contains('expanded'), value: input.value,
+ }));
+ await page.keyboard.press('Escape');
+ const searchEscape = await locationState();
+ check('Location: Escape clears typed search and restores disclosure focus',
+ searchTab.reached && typedSearch.focused && typedSearch.expanded && typedSearch.value === 'focus cleanup'
+ && searchEscape.focusedId === 'location-bar-toggle' && searchEscape.locationExpanded === 'false'
+ && !searchEscape.searchExpanded && searchEscape.searchValue === '', JSON.stringify({ typedSearch, searchEscape }));
+
+ await page.focus('#control-panel-toggle');
+ await page.keyboard.press('Enter'); // begin the Map Source handoff
+ const siblingTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 30 });
+ await page.keyboard.press('Enter');
+ await new Promise((resolve) => setTimeout(resolve, 1100));
+ const siblingState = await locationState();
+ check('Location: sibling opening cancels pending Map Source focus',
+ siblingTab.reached && siblingState.focusedId === 'location-bar-toggle'
+ && siblingState.locationExpanded === 'true'
+ && await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded') === 'false'),
+ JSON.stringify(siblingState));
+ await tabTo('.dock-pin-btn[data-pin-target="location-bar"]', { limit: 3 });
+ await page.keyboard.press('Enter');
+ await page.focus('#control-panel-toggle');
+ await page.keyboard.press('Enter');
+ await new Promise((resolve) => setTimeout(resolve, 400));
+ const pinnedSibling = await locationState();
+ check('Location: pinned tray survives sibling opening',
+ pinnedSibling.pinned && pinnedSibling.locationExpanded === 'true'
+ && await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded') === 'true'),
+ JSON.stringify(pinnedSibling));
+ await page.focus('#location-bar-toggle');
+ await tabTo('.dock-pin-btn[data-pin-target="location-bar"]', { limit: 3 });
+ await page.keyboard.press('Enter');
+ check('Location: pin can be removed by keyboard', !(await locationState()).pinned);
+
+ for (const width of [1000, 620, 480]) {
+ await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
+ await page.evaluate(() => {
+ const manager = window.__godsEyeView.styleManager;
+ manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false });
+ manager.setPanelCollapsed('location-bar', true, { persist: false, syncShare: false });
+ });
+ await page.focus('#gev-voice-button');
+ const closedTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 8 });
+ const closed = await locationState();
+ check(`Location: ${width} px closed Tab ring is visible without opening`,
+ closedTab.reached && hasVisibleControlFocus(closed) && closed.locationExpanded === 'false', JSON.stringify(closed));
+ await page.screenshot({ path: path.join(shotsDir, `${width}-location-closed-focus.png`) });
+ await page.keyboard.press('Enter');
+ await pressTabs(1); // disclosure -> pin
+ const openTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 3 });
+ const open = await locationState();
+ check(`Location: ${width} px open ShiftTab ring is visible`,
+ openTab.reached && hasVisibleControlFocus(open) && open.locationExpanded === 'true', JSON.stringify(open));
+ await page.screenshot({ path: path.join(shotsDir, `${width}-location-open-focus.png`) });
+ await page.keyboard.press('Escape');
+ }
+ } finally {
+ await page.evaluate(() => {
+ window.__qaLocationFocus?.restore();
+ delete window.__qaLocationFocus;
+ const manager = window.__godsEyeView.styleManager;
+ for (const id of ['control-panel', 'location-bar']) {
+ manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false });
+ manager.setPanelCollapsed(id, true, { persist: false, syncShare: false });
+ }
+ });
+ }
+
+ // The existing dev-only QA registration seam supplies stable ON/STALE rows
+ // through the real manager/renderer. These fixtures fetch nothing, render no
+ // entities and have no periodic update; production layers are never toggled.
+ let dataSetup;
+ try {
+ dataSetup = await page.evaluate(async () => {
+ const manager = window.__godsEyeView.dataManager;
+ const production = () => manager.getAll().filter((layer) => !layer.id.startsWith('qa-keyboard-focus-'))
+ .map((layer) => ({
+ id: layer.id, enabled: layer.enabled, phase: layer.lifecycleState, uncertain: layer.lifecycleUncertain,
+ visibilityIntentEpoch: manager.layers.get(layer.id).visibilityIntentEpoch,
+ paramsIntentEpoch: manager.layers.get(layer.id).paramsIntentEpoch,
+ }));
+ // Inspect this known layer-state key only. Keep its value inside the page;
+ // evidence receives equality booleans, never stored contents.
+ const durableBefore = localStorage.getItem('gev:layer-state:v2');
+ const state = { ids: [], before: production(), production, durableBefore, collapsed: document.getElementById('data-panel').classList.contains('collapsed') };
+ window.__qaDataFocus = state;
+ if (typeof window.__gevQaRegisterLayer !== 'function' || typeof window.__gevQaUnregisterLayer !== 'function') {
+ return { ready: false, reason: 'Existing dev-only QA registration seam is unavailable' };
+ }
+ for (const [suffix, stale] of [['on', false], ['status', true]]) {
+ const id = `qa-keyboard-focus-${suffix}`;
+ window.__gevQaRegisterLayer(manager, {
+ id, name: `QA focus ${stale ? 'STALE' : 'ON'}`, icon: 'β', source: 'Local focus fixture',
+ init() {}, enable() {}, disable() {}, destroy() {}, update() {},
+ getStats: () => ({ count: 1, lastUpdate: Date.now() - (stale ? 60_000 : 0), stale, source: 'Local focus fixture' }),
+ });
+ state.ids.push(id);
+ await manager.setEnabled(id, true, { origin: 'programmatic' });
+ }
+ const transitionId = 'qa-keyboard-focus-transition';
+ const transition = {
+ id: transitionId,
+ enableCalls: 0,
+ disableCalls: 0,
+ releaseEnable: null,
+ releaseDisable: null,
+ };
+ state.transition = transition;
+ window.__gevQaRegisterLayer(manager, {
+ id: transitionId,
+ name: 'QA focus transition',
+ icon: 'β',
+ source: 'Local focus fixture',
+ init() {},
+ enable() {
+ transition.enableCalls += 1;
+ return new Promise((resolve) => {
+ transition.releaseEnable = () => {
+ transition.releaseEnable = null;
+ resolve();
+ };
+ });
+ },
+ disable() {
+ transition.disableCalls += 1;
+ return new Promise((resolve) => {
+ transition.releaseDisable = () => {
+ transition.releaseDisable = null;
+ resolve();
+ };
+ });
+ },
+ destroy() {},
+ update() {},
+ getStats: () => ({ count: 1, lastUpdate: Date.now(), source: 'Local focus fixture' }),
+ });
+ state.ids.push(transitionId);
+ manager._renderToggles();
+ const offId = manager.getAll().find((layer) => !state.ids.includes(layer.id) && layer.showInTogglePanel && !layer.enabled)?.id;
+ return {
+ ready: Boolean(offId), offId, fixtureIds: state.ids.slice(0, 2), transitionId,
+ productionUnchanged: JSON.stringify(state.before) === JSON.stringify(production()),
+ durableUnchanged: durableBefore === localStorage.getItem('gev:layer-state:v2'),
+ };
+ });
+ check('Data Layers: explicit dev fixtures provide status coverage without changing production layers',
+ dataSetup.ready && dataSetup.productionUnchanged && dataSetup.durableUnchanged, JSON.stringify(dataSetup));
+ if (dataSetup.ready) {
+ for (const width of [1000, 620, 480]) {
+ await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
+ await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
+ await page.focus('#data-panel .panel-collapse-btn');
+ const targets = [[dataSetup.offId, 'OFF', false], [dataSetup.fixtureIds[0], 'ON', true], [dataSetup.fixtureIds[1], 'STALE', true]];
+ for (const [id, label, enabled] of targets) {
+ const navigation = await tabTo(`[data-layer-id="${id}"] .data-toggle-btn`);
+ const state = await layerFocusState(id);
+ check(`Data Layers: ${width} px ${label} Tab focus ring is visible`,
+ navigation.reached && state.layerId === id && hasVisibleControlFocus(state)
+ && state.label === label && state.buttonActive === enabled && state.lifecycle?.enabled === enabled
+ && state.lifecycle?.lifecycleState === (enabled ? 'enabled' : 'disabled') && !state.lifecycle?.uncertain,
+ JSON.stringify({ setup: enabled ? 'dev QA fixture' : 'real production OFF row; not activated', navigation, state }));
+ await page.screenshot({ path: path.join(shotsDir, `${width}-data-${label.toLowerCase()}-focus.png`) });
+ if (label === 'OFF') {
+ const passive = await page.evaluate((layerId) => {
+ const probe = window.__qaDataFocus;
+ const focusBefore = document.activeElement;
+ const productionBefore = JSON.stringify(probe.production());
+ const durableBefore = localStorage.getItem('gev:layer-state:v2');
+ window.__godsEyeView.dataManager._refreshTogglePanel();
+ return {
+ focusRetained: document.activeElement === focusBefore
+ && focusBefore.matches('.data-toggle-btn')
+ && focusBefore.closest('[data-layer-id]')?.dataset.layerId === layerId,
+ productionUnchanged: productionBefore === JSON.stringify(probe.production())
+ && productionBefore === JSON.stringify(probe.before),
+ durableUnchanged: durableBefore === localStorage.getItem('gev:layer-state:v2')
+ && durableBefore === probe.durableBefore,
+ };
+ }, id);
+ const refreshed = await layerFocusState(id);
+ check(`Data Layers: ${width} px passive refresh preserves OFF focus and state`,
+ passive.focusRetained && passive.productionUnchanged && passive.durableUnchanged
+ && hasVisibleControlFocus(refreshed) && refreshed.label === 'OFF' && !refreshed.lifecycle?.enabled,
+ JSON.stringify({ passive, refreshed }));
+ }
+ if (label === 'STALE') check(`Data Layers: ${width} px native Tab scrolls to the lower status row`,
+ state.listScrollHeight > state.listClientHeight && state.listScrollTop > 0 && hasVisibleControlFocus(state), JSON.stringify(state));
+ }
+ }
+ check('Data Layers: focus traversal leaves production visibility unchanged', await page.evaluate(() => (
+ JSON.stringify(window.__qaDataFocus.before) === JSON.stringify(window.__qaDataFocus.production())
+ && window.__qaDataFocus.durableBefore === localStorage.getItem('gev:layer-state:v2')
+ )));
+
+ await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
+ await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
+ await page.focus('#data-panel .panel-collapse-btn');
+ const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`);
+ const transitionBefore = await layerFocusState(dataSetup.transitionId);
+ check('Data Layers: transition fixture receives native Tab focus',
+ transitionTab.reached && transitionBefore.label === 'OFF' && hasVisibleControlFocus(transitionBefore),
+ JSON.stringify({ transitionTab, transitionBefore }));
+
+ await page.keyboard.press('Space');
+ await page.waitForFunction((layerId) => {
+ const state = window.__qaDataFocus?.transition;
+ const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
+ return state?.enableCalls === 1 && typeof state.releaseEnable === 'function'
+ && button?.textContent.trim() === 'ENABLING';
+ }, { timeout: 5_000 }, dataSetup.transitionId);
+ const enabling = await layerFocusState(dataSetup.transitionId);
+ check('Data Layers: focused Space keeps a visible ring through ENABLING',
+ hasVisibleControlFocus(enabling) && enabling.label === 'ENABLING' && !enabling.disabled
+ && enabling.ariaDisabled === 'true' && enabling.ariaBusy === 'true', JSON.stringify(enabling));
+ const enablingEpoch = await page.evaluate((layerId) => (
+ window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch
+ ), dataSetup.transitionId);
+ await page.keyboard.press('Space');
+ const enablingRepeat = await page.evaluate((layerId) => ({
+ calls: window.__qaDataFocus.transition.enableCalls,
+ epoch: window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch,
+ }), dataSetup.transitionId);
+ check('Data Layers: repeated Space is inert while ENABLING',
+ enablingRepeat.calls === 1 && enablingRepeat.epoch === enablingEpoch, JSON.stringify(enablingRepeat));
+ await page.evaluate(() => window.__qaDataFocus.transition.releaseEnable());
+ await page.waitForFunction((layerId) => {
+ const state = window.__godsEyeView.dataManager.getLayerLifecycleState(layerId);
+ return state.enabled && state.lifecycleState === 'enabled';
+ }, { timeout: 5_000 }, dataSetup.transitionId);
+ const enabled = await layerFocusState(dataSetup.transitionId);
+ check('Data Layers: settled ON keeps the same visible keyboard focus',
+ hasVisibleControlFocus(enabled) && !enabled.disabled && enabled.ariaDisabled === 'false'
+ && enabled.ariaBusy === 'false' && enabled.lifecycle?.enabled, JSON.stringify(enabled));
+
+ await page.keyboard.press('Space');
+ await page.waitForFunction((layerId) => {
+ const state = window.__qaDataFocus?.transition;
+ const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
+ return state?.disableCalls === 1 && typeof state.releaseDisable === 'function'
+ && button?.textContent.trim() === 'DISABLING';
+ }, { timeout: 5_000 }, dataSetup.transitionId);
+ const disabling = await layerFocusState(dataSetup.transitionId);
+ check('Data Layers: focused Space keeps a visible ring through DISABLING',
+ hasVisibleControlFocus(disabling) && disabling.label === 'DISABLING' && !disabling.disabled
+ && disabling.ariaDisabled === 'true' && disabling.ariaBusy === 'true', JSON.stringify(disabling));
+ const disablingEpoch = await page.evaluate((layerId) => (
+ window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch
+ ), dataSetup.transitionId);
+ await page.keyboard.press('Space');
+ const disablingRepeat = await page.evaluate((layerId) => ({
+ calls: window.__qaDataFocus.transition.disableCalls,
+ epoch: window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch,
+ }), dataSetup.transitionId);
+ check('Data Layers: repeated Space is inert while DISABLING',
+ disablingRepeat.calls === 1 && disablingRepeat.epoch === disablingEpoch, JSON.stringify(disablingRepeat));
+ await page.evaluate(() => window.__qaDataFocus.transition.releaseDisable());
+ await page.waitForFunction((layerId) => {
+ const state = window.__godsEyeView.dataManager.getLayerLifecycleState(layerId);
+ return !state.enabled && state.lifecycleState === 'disabled';
+ }, { timeout: 5_000 }, dataSetup.transitionId);
+ const disabled = await layerFocusState(dataSetup.transitionId);
+ check('Data Layers: settled OFF keeps the same visible keyboard focus',
+ hasVisibleControlFocus(disabled) && !disabled.disabled && disabled.ariaDisabled === 'false'
+ && disabled.ariaBusy === 'false' && !disabled.lifecycle?.enabled, JSON.stringify(disabled));
+ }
+ } finally {
+ const cleaned = await page.evaluate(async () => {
+ const state = window.__qaDataFocus;
+ if (!state) return { complete: false, reason: 'No fixture setup state' };
+ const manager = window.__godsEyeView.dataManager;
+ state.transition?.releaseEnable?.();
+ state.transition?.releaseDisable?.();
+ const removed = [];
+ for (const id of state.ids) removed.push(await window.__gevQaUnregisterLayer(manager, id));
+ manager._renderToggles();
+ window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', state.collapsed, { persist: false, syncShare: false });
+ // A native user-origin toggle legitimately asks the production state
+ // coordinator to persist. Restore the exact pre-fixture value so this
+ // hermetic QA journey leaves the user's durable layer snapshot untouched.
+ if (state.durableBefore === null) localStorage.removeItem('gev:layer-state:v2');
+ else localStorage.setItem('gev:layer-state:v2', state.durableBefore);
+ const result = {
+ complete: removed.every(Boolean) && state.ids.every((id) => !manager.layers.has(id)),
+ productionUnchanged: JSON.stringify(state.before) === JSON.stringify(state.production()),
+ durableUnchanged: state.durableBefore === localStorage.getItem('gev:layer-state:v2'),
+ };
+ delete window.__qaDataFocus;
+ return result;
+ });
+ check('Data Layers: fixture teardown restores the original production registry and visibility',
+ cleaned.complete && cleaned.productionUnchanged && cleaned.durableUnchanged, JSON.stringify(cleaned));
+ await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
+ }
+
check('no new page or console errors', consoleErrors.length === 0, consoleErrors.join(' | '));
} finally {
await browser.close();
diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs
new file mode 100644
index 000000000..56d5c9d33
--- /dev/null
+++ b/src/cockpitSignalFocus.test.mjs
@@ -0,0 +1,223 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
+
+const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
+const renderStart = source.indexOf(' renderCockpitSignals() {');
+const renderEnd = source.indexOf(' setContextCollapsed(', renderStart);
+assert.ok(renderStart >= 0 && renderEnd > renderStart);
+const clickSource = source.match(/this\._listen\(this\.signalList, 'click', (\(event\) => \{[\s\S]*?\n \})\);/)?.[1];
+assert.ok(clickSource, 'the installed signal click route exists');
+
+// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
+// object reference alone is insufficient if reconciliation disconnects it.
+function fixture() {
+ const document = { activeElement: null };
+ let focusLosses = 0;
+ let focusCalls = 0;
+ class Node {
+ constructor(tagName) {
+ this.tagName = tagName.toUpperCase();
+ this.children = [];
+ this.parentNode = null;
+ this.dataset = {};
+ this.attributes = new Map();
+ this.className = '';
+ this._text = '';
+ this.classList = {
+ add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
+ };
+ }
+ get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); }
+ get firstElementChild() { return this.children[0] || null; }
+ get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; }
+ get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
+ set textContent(value) { this.replaceChildren(); this._text = String(value); }
+ setAttribute(name, value) { this.attributes.set(name, String(value)); }
+ getAttribute(name) { return this.attributes.get(name) ?? null; }
+ contains(node) { return this === node || this.children.some((child) => child.contains(node)); }
+ append(...nodes) { for (const node of nodes) this.insertBefore(node, null); }
+ insertBefore(node, anchor) {
+ assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent');
+ if (node === anchor) return node;
+ node.remove();
+ const index = anchor === null ? this.children.length : this.children.indexOf(anchor);
+ this.children.splice(index, 0, node);
+ node.parentNode = this;
+ return node;
+ }
+ remove() {
+ if (!this.parentNode) return;
+ if (this.contains(document.activeElement)) {
+ document.activeElement = document.body;
+ focusLosses += 1;
+ }
+ this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
+ this.parentNode = null;
+ }
+ replaceChildren(...nodes) {
+ for (const child of [...this.children]) child.remove();
+ this._text = '';
+ this.append(...nodes);
+ }
+ matches(selector) {
+ if (selector === 'button[data-signal-layer][data-signal-id]') {
+ return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null;
+ }
+ return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1))
+ : this.tagName === selector.toUpperCase();
+ }
+ closest(selector) {
+ for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node;
+ return null;
+ }
+ querySelectorAll(selector) {
+ return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]);
+ }
+ focus() {
+ assert.ok(this.isConnected, 'a focus destination must still be connected');
+ focusCalls += 1;
+ document.activeElement = this;
+ }
+ }
+ document.body = new Node('body');
+ document.createElement = (tag) => new Node(tag);
+ document.activeElement = document.body;
+ const controller = new (new Function('document', 'formatAwarenessLabel',
+ `return class { ${source.slice(renderStart, renderEnd)} };`)(document, formatAwarenessLabel))();
+ controller.signalList = new Node('ol');
+ controller.signalToggle = new Node('button');
+ controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
+ controller.briefPageIndex = 0;
+ controller.signalItems = [];
+ controller.signalSignatures = new Map();
+ controller.scheduleContextLayout = () => {};
+ const display = new Node('button');
+ const radio = new Node('button');
+ const outside = new Node('button');
+ document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
+ const selected = [];
+ const click = new Function('militaryAwarenessLayer', `return ${clickSource};`)({
+ focusTarget: (...args) => selected.push(args),
+ });
+ const buttons = () => controller.signalList.querySelectorAll('button');
+ const tab = () => {
+ const nodes = document.body.querySelectorAll('button');
+ const index = nodes.indexOf(document.activeElement);
+ nodes[(index + 1) % nodes.length].focus();
+ };
+ return { controller, document, buttons, display, radio, outside, selected, click, tab,
+ losses: () => focusLosses, focusCalls: () => focusCalls };
+}
+
+const item = (id, overrides = {}) => ({
+ key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT Β· 2 KM',
+ target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides,
+});
+const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); };
+const snapshot = (distanceM = 2000) => ({
+ evaluatedAt: 1_700_000_000_000,
+ subject: { layerId: 'flights', id: 'current', label: 'CURRENT' },
+ cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }],
+});
+
+test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
+ const f = fixture();
+ f.controller.updateCockpitSignals(snapshot(), 0);
+ const target = f.buttons()[1]; target.focus();
+ const calls = f.focusCalls();
+ for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0);
+ assert.equal(f.buttons()[1], target);
+ assert.equal(f.document.activeElement, target);
+ assert.equal(f.losses(), 0);
+ assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it');
+ assert.match(f.controller.signalList.textContent, /2\.5 KM/);
+});
+
+test('signal content and accessible name update without replacing the active target', () => {
+ const f = fixture(); render(f, [item('a')]);
+ const target = f.buttons()[0]; target.focus();
+ render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]);
+ assert.equal(f.buttons()[0], target);
+ assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
+ assert.match(target.textContent, /RENAMED/);
+ assert.match(f.controller.signalList.textContent, /CURRENT/);
+ assert.equal(f.controller.signalList.children[0].className, 'track actionable');
+ assert.equal(f.document.activeElement, target);
+ assert.equal(f.losses(), 0);
+});
+
+test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
+ const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id)));
+ const target = f.buttons()[1]; target.focus();
+ const calls = f.focusCalls();
+ for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) {
+ render(f, order.map((id) => item(id)));
+ assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order);
+ assert.equal(f.document.activeElement, target);
+ assert.equal(f.losses(), 0);
+ }
+ assert.equal(f.focusCalls(), calls);
+});
+
+test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
+ const f = fixture(); render(f, [item('a'), item('b')]);
+ const target = f.buttons()[1]; target.focus();
+ f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE');
+ assert.equal(f.document.activeElement, target);
+ assert.equal(f.losses(), 0);
+ const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } };
+ f.click(event);
+ assert.equal(event.prevented, true);
+ assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
+});
+
+test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
+ const f = fixture(); render(f, [item('a'), item('b')]);
+ const removed = f.buttons()[1]; removed.focus();
+ const calls = f.focusCalls();
+ render(f, [item('a')]);
+ assert.equal(removed.isConnected, false);
+ assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
+ assert.equal(f.focusCalls(), calls + 1);
+ f.tab(); f.tab(); f.tab();
+ assert.equal(f.document.activeElement, f.display);
+ render(f, [item('a', { detail: 'UPDATED' })]);
+ assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back');
+ f.tab();
+ assert.equal(f.document.activeElement, f.radio);
+});
+
+test('a changed target cannot reuse a focused action for another flight', () => {
+ const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]);
+ const removed = f.buttons()[0]; removed.focus();
+ render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]);
+ const target = f.buttons()[0];
+ assert.notEqual(target, removed);
+ assert.equal(removed.isConnected, false);
+ assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
+ f.click({ target, preventDefault() {} });
+ assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]);
+});
+
+test('empty, informational and duplicate-key rows have no stale or aliased actions', () => {
+ const f = fixture();
+ render(f, [item('a'), item('a')]);
+ const [first, second] = f.buttons();
+ assert.notEqual(first, second);
+ second.focus();
+ render(f, [item('a'), item('a')]);
+ assert.deepEqual(f.buttons(), [first, second]);
+ assert.equal(f.document.activeElement, second);
+ render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
+ assert.equal(f.buttons().length, 0);
+ assert.match(f.controller.signalList.textContent, /UNKNOWN/);
+ assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
+ f.outside.focus();
+ render(f, []);
+ assert.equal(f.controller.signalList.children.length, 0);
+ render(f, [item('a')]);
+ assert.notEqual(f.buttons()[0], first);
+ assert.equal(f.document.activeElement, f.outside);
+});
diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs
new file mode 100644
index 000000000..7547e0f52
--- /dev/null
+++ b/src/contextTabKeyboard.test.mjs
@@ -0,0 +1,161 @@
+import { readFileSync } from 'node:fs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
+const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
+const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+
+test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
+ for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
+ const button = html.match(new RegExp(`
`));
+ 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/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/rocketLaunches.js b/src/data/rocketLaunches.js
index e8fda9b37..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.
@@ -2327,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 }) => {
@@ -2337,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) {
@@ -2384,7 +2515,7 @@ function selectMissionAt(index) {
focusMission(launch);
}
-function clearMissionRosterHover() {
+function clearMissionRosterPreviewState() {
if (_missionRosterHoverTimer) clearTimeout(_missionRosterHoverTimer);
_missionRosterHoverTimer = null;
const changed = _hoveredRosterLaunchId !== null;
@@ -2392,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);
@@ -2414,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);
}
@@ -2499,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]')
@@ -2506,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';
@@ -3539,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 27d29ff12..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)');
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
index 7d19a3f0f..78e3e0fad 100644
--- a/src/mapSourceFocus.test.mjs
+++ b/src/mapSourceFocus.test.mjs
@@ -4,13 +4,21 @@ 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
+assert.ok(initStart >= 0 && initEnd > initStart && escapeStart >= 0 && escapeEnd > escapeStart
&& closeStart >= 0 && closeEnd > closeStart);
assert.ok(syncStart >= 0 && syncEnd > syncStart);
@@ -46,13 +54,16 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
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, first, active, elsewhere].filter(Boolean);
+ 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].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' };
@@ -61,6 +72,9 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
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;
};
@@ -86,6 +100,7 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
}
};
disclosure.focus = () => focus(disclosure);
+ if (locationDisclosure) locationDisclosure.focus = () => focus(locationDisclosure);
for (const chip of [first, active]) {
chip.focus = () => { focusCalls += 1; if (!hidden) focus(chip); };
}
@@ -93,7 +108,7 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
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(closeStart, closeEnd)},\n${source.slice(syncStart, syncEnd)}});`)(
+ `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 = [];
@@ -117,14 +132,28 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
timers.delete(next[0]); now = next[1].at; next[1].callback();
};
return {
- manager, panel, location, disclosure,
+ 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(); },
};
@@ -136,6 +165,17 @@ test('Enter focuses selected OSM rather than the first chip', () => {
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);
@@ -230,6 +270,114 @@ test('pointer leave preserves established keyboard focus and the open tray', ()
});
+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();
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/ui.js b/src/ui.js
index 29f3cfa57..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.
@@ -4263,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;
@@ -4281,13 +4399,10 @@ export class StyleManager {
});
panelEl.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
+ if (!event.defaultPrevented) this._collapsePanelOnEscape(event, panelId);
cancelMapSourceFocus();
- if (panelEl.classList.contains('collapsed')) return;
- event.preventDefault();
clearOpen();
clearClose();
- this.setPanelCollapsed(panelId, true, { explicit: true });
- disclosure?.focus?.({ preventScroll: true });
});
}
@@ -4657,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(
@@ -4674,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(
@@ -4693,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',
@@ -4708,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');
});
});
}
@@ -4923,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 });
@@ -5078,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();
}
}
@@ -5326,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;
@@ -5574,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, {
@@ -5592,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 });
}
};
@@ -5644,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;
@@ -5947,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);
@@ -5959,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);
@@ -5971,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) {
@@ -7475,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}`);
@@ -9694,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({
@@ -9728,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;
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
-