From 56d52e284f3fba88d572262eb0db863073782c53 Mon Sep 17 00:00:00 2001 From: Tom-Neverwinter <3943634+Tom-Neverwinter@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:22:46 -0500 Subject: [PATCH 1/6] Focus the Map Source tray when it is actually visible, not 240ms later (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `qa:map-source-tray` failed on a clean checkout at "Enter opens the tray and hands focus to a Map Source tile". The gate was right: this is a real keyboard-accessibility bug, not a flaky assertion. Opening the tray schedules the focus hand-off to a Map Source tile 240ms later (scheduleMapSourceFocus). The tray itself fades in behind a 180ms `visibility` transition on .dock-popover-content, and a chip inside it cannot take focus until that transition lands. Those two timers race. When the machine is slow enough that the fade has not finished at the 240ms mark, `chip.focus()` is called on a `visibility: hidden` element — which is a SILENT no-op — and nothing retries. The tray sits open with keyboard focus stranded on the disclosure, unreachable. Instrumenting HTMLElement.prototype.focus during a failing run showed exactly that: focus() called once on the photoreal chip, the chip connected and not disabled, computed visibility `hidden`, activeElement unchanged. Fix the hand-off rather than the delay: - focusMapSource now reports whether focus actually landed (activeElement === chip) instead of assuming .focus() worked. - scheduleMapSourceFocus retries on a 30ms cadence until it lands, bounded to ~720ms past the first attempt so a permanently hidden tray cannot spin. Also stop the QA gate racing the same timers. Its keyboard checks slept a fixed 300ms and asserted focus had already moved; they now wait on controller truth (aria-expanded plus where focus landed), the way the Bing-switch check already did. That replaces four fixed sleeps, not just the long-Enter-hold one the issue named. The wait only settles state — the check() below each one is still the assertion, so a swallowed timeout surfaces as that check failing with real values rather than an opaque puppeteer error. Verified against a preview build: `qa:map-source-tray` goes from 3 failures to 20/20 passing, twice in a row. Unit suite unchanged (2674 passing). The #44 half of the original work is intentionally dropped: upstream's track-regression.mjs already fixed the ground-3d sampleHeight floor, and more thoroughly — it pins no absolute call count at all and adds a guard against a timed-out driver falsely looking flat. Closes #54. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q6VRsPswjNtZfv4jzgkCUf --- scripts/qa-map-source-tray.mjs | 33 +++++++++++++++++++++++++++++---- src/ui.js | 28 +++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 0071475a3..9826b952e 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -190,9 +190,32 @@ try { JSON.stringify(esriTileFailureFallback), ); + // The tray's state changes are timer-driven: opening schedules the focus + // hand-off to a Map Source tile 240ms later (scheduleMapSourceFocus in + // ui.js). A fixed sleep races that timer rather than observing it settle — + // 300ms left only 60ms of margin, which is what made these checks flake on + // a clean checkout (#54). Wait on controller truth instead, the way the + // Bing-switch check further down already does. + // The wait only settles state; the check() below it is still the assertion, + // so a swallowed timeout surfaces as that check failing with real values + // rather than as an opaque puppeteer error. + const waitTray = (wantExpanded, wantFocus) => page.waitForFunction( + (expanded, focus) => { + const toggle = document.getElementById('control-panel-toggle'); + if (toggle?.getAttribute('aria-expanded') !== expanded) return false; + if (!focus) return true; + const active = document.activeElement; + return focus === 'toggle' + ? active?.id === 'control-panel-toggle' + : active?.dataset?.stackId === focus; + }, + { timeout: 2000 }, + wantExpanded, wantFocus, + ).catch(() => {}); + await page.focus('#control-panel-toggle'); await page.keyboard.press('Enter'); - await new Promise((resolve) => setTimeout(resolve, 300)); + await waitTray('true', 'photoreal'); const keyboardOpen = await page.evaluate(() => ({ expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'), activeStack: document.activeElement?.dataset?.stackId || null, @@ -204,7 +227,7 @@ try { ); await page.keyboard.press('Escape'); - await new Promise((resolve) => setTimeout(resolve, 40)); + await waitTray('false', 'toggle'); const keyboardClose = await page.evaluate(() => ({ expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'), activeId: document.activeElement?.id || null, @@ -216,7 +239,7 @@ try { ); await page.keyboard.press('Space'); - await new Promise((resolve) => setTimeout(resolve, 300)); + await waitTray('true', 'photoreal'); const spaceOpen = await page.evaluate(() => ({ expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'), activeStack: document.activeElement?.dataset?.stackId || null, @@ -231,9 +254,11 @@ try { await page.keyboard.down('Enter'); await new Promise((resolve) => setTimeout(resolve, 320)); await page.keyboard.up('Enter'); + await waitTray('true', 'photoreal'); // let the hold's scheduled focus hand-off land await page.keyboard.press('Escape'); + await waitTray('false', 'toggle'); await page.keyboard.press('Enter'); - await new Promise((resolve) => setTimeout(resolve, 300)); + await waitTray('true', 'photoreal'); const longHoldRecovery = await page.evaluate(() => ({ expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'), activeStack: document.activeElement?.dataset?.stackId || null, diff --git a/src/ui.js b/src/ui.js index 668e752d4..ab9d7bd4c 100644 --- a/src/ui.js +++ b/src/ui.js @@ -4195,16 +4195,34 @@ export class StyleManager { }); const focusMapSource = () => { - if (panelId !== 'control-panel') return; - panelEl.querySelector('.map-stack-chip.active, .map-stack-chip')?.focus?.({ preventScroll: true }); + if (panelId !== 'control-panel') return false; + const chip = panelEl.querySelector('.map-stack-chip.active, .map-stack-chip'); + if (!chip?.focus) return false; + chip.focus({ preventScroll: true }); + // .focus() on a still-hidden element is a SILENT no-op, so the caller + // has to check whether focus actually landed rather than assume it did. + return document.activeElement === chip; }; + // The tray opens behind a 180ms `visibility` transition (.dock-popover-content + // in style.css), and a chip inside it cannot take focus until that lands. + // A single fixed delay therefore races the transition: when the machine is + // slow enough that the fade has not finished by the time the timer fires, + // focus() silently does nothing and the keyboard user is stranded on the + // disclosure with an open tray they cannot reach (#54). Retry on a short + // cadence until focus actually lands, bounded so a permanently hidden tray + // cannot spin. const scheduleMapSourceFocus = () => { clearTimeout(disclosureFocusTimer); - disclosureFocusTimer = window.setTimeout(() => { + let attempts = 0; + const attemptFocus = () => { disclosureFocusTimer = null; - if (!panelEl.classList.contains('collapsed')) focusMapSource(); - }, 240); + if (panelEl.classList.contains('collapsed')) return; + if (focusMapSource()) return; + if (++attempts > 24) return; // ~720ms past the first try, then give up + disclosureFocusTimer = window.setTimeout(attemptFocus, 30); + }; + disclosureFocusTimer = window.setTimeout(attemptFocus, 240); }; const toggleDisclosure = ({ focusSource = false } = {}) => { From 6c49c2aa227e2fc2f215a226ddd965808f5cf436 Mon Sep 17 00:00:00 2001 From: Tom-Neverwinter <3943634+Tom-Neverwinter@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:10:05 -0500 Subject: [PATCH 2/6] fix(qa): de-flake the Esri fallback check's fixed 50ms DOM settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last fixed sleep in this harness: after the controller commits `activeId = 'osm'`, the fallback callback re-syncs the chips and Cesium drops the "Powered by Esri" credit on a later render frame again. A flat 50ms covered that most runs and not a slow one — observed failing once in 12 runs on Windows with `creditVisible: true` after the fallback. Same root cause and same remedy as the tray timers above: poll the observable truth (credit gone AND the pressed chip is osm), bounded at 3s, instead of guessing a duration. 0 failures in 15 consecutive runs after this, against 1-in-12 before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C3eGYgDYBo2BCUeDGwGvrp --- scripts/qa-map-source-tray.mjs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 9826b952e..76e88fb42 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -158,9 +158,18 @@ try { await new Promise((resolve) => setTimeout(resolve, 25)); } // The controller commits `activeId` before its fallback promise callback - // emits the terminal error state that re-syncs the chips. Give that - // callback one turn so the DOM assertion observes the completed contract. - await new Promise((resolve) => setTimeout(resolve, 50)); + // emits the terminal error state that re-syncs the chips, and Cesium drops + // the Esri credit on a later render frame again. A fixed delay races both: + // 50ms was enough most runs and not enough on a slow one, which is the same + // flake as the tray timers above (#54). Poll the observable truth instead. + const domDeadline = performance.now() + 3000; + const settled = () => !document.body.innerText.includes('Powered by Esri') + && [...document.querySelectorAll('.map-stack-chip')] + .filter((chip) => chip.getAttribute('aria-pressed') === 'true') + .every((chip) => chip.dataset.stackId === 'osm'); + while (!settled() && performance.now() < domDeadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } const afterTwo = { activeId: controller.getActiveId(), lastError: controller.getState().lastError, From ea344c952f0888a8777931ddd37337589755c888 Mon Sep 17 00:00:00 2001 From: Tom-Neverwinter <3943634+Tom-Neverwinter@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:01:32 -0500 Subject: [PATCH 3/6] fix(qa): don't pass the macOS-only Metal ANGLE backend on other platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six QA harnesses launched Chrome with `--use-angle=metal` unconditionally (or defaulted to it). Metal is a macOS-only ANGLE backend: on Windows and Linux it makes WebGL initialization fail outright, so CesiumWidget never constructs, the app never boots, and the harness dies at its `waitForFunction` boot wait — before running a single assertion: Error constructing CesiumWidget. RuntimeError: The browser supports WebGL, but initialization failed. TimeoutError: Waiting failed: 60000ms exceeded Affected: qa-map-source-tray, qa-cockpit-utility, qa-view-target-prewarm, qa-firstrun (unconditional), qa-cockpit-plates (GPU branch taken off-Mac too) and qa-floor-hold (`--angle` defaulted to metal everywhere). Fixed by adopting the platform check qa-flyroute-cinema.mjs already uses: real GPU via Metal on darwin, the portable swiftshader rasterizer elsewhere — which is what the other ~20 QA scripts already pass. No behavior change on macOS. Verified on Windows 11: `npm run qa:map-source-tray` went from 0 assertions (boot timeout) to all 20 checks running. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C3eGYgDYBo2BCUeDGwGvrp --- scripts/qa-cockpit-plates.mjs | 4 +++- scripts/qa-cockpit-utility.mjs | 8 +++++++- scripts/qa-firstrun.mjs | 5 ++++- scripts/qa-floor-hold.mjs | 3 ++- scripts/qa-map-source-tray.mjs | 10 +++++++++- scripts/qa-view-target-prewarm.mjs | 8 +++++++- 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/scripts/qa-cockpit-plates.mjs b/scripts/qa-cockpit-plates.mjs index d4804d754..117dd7471 100644 --- a/scripts/qa-cockpit-plates.mjs +++ b/scripts/qa-cockpit-plates.mjs @@ -222,7 +222,9 @@ async function main() { args: [ '--no-sandbox', '--disable-setuid-sandbox', - ...(SWIFTSHADER + // Metal is a macOS-only ANGLE backend; off-Mac the GPU branch has to fall + // back to the software rasterizer or WebGL init fails outright. + ...(SWIFTSHADER || process.platform !== 'darwin' ? ['--use-gl=angle', '--use-angle=swiftshader'] : ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']), '--disable-dev-shm-usage', diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 1d3e91aab..df7e24e63 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -20,7 +20,13 @@ const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candid const browser = await puppeteer.launch({ headless: headful ? false : 'new', ...(executablePath ? { executablePath } : {}), - args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'], + // Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init. + args: [ + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + '--no-sandbox', + ], }); const page = await browser.newPage(); const failures = []; diff --git a/scripts/qa-firstrun.mjs b/scripts/qa-firstrun.mjs index 3c65abab2..68a9f3fb5 100644 --- a/scripts/qa-firstrun.mjs +++ b/scripts/qa-firstrun.mjs @@ -491,7 +491,10 @@ async function main() { ...(executablePath ? { executablePath } : {}), args: [ '--no-sandbox', '--disable-setuid-sandbox', - '--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist', + // Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init. + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] + : ['--use-gl=angle', '--use-angle=swiftshader']), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ], diff --git a/scripts/qa-floor-hold.mjs b/scripts/qa-floor-hold.mjs index 9cdc85f80..9ae285ad2 100644 --- a/scripts/qa-floor-hold.mjs +++ b/scripts/qa-floor-hold.mjs @@ -37,7 +37,8 @@ import path from 'node:path'; const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const argv = Object.fromEntries(process.argv.slice(2) .map((a) => a.replace(/^--/, '').split('=')).map(([k, v]) => [k, v ?? true])); -const ANGLE = String(argv.angle || 'metal'); +// Metal is a macOS-only ANGLE backend; defaulting to it off-Mac fails WebGL init. +const ANGLE = String(argv.angle || (process.platform === 'darwin' ? 'metal' : 'swiftshader')); const HEADFUL = !!argv.headful; // Austin airport apron by default — the site qa-floor-verify pins, where the // mesh sits ~150 m above the geoid and burial is unmistakable. diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 76e88fb42..d93a5b053 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -29,7 +29,15 @@ fs.mkdirSync(shotsDir, { recursive: true }); const browser = await puppeteer.launch({ headless: headful ? false : 'new', executablePath, - args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'], + // Metal is a macOS-only ANGLE backend: passing it on Windows or Linux makes + // WebGL initialization fail outright, so Cesium never constructs and this + // harness dies at the boot wait before a single assertion runs. + args: [ + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + '--no-sandbox', + ], }); const page = await browser.newPage(); const failures = []; diff --git a/scripts/qa-view-target-prewarm.mjs b/scripts/qa-view-target-prewarm.mjs index c44cab207..289540d44 100644 --- a/scripts/qa-view-target-prewarm.mjs +++ b/scripts/qa-view-target-prewarm.mjs @@ -39,7 +39,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 = []; From 0584a1d71dcf5c90ab6e6c88d23dde1f1be6d656 Mon Sep 17 00:00:00 2001 From: Tom-Neverwinter <3943634+Tom-Neverwinter@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:41:17 -0500 Subject: [PATCH 4/6] fix: sanitize proxy error payloads, loopback dev-cctv, drop doc drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small, independently-verified issues from the audit backlog: - #262/#263: the celestrak, terrain-heights, adsbdb and launch-library proxies put raw `err.message` / raw upstream error bodies into HTTP responses, contradicting the sanitized-error property KNOWN-ISSUES.md documents. They now log the real error server-side (matching the GBFS and CCTV proxy convention) and return a generic message. - #265: scripts/dev-cctv.sh defaulted to HOST=0.0.0.0 while dev-fresh.sh and dev-secure.sh default to loopback. Now loopback, opt in with HOST=0.0.0.0. - #274: naturalEarthRegions.js's internal haversineKm took (lon, lat, lon, lat) while every other haversineKm in src/ takes (lat, lon, lat, lon). Same name, same signature shape, opposite order — a silent wrong-distance landmine. Reordered to match; the one call site was updated, so behavior is unchanged. - #270: removed CCTV_AUTO_CALIBRATE and CCTV_DRAPE_MESH from .env.example; no code anywhere reads them. Not done: deduplicating the ~10 haversine implementations into one shared utility. That is issue #188's scope and a far larger diff; removing the argument-order landmine is what makes the current state safe. Verified: npm test (2705 pass / 0 fail), npm run build green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C3eGYgDYBo2BCUeDGwGvrp --- .env.example | 2 -- CHANGELOG.md | 10 ++++++++++ scripts/dev-cctv.sh | 3 ++- src/data/naturalEarthRegions.js | 5 +++-- vite.config.js | 13 +++++++++---- 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 2a5479514..63cc90bf8 100644 --- a/.env.example +++ b/.env.example @@ -138,5 +138,3 @@ AISSTREAM_API_KEY= # CCTV_MAX_SOURCES=48 # overall cap on registered cameras # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force Austin-only sources -# CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag) -# CCTV_DRAPE_MESH=1 # drape frames onto 3D geometry (client flag) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa46..63cb46041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- `scripts/dev-cctv.sh` now binds to `localhost` by default, matching + `dev-fresh.sh` and `dev-secure.sh`. Set `HOST=0.0.0.0` to opt in to LAN + exposure. (#265) +- The Celestrak, terrain-heights, adsbdb, and launch-library proxies no longer + place raw exception text or upstream error bodies in HTTP responses; the real + error is logged server-side and the client gets a generic message. (#262, #263) +- `naturalEarthRegions.js`’s internal `haversineKm` takes `(lat, lon, lat, lon)` + like every sibling implementation instead of longitude-first. (#274) +- Dropped `CCTV_AUTO_CALIBRATE` and `CCTV_DRAPE_MESH` from `.env.example`; no + code reads them. (#270) - Mapped-site outages show their scheduled retry countdown and distinguish known Overpass rate limits, timeouts, and query failures. Search feedback no longer claims a refresh succeeded while the layer is unavailable or loading. diff --git a/scripts/dev-cctv.sh b/scripts/dev-cctv.sh index b69d84a14..339657af8 100755 --- a/scripts/dev-cctv.sh +++ b/scripts/dev-cctv.sh @@ -5,7 +5,8 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" PORT="${PORT:-4173}" -HOST="${HOST:-0.0.0.0}" +# Local-only by default; set HOST=0.0.0.0 explicitly to expose on the LAN. +HOST="${HOST:-localhost}" CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}" CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}" CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}" diff --git a/src/data/naturalEarthRegions.js b/src/data/naturalEarthRegions.js index 5929a8921..447cb1ba4 100644 --- a/src/data/naturalEarthRegions.js +++ b/src/data/naturalEarthRegions.js @@ -34,7 +34,8 @@ function ringAreaKm2(ring) { return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2); } -function haversineKm(lon1, lat1, lon2, lat2) { +/** Great-circle distance in km. Arg order matches every sibling: (lat, lon, lat, lon). */ +function haversineKm(lat1, lon1, lat2, lon2) { const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const h = Math.sin(dLat / 2) ** 2 @@ -147,7 +148,7 @@ function buildEntries(pack, kind) { polygons, areaKm2, bbox: [minLon, minLat, maxLon, maxLat], - bboxDiagonalKm: haversineKm(minLon, minLat, maxLon, maxLat), + bboxDiagonalKm: haversineKm(minLat, minLon, maxLat, maxLon), }); } return out; diff --git a/vite.config.js b/vite.config.js index 34dac5291..9b283704a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1645,7 +1645,8 @@ function celestrakProxy() { send(502, 'celestrak fetch failed and no cache available', 'NONE'); } } catch (err) { - send(500, `celestrak proxy error: ${err?.message || err}`, 'ERROR'); + console.error('[celestrak-proxy]', err?.message || String(err)); + send(500, 'celestrak proxy error', 'ERROR'); } }); }, @@ -1755,10 +1756,12 @@ function rocketLaunchesProxy() { send(res, 200, stale.body, 'STALE-ERROR'); return; } + // Upstream error bodies stay server-side; the client gets a generic message. + if (error?.upstreamBody) console.warn('[launch-library-proxy] upstream error body:', error.upstreamBody); send( res, Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus : 502, - error?.upstreamBody || JSON.stringify({ error: 'Launch Library 2 unavailable' }), + JSON.stringify({ error: 'Launch Library 2 unavailable' }), 'NONE', ); } @@ -2373,7 +2376,8 @@ function terrainHeightsProxy() { } send(outcome.status, outcome.body); } catch (err) { - send(500, { error: `terrain heights proxy error: ${err?.message || err}` }); + console.error('[terrain-heights-proxy]', err?.message || String(err)); + send(500, { error: 'terrain heights proxy error' }); } }); }, @@ -2494,7 +2498,8 @@ function adsbdbProxy() { } return send(404, { error: 'unknown endpoint' }); } catch (err) { - return send(500, { error: String(err?.message || err) }); + console.error('[adsbdb-proxy]', err?.message || String(err)); + return send(500, { error: 'adsbdb proxy error' }); } }); }, From 60812df34cda427d6526f315ffac941ea6b89a96 Mon Sep 17 00:00:00 2001 From: manjunath22466 Date: Thu, 10 Sep 2026 22:08:05 -0700 Subject: [PATCH 5/6] fix(a11y): keep Map Source focus on the selected source and cancel stale requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build on Tom-Neverwinter’s Map Source focus retry. Prefer the selected tile, and revoke delayed focus when the user moves focus, clicks, or closes the tray. --- src/mapSourceFocus.test.mjs | 232 ++++++++++++++++++++++++++++++++++++ src/ui.js | 33 ++++- 2 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 src/mapSourceFocus.test.mjs diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs new file mode 100644 index 000000000..4e4c56b64 --- /dev/null +++ b/src/mapSourceFocus.test.mjs @@ -0,0 +1,232 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +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 initStart = source.indexOf(' _initAutoHoverPanel('); +const initEnd = source.indexOf(' /**\n * Sets up drag-to-reposition', initStart); +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 + && closeStart >= 0 && closeEnd > closeStart); +assert.ok(syncStart >= 0 && syncEnd > syncStart); + +function harness({ hidden = false, selected = true, noChips = false } = {}) { + let now = 0; + let nextTimer = 0; + let focusCalls = 0; + const timers = new Map(); + const document = { activeElement: null }; + const makeNode = (id, attributes = {}) => { + const listeners = new Map(); + const classes = new Set(); + return { + id, listeners, classes, hovered: false, + setAttribute(name, value) { attributes[name] = String(value); }, + getAttribute(name) { return attributes[name] ?? null; }, + classList: { + contains: (name) => classes.has(name), + remove: (name) => classes.delete(name), + toggle(name, value) { if (value) classes.add(name); else classes.delete(name); }, + }, + addEventListener(type, callback) { + if (!listeners.has(type)) listeners.set(type, []); + listeners.get(type).push(callback); + }, + matches(selector) { return selector === ':hover' ? this.hovered : true; }, + closest() { return this.isDisclosure ? this : null; }, + }; + }; + const panel = makeNode('control-panel'); + const location = makeNode('location-bar'); + panel.classes.add('collapsed'); + location.classes.add('collapsed'); + const disclosure = makeNode('control-panel-toggle'); + disclosure.isDisclosure = true; + const first = makeNode('photoreal'); + const active = makeNode('osm'); + const elsewhere = makeNode('elsewhere'); + const nodes = [panel, location, disclosure, 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); + 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' }; + if (noChips) return null; + if (selector === '.map-stack-chip.active') return selected ? active : null; + return selector === '.map-stack-chip' ? first : null; + }; + location.querySelector = (selector) => { + if (selector.split(',').some((part) => part.trim() === '.location-toolbar-label')) return { textContent: 'LOCATION' }; + return null; + }; + panel.querySelectorAll = location.querySelectorAll = () => []; + const emit = (node, type, values = {}) => { + assert.ok(node, 'the event target exists in the actual panel markup'); + const event = { + target: node, defaultPrevented: false, propagationStopped: false, + preventDefault() { this.defaultPrevented = true; }, + stopPropagation() { this.propagationStopped = true; }, + ...values, + }; + for (const callback of node.listeners.get(type) || []) callback(event); + return event; + }; + const focus = (node) => { + const prior = document.activeElement; + if (prior === node) return; + document.activeElement = node; + for (const owner of [panel, location]) { + if (owner.contains(prior)) emit(owner, 'focusout', { target: prior, relatedTarget: node }); + if (owner.contains(node)) emit(owner, 'focusin', { target: node, relatedTarget: prior }); + } + }; + disclosure.focus = () => focus(disclosure); + for (const chip of [first, active]) { + chip.focus = () => { focusCalls += 1; if (!hidden) focus(chip); }; + } + const window = { + 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)}});`)( + document, window, (id) => timers.delete(id), { now: () => now }, () => {}, + ); + const saves = []; + const claims = []; + let shareSyncs = 0; + const manager = { + ...methods, + _savePanelCollapsedState(...args) { saves.push(args); }, + _scheduleLeftPanelLayout() {}, _scheduleRightPanelLayout() {}, + shareLinkManager: { + claimRestoreLane(...args) { claims.push(args); }, + onPanelStateChange() { shareSyncs += 1; }, + }, + }; + manager._initAutoHoverPanel('control-panel'); + manager._initAutoHoverPanel('location-bar'); + disclosure.focus(); + const tick = () => { + const next = [...timers.entries()].sort((a, b) => a[1].at - b[1].at)[0]; + if (!next) return; + timers.delete(next[0]); now = next[1].at; next[1].callback(); + }; + return { + manager, panel, location, disclosure, + 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 }); }, + escape() { emit(panel, 'keydown', { key: 'Escape' }); }, + click(detail = 1) { emit(disclosure, 'click', { detail }); }, + drain() { for (let i = 0; i < 40 && timers.size; i += 1) tick(); }, + }; +} + +test('Enter focuses selected OSM rather than the first chip', () => { + const h = harness(); h.key(); h.tick(); + assert.equal(h.document.activeElement, h.active); + assert.equal(h.panel.classList.contains('collapsed'), false); + assert.equal(h.timers.size, 0); +}); +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); + assert.equal(h.panel.classList.contains('collapsed'), false); +}); +test('missing selection falls back to the first chip', () => { + const h = harness({ selected: false }); h.key(); h.tick(); + assert.equal(h.document.activeElement, h.first); +}); +test('delayed visibility retries until focus lands', () => { + const h = harness({ hidden: true }); h.key(); h.tick(); h.tick(); + assert.equal(h.document.activeElement, h.disclosure); + assert.equal(h.timers.size, 1); h.show(); h.tick(); + assert.equal(h.document.activeElement, h.active); + assert.equal(h.calls(), 3); assert.equal(h.timers.size, 0); +}); +test('selection is read again when a delayed attempt can focus', () => { + const h = harness({ hidden: true, selected: false }); h.key(); h.tick(); + h.select(true); h.show(); h.tick(); + assert.equal(h.document.activeElement, h.active); +}); +test('permanently hidden and missing targets have bounded work', () => { + for (const options of [{ hidden: true }, { noChips: true }]) { + const h = harness(options); h.key(); h.drain(); + assert.equal(h.timers.size, 0); + assert.equal(h.calls(), options.noChips ? 0 : 25); + assert.equal(h.document.activeElement, h.disclosure); + } +}); +test('focus departure cancels immediately even if focus returns before the timer', () => { + const h = harness(); h.key(); h.focus(h.elsewhere); h.focus(h.disclosure); + assert.equal(h.timers.size, 0); h.drain(); + assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0); +}); +test('deliberate focus movement inside the tray cancels pending retries', () => { + const h = harness({ hidden: true }); h.key(); h.tick(); h.focus(h.first); + assert.equal(h.timers.size, 0); h.show(); h.drain(); + assert.equal(h.document.activeElement, h.first); assert.equal(h.calls(), 1); +}); +test('Escape cancels immediately, closes the tray and restores disclosure focus', () => { + const h = harness({ hidden: true }); h.key(); h.tick(); h.escape(); + assert.equal(h.timers.size, 0); assert.equal(h.document.activeElement, h.disclosure); + assert.equal(h.panel.classList.contains('collapsed'), true); h.show(); h.drain(); + assert.equal(h.calls(), 1); +}); +test('the central close seam cancels before both transition and already-closed return', () => { + for (const alreadyClosed of [false, true]) { + const h = harness(); h.key(); + if (alreadyClosed) h.panel.classes.add('collapsed'); + h.manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false }); + assert.equal(h.timers.size, 0); h.drain(); assert.equal(h.calls(), 0); + } +}); +test('opening the unpinned Location sibling cancels the Map Source request', () => { + const h = harness(); h.key(); h.manager.setPanelCollapsed('location-bar', false); + assert.equal(h.panel.classList.contains('collapsed'), true); + assert.equal(h.timers.size, 0); h.drain(); assert.equal(h.calls(), 0); +}); +test('a pinned Map Source tray retains its own request when the sibling opens', () => { + const h = harness(); h.panel.classes.add('dock-pinned'); h.key(); + h.manager.setPanelCollapsed('location-bar', false); h.tick(); + assert.equal(h.panel.classList.contains('collapsed'), false); + assert.equal(h.document.activeElement, h.active); +}); +test('pointer activation revokes a pending keyboard request without moving focus', () => { + const h = harness(); h.key(); h.emit(h.panel, 'pointerdown'); + assert.equal(h.timers.size, 0); h.drain(); + assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0); +}); +test('close then pointer or programmatic reopen cannot inherit a keyboard request', () => { + for (const reopen of [(h) => h.click(1), (h) => h.manager.setPanelCollapsed('control-panel', false)]) { + const h = harness(); h.key(); const stale = [...h.timers.values()][0].callback; + h.escape(); reopen(h); stale(); h.drain(); + assert.equal(h.panel.classList.contains('collapsed'), false); + assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0); + } +}); +test('a stale callback cannot focus or clear the timer owned by a new keyboard opening', () => { + const h = harness(); h.key(); const stale = [...h.timers.values()][0].callback; + h.escape(); h.key(); const timer = [...h.timers.keys()][0]; stale(); + assert.equal(h.calls(), 0); assert.equal([...h.timers.keys()][0], timer); + h.tick(); assert.equal(h.document.activeElement, h.active); +}); +test('plain pointer opening does not request focus', () => { + const h = harness(); h.click(1); h.drain(); + assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0); +}); +test('pointer leave preserves established keyboard focus and the open tray', () => { + const h = harness(); h.key(); h.tick(); h.emit(h.panel, 'pointerleave', { pointerType: 'mouse' }); h.drain(); + assert.equal(h.document.activeElement, h.active); + assert.equal(h.panel.classList.contains('collapsed'), false); +}); + + diff --git a/src/ui.js b/src/ui.js index 9b8d22ed7..1598f40e9 100644 --- a/src/ui.js +++ b/src/ui.js @@ -4107,6 +4107,17 @@ export class StyleManager { let closeTimer = null; let lastWheelTime = 0; let disclosureFocusTimer = null; + let focusRequest = 0; + + const cancelMapSourceFocus = () => { + clearTimeout(disclosureFocusTimer); + disclosureFocusTimer = null; + focusRequest += 1; + }; + if (panelId === 'control-panel') { + this._cancelMapSourceFocus?.(); + this._cancelMapSourceFocus = cancelMapSourceFocus; + } const clearOpen = () => { if (!openTimer) return; @@ -4190,13 +4201,15 @@ export class StyleManager { }); panelEl.addEventListener('pointerdown', () => { + cancelMapSourceFocus(); clearOpen(); clearClose(); }); const focusMapSource = () => { if (panelId !== 'control-panel') return false; - const chip = panelEl.querySelector('.map-stack-chip.active, .map-stack-chip'); + const chip = panelEl.querySelector('.map-stack-chip.active') + || panelEl.querySelector('.map-stack-chip'); if (!chip?.focus) return false; chip.focus({ preventScroll: true }); // .focus() on a still-hidden element is a SILENT no-op, so the caller @@ -4213,12 +4226,19 @@ export class StyleManager { // cadence until focus actually lands, bounded so a permanently hidden tray // cannot spin. const scheduleMapSourceFocus = () => { - clearTimeout(disclosureFocusTimer); + cancelMapSourceFocus(); + if (panelId !== 'control-panel') return; + const request = focusRequest; let attempts = 0; const attemptFocus = () => { + if (request !== focusRequest) return; disclosureFocusTimer = null; - if (panelEl.classList.contains('collapsed')) return; + if (this._disposed || panelEl.classList.contains('collapsed')) return; + // A Tab or click elsewhere owns focus now. A delayed transition must + // not pull the keyboard back into a tray the user has already left. + if (document.activeElement !== disclosure) return; if (focusMapSource()) return; + if (request !== focusRequest) return; if (++attempts > 24) return; // ~720ms past the first try, then give up disclosureFocusTimer = window.setTimeout(attemptFocus, 30); }; @@ -4226,6 +4246,7 @@ export class StyleManager { }; const toggleDisclosure = ({ focusSource = false } = {}) => { + cancelMapSourceFocus(); clearOpen(); clearClose(); const shouldOpen = panelEl.classList.contains('collapsed'); @@ -4254,11 +4275,14 @@ export class StyleManager { panelEl.addEventListener('focusin', () => clearClose()); panelEl.addEventListener('focusout', (event) => { + cancelMapSourceFocus(); if (panelEl.contains(event.relatedTarget)) return; scheduleClose(); }); panelEl.addEventListener('keydown', (event) => { - if (event.key !== 'Escape' || panelEl.classList.contains('collapsed')) return; + if (event.key !== 'Escape') return; + cancelMapSourceFocus(); + if (panelEl.classList.contains('collapsed')) return; event.preventDefault(); clearOpen(); clearClose(); @@ -7695,6 +7719,7 @@ export class StyleManager { persist = true, syncShare = true, } = {}) { + if (panelId === 'control-panel' && collapsed) this._cancelMapSourceFocus?.(); const panelEl = document.getElementById(panelId); if (!panelEl) return; if (explicit && !restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId); From 231d339e92ee412beb429f458857d73c94f8ca33 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 10 Sep 2026 22:10:09 -0700 Subject: [PATCH 6/6] test(a11y): cover delayed and interrupted Map Source focus Keep the Esri settlement assertion exact, verify selected-source focus and Tab departure in Chromium, cancel pending work during disposal, and document the focused behavior. --- CHANGELOG.md | 4 ++ docs/CURRENT-STATE.md | 7 +++ scripts/qa-map-source-tray.mjs | 82 ++++++++++++++++++++++++++++------ src/mapSourceFocus.test.mjs | 7 +++ src/mapStackChips.test.mjs | 2 +- src/ui.js | 1 + 6 files changed, 89 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b84c249a1..e2c9df856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- Map Source keyboard opening retries focus until the selected tile is visible. + Leaving the disclosure, pointer interaction, or closing the tray cancels the + pending handoff so delayed work cannot pull focus back. + - Scope, Bloom, Sharpen, location search and generated style sliders expose explicit accessible names. The first-run checkbox retains its native label. - FIRMS records a source as successful only after appending its rows, avoiding diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0e3f20ad4..495c843e4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +## Map Source keyboard focus + +Keyboard opening focuses the selected map-source tile, falling back to the first +only when no source is selected. A bounded retry handles delayed tray visibility. +Moving focus away, pointer interaction, closing the tray, or disposing the UI +cancels pending work; a later reopening cannot inherit an old focus request. + ## September 8, 2026 Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index d93a5b053..7dbe1b570 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -172,9 +172,8 @@ try { // flake as the tray timers above (#54). Poll the observable truth instead. const domDeadline = performance.now() + 3000; const settled = () => !document.body.innerText.includes('Powered by Esri') - && [...document.querySelectorAll('.map-stack-chip')] - .filter((chip) => chip.getAttribute('aria-pressed') === 'true') - .every((chip) => chip.dataset.stackId === 'osm'); + && JSON.stringify([...document.querySelectorAll('.map-stack-chip[aria-pressed="true"]')] + .map((chip) => chip.dataset.stackId)) === JSON.stringify(['osm']); while (!settled() && performance.now() < domDeadline) { await new Promise((resolve) => setTimeout(resolve, 25)); } @@ -230,16 +229,17 @@ try { wantExpanded, wantFocus, ).catch(() => {}); + const keyboardSource = await page.evaluate(() => window.__godsEyeView.styleManager.mapStackController.getActiveId()); await page.focus('#control-panel-toggle'); await page.keyboard.press('Enter'); - await waitTray('true', 'photoreal'); + await waitTray('true', keyboardSource); const keyboardOpen = await page.evaluate(() => ({ expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'), activeStack: document.activeElement?.dataset?.stackId || null, })); check( - 'Enter opens the tray and hands focus to a Map Source tile', - keyboardOpen.expanded === 'true' && keyboardOpen.activeStack === 'photoreal', + 'Enter opens the tray and hands focus to the selected Map Source tile', + keyboardOpen.expanded === 'true' && keyboardOpen.activeStack === keyboardSource, JSON.stringify(keyboardOpen), ); @@ -256,14 +256,14 @@ try { ); await page.keyboard.press('Space'); - await waitTray('true', 'photoreal'); + await waitTray('true', keyboardSource); const spaceOpen = await page.evaluate(() => ({ expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'), activeStack: document.activeElement?.dataset?.stackId || null, })); check( 'Space opens the tray through the same keyboard path', - spaceOpen.expanded === 'true' && spaceOpen.activeStack === 'photoreal', + spaceOpen.expanded === 'true' && spaceOpen.activeStack === keyboardSource, JSON.stringify(spaceOpen), ); @@ -271,21 +271,68 @@ try { await page.keyboard.down('Enter'); await new Promise((resolve) => setTimeout(resolve, 320)); await page.keyboard.up('Enter'); - await waitTray('true', 'photoreal'); // let the hold's scheduled focus hand-off land + await waitTray('true', keyboardSource); // let the hold's scheduled focus hand-off land await page.keyboard.press('Escape'); await waitTray('false', 'toggle'); await page.keyboard.press('Enter'); - await waitTray('true', 'photoreal'); + await waitTray('true', keyboardSource); const longHoldRecovery = await page.evaluate(() => ({ expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'), activeStack: document.activeElement?.dataset?.stackId || null, })); check( 'long Enter hold cannot strand the disclosure keyboard path', - longHoldRecovery.expanded === 'true' && longHoldRecovery.activeStack === 'photoreal', + longHoldRecovery.expanded === 'true' && longHoldRecovery.activeStack === keyboardSource, JSON.stringify(longHoldRecovery), ); + // Force a delayed visible state while using the real controller and keyboard routes. + const hideTray = () => page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setPanelCollapsed('control-panel', true); + window.__qaTrayStyles = [...document.querySelectorAll('.map-stack-chip')] + .map((chip) => [chip, chip.style.cssText]); + for (const [chip] of window.__qaTrayStyles) chip.style.setProperty('visibility', 'hidden', 'important'); + document.getElementById('control-panel-toggle').focus(); + }); + const showTray = () => page.evaluate(() => { + for (const [chip, cssText] of window.__qaTrayStyles) chip.style.cssText = cssText; + delete window.__qaTrayStyles; + }); + await hideTray(); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 350)); + const delayedBefore = await page.evaluate(() => document.activeElement?.id); + await showTray(); + await waitTray('true', keyboardSource); + const delayedAfter = await page.evaluate(() => document.activeElement?.dataset?.stackId); + check('a delayed visible tray receives selected-source focus after the first attempt', + delayedBefore === 'control-panel-toggle' && delayedAfter === keyboardSource, + JSON.stringify({ delayedBefore, delayedAfter, keyboardSource })); + + await hideTray(); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 350)); + await page.keyboard.press('Tab'); + const departure = await page.evaluate(() => { + window.__qaDepartedFocus = document.activeElement; + return document.activeElement !== document.getElementById('control-panel-toggle'); + }); + await showTray(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const focusRetained = await page.evaluate(() => { + const same = document.activeElement === window.__qaDepartedFocus; + delete window.__qaDepartedFocus; + return same; + }); + 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.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); + await waitTray('true', keyboardSource); + if (forceKeyless) { await page.evaluate(async () => { const styleManager = window.__godsEyeView.styleManager; @@ -617,8 +664,17 @@ try { await new Promise((resolve) => setTimeout(resolve, 200)); await page.focus('#control-panel-toggle'); await page.keyboard.press('Enter'); // opens and hands focus to the active tile - await new Promise((resolve) => setTimeout(resolve, 400)); - await page.keyboard.press('Tab'); // tab ONTO a tile, keyboard modality + const selectedForHold = await page.evaluate(() => window.__godsEyeView.styleManager.mapStackController.getActiveId()); + await waitTray('true', selectedForHold); + // Keyless starts on OSM, the last tile. Tab forward there correctly leaves + // the tray, so navigate to a neighbouring tile in the available direction. + const activeIsLastTile = await page.evaluate(() => { + const chips = [...document.querySelectorAll('#map-stack-chips .map-stack-chip')]; + return document.activeElement === chips.at(-1); + }); + if (activeIsLastTile) await page.keyboard.down('Shift'); + await page.keyboard.press('Tab'); + if (activeIsLastTile) await page.keyboard.up('Shift'); await page.keyboard.press('Enter'); // activate it from the keyboard await new Promise((resolve) => setTimeout(resolve, 200)); const keyboardAfterActivate = await page.evaluate(() => ({ diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index 4e4c56b64..7d19a3f0f 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -230,3 +230,10 @@ test('pointer leave preserves established keyboard focus and the open tray', () }); + +test('disposed UI cannot complete a pending handoff', () => { + const h = harness({ hidden: true }); h.key(); h.tick(); + h.manager._disposed = true; h.show(); h.drain(); + assert.equal(h.document.activeElement, h.disclosure); + assert.equal(h.calls(), 1); assert.equal(h.timers.size, 0); +}); diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs index 4472bd447..7f425fd12 100644 --- a/src/mapStackChips.test.mjs +++ b/src/mapStackChips.test.mjs @@ -344,7 +344,7 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse 'the compact wing must expose a semantic keyboard disclosure', ); assert.match(ui, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/); - assert.match(ui, /map-stack-chip\.active, \.map-stack-chip/); + assert.match(ui, /querySelector\('\.map-stack-chip\.active'\)\s*\|\| panelEl\.querySelector\('\.map-stack-chip'\)/); assert.match( ui, diff --git a/src/ui.js b/src/ui.js index 1598f40e9..29f3cfa57 100644 --- a/src/ui.js +++ b/src/ui.js @@ -10167,6 +10167,7 @@ export class StyleManager { this._globalStatusNotice = null; if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true; this._disposed = true; + this._cancelMapSourceFocus?.(); // Revoke persistence/hash authority before teardown can emit manager changes. this._layerStateCoordinator?.destroy(); this._layerStateCoordinator = null;