From 5228ab9e40d9881a27f2207bb4b257612d62717c Mon Sep 17 00:00:00 2001 From: manjunath22466 Date: Thu, 10 Sep 2026 19:17:42 +0530 Subject: [PATCH] fix: preserve keyboard focus and separate Space activation from voice Keep focus visible across controls and stable during Contacts, Cockpit and mission roster updates. Preserve native short Space activation, and claim push-to-talk only after a 500 ms hold. Add panel Escape handling and keyboard-accessible attribution controls. Retains the Map Source foundation contributed in https://github.com/bilawalsidhu/gods-eye-view/pull/171. --- CHANGELOG.md | 43 ++ docs/CURRENT-STATE.md | 61 ++- index.html | 9 +- scripts/qa-map-source-tray.mjs | 779 +++++++++++++++++++++++++++- src/cockpitSignalFocus.test.mjs | 223 ++++++++ src/contextTabKeyboard.test.mjs | 161 ++++++ src/creditKeyboard.js | 70 +++ src/creditKeyboard.test.mjs | 188 +++++++ src/data/manager.js | 18 +- src/data/manager.test.mjs | 158 ++++++ src/data/militaryAwareness.js | 41 +- src/data/militaryAwareness.test.mjs | 93 ++++ src/data/rocketLaunches.js | 170 +++++- src/data/rocketLaunches.test.mjs | 149 ++++++ src/keyboardFocusStyles.test.mjs | 86 +++ src/main.js | 2 + src/mapSourceFocus.test.mjs | 158 +++++- src/panelEscape.test.mjs | 169 ++++++ src/ui.js | 259 ++++++--- src/voice/gevRealtime.js | 188 +++++-- src/voice/gevRealtime.test.mjs | 431 ++++++++++++++- style.css | 201 ++++++- 22 files changed, 3500 insertions(+), 157 deletions(-) create mode 100644 src/cockpitSignalFocus.test.mjs create mode 100644 src/contextTabKeyboard.test.mjs create mode 100644 src/creditKeyboard.js create mode 100644 src/creditKeyboard.test.mjs create mode 100644 src/keyboardFocusStyles.test.mjs create mode 100644 src/panelEscape.test.mjs 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: --
Landmark: --
-
+
@@ -727,7 +729,7 @@ CONTACTS - @@ -765,6 +767,7 @@
LOADING 30-DAY MISSION INDEX
+

TAB PREVIEWS · ENTER / SPACE SELECTS

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(``; + return ``; }).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
- @@ -2571,7 +2695,7 @@ function createVoiceControl({ reset = false } = {}) {