mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
23 files changed
+3786
-303
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'\)/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -982,6 +982,43 @@ function navigationState() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable identity for a delegated Contacts-panel button across live repaints. */
|
||||
export function awarenessPanelControlKey(element) {
|
||||
const control = element?.closest?.(
|
||||
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||
);
|
||||
if (!control) return null;
|
||||
if (control.dataset.awarenessAction) return `action:${control.dataset.awarenessAction}`;
|
||||
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
|
||||
}
|
||||
|
||||
/** Capture stable identity so a live repaint can restore only the same control. */
|
||||
export function captureAwarenessPanelFocus(panel, activeElement = document.activeElement) {
|
||||
if (!panel?.contains?.(activeElement)) return null;
|
||||
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
|
||||
return { key: 'continuation' };
|
||||
}
|
||||
const key = awarenessPanelControlKey(activeElement);
|
||||
if (!key) return null;
|
||||
return { key };
|
||||
}
|
||||
|
||||
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
|
||||
export function restoreAwarenessPanelFocus(panel, snapshot) {
|
||||
if (!panel || !snapshot) return null;
|
||||
const continuation = panel.querySelector('[data-awareness-focus-continuation]');
|
||||
const controls = [...panel.querySelectorAll(
|
||||
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||
)].filter((control) => !control.disabled);
|
||||
const retained = snapshot.key === 'continuation'
|
||||
? continuation
|
||||
: controls.find((control) => awarenessPanelControlKey(control) === snapshot.key);
|
||||
const target = retained
|
||||
|| continuation;
|
||||
target?.focus?.({ preventScroll: true });
|
||||
return target || null;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[char]);
|
||||
}
|
||||
@@ -993,11 +1030,13 @@ function renderResults() {
|
||||
const markup = `<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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user