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 } = {}) {