Merge pull request #291: preserve keyboard focus and Space activation

Preserve the contributor commits for keyboard accessibility and focused browser QA.
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-10 23:54:25 -07:00
commit c7ef01827d
23 files changed
+3786 -303

No files matched your search

+43
View File
@@ -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.
+60 -1
View File
@@ -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
+6 -3
View File
@@ -548,14 +548,16 @@
<div class="panel-glow"></div>
<div class="location-inner">
<div class="location-toolbar">
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION">
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel">+</button>
</div>
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
<div id="location-mini-city" class="location-mini-city">📍 Location: --</div>
<div id="location-mini-poi" class="location-mini-poi">Landmark: --</div>
</div>
<div class="dock-popover-content">
<div id="location-bar-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open"><img src="/pin.svg" alt="" /></button>
<div id="poi-row" class="poi-row-container"></div>
<div class="location-bar-divider" id="location-bar-divider"></div>
@@ -727,7 +729,7 @@
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span>CONTACTS</span>
</button>
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="-1" aria-selected="false" aria-controls="context-missions-view">
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-missions-view">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span>SPACE MISSIONS</span>
</button>
@@ -765,6 +767,7 @@
<div class="space-mission-roster-list" data-mission-roster-list>
<div class="space-mission-roster-empty">LOADING 30-DAY MISSION INDEX</div>
</div>
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
</div>
</div>
</section>
+286 -146
View File
@@ -20,7 +20,13 @@ const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candid
const browser = await puppeteer.launch({
headless: headful ? false : 'new',
...(executablePath ? { executablePath } : {}),
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
// Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
args: [
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--no-sandbox',
],
});
const page = await browser.newPage();
const failures = [];
@@ -54,8 +60,22 @@ const check = (name, passed, detail) => {
try {
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.setRequestInterception(true);
page.on('request', (request) => {
const routeQaRequest = (request) => {
const url = new URL(request.url());
// This harness verifies UI/lifecycle behavior, not DEM accuracy. Keep an
// unrelated upstream terrain outage out of the rendered interaction gate.
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/terrain/heights') {
const points = (url.searchParams.get('points') || '').split(';').filter(Boolean);
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: points.map((point) => {
const [lon, lat] = point.split(',').map(Number);
return { lon, lat, elevation: 0, geoid: 0, ellipsoid: 0 };
}) }),
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') {
request.respond({
status: 200,
@@ -111,7 +131,8 @@ try {
return;
}
request.continue();
});
};
page.on('request', routeQaRequest);
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
await page.waitForFunction(
@@ -768,146 +789,189 @@ try {
// was created to fix.
// Holding the network reproduces the field exactly and the panel tracks it,
// which is what gives these assertions teeth.
const deferredInstallationReadiness = await page.evaluate(async () => {
const { styleManager, dataManager } = window.__godsEyeView;
const entry = dataManager.layers.get('military-installations');
if (!entry?.module) return { exercised: false, reason: 'military-installations missing' };
const settleWithin = (promise, ms) => Promise.race([
promise.then(
(value) => ({ settled: true, value }),
(error) => ({ settled: true, value: `error: ${String(error?.message || error)}` }),
),
new Promise((resolve) => { setTimeout(() => resolve({ settled: false, value: null }), ms); }),
]);
const row = () => [...document.querySelectorAll('#military-awareness-panel .military-awareness-row')]
.find((candidate) => candidate.querySelector('strong')?.textContent?.trim() === 'Mapped installations');
const installationCount = () => row()?.querySelector('b')?.textContent?.trim() || null;
/** The REASON text, which is what tracks availability: the cohort engine
* answers 'feed unavailable' / 'feed stale' when it refuses to count. */
const installationReason = () => row()?.querySelector('small')?.textContent?.trim() || null;
const realFetch = window.fetch;
let released = false;
let requestSeen = false;
let transition = null;
try {
await styleManager._selectContextMode(null);
await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' });
// Hold the first Overpass request open, honouring the module's own
// AbortSignal so its camera-settle abort/refetch behaves as it does live.
window.fetch = (input, init) => {
const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url;
let url = null;
try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); }
if (url.pathname !== '/api/military-installations') return realFetch(input, init);
requestSeen = true;
const signal = init?.signal;
return new Promise((resolve, reject) => {
let done = false;
const fail = () => {
if (done) return;
done = true;
const error = new Error('aborted');
error.name = 'AbortError';
reject(error);
};
if (signal) {
if (signal.aborted) return fail();
signal.addEventListener('abort', fail, { once: true });
}
const tick = () => {
if (done) return;
if (released) {
done = true;
resolve(new Response(
JSON.stringify({ elements: [], retrievedAt: new Date().toISOString() }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
));
return;
}
setTimeout(tick, 100);
};
tick();
// Earlier scenarios have already loaded installations. Use a fresh page so
// a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch
// assertion when camera motion supersedes the manager's initial update.
const readinessPage = await browser.newPage();
let deferredInstallationReadiness;
try {
await readinessPage.setViewport({ width: 1440, height: 900 });
readinessPage.on('pageerror', (error) => consoleErrors.push(error.message));
await readinessPage.setRequestInterception(true);
readinessPage.on('request', (request) => {
const url = new URL(request.url());
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky') {
const now = Math.floor(Date.now() / 1000);
request.respond({
status: 200, contentType: 'application/json',
body: JSON.stringify({ time: now, states: [
['aaa051', 'QA051', 'Synthetic', now, now, -97.7431, 30.2672,
9000, false, 230, 90, 0, null, 9000, null, false, 0],
] }),
});
};
transition = styleManager._selectContextMode('flights');
// The activation promise stays PENDING while the first fetch is out, and
// the Contacts panel comes up behind it — that is what a deferred
// dependency is for. Verified live on :4272: the panel renders and reads
// a non-numeric count for the whole of a 17 s first fetch.
const started = await settleWithin((async () => {
for (let i = 0; i < 100 && !(requestSeen && row()); i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
return requestSeen && Boolean(row());
})(), 12_000);
styleManager.cockpitView.syncEntry();
const pendingLifecycle = dataManager.getLayerLifecycleState('military-installations');
const pendingCount = installationCount();
const pendingReason = installationReason();
// THE WINDOW: first request outstanding, panel live. Sampled more than
// once — the field report was a panel sitting on a fabricated 0 for ~13 s,
// so a single lucky read is not evidence.
const fetchingCounts = [];
const fetchingReasons = [];
for (let i = 0; i < 6; i++) {
fetchingCounts.push(installationCount());
fetchingReasons.push(installationReason());
await new Promise((resolve) => setTimeout(resolve, 250));
return;
}
released = true;
const contacts = await settleWithin(transition, 20_000);
styleManager.cockpitView.syncEntry();
// Measured AFTER activation settles, not during it. The sibling pin above
// ("Contacts blocks early Cockpit entry, then adopts the already tracked
// flight after settlement") establishes that Contacts HIDES the entry
// while the transition is changing — that is the designed contract, and
// asserting the opposite (as this scenario used to) could never pass.
// What matters is that a slow dependency does not lock it away for good.
const cockpitAvailableAfterActivation = !document.getElementById('cockpit-entry')?.hidden;
const installed = await settleWithin((async () => {
while (dataManager.getLayerLifecycleState('military-installations')?.lifecycleState !== 'enabled') {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 200));
return true;
})(), 20_000);
const answered = await settleWithin((async () => {
for (let i = 0; i < 60 && !/^\d+$/.test(String(installationCount())); i++) {
await new Promise((resolve) => setTimeout(resolve, 150));
}
return true;
})(), 12_000);
return {
exercised: true,
started,
contacts,
pendingLifecycle,
pendingCount,
pendingReason,
cockpitAvailableAfterActivation,
installed,
settledLifecycle: dataManager.getLayerLifecycleState('military-installations'),
fetchingCounts,
fetchingReasons,
answered,
settledCount: installationCount(),
settledReason: installationReason(),
};
} finally {
released = true;
window.fetch = realFetch;
if (transition) await settleWithin(transition, 20_000);
if (styleManager._contextMode !== 'flights') {
await settleWithin(styleManager._selectContextMode('flights'), 15_000);
routeQaRequest(request);
});
const readinessUrl = new URL(appUrl);
readinessUrl.searchParams.set('welcome', '0');
await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' });
await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager
&& document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 });
await readinessPage.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
await manager.setEnabled('flights', true, { origin: 'user' });
if (!manager.layers.get('flights').module.trackById('aaa051')) {
throw new Error('First-fetch fixture aircraft was not tracked');
}
}
});
if (manager.layers.get('military-installations').module.getStats().lastUpdate != null) {
throw new Error('First-fetch fixture already has an installation snapshot');
}
});
deferredInstallationReadiness = await readinessPage.evaluate(async () => {
const { styleManager, dataManager } = window.__godsEyeView;
const entry = dataManager.layers.get('military-installations');
if (!entry?.module) return { exercised: false, reason: 'military-installations missing' };
const settleWithin = (promise, ms) => Promise.race([
promise.then(
(value) => ({ settled: true, value }),
(error) => ({ settled: true, value: `error: ${String(error?.message || error)}` }),
),
new Promise((resolve) => { setTimeout(() => resolve({ settled: false, value: null }), ms); }),
]);
const row = () => [...document.querySelectorAll('#military-awareness-panel .military-awareness-row')]
.find((candidate) => candidate.querySelector('strong')?.textContent?.trim() === 'Mapped installations');
const installationCount = () => row()?.querySelector('b')?.textContent?.trim() || null;
/** The REASON text, which is what tracks availability: the cohort engine
* answers 'feed unavailable' / 'feed stale' when it refuses to count. */
const installationReason = () => row()?.querySelector('small')?.textContent?.trim() || null;
const realFetch = window.fetch;
let released = false;
let requestSeen = false;
let transition = null;
try {
await styleManager._selectContextMode(null);
await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' });
// Hold the first Overpass request open, honouring the module's own
// AbortSignal so its camera-settle abort/refetch behaves as it does live.
window.fetch = (input, init) => {
const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url;
let url = null;
try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); }
if (url.pathname !== '/api/military-installations') return realFetch(input, init);
requestSeen = true;
const signal = init?.signal;
return new Promise((resolve, reject) => {
let done = false;
const fail = () => {
if (done) return;
done = true;
const error = new Error('aborted');
error.name = 'AbortError';
reject(error);
};
if (signal) {
if (signal.aborted) return fail();
signal.addEventListener('abort', fail, { once: true });
}
const tick = () => {
if (done) return;
if (released) {
done = true;
resolve(new Response(
JSON.stringify({ elements: [], retrievedAt: new Date().toISOString() }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
));
return;
}
setTimeout(tick, 100);
};
tick();
});
};
transition = styleManager._selectContextMode('flights');
// The activation promise stays PENDING while the first fetch is out, and
// the Contacts panel comes up behind it — that is what a deferred
// dependency is for. Verified live on :4272: the panel renders and reads
// a non-numeric count for the whole of a 17 s first fetch.
const started = await settleWithin((async () => {
for (let i = 0; i < 100 && !(requestSeen && row()); i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
return requestSeen && Boolean(row());
})(), 12_000);
styleManager.cockpitView.syncEntry();
const pendingLifecycle = dataManager.getLayerLifecycleState('military-installations');
const pendingCount = installationCount();
const pendingReason = installationReason();
// THE WINDOW: first request outstanding, panel live. Sampled more than
// once — the field report was a panel sitting on a fabricated 0 for ~13 s,
// so a single lucky read is not evidence.
const fetchingCounts = [];
const fetchingReasons = [];
for (let i = 0; i < 6; i++) {
fetchingCounts.push(installationCount());
fetchingReasons.push(installationReason());
await new Promise((resolve) => setTimeout(resolve, 250));
}
released = true;
const contacts = await settleWithin(transition, 20_000);
styleManager.cockpitView.syncEntry();
// Measured AFTER activation settles, not during it. The sibling pin above
// ("Contacts blocks early Cockpit entry, then adopts the already tracked
// flight after settlement") establishes that Contacts HIDES the entry
// while the transition is changing — that is the designed contract, and
// asserting the opposite (as this scenario used to) could never pass.
// What matters is that a slow dependency does not lock it away for good.
const cockpitAvailableAfterActivation = !document.getElementById('cockpit-entry')?.hidden;
const installed = await settleWithin((async () => {
while (dataManager.getLayerLifecycleState('military-installations')?.lifecycleState !== 'enabled') {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 200));
return true;
})(), 20_000);
const answered = await settleWithin((async () => {
for (let i = 0; i < 60 && !/^\d+$/.test(String(installationCount())); i++) {
await new Promise((resolve) => setTimeout(resolve, 150));
}
return true;
})(), 12_000);
return {
exercised: true,
started,
contacts,
pendingLifecycle,
pendingCount,
pendingReason,
cockpitAvailableAfterActivation,
installed,
settledLifecycle: dataManager.getLayerLifecycleState('military-installations'),
fetchingCounts,
fetchingReasons,
answered,
settledCount: installationCount(),
settledReason: installationReason(),
};
} finally {
released = true;
window.fetch = realFetch;
if (transition) await settleWithin(transition, 20_000);
if (styleManager._contextMode !== 'flights') {
await settleWithin(styleManager._selectContextMode('flights'), 15_000);
}
}
});
} finally {
await readinessPage.close();
await page.bringToFront();
}
// The panel has two truthful ways to say "nothing has answered yet" — the
// cohort count renders `?`, and an UNKNOWN relationship renders as that word.
// Which one is on screen depends on which panel surface is mounted, and the
@@ -916,6 +980,11 @@ try {
// assert numeric-vs-not, which is exactly the field report.
const readsUnknown = (value) => typeof value === 'string' && value.length > 0 && !/^\d/.test(value);
const readsNumber = (value) => typeof value === 'string' && /^\d+$/.test(value);
// Loading has explicit copy since the installations reliability update.
// Keep the numeric/count gates below: wording alone never proves readiness.
const readsPendingReason = (value) => (
/unavailable|stale|Mapped sites not loaded|Fetching mapped sites…/i.test(String(value))
);
check(
'mapped installations load behind Contacts without a false all-clear or a locked Cockpit',
deferredInstallationReadiness.exercised
@@ -924,7 +993,7 @@ try {
&& deferredInstallationReadiness.contacts.value === true
&& deferredInstallationReadiness.pendingLifecycle?.lifecycleState === 'enabling'
&& readsUnknown(deferredInstallationReadiness.pendingCount)
&& /unavailable|stale/i.test(String(deferredInstallationReadiness.pendingReason))
&& readsPendingReason(deferredInstallationReadiness.pendingReason)
&& deferredInstallationReadiness.cockpitAvailableAfterActivation
&& deferredInstallationReadiness.installed?.settled,
JSON.stringify(deferredInstallationReadiness),
@@ -937,10 +1006,11 @@ try {
&& deferredInstallationReadiness.fetchingCounts.length > 0
&& deferredInstallationReadiness.fetchingCounts.every(readsUnknown)
&& Array.isArray(deferredInstallationReadiness.fetchingReasons)
&& deferredInstallationReadiness.fetchingReasons.every((r) => /unavailable|stale/i.test(String(r))),
&& deferredInstallationReadiness.fetchingReasons.every(readsPendingReason),
JSON.stringify({
settledLifecycle: deferredInstallationReadiness.settledLifecycle,
fetchingCounts: deferredInstallationReadiness.fetchingCounts,
fetchingReasons: deferredInstallationReadiness.fetchingReasons,
}),
);
check(
@@ -948,7 +1018,7 @@ try {
deferredInstallationReadiness.exercised
&& deferredInstallationReadiness.answered?.settled
&& readsNumber(deferredInstallationReadiness.settledCount)
&& !/unavailable|stale/i.test(String(deferredInstallationReadiness.settledReason)),
&& !readsPendingReason(deferredInstallationReadiness.settledReason),
JSON.stringify({
answered: deferredInstallationReadiness.answered,
settledCount: deferredInstallationReadiness.settledCount,
@@ -1000,7 +1070,12 @@ try {
);
const zoomedRange = cameraRange();
const refocused = awareness?.focusCurrent?.() === true;
await new Promise((resolve) => setTimeout(resolve, 180));
// The follow frame commits in Cesium preUpdate. A fixed 180ms delay can
// sample before that frame on a busy software renderer.
const frameDeadline = performance.now() + 5000;
while (cameraRange() >= 50_000 && performance.now() < frameDeadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
const focusedRange = cameraRange();
const after = awareness?.getContextSnapshot?.()?.subject || null;
return {
@@ -1339,12 +1414,25 @@ try {
const { styleManager, dataManager, viewer } = window.__godsEyeView;
const awareness = dataManager.layers.get('military-awareness')?.module;
const entity = viewer.trackedEntity || styleManager.cockpitView.trackedEntity;
const nextFrame = () => new Promise((resolve, reject) => {
const timer = setTimeout(() => {
remove();
reject(new Error('Cockpit camera ownership check did not render a frame'));
}, 5000);
const remove = viewer.scene.postRender.addEventListener(() => {
remove();
clearTimeout(timer);
resolve();
});
});
// Let revoked listeners retire before comparing ownership across exit.
await nextFrame();
const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners;
const exited = styleManager.cockpitView.exit() === true;
await new Promise((resolve) => setTimeout(resolve, 80));
await nextFrame();
const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners;
const refocused = awareness?.focusCurrent?.() === true;
await new Promise((resolve) => setTimeout(resolve, 80));
await nextFrame();
return {
exited,
refocused,
@@ -1907,6 +1995,58 @@ try {
&& contextTransition.label === 'Collapse Contact panel',
JSON.stringify(contextTransition),
);
// Use the real connected DOM to catch focus loss caused by moving live rows.
const signalFocus = await page.evaluate(() => {
const cockpit = window.__godsEyeView.styleManager.cockpitView;
const originalItems = cockpit.signalItems;
const wasCollapsed = cockpit.signalCollapsed;
cockpit.setSignalCollapsed(false);
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
try {
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
button.focus();
const acquired = document.activeElement === button;
cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')];
cockpit.renderCockpitSignals();
const retained = document.activeElement === button && button.isConnected;
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
cockpit.signalItems = [item('focus-b')];
cockpit.renderCockpitSignals();
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
const continued = document.activeElement === continuation;
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
} finally {
cockpit.signalItems = originalItems;
cockpit.renderCockpitSignals();
cockpit.setSignalCollapsed(wasCollapsed);
}
});
check('Cockpit live reorder retains the same connected focus owner',
signalFocus.acquired && signalFocus.retained
&& JSON.stringify(signalFocus.order) === JSON.stringify(['focus-c', 'focus-b', 'focus-a']),
JSON.stringify(signalFocus));
check('a departed Cockpit contact continues at the footer without later reclaiming focus',
signalFocus.continued && signalFocus.notReclaimed, JSON.stringify(signalFocus));
// Real keyboard events also verify the nested lightbox does not exit Cockpit.
await page.focus('#cesium-credits .cesium-credit-expand-link');
await page.keyboard.press('Enter');
check('keyboard attribution opening focuses Close inside Cockpit', await page.evaluate(() => (
document.activeElement?.classList.contains('cesium-credit-lightbox-close')
&& document.querySelector('.cesium-credit-expand-link').getAttribute('aria-expanded') === 'true'
)));
await page.screenshot({ path: path.join(shotsDir, 'keyboard-attribution.png') });
await page.keyboard.press('Escape');
check('attribution Escape restores focus and keeps Cockpit active', await page.evaluate(() => (
document.activeElement?.classList.contains('cesium-credit-expand-link')
&& document.activeElement.getAttribute('aria-expanded') === 'false'
&& window.__godsEyeView.styleManager.cockpitView.active
)));
await page.screenshot({ path: path.join(shotsDir, 'restored-desktop.png') });
check(
'restored screenshot remains in a real Cockpit session',
+772 -7
View File
@@ -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();
+223
View File
@@ -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);
});
+161
View File
@@ -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(`<button id="${id}"[\\s\\S]*?</button>`));
assert.ok(button, `${id} is missing`);
assert.match(button[0], /role="tab"/);
assert.match(button[0], /tabindex="0"/);
}
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
assert.ok(sync, 'Context mode sync is missing');
assert.match(sync[1], /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/);
assert.match(sync[1], /button\.tabIndex = 0/);
assert.doesNotMatch(sync[1], /tabIndex\s*=\s*[^;]*\?\s*-1/);
});
test('Context transition state preserves focus and Tab availability until settle', () => {
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
assert.ok(sync, 'Context mode sync is missing');
const attributes = () => new Map();
const makeButton = () => {
const attrs = attributes();
let disabled = false;
const button = {
attrs,
tabIndex: -1,
classList: { toggle() {} },
setAttribute(name, value) { attrs.set(name, String(value)); },
get disabled() { return disabled; },
set disabled(value) {
disabled = Boolean(value);
// Model the browser behavior that exposed this regression: native
// disabled drops focus immediately.
if (disabled && globalThis.document?.activeElement === button) {
globalThis.document.activeElement = null;
}
},
};
return button;
};
const contacts = makeButton();
const missions = makeButton();
const panel = { classList: { toggle() {} }, setAttribute() {} };
const priorDocument = globalThis.document;
globalThis.document = { activeElement: missions, getElementById: () => panel };
const owner = {
_contextMode: null,
_contextModeChanging: true,
_globalContextFlightsBtn: contacts,
_globalContextMissionsBtn: missions,
_contextModeStandby: {},
_contextFlightsView: {},
_contextMissionsView: {},
cockpitView: { syncEntry() {} },
_syncContactsDetection() {},
_scheduleRightPanelLayout() {},
};
try {
Function(sync[1]).call(owner);
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'true');
assert.equal(button.attrs.get('aria-busy'), 'true');
}
owner._contextModeChanging = false;
owner._contextMode = 'space-missions';
Function(sync[1]).call(owner);
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'false');
assert.equal(button.attrs.get('aria-busy'), 'false');
}
} finally {
globalThis.document = priorDocument;
}
});
test('Context activation and Clear All never native-disable tabs and guard repeated clicks', () => {
const init = ui.slice(
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const select = ui.slice(
ui.indexOf('async _selectContextMode('),
ui.indexOf('async _deactivateContextForLayerChange(', ui.indexOf('async _selectContextMode(')),
);
const clear = ui.slice(
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
);
assert.equal((init.match(/if \(this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2);
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
});
test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
const init = ui.match(/_initGlobalContextPanel\(\) \{([\s\S]*?)\n this\._globalContextFlightsBtn\?\.addEventListener/);
assert.ok(init, 'Context panel initialization is missing');
assert.match(init[1], /event\.key === 'ArrowRight'/);
assert.match(init[1], /event\.key === 'ArrowLeft'/);
assert.match(init[1], /event\.key === 'Home'/);
assert.match(init[1], /event\.key === 'End'/);
assert.match(init[1], /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
assert.match(init[1], /contextTabs\[nextIndex\]\.click\(\)/);
});
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const focusRule = rules.find(([, selector, body]) => (
selector.trim().endsWith('.context-mode-button:focus-visible')
&& /outline:\s*2px solid var\(--text-primary\)/.test(body)
));
assert.ok(focusRule, 'Context focus-visible rule must draw a two-pixel outline');
assert.match(focusRule[2], /outline-offset:\s*-3px/);
const activeRule = rules.find(([, selector]) => selector.trim().endsWith('.context-mode-button.active'));
assert.ok(activeRule, 'Context active-state rule is missing');
assert.doesNotMatch(activeRule[2], /outline:\s*none/);
});
test('Context async action buttons remain focused while busy', () => {
const init = ui.slice(
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const radio = ui.slice(
ui.indexOf('const toggleRadio = async (trigger) => {'),
ui.indexOf('this._contextRadioToggleBtn?.addEventListener', ui.indexOf('const toggleRadio = async (trigger) => {')),
);
const radioSync = ui.slice(
ui.indexOf('_renderRadioState(state) {'),
ui.indexOf('if (this._radioFilter)', ui.indexOf('_renderRadioState(state) {')),
);
const clear = ui.slice(
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
);
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
for (const name of ['_radioEnableBtn', '_contextRadioMiniEnableBtn', '_cockpitRadioEnableBtn']) {
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
}
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
assert.match(clear, /_clearSelectedLayersBtn\.setAttribute\('aria-busy', 'true'\)/);
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
* Cesium retains ownership of showing and hiding its required credit lightbox.
*/
export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.('#cesium-credits .cesium-credit-expand-link');
const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
if (!expand || !lightbox || !close) return false;
const overlay = lightbox.parentElement;
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
expand.setAttribute('role', 'button');
expand.setAttribute('tabindex', '0');
expand.setAttribute('aria-haspopup', 'dialog');
expand.setAttribute('aria-controls', lightbox.id);
expand.setAttribute('aria-expanded', 'false');
close.setAttribute('role', 'button');
close.setAttribute('tabindex', '0');
close.setAttribute('aria-label', 'Close data attribution');
if (expand.dataset.gevKeyboardReady === 'true') return true;
expand.dataset.gevKeyboardReady = 'true';
const installKeyboardActivation = (control) => {
let spacePressed = false;
control.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
if (!event.repeat) control.click();
return;
}
if (event.key !== ' ') return;
event.preventDefault();
if (!event.repeat) spacePressed = true;
});
control.addEventListener('keyup', (event) => {
if (event.key !== ' ') return;
event.preventDefault();
if (!spacePressed) return;
spacePressed = false;
control.click();
});
// A long Space hold may hand control to push-to-talk and move focus away.
// Cancelling the armed release prevents that eventual keyup from also
// activating the attribution control.
control.addEventListener('blur', () => { spacePressed = false; });
};
const closeAndRestoreFocus = () => {
expand.setAttribute('aria-expanded', 'false');
expand.focus();
};
installKeyboardActivation(expand);
installKeyboardActivation(close);
lightbox.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
close.click();
});
expand.addEventListener('click', () => {
expand.setAttribute('aria-expanded', 'true');
close.focus();
});
close.addEventListener('click', closeAndRestoreFocus);
overlay?.addEventListener?.('click', (event) => {
if (!lightbox.contains(event.target)) closeAndRestoreFocus();
});
return true;
}
+188
View File
@@ -0,0 +1,188 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
function fakeElement() {
const attributes = new Map();
const listeners = new Map();
return {
dataset: {},
focused: false,
id: '',
setAttribute(name, value) { attributes.set(name, String(value)); },
getAttribute(name) { return attributes.get(name) ?? null; },
addEventListener(type, listener) {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type).push(listener);
},
dispatch(type, event = {}) {
const dispatched = {
currentTarget: this,
target: this,
defaultPrevented: false,
propagationStopped: false,
preventDefault() { this.defaultPrevented = true; },
stopPropagation() { this.propagationStopped = true; },
...event,
};
for (const listener of listeners.get(type) || []) listener(dispatched);
return dispatched;
},
click() {
this.onclick?.();
this.dispatch('click');
},
focus() { this.focused = true; },
listenerCount(type) { return listeners.get(type)?.length || 0; },
};
}
function fixture() {
const expand = fakeElement();
const close = fakeElement();
const lightbox = fakeElement();
lightbox.querySelector = () => close;
lightbox.contains = (target) => target === lightbox || target === close;
const overlay = fakeElement();
lightbox.parentElement = overlay;
const root = {
querySelector(selector) {
if (selector === '#cesium-credits .cesium-credit-expand-link') return expand;
if (selector === '.cesium-credit-lightbox') return lightbox;
return null;
},
};
return { close, expand, lightbox, overlay, root };
}
test('Cesium Data attribution and close controls join the keyboard tab order', () => {
const f = fixture();
assert.equal(configureCreditKeyboardAccess(f.root), true);
assert.equal(f.expand.getAttribute('role'), 'button');
assert.equal(f.expand.getAttribute('tabindex'), '0');
assert.equal(f.expand.getAttribute('aria-haspopup'), 'dialog');
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
assert.equal(f.close.getAttribute('role'), 'button');
assert.equal(f.close.getAttribute('tabindex'), '0');
assert.equal(f.close.getAttribute('aria-label'), 'Close data attribution');
});
test('Enter opens and closes attribution on initial keydown', () => {
const f = fixture();
let opened = 0;
let closed = 0;
f.expand.onclick = () => { opened++; };
f.close.onclick = () => { closed++; };
configureCreditKeyboardAccess(f.root);
const openEvent = f.expand.dispatch('keydown', { key: 'Enter' });
assert.equal(openEvent.defaultPrevented, true);
assert.equal(opened, 1);
assert.equal(f.expand.getAttribute('aria-expanded'), 'true');
assert.equal(f.close.focused, true);
const closeEvent = f.close.dispatch('keydown', { key: 'Enter' });
assert.equal(closeEvent.defaultPrevented, true);
assert.equal(closed, 1);
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
assert.equal(f.expand.focused, true);
});
test('Space opens and closes attribution only on key release', () => {
const f = fixture();
let opened = 0;
let closed = 0;
f.expand.onclick = () => { opened++; };
f.close.onclick = () => { closed++; };
configureCreditKeyboardAccess(f.root);
const openDown = f.expand.dispatch('keydown', { key: ' ' });
assert.equal(openDown.defaultPrevented, true);
assert.equal(opened, 0);
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
const openUp = f.expand.dispatch('keyup', { key: ' ' });
assert.equal(openUp.defaultPrevented, true);
assert.equal(opened, 1);
assert.equal(f.expand.getAttribute('aria-expanded'), 'true');
assert.equal(f.close.focused, true);
const closeDown = f.close.dispatch('keydown', { key: ' ' });
assert.equal(closeDown.defaultPrevented, true);
assert.equal(closed, 0);
const closeUp = f.close.dispatch('keyup', { key: ' ' });
assert.equal(closeUp.defaultPrevented, true);
assert.equal(closed, 1);
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
assert.equal(f.expand.focused, true);
});
test('held activation keys do not repeat open or close actions', () => {
const f = fixture();
let opened = 0;
f.expand.onclick = () => { opened++; };
configureCreditKeyboardAccess(f.root);
f.expand.dispatch('keydown', { key: ' ' });
const repeated = f.expand.dispatch('keydown', { key: ' ', repeat: true });
assert.equal(repeated.defaultPrevented, true);
assert.equal(opened, 0);
f.expand.dispatch('keyup', { key: ' ' });
assert.equal(opened, 1);
f.expand.dispatch('keyup', { key: ' ' });
assert.equal(opened, 1);
const g = fixture();
let enterOpened = 0;
g.expand.onclick = () => { enterOpened++; };
configureCreditKeyboardAccess(g.root);
g.expand.dispatch('keydown', { key: 'Enter' });
g.expand.dispatch('keydown', { key: 'Enter', repeat: true });
assert.equal(enterOpened, 1);
});
test('losing focus during a Space hold cancels release activation', () => {
const f = fixture();
let opened = 0;
f.expand.onclick = () => { opened++; };
configureCreditKeyboardAccess(f.root);
f.expand.dispatch('keydown', { key: ' ' });
f.expand.dispatch('blur');
const release = f.expand.dispatch('keyup', { key: ' ' });
assert.equal(release.defaultPrevented, true);
assert.equal(opened, 0);
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
});
test('Escape and backdrop dismissal close attribution and restore its disclosure', () => {
const f = fixture();
let closed = 0;
f.close.onclick = () => { closed++; };
configureCreditKeyboardAccess(f.root);
f.expand.click();
const escape = f.lightbox.dispatch('keydown', { key: 'Escape', target: f.close });
assert.equal(escape.defaultPrevented, true);
assert.equal(escape.propagationStopped, true);
assert.equal(closed, 1);
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
assert.equal(f.expand.focused, true);
f.expand.focused = false;
f.expand.click();
f.overlay.dispatch('click', { target: f.overlay });
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
assert.equal(f.expand.focused, true);
});
test('credit keyboard setup is idempotent and safely declines incomplete markup', () => {
const f = fixture();
assert.equal(configureCreditKeyboardAccess(f.root), true);
assert.equal(configureCreditKeyboardAccess(f.root), true);
assert.equal(f.expand.listenerCount('keydown'), 1);
assert.equal(f.expand.listenerCount('keyup'), 1);
assert.equal(f.expand.listenerCount('blur'), 1);
assert.equal(f.close.listenerCount('keydown'), 1);
assert.equal(f.close.listenerCount('keyup'), 1);
assert.equal(f.close.listenerCount('blur'), 1);
assert.equal(f.lightbox.listenerCount('keydown'), 1);
assert.equal(f.overlay.listenerCount('click'), 1);
assert.equal(configureCreditKeyboardAccess({ querySelector: () => null }), false);
});
+15 -3
View File
@@ -2043,16 +2043,23 @@ export class DataLayerManager {
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
this._syncToggleButton(toggle, layer);
toggle.addEventListener('click', async () => {
toggle.disabled = true;
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
// the lifecycle control focusable while it is busy, and enforce the
// same single-flight interaction contract through ARIA instead.
if (toggle.getAttribute('aria-disabled') === 'true') return;
toggle.setAttribute('aria-disabled', 'true');
toggle.setAttribute('aria-busy', 'true');
try {
await this.setEnabled(layer.id, !this.isEnabled(layer.id), { origin: 'user' });
} catch (error) {
console.warn(`[Data] ${layer.id} toggle error:`, error);
} finally {
toggle.disabled = false;
const current = this.getAll().find(({ id }) => id === layer.id);
if (current) this._syncToggleButton(toggle, current);
}
});
@@ -2266,7 +2273,12 @@ export class DataLayerManager {
button.dataset.feedState = transitioning
? layer.lifecycleState
: (uncertain ? 'uncertain' : feedState);
button.disabled = transitioning;
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
// click guard above prevents repeat activation without the focus loss caused
// by native `disabled`.
button.disabled = false;
button.setAttribute('aria-disabled', String(transitioning));
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
+158
View File
@@ -2757,6 +2757,7 @@ function makeControlElement() {
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
addEventListener(name, handler) { this.listeners[name] = handler; },
setAttribute(name, value) { this.attributes[name] = String(value); },
getAttribute(name) { return this.attributes[name] ?? null; },
closest(selector) {
const className = selector.slice(1);
return String(this.className).split(/\s+/).includes(className) ? this : null;
@@ -2783,6 +2784,163 @@ function makeControlElement() {
return element;
}
test('keyboard focus survives Data Layer enabling and disabling transitions', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const mgr = new DataLayerManager({});
let releaseEnable;
let releaseDisable;
const calls = { enable: 0, disable: 0 };
mgr.register({
id: 'focus-layer',
name: 'Focus layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
calls.enable += 1;
return new Promise((resolve) => { releaseEnable = resolve; });
},
disable() {
calls.disable += 1;
return new Promise((resolve) => { releaseDisable = resolve; });
},
update() {},
destroy() {},
getStats() { return { count: 1, lastUpdate: Date.now() }; },
});
const container = makeControlElement();
const waitFor = async (predicate, label) => {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (predicate()) return;
await Promise.resolve();
}
assert.fail(`timed out waiting for ${label}`);
};
try {
mgr.buildTogglePanel(container);
const row = container.querySelector('[data-layer-id="focus-layer"]');
const button = row.querySelector('.data-toggle-btn');
button.focus();
const enablingClick = button.listeners.click();
await waitFor(() => typeof releaseEnable === 'function', 'enable transition');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'ENABLING');
await button.listeners.click();
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
releaseEnable();
await enablingClick;
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(mgr.isEnabled('focus-layer'), true);
const disablingClick = button.listeners.click();
await waitFor(() => typeof releaseDisable === 'function', 'disable transition');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false, 'DISABLING never uses native disabled');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'DISABLING');
await button.listeners.click();
assert.equal(calls.disable, 1, 'repeat activation is inert while disabling');
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
releaseDisable();
await disablingClick;
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(mgr.isEnabled('focus-layer'), false);
} finally {
releaseEnable?.();
releaseDisable?.();
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('a failed Data Layer transition clears busy state without losing keyboard focus', async () => {
const originalDocument = globalThis.document;
const originalWarn = console.warn;
globalThis.document = { createElement: makeControlElement, activeElement: null };
console.warn = () => {};
const mgr = new DataLayerManager({});
let rejectEnable;
let enableCalls = 0;
mgr.register({
id: 'focus-failure-layer',
name: 'Focus failure layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
enableCalls += 1;
if (enableCalls > 1) return true;
return new Promise((resolve, reject) => { rejectEnable = reject; });
},
disable() { return true; },
update() {},
destroy() {},
getStats() { return { count: 0, lastUpdate: null }; },
});
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
const button = container
.querySelector('[data-layer-id="focus-failure-layer"]')
.querySelector('.data-toggle-btn');
button.focus();
const failedClick = button.listeners.click();
for (let attempt = 0; attempt < 20 && typeof rejectEnable !== 'function'; attempt += 1) {
await Promise.resolve();
}
assert.equal(typeof rejectEnable, 'function');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
rejectEnable(new Error('QA enable rejected'));
await failedClick;
assert.equal(globalThis.document.activeElement, button, 'failure settlement preserves focus');
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(button.textContent, 'OFF');
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
enabled: false,
lifecycleState: 'disabled',
uncertain: false,
});
await button.listeners.click();
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
assert.equal(globalThis.document.activeElement, button);
assert.equal(mgr.isEnabled('focus-failure-layer'), true);
} finally {
rejectEnable?.(new Error('test cleanup'));
console.warn = originalWarn;
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
/** Collect every node in a rendered subtree carrying `className`. */
function collectByClass(node, className) {
const found = [];
+40 -1
View File
@@ -982,6 +982,43 @@ function navigationState() {
};
}
/** Stable identity for a delegated Contacts-panel button across live repaints. */
export function awarenessPanelControlKey(element) {
const control = element?.closest?.(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
);
if (!control) return null;
if (control.dataset.awarenessAction) return `action:${control.dataset.awarenessAction}`;
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
}
/** Capture stable identity so a live repaint can restore only the same control. */
export function captureAwarenessPanelFocus(panel, activeElement = document.activeElement) {
if (!panel?.contains?.(activeElement)) return null;
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
return { key: 'continuation' };
}
const key = awarenessPanelControlKey(activeElement);
if (!key) return null;
return { key };
}
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
export function restoreAwarenessPanelFocus(panel, snapshot) {
if (!panel || !snapshot) return null;
const continuation = panel.querySelector('[data-awareness-focus-continuation]');
const controls = [...panel.querySelectorAll(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
)].filter((control) => !control.disabled);
const retained = snapshot.key === 'continuation'
? continuation
: controls.find((control) => awarenessPanelControlKey(control) === snapshot.key);
const target = retained
|| continuation;
target?.focus?.({ preventScroll: true });
return target || null;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[char]);
}
@@ -993,11 +1030,13 @@ function renderResults() {
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note">Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
panel.hidden = false;
if (state.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
panel.innerHTML = markup;
state.panelMarkup = markup;
restoreAwarenessPanelFocus(panel, focusSnapshot);
}
}
+93
View File
@@ -10,14 +10,17 @@ import militaryAwarenessLayer, {
awarenessRefreshIntervalMs,
awarenessRefreshRequired,
awarenessResultsAreLive,
awarenessPanelControlKey,
buildAwarenessContextSnapshot,
canNavigateAwarenessNext,
contactsWindowFromSnapshot,
contextTargetFlyToAllowed,
captureAwarenessPanelFocus,
summarizeInstallationViewport,
findCompatibleHistoryIndex,
AWARENESS_QUERY_LIMIT,
historySubjectSnapshot,
restoreAwarenessPanelFocus,
} from './militaryAwareness.js';
import flightsLayer, {
_setTrackedFlightRefreshStateForTest,
@@ -212,6 +215,96 @@ function installAwarenessRuntime({
};
}
function awarenessControl(dataset, { disabled = false } = {}) {
return {
dataset,
disabled,
focusCalls: 0,
closest(selector) {
if (selector === '[data-awareness-focus-continuation]') {
return this.isContinuation ? this : null;
}
return selector.includes('button') && !this.isContinuation ? this : null;
},
matches(selector) {
return selector === '[data-awareness-focus-continuation]' && this.isContinuation;
},
focus(options) {
this.focusCalls += 1;
this.focusOptions = options;
},
};
}
function awarenessPanel(controls) {
const continuation = awarenessControl({});
continuation.isContinuation = true;
return {
controls,
continuation,
contains(element) { return controls.includes(element) || element === continuation; },
querySelectorAll() { return this.controls; },
querySelector(selector) {
return selector === '[data-awareness-focus-continuation]' ? continuation : null;
},
};
}
test('Contacts live repaint restores only stable identity and never retargets another contact', () => {
const previous = awarenessControl({ awarenessAction: 'previous' });
const focus = awarenessControl({ awarenessAction: 'focus' });
const next = awarenessControl({ awarenessAction: 'next' });
const alpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' });
const bravo = awarenessControl({ awarenessLayer: 'military', awarenessId: 'def456' });
const before = awarenessPanel([previous, focus, next, alpha, bravo]);
const alphaSnapshot = captureAwarenessPanelFocus(before, alpha);
assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' });
assert.equal(awarenessPanelControlKey(next), 'action:next');
const retainedAlpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' });
const retained = awarenessPanel([previous, focus, next, bravo, retainedAlpha]);
assert.equal(restoreAwarenessPanelFocus(retained, alphaSnapshot), retainedAlpha);
assert.equal(retainedAlpha.focusCalls, 1, 'identity wins even when live distance reorders rows');
assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true });
const forward = awarenessControl({ awarenessLayer: 'ais-live-vessels', awarenessId: '789' });
const departed = awarenessPanel([previous, focus, next, forward]);
assert.equal(restoreAwarenessPanelFocus(departed, alphaSnapshot), departed.continuation);
assert.equal(forward.focusCalls, 0, 'automatic paging must not transfer focus to another contact');
assert.equal(departed.continuation.focusCalls, 1);
const bravoSnapshot = captureAwarenessPanelFocus(before, bravo);
const noRemainingRow = awarenessPanel([previous, focus, next, alpha]);
assert.equal(
restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot),
noRemainingRow.continuation,
);
assert.equal(noRemainingRow.continuation.focusCalls, 1, 'the end sentinel continues Tab beyond the list');
const continuationSnapshot = captureAwarenessPanelFocus(departed, departed.continuation);
assert.deepEqual(continuationSnapshot, { key: 'continuation' });
const repaintedAgain = awarenessPanel([previous, focus, next, forward]);
assert.equal(
restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot),
repaintedAgain.continuation,
);
assert.equal(
repaintedAgain.continuation.focusCalls,
1,
'later live repaints keep focus on the continuation point until the user tabs onward',
);
});
test('Contacts continuation target is the visible explanatory note', () => {
const source = fs.readFileSync(new URL('./militaryAwareness.js', import.meta.url), 'utf8');
assert.match(
source,
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped\/observed context\./,
);
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
});
test('awareness disable settles every owned dependency release before resolving', async () => {
let releasing = false;
let releaseDependencies;
+146 -24
View File
@@ -61,6 +61,7 @@ let _missionPanel = null;
let _missionRoster = null;
let _missionRosterHoverTimer = null;
let _hoveredRosterLaunchId = null;
let _missionRosterPreviewOwnership = null;
let _missionHoverReticleImage = null;
let _replayVehicleOverlay = null;
let _replayVehicleOverlayText = '';
@@ -786,6 +787,115 @@ export function missionHoverPreviewRange(cameraHeight) {
);
}
/**
* Coordinate pointer and keyboard ownership of the temporary roster preview.
* The most recent input owns the preview until it leaves, then any remaining
* input resumes ownership. This lets keyboard focus take over from a pointer
* resting on another row without losing that pointer preview on blur.
* @param {{preview: (index: number) => void, clear: () => void}} handlers
* @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void,
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void,
* reset: () => void}}
*/
export function createMissionRosterPreviewOwnership({ preview, clear }) {
let pointerIndex = null;
let focusIndex = null;
let latestOwner = null;
let activeIndex = null;
const sync = () => {
const nextIndex = latestOwner === 'pointer'
? pointerIndex ?? focusIndex
: focusIndex ?? pointerIndex;
if (nextIndex === activeIndex) return;
activeIndex = nextIndex;
if (Number.isInteger(nextIndex)) preview(nextIndex);
else clear();
};
return {
pointerEnter(index) {
pointerIndex = index;
latestOwner = 'pointer';
sync();
},
pointerLeave() {
pointerIndex = null;
if (latestOwner === 'pointer') latestOwner = focusIndex === null ? null : 'focus';
sync();
},
focus(index) {
focusIndex = index;
latestOwner = 'focus';
sync();
},
blur(nextIndex = null) {
focusIndex = Number.isInteger(nextIndex) ? nextIndex : null;
if (latestOwner === 'focus') latestOwner = focusIndex === null
? (pointerIndex === null ? null : 'pointer')
: 'focus';
sync();
},
reset() {
pointerIndex = null;
focusIndex = null;
latestOwner = null;
activeIndex = null;
clear();
},
};
}
/**
* Bind native keyboard focus events for one rendered roster row.
* @param {Element} button Mission roster button.
* @param {number} index Source-array mission index.
* @param {{pointerEnter: (index: number) => void, pointerLeave: () => void,
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership
* @param {Element} roster Roster root used to reject focus targets outside the list.
*/
export function bindMissionRosterItemKeyboardPreview(button, index, ownership, roster) {
button.addEventListener('mouseenter', () => ownership.pointerEnter(index));
button.addEventListener('mouseleave', () => ownership.pointerLeave());
button.addEventListener('focus', () => ownership.focus(index));
button.addEventListener('blur', (event) => {
const nextButton = event.relatedTarget?.closest?.('[data-mission-roster-index]');
const nextIndex = nextButton && roster.contains(nextButton)
? Number(nextButton.dataset.missionRosterIndex)
: null;
ownership.blur(nextIndex);
});
}
/** Capture the exact mission identity owned by keyboard focus before a refresh. */
export function captureMissionRosterFocus(list, activeElement = globalThis.document?.activeElement) {
const button = activeElement?.closest?.('[data-mission-roster-id]');
if (!button || !list?.contains(button)) return null;
const launchId = button.dataset.missionRosterId;
return launchId ? { launchId } : null;
}
/** Restore focus by mission identity, or continue after the list if it departed. */
export function restoreMissionRosterFocus(list, snapshot, continuation) {
if (!snapshot?.launchId) return 'none';
const button = Array.from(list?.querySelectorAll?.('[data-mission-roster-id]') || [])
.find((candidate) => candidate.dataset.missionRosterId === snapshot.launchId);
if (button) {
button.focus({ preventScroll: true });
return 'restored';
}
if (continuation?.focus) {
continuation.focus({ preventScroll: true });
return 'continued';
}
return 'none';
}
/** Resolve a pending preview against the current refresh, never a stale object. */
export function resolveMissionRosterPreviewLaunch(launches, launchId) {
return (launches || []).find((candidate) => candidate.id === launchId) || null;
}
/**
* Format a mission epoch for compact on-globe replay labels.
* @param {string|Date|null} launchTime ISO-8601 mission time or Date.
@@ -2327,9 +2437,16 @@ function renderMissionRoster() {
const count = _missionRoster.querySelector('[data-mission-roster-count]');
if (count) count.textContent = `${_launches.length} / 30D`;
if (!list) return;
const focusSnapshot = captureMissionRosterFocus(list);
_missionRosterPreviewOwnership?.reset();
const entries = missionRosterEntries(_launches);
if (!entries.length) {
list.innerHTML = '<div class="space-mission-roster-empty">NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW</div>';
restoreMissionRosterFocus(
list,
focusSnapshot,
_missionRoster.querySelector('[data-mission-roster-focus-continuation]'),
);
return;
}
list.innerHTML = entries.map(({ launch, index }) => {
@@ -2337,8 +2454,22 @@ function renderMissionRoster() {
const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE';
const provider = launch.provider || 'UNSPECIFIED OPERATOR';
const label = shortMissionLabel(launch.name, 27).toUpperCase();
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
}).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);
+149
View File
@@ -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)');
+86
View File
@@ -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/);
});
+2
View File
@@ -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
+153 -5
View File
@@ -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('<div id="location-bar"'), markup.indexOf('<div id="left-panel-stack"'));
const locationToggleMarkup = locationMarkup.match(/<button\b([^>]*\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], /<button\b/);
assert.match(locationMarkup.slice(locationToggleMarkup.index + locationToggleMarkup[0].length),
/^\s*<button class="panel-collapse-btn" data-collapse-target="location-bar"/);
assert.match(locationMarkup, /<div id="location-bar-popover" class="dock-popover-content">/);
});
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();
+169
View File
@@ -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',
);
});
+201 -58
View File
@@ -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;
+156 -32
View File
@@ -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 } = {}) {
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="Estimated session cost">~$0.00</span>
</div>
</div>
<button id="gev-voice-button" type="button" aria-label="Voice control — hold Space to speak; click to toggle voice" aria-describedby="gev-voice-help">
<button id="gev-voice-button" type="button" aria-label="Voice control — activate to toggle voice; hold Space to speak" aria-describedby="gev-voice-help">
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
<span class="gev-mic-label">ON/OFF</span>
</button>
@@ -2571,7 +2695,7 @@ function createVoiceControl({ reset = false } = {}) {
</div>
<div id="gev-voice-help" class="gev-voice-help-tray" role="tooltip">
<span class="gev-voice-help-kicker">VOICE CONTROL</span>
<span class="gev-voice-help-detail">Hold Space to speak · click mic to toggle voice</span>
<span class="gev-voice-help-detail">Hold Space to speak · tap Space to activate focused controls</span>
</div>
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
<div class="gev-voice-error-header">
+427 -4
View File
@@ -12,7 +12,11 @@ import {
GevRealtimeController,
gateVoiceVisualizerLevel,
isBenignViewportDeleteError,
isEditingSpaceTarget,
isInteractiveSpaceTarget,
isPushToTalkKey,
isPushToTalkSurface,
PUSH_TO_TALK_HOLD_DELAY_MS,
resolveVoiceControlHint,
resolveVoiceVisualizerSpeaker,
selectVoiceVisualizerSignal,
@@ -30,23 +34,442 @@ import {
import { createVoiceCostTracker } from './voiceCost.js';
test('push-to-talk recognizes Space by code or key', () => {
assert.equal(PUSH_TO_TALK_HOLD_DELAY_MS, 500);
assert.equal(isPushToTalkKey({ code: 'Space', key: 'Unidentified' }), true);
assert.equal(isPushToTalkKey({ code: '', key: ' ' }), true);
assert.equal(isPushToTalkKey({ code: 'KeyM', key: 'm' }), false);
});
test('push-to-talk ignores typing targets and modified shortcuts', () => {
test('push-to-talk protects text entry but arbitrates non-editing controls', () => {
const plainTarget = { isContentEditable: false, closest: () => null };
assert.equal(shouldHandlePushToTalkKeyDown({ code: 'Space', target: plainTarget }), true);
assert.equal(shouldHandlePushToTalkKeyDown({ code: 'Space', target: plainTarget, metaKey: true }), false);
assert.equal(shouldHandlePushToTalkKeyDown({
code: 'Space',
target: { isContentEditable: false, closest: () => ({ tagName: 'INPUT' }) },
target: pushToTalkTarget({ tagName: 'INPUT', type: 'text' }),
}), false);
assert.equal(shouldHandlePushToTalkKeyDown({
code: 'Space',
target: { isContentEditable: true, closest: () => null },
}), false);
assert.equal(shouldHandlePushToTalkKeyDown({
code: 'Space',
target: pushToTalkTarget({ tagName: 'BUTTON' }),
}), true);
assert.equal(isEditingSpaceTarget(pushToTalkTarget({ tagName: 'INPUT', type: 'range' })), false);
});
// Dispatch through the installed document/window listeners, keeping WebRTC at
// the start boundary. Native button activation itself belongs to browser QA.
function createPushToTalkFixture(t) {
const savedGlobals = new Map(['document', 'window', 'setTimeout', 'clearTimeout'].map((name) => (
[name, Object.getOwnPropertyDescriptor(globalThis, name)]
)));
const eventHub = () => {
const listeners = new Map();
return {
addEventListener(type, listener) {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(listener);
},
removeEventListener(type, listener) { listeners.get(type)?.delete(listener); },
dispatch(type, event = {}) {
for (const listener of listeners.get(type) || []) listener(event);
return event;
},
count(type) { return listeners.get(type)?.size || 0; },
};
};
let clock = 0;
let nextTimerId = 1;
const timers = new Map();
const setTimeout = (callback, delay = 0) => {
const id = nextTimerId++;
timers.set(id, { callback, due: clock + Number(delay || 0) });
return id;
};
const clearTimeout = (id) => timers.delete(id);
const advance = (milliseconds) => {
const end = clock + milliseconds;
while (true) {
const due = [...timers.entries()]
.filter(([, timer]) => timer.due <= end)
.sort((a, b) => a[1].due - b[1].due || a[0] - b[0])[0];
if (!due) break;
const [id, timer] = due;
timers.delete(id);
clock = timer.due;
timer.callback();
}
clock = end;
};
const document = { ...eventHub(), visibilityState: 'visible', activeElement: null };
const window = eventHub();
Object.assign(globalThis, { document, window, setTimeout, clearTimeout });
const starts = [];
const radio = [];
const controllers = [];
const makeController = () => {
const microphone = { enabled: false, stopped: false, stop() { this.stopped = true; } };
const controller = new GevRealtimeController({
ui: {
root: { dataset: {}, querySelectorAll: () => [], remove() {} },
status: {}, detail: {},
},
runner: async () => ({}),
radioLayer: {
pause: () => radio.push('pause'),
setVoiceDucked: (ducked) => radio.push(ducked ? 'duck' : 'unduck'),
},
});
controller.debugLog = () => {};
controller.start = ({ pushToTalk }) => {
starts.push({ pushToTalk });
controller.pushToTalkMode = pushToTalk;
controller.status = 'listening';
controller.stream = {
getAudioTracks: () => [microphone],
getTracks: () => [microphone],
};
controller.setMicrophoneEnabled(true);
};
controllers.push(controller);
controller.bindPushToTalkShortcut();
return { controller, microphone };
};
t.after(() => {
for (const controller of controllers) controller.stop({ removeUi: true });
for (const [name, descriptor] of savedGlobals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete globalThis[name];
}
});
const key = (type, target, options = {}) => {
document.activeElement = options.activeElement === undefined ? target : options.activeElement;
return document.dispatch(type, {
code: 'Space', key: ' ', target, defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; },
...options,
});
};
return { advance, document, window, starts, radio, key, makeController, timers, ...makeController() };
}
function pushToTalkTarget({
tagName = null,
id = null,
style = null,
parentElement = null,
role = null,
tabIndex = null,
href = null,
controls = false,
type = null,
contentEditable = false,
mapSurface = false,
} = {}) {
const resolvedTagName = (tagName || (style ? 'BUTTON' : 'DIV')).toUpperCase();
return {
tagName: resolvedTagName,
id,
style,
parentElement,
role,
tabIndex,
href,
controls,
type,
blurred: 0,
blur() { this.blurred += 1; },
isContentEditable: contentEditable,
closest(selector) {
for (let node = this; node; node = node.parentElement) {
if (selector === '#cesiumContainer' && (node.id === 'cesiumContainer' || node.mapSurface)) {
return node;
}
for (const part of selector.split(/,\s*/)) {
if (part.toUpperCase() === node.tagName) return node;
if (part === 'a[href]' && node.tagName === 'A' && node.href !== null) return node;
if (part === 'audio[controls]' && node.tagName === 'AUDIO' && node.controls) return node;
if (part === 'video[controls]' && node.tagName === 'VIDEO' && node.controls) return node;
if (part === '[contenteditable]' && node.isContentEditable) return node;
if (part === '[tabindex]' && node.tabIndex !== null) return node;
const roleMatch = part.match(/^\[role="([^"]+)"\]$/);
if (roleMatch && node.role === roleMatch[1]) return node;
}
}
return null;
},
mapSurface,
};
}
test('push-to-talk starts from Cesium canvases and the document background', (t) => {
const f = createPushToTalkFixture(t);
const cesiumContainer = pushToTalkTarget({ tagName: 'DIV', id: 'cesiumContainer' });
const cesiumCanvas = pushToTalkTarget({
tagName: 'CANVAS',
tabIndex: 0,
parentElement: cesiumContainer,
});
const overlayCanvas = pushToTalkTarget({
tagName: 'CANVAS',
id: 'world-overlay-canvas',
tabIndex: 0,
});
for (const [index, canvas] of [cesiumCanvas, overlayCanvas].entries()) {
assert.equal(isPushToTalkSurface(canvas), true);
assert.equal(isInteractiveSpaceTarget(canvas), false);
assert.equal(f.key('keydown', canvas).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.starts.length, 1, 'the first map hold opens one reusable voice session');
assert.equal(f.controller.pushToTalkKeyHeld, true);
assert.equal(f.microphone.enabled, true, `map hold ${index + 1} enables the microphone`);
assert.equal(f.key('keyup', canvas).defaultPrevented, true);
assert.equal(f.microphone.enabled, false, `map release ${index + 1} mutes the microphone`);
}
const background = pushToTalkTarget();
assert.equal(f.key('keydown', background).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.controller.pushToTalkKeyHeld, true);
assert.equal(f.key('keyup', background).defaultPrevented, true);
});
test('short Space on a focused control preserves release activation', (t) => {
const f = createPushToTalkFixture(t);
const button = pushToTalkTarget({ tagName: 'BUTTON' });
let activations = 0;
assert.equal(f.key('keydown', button).defaultPrevented, false);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
const release = f.key('keyup', button);
if (!release.defaultPrevented) activations += 1;
assert.equal(release.defaultPrevented, false);
assert.equal(activations, 1);
assert.equal(button.blurred, 0);
assert.deepEqual(f.starts, []);
});
test('long Space blurs a focused control before voice and consumes release', (t) => {
const f = createPushToTalkFixture(t);
const button = pushToTalkTarget({ tagName: 'BUTTON' });
const order = [];
button.blur = () => {
button.blurred += 1;
order.push('blur');
f.document.activeElement = null;
};
f.controller.pauseRadioForVoice = () => order.push('voice');
assert.equal(f.key('keydown', button).defaultPrevented, false);
assert.equal(f.key('keydown', button, { repeat: true }).defaultPrevented, false);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.deepEqual(order, ['blur', 'voice']);
assert.equal(button.blurred, 1);
assert.equal(f.controller.pushToTalkKeyHeld, true);
assert.equal(f.key('keydown', button, { repeat: true, activeElement: null }).defaultPrevented, true);
let activations = 0;
const release = f.key('keyup', button, { activeElement: null });
if (!release.defaultPrevented) activations += 1;
assert.equal(release.defaultPrevented, true);
assert.equal(activations, 0);
assert.equal(f.microphone.enabled, false);
});
test('push-to-talk still ignores modified, already-handled and typing keydowns', (t) => {
const f = createPushToTalkFixture(t);
const target = pushToTalkTarget();
for (const modifier of ['altKey', 'ctrlKey', 'metaKey', 'shiftKey']) {
assert.equal(f.key('keydown', target, { [modifier]: true }).defaultPrevented, false);
}
f.key('keydown', target, { defaultPrevented: true });
f.key('keydown', target, { code: 'Enter', key: 'Enter' });
for (const tagName of ['INPUT', 'TEXTAREA']) f.key('keydown', pushToTalkTarget({ tagName }));
f.key('keydown', pushToTalkTarget({ role: 'textbox' }));
f.key('keydown', { isContentEditable: true });
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
assert.equal(f.controller.spaceKeyHeld, false);
});
test('push-to-talk starts once at 500ms and repeat does not reset the threshold', (t) => {
const f = createPushToTalkFixture(t);
const background = pushToTalkTarget();
assert.equal(f.key('keydown', background).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, true);
f.advance(1);
assert.deepEqual(f.starts, [{ pushToTalk: true }]);
assert.equal(f.controller.pushToTalkKeyHeld, true);
assert.equal(f.microphone.enabled, true);
const radioAfterStart = f.radio.slice();
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS * 2);
assert.deepEqual(f.radio, radioAfterStart);
assert.equal(f.starts.length, 1);
assert.equal(
f.key('keyup', pushToTalkTarget({ tagName: 'BUTTON' })).defaultPrevented,
true,
'release follows a voice-owned hold after focus moves',
);
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.microphone.enabled, false);
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
assert.equal(f.controller.ui.detail.textContent, 'Hold Space to talk');
});
test('push-to-talk treats a Space release before 500ms as a background tap', (t) => {
const f = createPushToTalkFixture(t);
const background = pushToTalkTarget();
assert.equal(f.key('keydown', background).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
assert.equal(f.key('keyup', background).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.timers.size, 0);
});
test('push-to-talk rechecks focus before the hold threshold claims voice', (t) => {
const f = createPushToTalkFixture(t);
const background = pushToTalkTarget();
const control = pushToTalkTarget({ tagName: 'BUTTON' });
assert.equal(f.key('keydown', background).defaultPrevented, true);
f.document.activeElement = control;
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
assert.equal(f.key('keyup', control).defaultPrevented, true, 'release follows the background gesture owner');
assert.equal(f.controller.spaceKeyHeld, false);
});
test('push-to-talk never claims a control-started hold after focus moves outside', (t) => {
const f = createPushToTalkFixture(t);
const control = pushToTalkTarget({ tagName: 'BUTTON' });
const background = pushToTalkTarget();
assert.equal(f.key('keydown', control).defaultPrevented, false);
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, false);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
assert.equal(f.key('keyup', background).defaultPrevented, false);
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
});
test('push-to-talk cancels pending and active holds on blur or hidden document', (t) => {
const f = createPushToTalkFixture(t);
const background = pushToTalkTarget();
for (const exit of ['blur', 'hidden']) {
const startsBeforePending = f.starts.length;
const radioBeforePending = f.radio.length;
f.key('keydown', background);
const staleCallback = [...f.timers.values()][0].callback;
if (exit === 'blur') f.window.dispatch('blur');
else {
f.document.visibilityState = 'hidden';
f.document.dispatch('visibilitychange');
}
staleCallback();
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.starts.length, startsBeforePending);
assert.equal(f.radio.length, radioBeforePending);
assert.equal(f.controller.spaceKeyHeld, false);
f.document.visibilityState = 'visible';
f.key('keydown', background);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.microphone.enabled, true);
if (exit === 'blur') f.window.dispatch('blur');
else {
f.document.visibilityState = 'hidden';
f.document.dispatch('visibilitychange');
}
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.microphone.enabled, false);
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
assert.equal(f.key('keyup', background).defaultPrevented, false);
f.document.visibilityState = 'visible';
}
});
test('push-to-talk timer refuses hidden or unfocused documents before lifecycle events arrive', (t) => {
const f = createPushToTalkFixture(t);
const background = pushToTalkTarget();
for (const state of ['hidden', 'unfocused']) {
f.key('keydown', background);
if (state === 'hidden') f.document.visibilityState = 'hidden';
else f.document.hasFocus = () => false;
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
assert.equal(f.key('keyup', background).defaultPrevented, true);
f.document.visibilityState = 'visible';
delete f.document.hasFocus;
}
});
test('focused controls and background Space preserve a click-started open mic', (t) => {
const f = createPushToTalkFixture(t);
f.controller.start({ pushToTalk: false });
f.starts.length = 0;
const style = pushToTalkTarget({ style: 'retro' });
f.key('keydown', style);
f.key('keydown', style, { repeat: true });
assert.equal(f.key('keyup', style).defaultPrevented, false);
assert.deepEqual(f.radio, []);
assert.equal(f.microphone.enabled, true);
assert.equal(f.controller.pushToTalkMode, false);
assert.equal(f.key('keydown', pushToTalkTarget()).defaultPrevented, true);
assert.equal(f.controller.spaceKeyHeld, true);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.timers.size, 0);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
assert.equal(f.key('keyup', style).defaultPrevented, true);
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.microphone.enabled, true, 'an outside Space gesture must not claim or mute an open-mic session');
assert.deepEqual(f.starts, []);
});
test('push-to-talk teardown removes installed listeners and replacement binds once', (t) => {
const f = createPushToTalkFixture(t);
const outside = pushToTalkTarget();
f.controller.bindPushToTalkShortcut();
assert.equal(f.document.count('keydown'), 1);
f.key('keydown', outside);
const staleCallback = [...f.timers.values()][0].callback;
f.controller.stop({ removeUi: true });
staleCallback();
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.microphone.stopped, false);
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.controller.pushToTalkMode, false);
assert.equal(f.document.count('keydown'), 0);
assert.equal(f.document.count('keyup'), 0);
assert.equal(f.document.count('visibilitychange'), 0);
assert.equal(f.window.count('blur'), 0);
f.key('keydown', outside);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.starts.length, 0, 'the removed controller must not respond');
const replacement = f.makeController();
replacement.controller.bindPushToTalkShortcut();
assert.equal(f.document.count('keydown'), 1);
assert.equal(f.document.count('keyup'), 1);
assert.equal(f.document.count('visibilitychange'), 1);
assert.equal(f.window.count('blur'), 1);
f.key('keydown', pushToTalkTarget({ tagName: 'INPUT', type: 'text' }));
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.starts.length, 0);
f.key('keydown', outside);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.starts.length, 1);
assert.equal(replacement.microphone.enabled, true);
f.key('keyup', outside);
assert.equal(replacement.microphone.enabled, false);
});
test('mic clicks are ignored while Space is physically held', () => {
@@ -57,12 +480,12 @@ test('mic clicks are ignored while Space is physically held', () => {
test('voice control help tray reflects the push-to-talk key state', () => {
assert.equal(
resolveVoiceControlHint(false, false),
'Hold Space to speak · click mic to toggle voice',
'Hold Space to speak · tap Space to activate focused controls',
);
assert.equal(resolveVoiceControlHint(true, true), 'Release Space to send');
assert.equal(
resolveVoiceControlHint(true, false),
'Hold Space to speak · click mic to toggle voice',
'Hold Space to speak · tap Space to activate focused controls',
);
});
+182 -19
View File
@@ -36,6 +36,39 @@
box-sizing: border-box;
}
/* Keyboard focus is an app-wide state and must survive selected/active button
skins. Individual controls can choose an inset/outset offset, but no local
`outline: none` rule may erase the ring. */
:where(
button,
a[href],
input,
select,
textarea,
summary,
[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']
):focus-visible {
outline: 2px solid var(--text-primary) !important;
outline-offset: 2px;
}
html, body {
width: 100%;
height: 100%;
@@ -703,7 +736,10 @@ body.cockpit-mode #cockpit-cloud-effects.active {
font-size: 13px;
line-height: 1;
cursor: pointer;
transition: all var(--transition-fast);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.panel-collapse-btn:hover {
@@ -739,11 +775,23 @@ body.cockpit-mode #cockpit-cloud-effects.active {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--btn-radius);
cursor: pointer;
transition: all var(--transition-fast);
/* Keep the keyboard focus outline immediate. */
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
overflow: hidden;
outline: none;
}
/* Keep keyboard focus visible inside the scrolling preset row. */
.style-btn:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
.style-btn::before {
content: '';
position: absolute;
@@ -788,7 +836,9 @@ body.cockpit-mode #cockpit-cloud-effects.active {
font-size: 20px;
line-height: 1;
color: var(--text-secondary);
transition: all var(--transition-fast);
transition:
color var(--transition-fast),
text-shadow var(--transition-fast);
}
.btn-label {
@@ -915,7 +965,12 @@ body.cockpit-mode #cockpit-cloud-effects.active {
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
cursor: pointer;
transition: all var(--transition-fast);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast),
border-radius var(--transition-fast);
outline: none;
}
@@ -1056,10 +1111,6 @@ body.cockpit-mode #cockpit-cloud-effects.active {
display: flex;
}
.pp-toggle-group .pp-toggle-btn {
transition: all var(--transition-fast), border-radius var(--transition-fast);
}
.pp-toggle-group .pp-slider-row.visible ~ .pp-toggle-btn,
.pp-toggle-group:has(.pp-slider-row.visible) .pp-toggle-btn {
border-radius: 8px 8px 0 0;
@@ -1372,12 +1423,31 @@ body.cockpit-mode #cockpit-cloud-effects.active {
letter-spacing: 0.6px;
text-transform: uppercase;
cursor: pointer;
transition: all var(--transition-fast);
transition: background-color var(--transition-fast), color var(--transition-fast);
}
.pp-mode-btn + .pp-mode-btn { border-left: 1px solid rgba(255, 255, 255, 0.14); }
.pp-mode-btn:hover { background: rgba(255, 255, 255, 0.08); color: var(--text-primary); }
.pp-mode-btn.active { background: var(--accent); color: #000; }
/* These controls also move into Cockpit Display; keep focus on the control
itself and inside clipped button groups, independent of selection color. */
.pp-toggle-btn:focus-visible,
.pp-select:focus-visible,
.pp-mode-btn:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
.pp-mode-btn:focus-visible {
box-shadow: inset 0 0 0 4px #061014;
}
.pp-slider:focus-visible,
.param-slider:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: 4px;
}
#clean-view-toggle {
width: var(--pp-expanded-width);
}
@@ -1695,7 +1765,12 @@ body.ui-clean-view #scene-runtime.active {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
cursor: pointer;
transition: all var(--transition-fast);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
outline: none;
white-space: nowrap;
font-family: var(--font-sans);
@@ -1705,6 +1780,14 @@ body.ui-clean-view #scene-runtime.active {
letter-spacing: 0.3px;
}
.location-pill:focus-visible,
.poi-pill:focus-visible,
.search-toggle-btn:focus-visible,
#location-search:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
.location-pill:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--glass-border-hover);
@@ -1767,7 +1850,12 @@ body.ui-clean-view #scene-runtime.active {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
cursor: pointer;
transition: all var(--transition-fast);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
outline: none;
white-space: nowrap;
flex-shrink: 0;
@@ -1831,7 +1919,11 @@ body.ui-clean-view #scene-runtime.active {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 50%;
cursor: pointer;
transition: all var(--transition-fast);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast);
outline: none;
font-size: 14px;
flex-shrink: 0;
@@ -1895,7 +1987,12 @@ body.ui-clean-view #scene-runtime.active {
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
cursor: pointer;
transition: all var(--transition-fast);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast),
transform var(--transition-fast);
outline: none;
}
@@ -3389,7 +3486,8 @@ body.cockpit-mode #view-switcher button {
.cockpit-utility-glyph:focus-visible {
border-color: var(--glass-border-hover);
color: var(--text-primary);
outline: none;
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
.cockpit-utility-popover {
position: static;
@@ -4766,6 +4864,14 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
letter-spacing: .09em;
text-align: center;
}
#space-mission-panel-host .space-mission-roster-focus-continuation {
margin: 0;
padding: 3px;
color: rgba(190, 230, 230, .58);
font: 7px/1.4 'JetBrains Mono', monospace;
letter-spacing: .08em;
text-align: center;
}
#space-mission-panel .space-mission-view-header {
display: flex;
align-items: center;
@@ -5351,6 +5457,12 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
color: #fff;
outline: none;
}
/* Keep focus distinct when the selected tab owns the accent colors. */
.context-mode-button:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
.context-mode-button.active {
border-color: rgba(0, 212, 255, .75);
color: #eaffff;
@@ -5849,12 +5961,24 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
cursor: pointer;
transition: all var(--transition-fast);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast),
min-width var(--transition-fast),
letter-spacing var(--transition-fast);
outline: none;
min-width: 38px;
text-align: center;
}
/* An inset ring stays visible inside the scrolling layer list. */
.data-toggle-btn:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
.data-toggle-btn.feed-loading,
.data-toggle-btn.feed-degraded,
.data-toggle-btn.feed-stale,
@@ -5933,7 +6057,11 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 3px;
cursor: pointer;
transition: all var(--transition-fast);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast);
outline: none;
}
@@ -6141,6 +6269,11 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
outline: none;
}
#cctv-camera-select:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
#cctv-camera-select:disabled {
opacity: 0.45;
}
@@ -6480,7 +6613,11 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
letter-spacing: 1.2px;
padding: 6px 9px;
cursor: pointer;
transition: all var(--transition-fast);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast);
white-space: nowrap;
flex: 1;
min-width: 72px;
@@ -6574,7 +6711,10 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
letter-spacing: 0.8px;
padding: 4px 6px;
cursor: pointer;
transition: all var(--transition-fast);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.scene-shot-btn:hover {
@@ -7746,6 +7886,26 @@ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
justify-content: center;
}
#command-dock #location-bar .dock-tray-toggle {
display: flex;
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 0;
color: inherit;
background: transparent;
border: 0;
font: inherit;
cursor: pointer;
outline: none;
}
#command-dock #location-bar .dock-tray-toggle:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: 3px;
border-radius: 3px;
}
#command-dock #control-panel .dock-tray-toggle {
box-sizing: border-box;
width: 100%;
@@ -8193,7 +8353,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(#intel-hud[data-variant='minim
transform-origin: bottom left;
transition:
opacity 180ms ease,
visibility 180ms ease,
visibility 0s linear 180ms,
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@@ -8215,6 +8375,9 @@ body:not(.ui-clean-view):not(.recording-mode):has(#intel-hud[data-variant='minim
pointer-events: auto;
transform: translateY(0) scale(1);
margin-bottom: 10px;
/* Make newly revealed controls enter the Tab order immediately. The base
rule still delays visibility on close so its opacity fade can finish. */
transition-delay: 0s;
}
#command-dock #location-bar.collapsed .dock-popover-content,