diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa46..931c492b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,84 @@ # Changelog +## 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. + +Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude. + +Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG. + This changelog records public product changes. For the authoritative description 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. +- CelesTrak, Launch Library, terrain-height, and aircraft-enrichment failures + return generic error messages. Related diagnostics omit raw exception details + and upstream error bodies; response statuses and cache fallback remain intact. + Includes the security fixes contributed by Tom-Neverwinter in PR #171. + ### 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 + contradictory success/failure status if aggregation throws. +- Radio country filtering and voice country requests now resolve common English + names and exonyms that `Intl.DisplayNames`' primary label omits, so requests + like "play radio in Turkey" no longer fail closed (Turkey → Türkiye, plus + Myanmar/Burma, UAE, Holland, Swaziland, East Timor, Cabo Verde, Vatican). + Ambiguous names such as a bare "Congo" or "Korea" still fail closed. - 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. @@ -29,6 +101,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md stale-data fallback. Concurrent identical requests share the same last-good fallback when all mirrors refuse, without duplicating upstream requests. +- Refresh vulnerable transitive dependencies and update browser/image tooling + to Puppeteer 25.10.0 and Sharp 0.35.4. Cesium remains on 1.138.0. + Browser QA awaits the new asynchronous executable-path lookup. + ## [0.1.1] — 2026-09-01 — Installation and live-data fixes ### Changed diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f4d861f4..640dfe805 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,7 +1,111 @@ # 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 +its Austin source file, Austin preference, 36-camera Austin limit, and 48-camera +total limit, with environment overrides. Keys are optional; credential loading +and names-only provider provenance follow the normal launcher. The default +binding is localhost. An explicit `HOST=0.0.0.0` uses the same LAN warning as +normal startup. + +The CelesTrak, Launch Library, terrain-height, and ADSBDB middleware return +fixed messages for unexpected failures. Launch Library retains its upstream +HTTP failure status and no-store policy but does not forward the upstream body. +Existing successful, in-flight, missing-data, and stale-cache behavior remains +in place. Failure diagnostics identify the service and, for Launch Library, +HTTP status without printing raw exception text or upstream bodies. + +## 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. + +Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude. + +Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG. + Updated: August 24, 2026 +## Control names for assistive technology + +Scope, Bloom, Sharpen and location search have explicit accessible names. +Generated style sliders use the same name as their visible parameter label. +The first-run suppression checkbox keeps its native wrapping label, so its +accessible name remains "Don't show this again". Control behavior is unchanged. + +## FIRMS source status + +The FIRMS proxy records source success after appending its rows. If aggregation +throws, that source reports failure without a contradictory success entry. +The existing per-record append continues to support large feeds; sequential +fetching, trailing-24-hour filtering and partial-success caching are unchanged. + ## Installations and map-source guidance - On an uncached Overpass failure, mapped installations keep their existing @@ -748,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 @@ -1684,9 +1788,11 @@ preserves object identity for an idempotent repeat of the same generation, and degrades without replacement if a fresh response presents an older generation. Snapshot records mark community metadata as untrusted, and Radio tool results omit station names so directory text never becomes model instruction context. -One bounded country parser maps recognized ISO codes and English/common names -through proxy metadata and final station selection, while malformed, non-ISO, -control-containing, and oversized inputs fail closed. Literal or resolved +One bounded country parser maps recognized ISO codes and English/common names — +including widely used exonyms the Intl display label omits (Turkey, Holland, +Burma, and similar) — through proxy metadata and final station selection, while +malformed, non-ISO, control-containing, ambiguous, and oversized inputs fail +closed. Literal or resolved non-global IPv4/IPv6 targets are refused. Destroy fully releases the Radio audio session, voice ducking/restoration, request state, filter, selection, volume, accepted snapshot, and feed telemetry before re-initialization; monotonic @@ -2611,3 +2717,11 @@ Replay transport uses one Play/Pause toggle plus Cancel. During ascent only the ## Maintenance Rule When runtime behavior or architecture changes, update this file in the same change set as code updates. + +## Dependency security baseline + +The lockfile uses DOMPurify 3.4.15, protobufjs 8.8.0, PostCSS 8.5.28, and +nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA uses Puppeteer 25.10.0; +image-processing tools use Sharp 0.35.4. QA scripts await Puppeteer's asynchronous +executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain +24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree. diff --git a/index.html b/index.html index e8b0b8ddd..87fb168bc 100644 --- a/index.html +++ b/index.html @@ -440,7 +440,7 @@ mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather softens the black mask edge; label fading is the separate Fade slider. --> - + 11% @@ -460,7 +460,7 @@ Bloom
- + 0%
@@ -470,7 +470,7 @@ Sharpen
- + 49%
@@ -548,14 +548,16 @@
- LOCATION +
📍 Location: --
Landmark: --
-
+
@@ -565,7 +567,7 @@ - +
@@ -727,7 +729,7 @@ CONTACTS - @@ -765,6 +767,7 @@
LOADING 30-DAY MISSION INDEX
+

TAB PREVIEWS · ENTER / SPACE SELECTS

diff --git a/package-lock.json b/package-lock.json index a6bd25dd0..f835ac31d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "prettier": "^3.9.6", "puppeteer": "^24.37.5", "sharp": "^0.34.5", + "puppeteer": "^25.10.0", + "sharp": "^0.35.4", "vite": "^6.0.0", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" @@ -28,31 +30,6 @@ "node": ">=24.14.0 <25 || >=26 <27" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@cesium/engine": { "version": "22.3.0", "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-22.3.0.tgz", @@ -105,9 +82,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, @@ -558,9 +535,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", "engines": { @@ -568,9 +545,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -581,19 +558,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -604,19 +581,39 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -631,9 +628,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -648,9 +645,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -665,9 +662,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -682,9 +679,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -699,9 +696,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -716,9 +713,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -733,9 +730,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -750,9 +747,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -767,9 +764,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -784,9 +781,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -797,19 +794,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -820,19 +817,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -843,19 +840,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -866,19 +863,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -889,19 +886,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -912,19 +909,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -935,19 +932,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -958,39 +955,56 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -1001,16 +1015,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -1021,16 +1035,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -1041,7 +1055,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1065,41 +1079,30 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", - "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", + "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "debug": "^4.4.3", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.4", - "tar-fs": "^3.1.1", - "yargs": "^17.7.2" + "modern-tar": "^0.8.4", + "yargs": "^18.0.0" }, "bin": { - "browsers": "lib/cjs/main-cli.js" + "browsers": "lib/main-cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/@puppeteer/browsers/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" + "node": ">=22.12.0" }, - "engines": { - "node": ">=6.0" + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" }, "peerDependenciesMeta": { - "supports-color": { + "proxy-agent": { + "optional": true + }, + "yauzl": { "optional": true } } @@ -1491,13 +1494,6 @@ "pnpm": ">=8" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "dev": true, - "license": "MIT" - }, "node_modules/@tweenjs/tween.js": { "version": "25.0.0", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", @@ -1517,17 +1513,6 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.2.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.2.tgz", - "integrity": "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "undici-types": "~7.16.0" - } - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1535,17 +1520,6 @@ "license": "MIT", "optional": true }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@zip.js/zip.js": { "version": "2.8.18", "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.18.tgz", @@ -1560,62 +1534,32 @@ "node": ">=18.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -1638,155 +1582,12 @@ "pnpm": ">=10.10.0" } }, - "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", - "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", - "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, - "node_modules/basic-ftp": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", - "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bitmap-sdf": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", "license": "MIT" }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/cesium": { "version": "1.138.0", "resolved": "https://registry.npmjs.org/cesium/-/cesium-1.138.0.tgz", @@ -1806,107 +1607,61 @@ } }, "node_modules/chromium-bidi": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", - "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", "dev": true, "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, "peerDependencies": { "devtools-protocol": "*" } }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1924,21 +1679,6 @@ "dev": true, "license": "MIT" }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1971,16 +1711,16 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1566079", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", - "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", + "version": "0.0.1666840", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", + "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", "dev": true, "license": "BSD-3-Clause" }, "node_modules/dompurify": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", - "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -2019,9 +1759,9 @@ } }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, @@ -2035,36 +1775,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2124,52 +1834,6 @@ "dev": true, "license": "MIT" }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -2177,16 +1841,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2197,72 +1851,6 @@ "node": ">= 0.6" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extract-zip/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2332,55 +1920,19 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/get-uri/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2415,87 +1967,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2503,33 +1974,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -2540,36 +1984,6 @@ "@types/estree": "*" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsep": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", @@ -2579,13 +1993,6 @@ "node": ">= 10.16.0" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -2617,12 +2024,18 @@ "integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg==", "license": "Apache-2.0" }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } }, "node_modules/long": { "version": "5.3.2", @@ -2630,16 +2043,6 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -2688,6 +2091,16 @@ "dev": true, "license": "MIT" }, + "node_modules/modern-tar": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz", + "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2696,9 +2109,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -2714,16 +2127,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/nosleep.js": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz", @@ -2743,106 +2146,12 @@ "node": ">= 0.8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "dev": true, - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2865,13 +2174,6 @@ "pbf": "bin/pbf" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2893,9 +2195,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -2913,7 +2215,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2948,9 +2250,9 @@ } }, "node_modules/protobufjs": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.4.tgz", - "integrity": "sha512-/+XMv9JalknuncEJSwsyEVlwcxVLKx2iaoSUXFZA86MJkdqyOdfrlB1sB7S6aKyUk9tl20YY+SgQe5J2sJHTcg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz", + "integrity": "sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg==", "license": "BSD-3-Clause", "dependencies": { "long": "^5.3.2" @@ -2965,119 +2267,44 @@ "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "license": "MIT" }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/puppeteer": { - "version": "24.37.5", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.5.tgz", - "integrity": "sha512-3PAOIQLceyEmn1Fi76GkGO2EVxztv5OtdlB1m8hMUZL3f8KDHnlvXbvCXv+Ls7KzF1R0KdKBqLuT/Hhrok12hQ==", + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.10.0.tgz", + "integrity": "sha512-9ZfkiaZDQWpGPJp9XTS+Bkn/D78hPvYmtjPfIBeybn05oeY6Jj7aiSbYdfcSQD2UMvC0vE7Yi9PSDo179euRzw==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.13.0", - "chromium-bidi": "14.0.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1566079", - "puppeteer-core": "24.37.5", - "typed-query-selector": "^2.12.0" + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.10.0", + "typed-query-selector": "^2.12.2" }, "bin": { - "puppeteer": "lib/cjs/puppeteer/node/cli.js" + "puppeteer": "lib/puppeteer/node/cli.js" }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, "node_modules/puppeteer-core": { - "version": "24.37.5", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.5.tgz", - "integrity": "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ==", + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz", + "integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.13.0", - "chromium-bidi": "14.0.0", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1566079", - "typed-query-selector": "^2.12.0", - "webdriver-bidi-protocol": "0.4.1", - "ws": "^8.19.0" + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.3", + "ws": "^8.21.3" }, "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=22.12.0" } }, "node_modules/quickselect": { @@ -3105,26 +2332,6 @@ "quickselect": "^3.0.0" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-protobuf-schema": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", @@ -3192,9 +2399,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -3253,120 +2460,55 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "dev": true, - "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/socks-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" }, "peerDependenciesMeta": { - "supports-color": { + "@types/node": { "optional": true } } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3395,92 +2537,37 @@ "node": ">= 0.8" } }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=8" - } - }, - "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" + "node": ">=12" }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/teex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", - "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "streamx": "^2.12.5" - } - }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/tinyglobby": { @@ -3531,20 +2618,12 @@ "license": "0BSD" }, "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", "dev": true, "license": "MIT" }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -3692,41 +2771,52 @@ "license": "Apache-2.0" }, "node_modules/webdriver-bidi-protocol": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", - "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", + "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", "dev": true, "license": "Apache-2.0" }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { @@ -3756,43 +2846,41 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^8.2.1", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "funding": { + "url": "https://github.com/sponsors/colinhacks" } } } diff --git a/package.json b/package.json index 662dbebf8..585f104d2 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "prettier": "^3.9.6", "puppeteer": "^24.37.5", "sharp": "^0.34.5", + "puppeteer": "^25.10.0", + "sharp": "^0.35.4", "vite": "^6.0.0", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" diff --git a/scripts/dev-cctv.sh b/scripts/dev-cctv.sh index b69d84a14..a1a248203 100755 --- a/scripts/dev-cctv.sh +++ b/scripts/dev-cctv.sh @@ -2,213 +2,12 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" -PORT="${PORT:-4173}" -HOST="${HOST:-0.0.0.0}" -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}" -CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}" +# A lighter CCTV testing preset; startup, credentials, and LAN warnings belong +# to the normal launcher. Explicit environment overrides remain supported. +export CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}" +export CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}" +export CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}" +export CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}" -if ! command -v npm >/dev/null 2>&1; then - echo "error: npm not found" - exit 1 -fi - -GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}" -GOOGLE_MAPS_API_KEY_KEYCHAIN="" -GOOGLE_MAPS_API_KEY_SOURCE="" -if command -v security >/dev/null 2>&1; then - for acct in "api-key" "default" "key"; do - GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)" - if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then - GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}" - break - fi - done -fi - -if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then - GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}" -elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then - GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}" - GOOGLE_MAPS_API_KEY_SOURCE="env" -else - GOOGLE_MAPS_API_KEY="" -fi -if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then - echo "error: Google Maps API key missing." - echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key" - exit 1 -fi - -read_keychain_secret() { - local service="$1" - local account="$2" - security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true -} - -load_opensky_oauth_from_file() { - local file_path="$1" - if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then - return - fi - if ! command -v node >/dev/null 2>&1; then - echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE" - return - fi - - local parsed - parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" "${file_path}" 2>/dev/null || true)" - if [[ "${parsed}" != *$'\t'* ]]; then - return - fi - local parsed_client_id="${parsed%%$'\t'*}" - local parsed_client_secret="${parsed#*$'\t'}" - - if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then - OPENSKY_CLIENT_ID="${parsed_client_id}" - fi - if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then - OPENSKY_CLIENT_SECRET="${parsed_client_secret}" - fi -} - -resolve_opensky_credentials() { - OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')" - OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-}" - case "${OPENSKY_AUTH_MODE}" in - basic|oauth|auto|anon) ;; - *) - echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'" - OPENSKY_AUTH_MODE="oauth" - ;; - esac - - OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-}" - OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-}" - OPENSKY_USERNAME="${OPENSKY_USERNAME:-}" - OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-}" - - if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then - for svc in "opensky-network" "opensky"; do - for acct in "username" "user" "login" "email" "default"; do - OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")" - if [[ -n "${OPENSKY_USERNAME}" ]]; then - break 2 - fi - done - done - fi - if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then - for svc in "opensky-network" "opensky"; do - for acct in "password" "pass" "token" "secret" "default"; do - OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")" - if [[ -n "${OPENSKY_PASSWORD}" ]]; then - break 2 - fi - done - done - fi - if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then - load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}" - fi - if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then - for svc in "opensky-network" "opensky"; do - for acct in "client_id" "client-id" "client" "api-key"; do - OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")" - if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then - break 2 - fi - done - done - fi - if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then - for svc in "opensky-network" "opensky"; do - for acct in "client_secret" "client-secret" "secret"; do - OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")" - if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then - break 2 - fi - done - done - fi - - case "${OPENSKY_AUTH_MODE}" in - basic) - OPENSKY_CLIENT_ID="" - OPENSKY_CLIENT_SECRET="" - ;; - oauth) - OPENSKY_USERNAME="" - OPENSKY_PASSWORD="" - ;; - anon) - OPENSKY_CLIENT_ID="" - OPENSKY_CLIENT_SECRET="" - OPENSKY_USERNAME="" - OPENSKY_PASSWORD="" - ;; - esac -} - -resolve_opensky_credentials - -echo "Starting God's Eye View with CCTV source pack..." -echo "URL: http://localhost:${PORT}/" -echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}" -if [[ -f "${CCTV_SOURCES_FILE}" ]]; then - echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE}" -else - echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE} (missing, using Austin open-data feed discovery)" -fi -echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}" -if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then - if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then - echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}" - else - echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)" - fi -fi -case "${OPENSKY_AUTH_MODE}" in - basic) - if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then - echo "OpenSky basic auth: configured" - else - echo "OpenSky basic auth: missing credentials" - fi - ;; - oauth) - if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then - echo "OpenSky OAuth: configured" - else - echo "OpenSky OAuth: missing client credentials" - fi - ;; - auto) - if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then - echo "OpenSky auto auth: OAuth configured" - elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then - echo "OpenSky auto auth: basic configured" - else - echo "OpenSky auto auth: no credentials found" - fi - ;; - anon) - echo "OpenSky auth: disabled (anonymous mode)" - ;; -esac - -GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY}" \ -CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE}" \ -CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN}" \ -CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES}" \ -CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES}" \ -OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE}" \ -OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE}" \ -OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID}" \ -OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET}" \ -OPENSKY_USERNAME="${OPENSKY_USERNAME}" \ -OPENSKY_PASSWORD="${OPENSKY_PASSWORD}" \ -npm run dev -- --host "${HOST}" --port "${PORT}" --force +exec bash "$ROOT_DIR/scripts/dev-fresh.sh" diff --git a/scripts/qa-attribution-b12.mjs b/scripts/qa-attribution-b12.mjs index 028878104..8875f1e19 100644 --- a/scripts/qa-attribution-b12.mjs +++ b/scripts/qa-attribution-b12.mjs @@ -34,7 +34,7 @@ mkdirSync(SHOT_DIR, { recursive: true }); const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 17250d487..0b636f21b 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -106,7 +106,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-cockpit-plates.mjs b/scripts/qa-cockpit-plates.mjs index d4804d754..f7a8f052f 100644 --- a/scripts/qa-cockpit-plates.mjs +++ b/scripts/qa-cockpit-plates.mjs @@ -139,7 +139,7 @@ const CHROME_CANDIDATES = [ // Version-pinned Chrome-for-Testing over the auto-updating system Chrome: // its software-GL behavior shifts across majors and has produced false // negatives in this repo's harnesses before. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean); diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 1d3e91aab..ffb3b0851 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -13,14 +13,20 @@ fs.mkdirSync(shotsDir, { recursive: true }); const chromeCandidates = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean); const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate)); 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', diff --git a/scripts/qa-enrich-ambient.mjs b/scripts/qa-enrich-ambient.mjs index 5b9d89816..400857b54 100644 --- a/scripts/qa-enrich-ambient.mjs +++ b/scripts/qa-enrich-ambient.mjs @@ -93,7 +93,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-failstate-b10.mjs b/scripts/qa-failstate-b10.mjs index 3b4582b9c..55d77a310 100644 --- a/scripts/qa-failstate-b10.mjs +++ b/scripts/qa-failstate-b10.mjs @@ -70,7 +70,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index ca5f23525..dd93013e6 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -52,7 +52,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-firstrun.mjs b/scripts/qa-firstrun.mjs index 3c65abab2..de41ba5a6 100644 --- a/scripts/qa-firstrun.mjs +++ b/scripts/qa-firstrun.mjs @@ -38,7 +38,7 @@ const SHOT_DIR = path.join(ROOT, 'qa-shots', 'firstrun'); const CHROME_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, // Version-pinned Chrome-for-Testing over the auto-updating system Chrome. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean); diff --git a/scripts/qa-floor-hold.mjs b/scripts/qa-floor-hold.mjs index 9cdc85f80..59542b547 100644 --- a/scripts/qa-floor-hold.mjs +++ b/scripts/qa-floor-hold.mjs @@ -56,7 +56,7 @@ function record(name, ok, detail) { } fs.mkdirSync(OUT_DIR, { recursive: true }); -const chrome = (() => { try { return puppeteer.executablePath(); } catch { return undefined; } })(); +const chrome = await puppeteer.executablePath().catch(() => undefined); const browser = await puppeteer.launch({ headless: HEADFUL ? false : 'new', executablePath: chrome, diff --git a/scripts/qa-floor-verify.mjs b/scripts/qa-floor-verify.mjs index afdf7ad0d..97b1483dd 100644 --- a/scripts/qa-floor-verify.mjs +++ b/scripts/qa-floor-verify.mjs @@ -26,7 +26,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // across majors. const chrome = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean).find((p) => fs.existsSync(p)); diff --git a/scripts/qa-flyroute-cinema.mjs b/scripts/qa-flyroute-cinema.mjs index 274015edb..3d4088a32 100644 --- a/scripts/qa-flyroute-cinema.mjs +++ b/scripts/qa-flyroute-cinema.mjs @@ -27,7 +27,7 @@ const SHOT_EVERY_MS = Number(getOpt('--shot-ms', '2000')); const CHROME_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), ].filter(Boolean); const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }); diff --git a/scripts/qa-heading-b3.mjs b/scripts/qa-heading-b3.mjs index 9b8214b0d..b585e71ae 100644 --- a/scripts/qa-heading-b3.mjs +++ b/scripts/qa-heading-b3.mjs @@ -123,7 +123,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-height-datum.mjs b/scripts/qa-height-datum.mjs index daa5b7e94..b9f42fefd 100644 --- a/scripts/qa-height-datum.mjs +++ b/scripts/qa-height-datum.mjs @@ -115,7 +115,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index 556320aab..bb43d968f 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -1205,7 +1205,7 @@ async function runBrowserGroup(record) { const emit = (id, res, ms) => { if (ids.includes(id)) record(CHECKS.find((c) => c.id === id), res, ms); }; const only = (id) => ids.includes(id); - const exe = (() => { try { return puppeteer.executablePath(); } catch { return null; } })(); + const exe = await puppeteer.executablePath().catch(() => null); const browser = await puppeteer.launch({ headless: HEADFUL ? false : 'new', ...(exe ? { executablePath: exe } : {}), diff --git a/scripts/qa-label-readability.mjs b/scripts/qa-label-readability.mjs index 3a4584274..b7d17752c 100644 --- a/scripts/qa-label-readability.mjs +++ b/scripts/qa-label-readability.mjs @@ -157,7 +157,7 @@ const CHROME_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, // Pin puppeteer's Chrome-for-Testing: system Chrome auto-updates underneath // the harnesses and its software-GL behaviour shifts across majors. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), ].filter(Boolean); function findChrome() { diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs index 30179931b..8d26dcb30 100644 --- a/scripts/qa-labels.mjs +++ b/scripts/qa-labels.mjs @@ -56,7 +56,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 0071475a3..900292ac2 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const shotsDir = path.join(repoRoot, 'qa-shots', 'map-source-tray'); +const shotsDir = process.env.QA_SHOTS_DIR || path.join(repoRoot, 'qa-shots', 'map-source-tray'); const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173'; const headful = process.argv.includes('--headful'); // The no-ion-token contract is a real shipped state that a normal keyed @@ -19,7 +19,7 @@ const headful = process.argv.includes('--headful'); const forceKeyless = process.argv.includes('--keyless') || process.env.QA_MAP_SOURCE_TRAY_KEYLESS === '1'; const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH - || (() => { try { return puppeteer.executablePath(); } catch { return null; } })(); + || await puppeteer.executablePath().catch(() => null); if (!executablePath || !fs.existsSync(executablePath)) { throw new Error('Puppeteer Chrome for Testing is unavailable'); @@ -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 = []; @@ -83,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); @@ -142,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(), @@ -158,9 +309,17 @@ 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') + && 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)); + } const afterTwo = { activeId: controller.getActiveId(), lastError: controller.getState().lastError, @@ -190,21 +349,52 @@ 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.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 new Promise((resolve) => setTimeout(resolve, 300)); + 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), ); 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,14 +406,14 @@ try { ); await page.keyboard.press('Space'); - await new Promise((resolve) => setTimeout(resolve, 300)); + 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), ); @@ -231,19 +421,278 @@ try { await page.keyboard.down('Enter'); await new Promise((resolve) => setTimeout(resolve, 320)); await page.keyboard.up('Enter'); + 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 new Promise((resolve) => setTimeout(resolve, 300)); + 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, { 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; @@ -273,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. @@ -304,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 @@ -575,8 +1042,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(() => ({ @@ -611,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)); @@ -638,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(); @@ -651,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(); @@ -669,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'); @@ -746,6 +1233,382 @@ try { await page.screenshot({ path: path.join(shotsDir, `legacy-${legacyId}.png`) }); } + // Location is a native disclosure: Enter is immediate, while Space waits for + // key release. A repeated keydown must not toggle or claim voice early. + await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 }); + await page.mouse.move(20, 20); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + for (const id of ['control-panel', 'location-bar']) { + manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false }); + manager.setPanelCollapsed(id, true, { persist: false, syncShare: false }); + } + const original = manager.setPanelCollapsed; + const probe = { transitions: [], restore: () => { manager.setPanelCollapsed = original; } }; + manager.setPanelCollapsed = function (id, ...args) { + const before = document.getElementById(id)?.classList.contains('collapsed'); + const result = original.call(this, id, ...args); + const after = document.getElementById(id)?.classList.contains('collapsed'); + if (id === 'location-bar' && before !== after) probe.transitions.push({ before, after }); + return result; + }; + window.__qaLocationFocus = probe; + }); + try { + await page.focus('#gev-voice-button'); + const locationTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 8 }); + const locationClosed = await locationState(); + check('Location: native disclosure semantics and closed Tab focus without opening', + locationTab.reached && hasVisibleControlFocus(locationClosed) + && locationClosed.tag === 'BUTTON' && locationClosed.type === 'button' + && locationClosed.controls === 'location-bar-popover' && locationClosed.popoverId === 'location-bar-popover' + && locationClosed.locationExpanded === 'false' && /^Expand Location$/i.test(locationClosed.locationLabel) + && locationClosed.transitions.length === 0, JSON.stringify({ locationTab, locationClosed })); + + await resetLocationTransitions(); + await page.keyboard.press('Enter'); + const locationEnter = await locationState(); + check('Location: Enter opens once with visible focus and accurate name', + hasVisibleControlFocus(locationEnter) && locationEnter.locationExpanded === 'true' + && /^Collapse Location$/i.test(locationEnter.locationLabel) && locationEnter.transitions.length === 1, + JSON.stringify(locationEnter)); + await page.keyboard.press('Escape'); + const locationEscape = await locationState(); + check('Location: Escape on the disclosure closes without retaining focus', + locationEscape.focusedId === null && locationEscape.locationExpanded === 'false' + && /^Expand Location$/i.test(locationEscape.locationLabel), JSON.stringify(locationEscape)); + + await page.focus('#location-bar-toggle'); + await resetLocationTransitions(); + const voiceBeforeLocationSpace = locationEscape.voice; + let locationSpaceHeld = false; + let locationSpaceDown; + let locationSpaceRepeat; + try { + await page.keyboard.down('Space'); + locationSpaceHeld = true; + locationSpaceDown = await locationState(); + await new Promise((resolve) => setTimeout(resolve, 320)); + await page.keyboard.down('Space'); + await page.keyboard.down('Space'); + locationSpaceRepeat = await locationState(); + await page.keyboard.up('Space'); + locationSpaceHeld = false; + } finally { + if (locationSpaceHeld) await page.keyboard.up('Space'); + } + const locationSpaceUp = await locationState(); + check('Location: short repeated Space toggles once on release without starting voice', + [locationSpaceDown, locationSpaceRepeat].every((state) => ( + state.focusedId === 'location-bar-toggle' && state.locationExpanded === 'false' + && state.transitions.length === 0 && state.voice.status === voiceBeforeLocationSpace.status + && state.voice.epoch === voiceBeforeLocationSpace.epoch + && state.voice.held === true && state.voice.pushToTalk === false + )) + && locationSpaceUp.focusedId === 'location-bar-toggle' + && locationSpaceUp.locationExpanded === 'true' && locationSpaceUp.transitions.length === 1 + && JSON.stringify(locationSpaceUp.voice) === JSON.stringify(voiceBeforeLocationSpace), + JSON.stringify({ down: locationSpaceDown, repeat: locationSpaceRepeat, up: locationSpaceUp })); + + const searchTab = await tabTo('#search-toggle', { limit: 30 }); + await page.keyboard.press('Enter'); + await page.keyboard.type('focus cleanup'); // no submission or navigation + const typedSearch = await page.$eval('#location-search', (input) => ({ + focused: document.activeElement === input, expanded: input.classList.contains('expanded'), value: input.value, + })); + await page.keyboard.press('Escape'); + const searchEscape = await locationState(); + check('Location: Escape clears typed search and restores disclosure focus', + searchTab.reached && typedSearch.focused && typedSearch.expanded && typedSearch.value === 'focus cleanup' + && searchEscape.focusedId === 'location-bar-toggle' && searchEscape.locationExpanded === 'false' + && !searchEscape.searchExpanded && searchEscape.searchValue === '', JSON.stringify({ typedSearch, searchEscape })); + + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); // begin the Map Source handoff + const siblingTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 30 }); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 1100)); + const siblingState = await locationState(); + check('Location: sibling opening cancels pending Map Source focus', + siblingTab.reached && siblingState.focusedId === 'location-bar-toggle' + && siblingState.locationExpanded === 'true' + && await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded') === 'false'), + JSON.stringify(siblingState)); + await tabTo('.dock-pin-btn[data-pin-target="location-bar"]', { limit: 3 }); + await page.keyboard.press('Enter'); + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 400)); + const pinnedSibling = await locationState(); + check('Location: pinned tray survives sibling opening', + pinnedSibling.pinned && pinnedSibling.locationExpanded === 'true' + && await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded') === 'true'), + JSON.stringify(pinnedSibling)); + await page.focus('#location-bar-toggle'); + await tabTo('.dock-pin-btn[data-pin-target="location-bar"]', { limit: 3 }); + await page.keyboard.press('Enter'); + check('Location: pin can be removed by keyboard', !(await locationState()).pinned); + + for (const width of [1000, 620, 480]) { + await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false }); + manager.setPanelCollapsed('location-bar', true, { persist: false, syncShare: false }); + }); + await page.focus('#gev-voice-button'); + const closedTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 8 }); + const closed = await locationState(); + check(`Location: ${width} px closed Tab ring is visible without opening`, + closedTab.reached && hasVisibleControlFocus(closed) && closed.locationExpanded === 'false', JSON.stringify(closed)); + await page.screenshot({ path: path.join(shotsDir, `${width}-location-closed-focus.png`) }); + await page.keyboard.press('Enter'); + await pressTabs(1); // disclosure -> pin + const openTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 3 }); + const open = await locationState(); + check(`Location: ${width} px open ShiftTab ring is visible`, + openTab.reached && hasVisibleControlFocus(open) && open.locationExpanded === 'true', JSON.stringify(open)); + await page.screenshot({ path: path.join(shotsDir, `${width}-location-open-focus.png`) }); + await page.keyboard.press('Escape'); + } + } finally { + await page.evaluate(() => { + window.__qaLocationFocus?.restore(); + delete window.__qaLocationFocus; + const manager = window.__godsEyeView.styleManager; + for (const id of ['control-panel', 'location-bar']) { + manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false }); + manager.setPanelCollapsed(id, true, { persist: false, syncShare: false }); + } + }); + } + + // The existing dev-only QA registration seam supplies stable ON/STALE rows + // through the real manager/renderer. These fixtures fetch nothing, render no + // entities and have no periodic update; production layers are never toggled. + let dataSetup; + try { + dataSetup = await page.evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + const production = () => manager.getAll().filter((layer) => !layer.id.startsWith('qa-keyboard-focus-')) + .map((layer) => ({ + id: layer.id, enabled: layer.enabled, phase: layer.lifecycleState, uncertain: layer.lifecycleUncertain, + visibilityIntentEpoch: manager.layers.get(layer.id).visibilityIntentEpoch, + paramsIntentEpoch: manager.layers.get(layer.id).paramsIntentEpoch, + })); + // Inspect this known layer-state key only. Keep its value inside the page; + // evidence receives equality booleans, never stored contents. + const durableBefore = localStorage.getItem('gev:layer-state:v2'); + const state = { ids: [], before: production(), production, durableBefore, collapsed: document.getElementById('data-panel').classList.contains('collapsed') }; + window.__qaDataFocus = state; + if (typeof window.__gevQaRegisterLayer !== 'function' || typeof window.__gevQaUnregisterLayer !== 'function') { + return { ready: false, reason: 'Existing dev-only QA registration seam is unavailable' }; + } + for (const [suffix, stale] of [['on', false], ['status', true]]) { + const id = `qa-keyboard-focus-${suffix}`; + window.__gevQaRegisterLayer(manager, { + id, name: `QA focus ${stale ? 'STALE' : 'ON'}`, icon: '◌', source: 'Local focus fixture', + init() {}, enable() {}, disable() {}, destroy() {}, update() {}, + getStats: () => ({ count: 1, lastUpdate: Date.now() - (stale ? 60_000 : 0), stale, source: 'Local focus fixture' }), + }); + state.ids.push(id); + await manager.setEnabled(id, true, { origin: 'programmatic' }); + } + const transitionId = 'qa-keyboard-focus-transition'; + const transition = { + id: transitionId, + enableCalls: 0, + disableCalls: 0, + releaseEnable: null, + releaseDisable: null, + }; + state.transition = transition; + window.__gevQaRegisterLayer(manager, { + id: transitionId, + name: 'QA focus transition', + icon: '◌', + source: 'Local focus fixture', + init() {}, + enable() { + transition.enableCalls += 1; + return new Promise((resolve) => { + transition.releaseEnable = () => { + transition.releaseEnable = null; + resolve(); + }; + }); + }, + disable() { + transition.disableCalls += 1; + return new Promise((resolve) => { + transition.releaseDisable = () => { + transition.releaseDisable = null; + resolve(); + }; + }); + }, + destroy() {}, + update() {}, + getStats: () => ({ count: 1, lastUpdate: Date.now(), source: 'Local focus fixture' }), + }); + state.ids.push(transitionId); + manager._renderToggles(); + const offId = manager.getAll().find((layer) => !state.ids.includes(layer.id) && layer.showInTogglePanel && !layer.enabled)?.id; + return { + ready: Boolean(offId), offId, fixtureIds: state.ids.slice(0, 2), transitionId, + productionUnchanged: JSON.stringify(state.before) === JSON.stringify(production()), + durableUnchanged: durableBefore === localStorage.getItem('gev:layer-state:v2'), + }; + }); + check('Data Layers: explicit dev fixtures provide status coverage without changing production layers', + dataSetup.ready && dataSetup.productionUnchanged && dataSetup.durableUnchanged, JSON.stringify(dataSetup)); + if (dataSetup.ready) { + for (const width of [1000, 620, 480]) { + await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); + await page.focus('#data-panel .panel-collapse-btn'); + const targets = [[dataSetup.offId, 'OFF', false], [dataSetup.fixtureIds[0], 'ON', true], [dataSetup.fixtureIds[1], 'STALE', true]]; + for (const [id, label, enabled] of targets) { + const navigation = await tabTo(`[data-layer-id="${id}"] .data-toggle-btn`); + const state = await layerFocusState(id); + check(`Data Layers: ${width} px ${label} Tab focus ring is visible`, + navigation.reached && state.layerId === id && hasVisibleControlFocus(state) + && state.label === label && state.buttonActive === enabled && state.lifecycle?.enabled === enabled + && state.lifecycle?.lifecycleState === (enabled ? 'enabled' : 'disabled') && !state.lifecycle?.uncertain, + JSON.stringify({ setup: enabled ? 'dev QA fixture' : 'real production OFF row; not activated', navigation, state })); + await page.screenshot({ path: path.join(shotsDir, `${width}-data-${label.toLowerCase()}-focus.png`) }); + if (label === 'OFF') { + const passive = await page.evaluate((layerId) => { + const probe = window.__qaDataFocus; + const focusBefore = document.activeElement; + const productionBefore = JSON.stringify(probe.production()); + const durableBefore = localStorage.getItem('gev:layer-state:v2'); + window.__godsEyeView.dataManager._refreshTogglePanel(); + return { + focusRetained: document.activeElement === focusBefore + && focusBefore.matches('.data-toggle-btn') + && focusBefore.closest('[data-layer-id]')?.dataset.layerId === layerId, + productionUnchanged: productionBefore === JSON.stringify(probe.production()) + && productionBefore === JSON.stringify(probe.before), + durableUnchanged: durableBefore === localStorage.getItem('gev:layer-state:v2') + && durableBefore === probe.durableBefore, + }; + }, id); + const refreshed = await layerFocusState(id); + check(`Data Layers: ${width} px passive refresh preserves OFF focus and state`, + passive.focusRetained && passive.productionUnchanged && passive.durableUnchanged + && hasVisibleControlFocus(refreshed) && refreshed.label === 'OFF' && !refreshed.lifecycle?.enabled, + JSON.stringify({ passive, refreshed })); + } + if (label === 'STALE') check(`Data Layers: ${width} px native Tab scrolls to the lower status row`, + state.listScrollHeight > state.listClientHeight && state.listScrollTop > 0 && hasVisibleControlFocus(state), JSON.stringify(state)); + } + } + check('Data Layers: focus traversal leaves production visibility unchanged', await page.evaluate(() => ( + JSON.stringify(window.__qaDataFocus.before) === JSON.stringify(window.__qaDataFocus.production()) + && window.__qaDataFocus.durableBefore === localStorage.getItem('gev:layer-state:v2') + ))); + + await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 }); + await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); + await page.focus('#data-panel .panel-collapse-btn'); + const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`); + const transitionBefore = await layerFocusState(dataSetup.transitionId); + check('Data Layers: transition fixture receives native Tab focus', + transitionTab.reached && transitionBefore.label === 'OFF' && hasVisibleControlFocus(transitionBefore), + JSON.stringify({ transitionTab, transitionBefore })); + + await page.keyboard.press('Space'); + await page.waitForFunction((layerId) => { + const state = window.__qaDataFocus?.transition; + const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`); + return state?.enableCalls === 1 && typeof state.releaseEnable === 'function' + && button?.textContent.trim() === 'ENABLING'; + }, { timeout: 5_000 }, dataSetup.transitionId); + const enabling = await layerFocusState(dataSetup.transitionId); + check('Data Layers: focused Space keeps a visible ring through ENABLING', + hasVisibleControlFocus(enabling) && enabling.label === 'ENABLING' && !enabling.disabled + && enabling.ariaDisabled === 'true' && enabling.ariaBusy === 'true', JSON.stringify(enabling)); + const enablingEpoch = await page.evaluate((layerId) => ( + window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch + ), dataSetup.transitionId); + await page.keyboard.press('Space'); + const enablingRepeat = await page.evaluate((layerId) => ({ + calls: window.__qaDataFocus.transition.enableCalls, + epoch: window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch, + }), dataSetup.transitionId); + check('Data Layers: repeated Space is inert while ENABLING', + enablingRepeat.calls === 1 && enablingRepeat.epoch === enablingEpoch, JSON.stringify(enablingRepeat)); + await page.evaluate(() => window.__qaDataFocus.transition.releaseEnable()); + await page.waitForFunction((layerId) => { + const state = window.__godsEyeView.dataManager.getLayerLifecycleState(layerId); + return state.enabled && state.lifecycleState === 'enabled'; + }, { timeout: 5_000 }, dataSetup.transitionId); + const enabled = await layerFocusState(dataSetup.transitionId); + check('Data Layers: settled ON keeps the same visible keyboard focus', + hasVisibleControlFocus(enabled) && !enabled.disabled && enabled.ariaDisabled === 'false' + && enabled.ariaBusy === 'false' && enabled.lifecycle?.enabled, JSON.stringify(enabled)); + + await page.keyboard.press('Space'); + await page.waitForFunction((layerId) => { + const state = window.__qaDataFocus?.transition; + const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`); + return state?.disableCalls === 1 && typeof state.releaseDisable === 'function' + && button?.textContent.trim() === 'DISABLING'; + }, { timeout: 5_000 }, dataSetup.transitionId); + const disabling = await layerFocusState(dataSetup.transitionId); + check('Data Layers: focused Space keeps a visible ring through DISABLING', + hasVisibleControlFocus(disabling) && disabling.label === 'DISABLING' && !disabling.disabled + && disabling.ariaDisabled === 'true' && disabling.ariaBusy === 'true', JSON.stringify(disabling)); + const disablingEpoch = await page.evaluate((layerId) => ( + window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch + ), dataSetup.transitionId); + await page.keyboard.press('Space'); + const disablingRepeat = await page.evaluate((layerId) => ({ + calls: window.__qaDataFocus.transition.disableCalls, + epoch: window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch, + }), dataSetup.transitionId); + check('Data Layers: repeated Space is inert while DISABLING', + disablingRepeat.calls === 1 && disablingRepeat.epoch === disablingEpoch, JSON.stringify(disablingRepeat)); + await page.evaluate(() => window.__qaDataFocus.transition.releaseDisable()); + await page.waitForFunction((layerId) => { + const state = window.__godsEyeView.dataManager.getLayerLifecycleState(layerId); + return !state.enabled && state.lifecycleState === 'disabled'; + }, { timeout: 5_000 }, dataSetup.transitionId); + const disabled = await layerFocusState(dataSetup.transitionId); + check('Data Layers: settled OFF keeps the same visible keyboard focus', + hasVisibleControlFocus(disabled) && !disabled.disabled && disabled.ariaDisabled === 'false' + && disabled.ariaBusy === 'false' && !disabled.lifecycle?.enabled, JSON.stringify(disabled)); + } + } finally { + const cleaned = await page.evaluate(async () => { + const state = window.__qaDataFocus; + if (!state) return { complete: false, reason: 'No fixture setup state' }; + const manager = window.__godsEyeView.dataManager; + state.transition?.releaseEnable?.(); + state.transition?.releaseDisable?.(); + const removed = []; + for (const id of state.ids) removed.push(await window.__gevQaUnregisterLayer(manager, id)); + manager._renderToggles(); + window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', state.collapsed, { persist: false, syncShare: false }); + // A native user-origin toggle legitimately asks the production state + // coordinator to persist. Restore the exact pre-fixture value so this + // hermetic QA journey leaves the user's durable layer snapshot untouched. + if (state.durableBefore === null) localStorage.removeItem('gev:layer-state:v2'); + else localStorage.setItem('gev:layer-state:v2', state.durableBefore); + const result = { + complete: removed.every(Boolean) && state.ids.every((id) => !manager.layers.has(id)), + productionUnchanged: JSON.stringify(state.before) === JSON.stringify(state.production()), + durableUnchanged: state.durableBefore === localStorage.getItem('gev:layer-state:v2'), + }; + delete window.__qaDataFocus; + return result; + }); + check('Data Layers: fixture teardown restores the original production registry and visibility', + cleaned.complete && cleaned.productionUnchanged && cleaned.durableUnchanged, JSON.stringify(cleaned)); + await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 }); + } + check('no new page or console errors', consoleErrors.length === 0, consoleErrors.join(' | ')); } finally { await browser.close(); diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 550500315..50d5b50c6 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -29,7 +29,7 @@ const HEADFUL = args.includes('--headful'); const chromeCandidates = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', ].filter(Boolean); diff --git a/scripts/qa-sprites-b5.mjs b/scripts/qa-sprites-b5.mjs index bc7d8f1e3..93eb6eb02 100644 --- a/scripts/qa-sprites-b5.mjs +++ b/scripts/qa-sprites-b5.mjs @@ -70,7 +70,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-traffic-baseline.mjs b/scripts/qa-traffic-baseline.mjs index b39759507..7059bf438 100644 --- a/scripts/qa-traffic-baseline.mjs +++ b/scripts/qa-traffic-baseline.mjs @@ -55,7 +55,7 @@ const DEGRADED_DELAY_MS = Math.max(0, getNumberOpt('--degraded-delay-ms', 2500)) const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-traffic-jamviz-ab.mjs b/scripts/qa-traffic-jamviz-ab.mjs index 72c540f81..73d28ca60 100644 --- a/scripts/qa-traffic-jamviz-ab.mjs +++ b/scripts/qa-traffic-jamviz-ab.mjs @@ -61,7 +61,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-traffic-preset-ab.mjs b/scripts/qa-traffic-preset-ab.mjs index f2c1d7352..44319af0d 100644 --- a/scripts/qa-traffic-preset-ab.mjs +++ b/scripts/qa-traffic-preset-ab.mjs @@ -77,7 +77,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 3f52c5c6e..04a4e8927 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -53,7 +53,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index 04ad124e3..51978292e 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -184,7 +184,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-vessel-datum.mjs b/scripts/qa-vessel-datum.mjs index ccd1464a0..188f447c7 100644 --- a/scripts/qa-vessel-datum.mjs +++ b/scripts/qa-vessel-datum.mjs @@ -61,7 +61,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/scripts/qa-view-target-prewarm.mjs b/scripts/qa-view-target-prewarm.mjs index c44cab207..272c2288e 100644 --- a/scripts/qa-view-target-prewarm.mjs +++ b/scripts/qa-view-target-prewarm.mjs @@ -32,7 +32,7 @@ fs.mkdirSync(shotsDir, { recursive: true }); const chromeCandidates = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean); const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate)); diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 994026249..4c5c9dedc 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -39,7 +39,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const CHROME_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', ].filter(Boolean); diff --git a/scripts/qa-voice-wav.mjs b/scripts/qa-voice-wav.mjs index ead1aec59..b75524e7a 100644 --- a/scripts/qa-voice-wav.mjs +++ b/scripts/qa-voice-wav.mjs @@ -30,7 +30,7 @@ if (fixtureSha256 !== expectedFixtureSha256) { const appOrigin = new URL(appUrl).origin; const browser = await puppeteer.launch({ headless: 'new', - executablePath: puppeteer.executablePath(), + executablePath: await puppeteer.executablePath(), args: [ '--no-sandbox', '--disable-setuid-sandbox', diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 51c78654f..4b1fd7df8 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -118,7 +118,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs new file mode 100644 index 000000000..56d5c9d33 --- /dev/null +++ b/src/cockpitSignalFocus.test.mjs @@ -0,0 +1,223 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js'; + +const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +const renderStart = source.indexOf(' renderCockpitSignals() {'); +const renderEnd = source.indexOf(' setContextCollapsed(', renderStart); +assert.ok(renderStart >= 0 && renderEnd > renderStart); +const clickSource = source.match(/this\._listen\(this\.signalList, 'click', (\(event\) => \{[\s\S]*?\n \})\);/)?.[1]; +assert.ok(clickSource, 'the installed signal click route exists'); + +// Model native focus loss on removal AND on ordinary DOM moves. Keeping an +// object reference alone is insufficient if reconciliation disconnects it. +function fixture() { + const document = { activeElement: null }; + let focusLosses = 0; + let focusCalls = 0; + class Node { + constructor(tagName) { + this.tagName = tagName.toUpperCase(); + this.children = []; + this.parentNode = null; + this.dataset = {}; + this.attributes = new Map(); + this.className = ''; + this._text = ''; + this.classList = { + add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); }, + }; + } + get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); } + get firstElementChild() { return this.children[0] || null; } + get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; } + get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); } + set textContent(value) { this.replaceChildren(); this._text = String(value); } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + getAttribute(name) { return this.attributes.get(name) ?? null; } + contains(node) { return this === node || this.children.some((child) => child.contains(node)); } + append(...nodes) { for (const node of nodes) this.insertBefore(node, null); } + insertBefore(node, anchor) { + assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent'); + if (node === anchor) return node; + node.remove(); + const index = anchor === null ? this.children.length : this.children.indexOf(anchor); + this.children.splice(index, 0, node); + node.parentNode = this; + return node; + } + remove() { + if (!this.parentNode) return; + if (this.contains(document.activeElement)) { + document.activeElement = document.body; + focusLosses += 1; + } + this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1); + this.parentNode = null; + } + replaceChildren(...nodes) { + for (const child of [...this.children]) child.remove(); + this._text = ''; + this.append(...nodes); + } + matches(selector) { + if (selector === 'button[data-signal-layer][data-signal-id]') { + return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null; + } + return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1)) + : this.tagName === selector.toUpperCase(); + } + closest(selector) { + for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node; + return null; + } + querySelectorAll(selector) { + return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]); + } + focus() { + assert.ok(this.isConnected, 'a focus destination must still be connected'); + focusCalls += 1; + document.activeElement = this; + } + } + document.body = new Node('body'); + document.createElement = (tag) => new Node(tag); + document.activeElement = document.body; + const controller = new (new Function('document', 'formatAwarenessLabel', + `return class { ${source.slice(renderStart, renderEnd)} };`)(document, formatAwarenessLabel))(); + controller.signalList = new Node('ol'); + controller.signalToggle = new Node('button'); + controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')]; + controller.briefPageIndex = 0; + controller.signalItems = []; + controller.signalSignatures = new Map(); + controller.scheduleContextLayout = () => {}; + const display = new Node('button'); + const radio = new Node('button'); + const outside = new Node('button'); + document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside); + const selected = []; + const click = new Function('militaryAwarenessLayer', `return ${clickSource};`)({ + focusTarget: (...args) => selected.push(args), + }); + const buttons = () => controller.signalList.querySelectorAll('button'); + const tab = () => { + const nodes = document.body.querySelectorAll('button'); + const index = nodes.indexOf(document.activeElement); + nodes[(index + 1) % nodes.length].focus(); + }; + return { controller, document, buttons, display, radio, outside, selected, click, tab, + losses: () => focusLosses, focusCalls: () => focusCalls }; +} + +const item = (id, overrides = {}) => ({ + key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM', + target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides, +}); +const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); }; +const snapshot = (distanceM = 2000) => ({ + evaluatedAt: 1_700_000_000_000, + subject: { layerId: 'flights', id: 'current', label: 'CURRENT' }, + cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }], +}); + +test('actual Context refresh retains the focused signal button across unchanged and changed details', () => { + const f = fixture(); + f.controller.updateCockpitSignals(snapshot(), 0); + const target = f.buttons()[1]; target.focus(); + const calls = f.focusCalls(); + for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0); + assert.equal(f.buttons()[1], target); + assert.equal(f.document.activeElement, target); + assert.equal(f.losses(), 0); + assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it'); + assert.match(f.controller.signalList.textContent, /2\.5 KM/); +}); + +test('signal content and accessible name update without replacing the active target', () => { + const f = fixture(); render(f, [item('a')]); + const target = f.buttons()[0]; target.focus(); + render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]); + assert.equal(f.buttons()[0], target); + assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED'); + assert.match(target.textContent, /RENAMED/); + assert.match(f.controller.signalList.textContent, /CURRENT/); + assert.equal(f.controller.signalList.children[0].className, 'track actionable'); + assert.equal(f.document.activeElement, target); + assert.equal(f.losses(), 0); +}); + +test('signal ranking changes move surrounding rows without disconnecting the focused row', () => { + const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id))); + const target = f.buttons()[1]; target.focus(); + const calls = f.focusCalls(); + for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) { + render(f, order.map((id) => item(id))); + assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order); + assert.equal(f.document.activeElement, target); + assert.equal(f.losses(), 0); + } + assert.equal(f.focusCalls(), calls); +}); + +test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => { + const f = fixture(); render(f, [item('a'), item('b')]); + const target = f.buttons()[1]; target.focus(); + f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE'); + assert.equal(f.document.activeElement, target); + assert.equal(f.losses(), 0); + const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } }; + f.click(event); + assert.equal(event.prevented, true); + assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]); +}); + +test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => { + const f = fixture(); render(f, [item('a'), item('b')]); + const removed = f.buttons()[1]; removed.focus(); + const calls = f.focusCalls(); + render(f, [item('a')]); + assert.equal(removed.isConnected, false); + assert.equal(f.document.activeElement, f.controller.briefTabs[0]); + assert.equal(f.focusCalls(), calls + 1); + f.tab(); f.tab(); f.tab(); + assert.equal(f.document.activeElement, f.display); + render(f, [item('a', { detail: 'UPDATED' })]); + assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back'); + f.tab(); + assert.equal(f.document.activeElement, f.radio); +}); + +test('a changed target cannot reuse a focused action for another flight', () => { + const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]); + const removed = f.buttons()[0]; removed.focus(); + render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]); + const target = f.buttons()[0]; + assert.notEqual(target, removed); + assert.equal(removed.isConnected, false); + assert.equal(f.document.activeElement, f.controller.briefTabs[0]); + f.click({ target, preventDefault() {} }); + assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]); +}); + +test('empty, informational and duplicate-key rows have no stale or aliased actions', () => { + const f = fixture(); + render(f, [item('a'), item('a')]); + const [first, second] = f.buttons(); + assert.notEqual(first, second); + second.focus(); + render(f, [item('a'), item('a')]); + assert.deepEqual(f.buttons(), [first, second]); + assert.equal(f.document.activeElement, second); + render(f, [item('status', { target: null, title: 'UNKNOWN' })]); + assert.equal(f.buttons().length, 0); + assert.match(f.controller.signalList.textContent, /UNKNOWN/); + assert.equal(f.document.activeElement, f.controller.briefTabs[0]); + f.outside.focus(); + render(f, []); + assert.equal(f.controller.signalList.children.length, 0); + render(f, [item('a')]); + assert.notEqual(f.buttons()[0], first); + assert.equal(f.document.activeElement, f.outside); +}); diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs new file mode 100644 index 000000000..7547e0f52 --- /dev/null +++ b/src/contextTabKeyboard.test.mjs @@ -0,0 +1,161 @@ +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + +test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => { + for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) { + const button = html.match(new RegExp(``; + return ``; }).join(''); + list.querySelectorAll('[data-mission-roster-index]').forEach((button) => { + const index = Number(button.dataset.missionRosterIndex); + bindMissionRosterItemKeyboardPreview( + button, + index, + _missionRosterPreviewOwnership, + _missionRoster, + ); + }); + restoreMissionRosterFocus( + list, + focusSnapshot, + _missionRoster.querySelector('[data-mission-roster-focus-continuation]'), + ); } function escapeMissionText(value) { @@ -2382,7 +2515,7 @@ function selectMissionAt(index) { focusMission(launch); } -function clearMissionRosterHover() { +function clearMissionRosterPreviewState() { if (_missionRosterHoverTimer) clearTimeout(_missionRosterHoverTimer); _missionRosterHoverTimer = null; const changed = _hoveredRosterLaunchId !== null; @@ -2390,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); @@ -2412,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); } @@ -2497,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]') @@ -2504,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'; @@ -3537,11 +3664,8 @@ const rocketLaunchesLayer = { _missionPanel = null; if (_missionRoster) { _missionRoster.onclick = null; - _missionRoster.onmouseover = null; - _missionRoster.onfocusin = null; - _missionRoster.onmouseleave = null; - _missionRoster.onfocusout = null; } + _missionRosterPreviewOwnership = null; _missionRoster = null; _viewer = null; if (_dataSource) viewer.dataSources.remove(_dataSource, true); diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index dd1ae4c3c..07b7666f6 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -6,9 +6,12 @@ import rocketLaunchesLayer, { _setRocketMissionOverlayHostForTest, _setSelectedRocketMissionForTest, approximateOrbitPath, + bindMissionRosterItemKeyboardPreview, + captureMissionRosterFocus, buildMissionPaths, cameraHeadingForPath, compactLaunchSiteName, + createMissionRosterPreviewOwnership, createRocketMissionElementOverlayEntry, createRocketMissionMarkerOverlayEntry, formatMissionEventTime, @@ -41,6 +44,8 @@ import rocketLaunchesLayer, { replayAscentDurationSeconds, replayStartAfterPause, replayVehicleScreenRotation, + resolveMissionRosterPreviewLaunch, + restoreMissionRosterFocus, releaseAircraftTracking, ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, @@ -490,6 +495,150 @@ test('preserves globe scale for roster hover previews', () => { assert.equal(missionHoverPreviewRange(Number.NaN), 5000000); }); +test('keyboard focus previews roster missions and clears without selecting them', () => { + const calls = []; + const ownership = createMissionRosterPreviewOwnership({ + preview: (index) => calls.push(['preview', index]), + clear: () => calls.push(['clear']), + }); + + ownership.focus(2); + ownership.blur(3); + ownership.blur(); + + assert.deepEqual(calls, [ + ['preview', 2], + ['preview', 3], + ['clear'], + ]); +}); + +test('rendered mission row focus and blur drive the preview ownership controller', () => { + const listeners = new Map(); + const calls = []; + const button = { + addEventListener(type, listener) { + listeners.set(type, listener); + }, + }; + const nextButton = { + dataset: { missionRosterIndex: '4' }, + closest: () => nextButton, + }; + const roster = { contains: (candidate) => candidate === nextButton }; + const ownership = { + pointerEnter: (index) => calls.push(['pointer-enter', index]), + pointerLeave: () => calls.push(['pointer-leave']), + focus: (index) => calls.push(['focus', index]), + blur: (index) => calls.push(['blur', index]), + }; + + bindMissionRosterItemKeyboardPreview(button, 2, ownership, roster); + listeners.get('mouseenter')(); + listeners.get('mouseleave')(); + listeners.get('focus')(); + listeners.get('blur')({ relatedTarget: nextButton }); + listeners.get('blur')({ relatedTarget: null }); + + assert.deepEqual(calls, [ + ['pointer-enter', 2], + ['pointer-leave'], + ['focus', 2], + ['blur', 4], + ['blur', null], + ]); +}); + +test('leaving a hovered row for an internal roster gap restores keyboard preview', () => { + const calls = []; + const ownership = createMissionRosterPreviewOwnership({ + preview: (index) => calls.push(['preview', index]), + clear: () => calls.push(['clear']), + }); + + ownership.focus(1); + ownership.pointerEnter(0); + ownership.pointerLeave(); + + assert.deepEqual(calls, [ + ['preview', 1], + ['preview', 0], + ['preview', 1], + ]); +}); + +test('mission roster refresh restores keyboard focus by stable mission identity after reorder', () => { + const focused = { + dataset: { missionRosterId: 'mission-b' }, + closest: () => focused, + }; + let restored = false; + const replacement = { + dataset: { missionRosterId: 'mission-b' }, + focus: ({ preventScroll }) => { restored = preventScroll; }, + }; + const list = { + contains: (candidate) => candidate === focused, + querySelectorAll: () => [ + { dataset: { missionRosterId: 'mission-c' } }, + replacement, + { dataset: { missionRosterId: 'mission-a' } }, + ], + }; + + const snapshot = captureMissionRosterFocus(list, focused); + assert.deepEqual(snapshot, { launchId: 'mission-b' }); + assert.equal(restoreMissionRosterFocus(list, snapshot, null), 'restored'); + assert.equal(restored, true); +}); + +test('mission roster refresh continues after the list when the focused mission departed', () => { + const focused = { + dataset: { missionRosterId: 'departed' }, + closest: () => focused, + }; + let continuationFocused = false; + const list = { + contains: (candidate) => candidate === focused, + querySelectorAll: () => [{ dataset: { missionRosterId: 'remaining' } }], + }; + const continuation = { + focus: ({ preventScroll }) => { continuationFocused = preventScroll; }, + }; + + const snapshot = captureMissionRosterFocus(list, focused); + assert.equal(restoreMissionRosterFocus(list, snapshot, continuation), 'continued'); + assert.equal(continuationFocused, true); +}); + +test('pending mission preview resolves the current refreshed record by identity', () => { + const stale = { id: 'mission-a', name: 'Stale' }; + const current = { id: 'mission-a', name: 'Current' }; + + assert.equal(resolveMissionRosterPreviewLaunch([current], stale.id), current); + assert.equal(resolveMissionRosterPreviewLaunch([], stale.id), null); +}); + +test('latest roster input owns preview and restores the remaining owner on leave', () => { + const calls = []; + const ownership = createMissionRosterPreviewOwnership({ + preview: (index) => calls.push(['preview', index]), + clear: () => calls.push(['clear']), + }); + + ownership.pointerEnter(0); + ownership.focus(1); + ownership.blur(); + ownership.pointerLeave(); + + assert.deepEqual(calls, [ + ['preview', 0], + ['preview', 1], + ['preview', 0], + ['clear'], + ]); +}); + test('assigns distinct stable colors to mission operators', () => { assert.equal(missionMarkerColor({ provider: 'NASA', name: 'Science Flight' }).toCssColorString(), 'rgb(255,159,67)'); assert.equal(missionMarkerColor({ provider: 'SpaceX', name: 'Starlink Group' }).toCssColorString(), 'rgb(76,201,240)'); @@ -1253,3 +1402,16 @@ test('disable reports a semantic failure while restoring the satellites dependen /could not restore the satellites layer/, ); }); + + +test('missing payload details stay unknown without inventing mass or classification', () => { + for (const [mass, expected] of [[null, null], [undefined, null], ['', null], [' ', null], + [false, null], [-1, null], ['invalid', null], [0, 0], ['125', 125]]) { + const [launch] = normalizeRocketLaunches([{ id: 'mass', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z', + payloads: [{ mass }] }], NOW); + assert.equal(launch.payloads[0].massKg, expected); + assert.equal(launch.payloads[0].name, 'Unnamed payload'); + } + const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW); + assert.deepEqual(launch.payloads, []); +}); diff --git a/src/devCctv.test.mjs b/src/devCctv.test.mjs new file mode 100644 index 000000000..a333a4fe3 --- /dev/null +++ b/src/devCctv.test.mjs @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const run = promisify(execFile); +const bashTest = process.platform === 'win32' ? test.skip : test; + +async function launch(overrides = {}, dotenv = '') { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gev-cctv-launch-')); + try { + await fs.mkdir(path.join(root, 'scripts')); + await fs.mkdir(path.join(root, 'bin')); + await fs.mkdir(path.join(root, 'src', 'data'), { recursive: true }); + await fs.copyFile(new URL('./data/cctv.js', import.meta.url), path.join(root, 'src', 'data', 'cctv.js')); + for (const name of ['dev-cctv.sh', 'dev-fresh.sh', 'read-dotenv-value.mjs']) { + await fs.copyFile(new URL(`../scripts/${name}`, import.meta.url), path.join(root, 'scripts', name)); + } + await fs.copyFile(new URL('./main.js', import.meta.url), path.join(root, 'src', 'main.js')); + await fs.mkdir(path.join(root, 'node_modules')); + await fs.symlink(fileURLToPath(new URL('../node_modules/vite', import.meta.url)), path.join(root, 'node_modules', 'vite'), 'dir'); + await fs.writeFile(path.join(root, '.env'), dotenv); + // Stub only external programs; both production launchers and dotenv parsing run. + for (const command of ['security', 'pkill', 'lsof']) { + await fs.writeFile(path.join(root, 'bin', command), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + } + await fs.writeFile(path.join(root, 'bin', 'npm'), `#!/usr/bin/env node +const fs = require('node:fs'); +fs.writeFileSync(process.env.CCTV_TEST_CAPTURE, JSON.stringify({ args: process.argv.slice(2), env: process.env, cwd: process.cwd() })); +`, { mode: 0o755 }); + const capture = path.join(root, 'capture.json'); + const result = await run('bash', [path.join(root, 'scripts', 'dev-cctv.sh')], { + cwd: os.tmpdir(), + env: { PATH: `${path.join(root, 'bin')}${path.delimiter}${process.env.PATH}`, CCTV_TEST_CAPTURE: capture, ...overrides }, + timeout: 30_000, + }); + return { ...JSON.parse(await fs.readFile(capture, 'utf8')), output: result.stdout + result.stderr, root }; + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +bashTest('CCTV preset starts keyless on localhost through the normal launcher', async () => { + const result = await launch(); + assert.deepEqual(result.args, ['run', 'dev', '--', '--host', 'localhost', '--port', '4173', '--force']); + assert.equal(result.cwd, result.root); + assert.equal(result.env.CCTV_SOURCES_FILE, 'config/cctv_sources.austin.json'); + assert.equal(result.env.CCTV_PREFER_AUSTIN, '1'); + assert.equal(result.env.CCTV_AUSTIN_MAX_SOURCES, '36'); + assert.equal(result.env.CCTV_MAX_SOURCES, '48'); + assert.equal(result.env.GEV_LAUNCHER, 'dev-fresh'); + assert.equal(result.env.GEV_KEY_SETUP_EXTERNAL_KEYS, ''); + assert.equal(result.env.GOOGLE_MAPS_API_KEY, undefined); + assert.match(result.output, /Startup map: Esri World Imagery/); + assert.doesNotMatch(result.output, /!! WARNING/); +}); + +bashTest('CCTV preset preserves explicit LAN and source overrides with a warning', async () => { + const result = await launch({ HOST: '0.0.0.0', PORT: '4999', CCTV_SOURCES_FILE: 'config/custom.json', CCTV_PREFER_AUSTIN: '0', CCTV_AUSTIN_MAX_SOURCES: '5', CCTV_MAX_SOURCES: '9', CCTV_CALTRANS_DISTRICTS: '', CCTV_TFL_ENABLED: '0' }); + assert.deepEqual(result.args.slice(-5), ['--host', '0.0.0.0', '--port', '4999', '--force']); + assert.equal(result.env.CCTV_SOURCES_FILE, 'config/custom.json'); + assert.equal(result.env.CCTV_PREFER_AUSTIN, '0'); + assert.equal(result.env.CCTV_AUSTIN_MAX_SOURCES, '5'); + assert.equal(result.env.CCTV_MAX_SOURCES, '9'); + assert.equal(result.env.CCTV_CALTRANS_DISTRICTS, ''); + assert.equal(result.env.CCTV_TFL_ENABLED, '0'); + assert.match(result.output, /!! WARNING: HOST=0\.0\.0\.0/); +}); + +bashTest('CCTV preset shares dotenv precedence and names-only credential provenance', async () => { + const result = await launch({ GOOGLE_MAPS_API_KEY: 'fixture-shell-maps' }, 'GOOGLE_MAPS_API_KEY=fixture-file-maps\nOPENAI_API_KEY=fixture-file-voice\n'); + assert.equal(result.env.GOOGLE_MAPS_API_KEY, 'fixture-shell-maps'); + assert.equal(result.env.OPENAI_API_KEY, 'fixture-file-voice'); + assert.equal(result.env.GEV_KEY_SETUP_EXTERNAL_KEYS, 'GOOGLE_MAPS_API_KEY'); + assert.doesNotMatch(result.output, /fixture-shell-maps|fixture-file-maps|fixture-file-voice/); +}); diff --git a/src/hudA11y.test.mjs b/src/hudA11y.test.mjs new file mode 100644 index 000000000..c2ef2e6dc --- /dev/null +++ b/src/hudA11y.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + +// Focused markup guards; actual computed names are checked in Chromium. +// Native labels and hidden inputs must not be treated as missing aria-labels. +test('HUD sliders and location search have descriptive explicit names', () => { + for (const [id, name] of [ + ['scope-feather-slider', 'Scope edge feather'], + ['bloom-intensity-slider', 'Bloom intensity'], + ['sharpen-intensity-slider', 'Sharpen intensity'], + ['location-search', 'Search location by name or coordinates'], + ]) { + const input = html.match(new RegExp(`]*\\bid="${id}"[^>]*>`))?.[0]; + assert.ok(input, `${id} exists`); + assert.ok(input.includes(`aria-label="${name}"`), `${id} has its descriptive name`); + } +}); + +test('the first-run checkbox keeps its native visible label', () => { + assert.match(html, /]*class="first-run-suppress"[^>]*>\s*\s*Don't show this again<\/span>\s*<\/label>/); +}); + +test('generated style sliders use the visible parameter label as their name', () => { + assert.match(ui, /label\.textContent\s*=\s*uMeta\.label;/); + assert.match(ui, /slider\.setAttribute\(['"]aria-label['"],\s*uMeta\.label\)/); +}); diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs new file mode 100644 index 000000000..3e6cfa541 --- /dev/null +++ b/src/keyboardFocusStyles.test.mjs @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + +function ruleBody(selector) { + const start = css.indexOf(selector); + assert.ok(start >= 0, `${selector} rule exists`); + const open = css.indexOf('{', start); + const close = css.indexOf('}', open); + return css.slice(open + 1, close); +} + +function ruleText(selector) { + const start = css.indexOf(selector); + assert.ok(start >= 0, `${selector} rule exists`); + const close = css.indexOf('}', css.indexOf('{', start)); + return css.slice(start, close + 1); +} + +function ruleBodyContaining(selector, declaration) { + let start = -1; + while ((start = css.indexOf(selector, start + 1)) >= 0) { + const open = css.indexOf('{', start); + const close = css.indexOf('}', open); + const body = css.slice(open + 1, close); + if (body.includes(declaration)) return body; + } + assert.fail(`${selector} rule containing ${declaration} exists`); +} + +test('the global keyboard ring survives local active and outline-reset rules', () => { + const rule = ruleText(':where(\n button,'); + assert.match(rule, /outline:\s*2px solid var\(--text-primary\) !important;/); + for (const selector of [ + "[role='button']", + "[role='radio']", + "[role='slider']", + "[role='tab']", + '[tabindex]', + 'a[href]', + ]) assert.ok(rule.includes(selector), `${selector} receives the global ring`); +}); + +test('Location city, POI, search toggle and search field use an inset ring', () => { + const rule = ruleText('.location-pill:focus-visible,'); + assert.match(rule, /\.poi-pill:focus-visible/); + assert.match(rule, /\.search-toggle-btn:focus-visible/); + assert.match(rule, /#location-search:focus-visible/); + assert.match(rule, /outline:\s*2px solid var\(--text-primary\)/); + assert.match(rule, /outline-offset:\s*-3px/); + for (const selector of ['.location-pill {', '.poi-pill {', '.search-toggle-btn {']) { + assert.doesNotMatch(ruleBody(selector), /transition:\s*all\b/); + } +}); + +test('Cockpit Display and Radio launcher glyphs have a complete inset ring', () => { + const body = ruleBody('.cockpit-utility-glyph:focus-visible'); + assert.match(body, /outline:\s*2px solid var\(--text-primary\)/); + assert.match(body, /outline-offset:\s*-3px/); + assert.doesNotMatch(body, /outline:\s*none/); +}); + +test('focusable controls do not animate the global outline', () => { + for (const selector of [ + '.panel-collapse-btn {', + '#top-center-actions button {', + '.data-toggle-chip {', + '.scene-btn {', + '.scene-shot-btn {', + ]) { + assert.doesNotMatch( + ruleBody(selector), + /transition:\s*all\b/, + `${selector} must leave the focus outline immediate`, + ); + } +}); + +test('opening a dock popover makes its controls keyboard-reachable immediately', () => { + const base = ruleBodyContaining('#command-dock .dock-popover-content {', 'visibility: hidden'); + assert.match(base, /visibility\s+0s\s+linear\s+180ms/); + const open = ruleText('#command-dock #location-bar:not(.collapsed) .dock-popover-content,'); + assert.match(open, /transition-delay:\s*0s/); +}); diff --git a/src/main.js b/src/main.js index 84fbf12fe..0146a7f4c 100644 --- a/src/main.js +++ b/src/main.js @@ -17,6 +17,7 @@ import militaryAwarenessLayer from './data/militaryAwareness.js'; import localDataLayers from './data/localLayers.js'; import { LAYER_STATE_REGISTRY } from './data/layerState.js'; import { registerDataCredits } from './data/dataCredits.js'; +import { configureCreditKeyboardAccess } from './creditKeyboard.js'; import { SceneDirector } from './scenes/director.js'; import { initGevVoiceCommands } from './voice/gevRealtime.js'; import { MapStackController } from './mapStackController.js'; @@ -131,6 +132,7 @@ async function init() { // expandable bottom-left credit lightbox (showOnScreen=false), so they never // clutter the on-globe line. See docs/pre-ship-audit-2026-07-01.md H11. registerDataCredits(viewer); + configureCreditKeyboardAccess(document); // Hide Cesium's default globe — Google Photorealistic 3D Tiles provide their own // globe at all LODs (street level → orbital). The default globe's 2D imagery diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs new file mode 100644 index 000000000..78e3e0fad --- /dev/null +++ b/src/mapSourceFocus.test.mjs @@ -0,0 +1,387 @@ +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 markup = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const locationMarkup = markup.slice(markup.indexOf('
]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/); +const locationToggleAttributes = Object.fromEntries( + [...(locationToggleMarkup?.[1] || '').matchAll(/([\w-]+)="([^"]*)"/g)].map((match) => [match[1], match[2]]), +); +const initStart = source.indexOf(' _initAutoHoverPanel('); +const initEnd = source.indexOf(' /**\n * Sets up drag-to-reposition', initStart); +const escapeStart = source.indexOf(' _collapsePanelOnEscape('); +const escapeEnd = source.indexOf(' _initCommandDockPins(', escapeStart); +const closeStart = source.indexOf(' setPanelCollapsed(panelId, collapsed, {'); +const closeEnd = source.indexOf(' /**\n * Toggles "clean view"', closeStart); +const syncStart = source.indexOf(' _syncPanelCollapseButton(panelEl) {'); +const syncEnd = source.indexOf(' /**\n * Converts a panel', syncStart); +assert.ok(initStart >= 0 && initEnd > initStart && escapeStart >= 0 && escapeEnd > escapeStart + && 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 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, 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, 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' }; + 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 === '[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; + }; + 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); + if (locationDisclosure) locationDisclosure.focus = () => focus(locationDisclosure); + 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(escapeStart, escapeEnd)},\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, 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(); }, + }; +} + +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('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); + 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); +}); + + +test('Location markup provides one named native disclosure linked to its popover', () => { + assert.ok(locationToggleMarkup, 'Location must expose a native button in the real markup'); + assert.equal(locationToggleAttributes.type, 'button'); + assert.equal(locationToggleAttributes['data-dock-toggle-target'], 'location-bar'); + assert.equal(locationToggleAttributes['aria-controls'], 'location-bar-popover'); + assert.equal(locationToggleAttributes['aria-expanded'], 'false'); + assert.match(locationToggleAttributes['aria-label'], /expand.*location/i); + assert.match(locationToggleAttributes.class, /(?:^|\s)dock-tray-toggle(?:\s|$)/); + assert.match(locationToggleMarkup[2], /class="location-toolbar-label"/); + assert.doesNotMatch(locationToggleMarkup[2], //); +}); + +test('Location Enter stays immediate while Space waits for native key release', () => { + for (const key of ['Enter', ' ']) { + const h = harness(); h.focus(h.locationDisclosure); h.drain(); + const event = h.locationKey(key); + assert.equal(event.defaultPrevented, key === 'Enter'); + assert.equal(event.propagationStopped, key === 'Enter'); + if (key === ' ') { + assert.equal(h.location.classList.contains('collapsed'), true); + h.locationKeyUp(key); + } + assert.equal(h.location.classList.contains('collapsed'), false); + assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'true'); + assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION'); + assert.equal(h.document.activeElement, h.locationDisclosure); + assert.equal(h.timers.size, 0); + assert.equal(h.calls(), 0); + const repeated = h.locationKey(key, true); + assert.equal(repeated.defaultPrevented, key === 'Enter'); + assert.equal(repeated.propagationStopped, key === 'Enter'); + assert.equal(h.location.classList.contains('collapsed'), false); + assert.equal(h.timers.size, 0); + const release = key === ' ' ? h.locationKeyUp(key, true) : h.emit(h.locationDisclosure, 'keyup', { key }); + assert.equal(release.defaultPrevented, false, 'global voice release must remain reachable'); + assert.equal(release.propagationStopped, false); + h.locationKey(key); + if (key === ' ') h.locationKeyUp(key); + assert.equal(h.location.classList.contains('collapsed'), true); + assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Expand LOCATION'); + } +}); + +test('Location synthesized click and Escape retain focus and its accessible name', () => { + const h = harness(); h.focus(h.locationDisclosure); h.drain(); + h.emit(h.locationDisclosure, 'click', { detail: 0 }); + assert.equal(h.location.classList.contains('collapsed'), false); + assert.equal(h.calls(), 0); + assert.equal(h.timers.size, 0); + h.focus(h.locationSearch); + const escape = h.locationEscape(); + assert.equal(escape.defaultPrevented, true); + assert.equal(h.location.classList.contains('collapsed'), true); + assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'false'); + assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Expand LOCATION'); + assert.equal(h.locationDisclosure.title, 'Expand LOCATION'); + assert.equal(h.document.activeElement, h.locationDisclosure); + h.drain(); + assert.equal(h.calls(), 0); +}); + +test('Location keyboard opening cancels the unpinned sibling and cannot revive its old callback', () => { + const h = harness({ hidden: true }); h.key(); h.tick(); + const stale = [...h.timers.values()][0].callback; + h.focus(h.locationDisclosure); h.locationKey(); + assert.equal(h.panel.classList.contains('collapsed'), true); + assert.equal(h.location.classList.contains('collapsed'), false); + const attemptsBefore = h.calls(); + h.show(); stale(); h.drain(); + assert.equal(h.calls(), attemptsBefore); + assert.equal(h.document.activeElement, h.locationDisclosure); +}); + +test('Location keyboard opening preserves a pinned sibling and a pinned Location survives sibling opening', () => { + const h = harness(); h.panel.classes.add('dock-pinned'); h.key(); h.tick(); + h.focus(h.locationDisclosure); h.locationKey(); h.drain(); + assert.equal(h.panel.classList.contains('collapsed'), false); + assert.equal(h.location.classList.contains('collapsed'), false); + assert.equal(h.document.activeElement, h.locationDisclosure); + h.location.classes.add('dock-pinned'); + h.manager.setPanelCollapsed('control-panel', true); + h.focus(h.disclosure); h.key(); h.tick(); + assert.equal(h.location.classList.contains('collapsed'), false); + assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'true'); + assert.equal(h.document.activeElement, h.active); +}); + +test('Location restoration syncs its real name without claiming focus, collapsing its sibling or persisting', () => { + const h = harness(); h.key(); h.tick(); + const saves = h.saves.length; + const claims = h.claims.length; + const shareSyncs = h.shareSyncs(); + h.manager.setPanelCollapsed('location-bar', false, { restore: true, persist: false, syncShare: false }); + assert.equal(h.panel.classList.contains('collapsed'), false); + assert.equal(h.location.classList.contains('collapsed'), false); + assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION'); + assert.equal(h.document.activeElement, h.active); + assert.equal(h.saves.length, saves); + assert.equal(h.claims.length, claims); + assert.equal(h.shareSyncs(), shareSyncs); + h.locationDisclosure.setAttribute('aria-label', 'stale'); + h.manager.setPanelCollapsed('location-bar', false, { restore: true, persist: false, syncShare: false }); + assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION', 'same-state synchronization keeps the Location name'); + assert.equal(h.timers.size, 0); +}); + +test('disposed UI cannot complete a pending handoff', () => { + const h = harness({ hidden: true }); h.key(); h.tick(); + 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/panelEscape.test.mjs b/src/panelEscape.test.mjs new file mode 100644 index 000000000..888267a75 --- /dev/null +++ b/src/panelEscape.test.mjs @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const source = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + +function method(name, nextName) { + const start = source.indexOf(` ${name}(`); + const end = source.indexOf(`\n ${nextName}(`, start); + assert.ok(start >= 0 && end > start, `${name} source is available`); + return source.slice(start, end); +} + +function panelFixture({ id = 'data-panel', nested = false, nestedCollapsed = false, onDisclosure = false } = {}) { + let collapsed = false; + let focused = false; + let blurred = false; + const disclosure = { + focus: () => { focused = true; }, + blur: () => { blurred = true; focused = false; }, + contains: (candidate) => candidate === disclosure, + }; + const panel = { + id, + classList: { contains: (name) => name === 'collapsed' && collapsed }, + contains: (target) => target?.panel === panel, + querySelector: () => disclosure, + }; + const inner = nested ? { id: 'param-slider-panel' } : panel; + const target = onDisclosure ? disclosure : { + panel, + closest: (selector) => ( + nested && nestedCollapsed && selector.includes(':not(.collapsed)') ? panel : inner + ), + }; + if (onDisclosure) { + disclosure.panel = panel; + disclosure.closest = () => panel; + } + const document = { getElementById: (candidate) => candidate === id ? panel : null }; + const manager = new Function('document', `return {${method('_collapsePanelOnEscape', '_initCommandDockPins')}};`)(document); + manager.setPanelCollapsed = (candidate, value, options) => { + assert.equal(candidate, id); + assert.equal(value, true); + assert.deepEqual(options, { explicit: true }); + collapsed = true; + }; + const event = { + key: 'Escape', target, defaultPrevented: false, propagationStopped: false, + preventDefault() { this.defaultPrevented = true; }, + stopPropagation() { this.propagationStopped = true; }, + }; + return { + manager, panel, inner, target, event, + collapsed: () => collapsed, + focused: () => focused, + blurred: () => blurred, + }; +} + +test('Escape collapses an expanded panel and returns focus to its disclosure', () => { + const fixture = panelFixture(); + assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true); + assert.equal(fixture.collapsed(), true); + assert.equal(fixture.focused(), true); + assert.equal(fixture.event.defaultPrevented, true); + assert.equal(fixture.event.propagationStopped, true); +}); + +test('Escape on an expanded panel disclosure closes it without retaining focus', () => { + const fixture = panelFixture({ onDisclosure: true }); + assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true); + assert.equal(fixture.collapsed(), true); + assert.equal(fixture.focused(), false); + assert.equal(fixture.blurred(), true); + assert.equal(fixture.event.defaultPrevented, true); + assert.equal(fixture.event.propagationStopped, true); +}); + +test('non-Escape, handled, collapsed, and outside events do not collapse a panel', () => { + for (const mutate of [ + (fixture) => { fixture.event.key = 'Enter'; }, + (fixture) => { fixture.event.defaultPrevented = true; }, + (fixture) => { fixture.manager.setPanelCollapsed('data-panel', true, { explicit: true }); }, + (fixture) => { fixture.event.target = { panel: null, closest: () => null }; }, + ]) { + const fixture = panelFixture(); + mutate(fixture); + const wasCollapsed = fixture.collapsed(); + assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), false); + assert.equal(fixture.collapsed(), wasCollapsed); + assert.equal(fixture.focused(), false); + } +}); + +test('an expanded nested panel owns Escape before its expanded parent', () => { + const inner = panelFixture({ id: 'param-slider-panel' }); + assert.equal(inner.manager._collapsePanelOnEscape(inner.event, 'param-slider-panel'), true); + + const outer = panelFixture({ id: 'pp-toggles', nested: true }); + assert.equal(outer.manager._collapsePanelOnEscape(outer.event, 'pp-toggles'), false); + assert.equal(outer.collapsed(), false); + assert.equal(outer.focused(), false); +}); + +test('a collapsed nested panel does not block the next Escape from closing its parent', () => { + const outer = panelFixture({ id: 'pp-toggles', nested: true, nestedCollapsed: true }); + assert.equal(outer.manager._collapsePanelOnEscape(outer.event, 'pp-toggles'), true); + assert.equal(outer.collapsed(), true); + assert.equal(outer.focused(), true); +}); + +test('Location Escape clears a hidden draft search before restoring disclosure focus', () => { + const fixture = panelFixture({ id: 'location-bar' }); + const removed = []; + let blurred = false; + fixture.manager._locationSearch = { + classList: { remove: (...names) => removed.push(...names) }, + value: 'focus cleanup', + blur: () => { blurred = true; }, + }; + assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'location-bar'), true); + assert.deepEqual(removed, ['expanded']); + assert.equal(fixture.manager._locationSearch.value, ''); + assert.equal(blurred, true); + assert.equal(fixture.focused(), true); +}); + +test('panel chrome wires Escape for every declared collapse target', () => { + const init = method('_initPanelChrome', '_collapsePanelOnEscape'); + assert.match(init, /for \(const targetId of targets\)[\s\S]*?addEventListener\('keydown'[\s\S]*?_collapsePanelOnEscape\(event, targetId\)/); + assert.match(source, /_initAutoHoverPanel[\s\S]*?if \(event\.key !== 'Escape'\) return;[\s\S]*?_collapsePanelOnEscape\(event, panelId\)[\s\S]*?clearOpen\(\);[\s\S]*?clearClose\(\);/); +}); + +test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', () => { + const onKeyDown = method('onKeyDown', 'enter'); + assert.match( + onKeyDown, + /event\.target\?\.closest\?\.\('\.cesium-credit-lightbox'\)[\s\S]*?return;/, + 'the focused attribution lightbox keeps ownership of Escape before Cockpit', + ); + assert.match( + onKeyDown, + /this\.context\?\.contains\(event\.target\)[\s\S]*?setContextCollapsed\(true\)[\s\S]*?event\.target === this\.contextToggle[\s\S]*?contextToggle\?\.blur[\s\S]*?contextToggle\?\.focus/, + ); + assert.match( + onKeyDown, + /this\.signalStream\?\.contains\(event\.target\)[\s\S]*?setSignalCollapsed\(true, \{ user: true \}\)[\s\S]*?event\.target === this\.signalToggle[\s\S]*?signalToggle\?\.blur[\s\S]*?signalToggle\?\.focus/, + ); + assert.ok(onKeyDown.indexOf('setContextCollapsed(true)') < onKeyDown.indexOf('this.exit()')); + assert.ok(onKeyDown.indexOf('setSignalCollapsed(true') < onKeyDown.indexOf('this.exit()')); +}); + +test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => { + assert.match( + source, + /const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/, + ); + assert.match( + source, + /const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/, + 'Cockpit utility disclosures clear their own focus when Escape closes them', + ); + assert.match( + source, + /_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/, + 'compact Radio disclosure clears its own focus when Escape closes it', + ); +}); diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs new file mode 100644 index 000000000..8b2e5d0ff --- /dev/null +++ b/src/proxyErrorResponses.test.mjs @@ -0,0 +1,170 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const source = readFileSync(new URL('../vite.config.js', import.meta.url), 'utf8'); +const detail = 'fixture-secret-token /internal/example '; + +// Execute the production middleware with isolated upstreams and cache storage. +// Top-level function closing braces start in column zero in this module. +function extract(name) { + const start = source.search(new RegExp(`(?:export )?(?:async )?function ${name}\\(`)); + assert.ok(start >= 0, `${name} must exist`); + const end = source.indexOf('\n}', start); + return source.slice(start, end + 2).replace(/^export /, ''); +} + +function fixture(name, overrides = {}, preview = false) { + const logs = []; + const deps = { + path, process: { cwd: () => '/fixture', env: {} }, + fsp: { + readFile: async () => { throw new Error('cache absent'); }, + stat: async () => { throw new Error('cache absent'); }, + mkdir: async () => {}, writeFile: async () => {}, + }, + fetch: async () => { throw new Error(detail); }, + console: { warn: (...args) => logs.push(args.join(' ')), error: (...args) => logs.push(args.join(' ')) }, + setInterval: () => ({ unref() {} }), + LL2_CACHE_TTL_MS: 15 * 60_000, + parseTerrainPoints: () => [[1, 2]], + resolveTerrainHeightRequest: async () => { throw new Error(detail); }, + ...overrides, + }; + const helpers = ['readResponseTextCapped', 'coalesceProxyRequest', 'launchLibraryRequestHeaders'].map(extract).join('\n'); + const plugin = new Function(...Object.keys(deps), `${helpers}\n${extract(name)}\nreturn ${name}();`)(...Object.values(deps)); + let middleware; + plugin[preview ? 'configurePreviewServer' : 'configureServer']({ middlewares: { use(_route, handler) { middleware = handler; } } }); + return { + logs, + async request(url = '/', method = 'GET') { + const response = { headersSent: false, writeHead(status, headers) { Object.assign(this, { status, headers, headersSent: true }); }, end(body) { this.body = body; } }; + await middleware({ url, method }, response); + assert.doesNotMatch(response.body, /fixture-secret-token|internal\/example|/); + assert.doesNotMatch(logs.join('\n'), /fixture-secret-token|internal\/example|/); + return response; + }, + }; +} + +for (const status of [401, 429, 500]) { + for (const preview of [false, true]) { + test(`Launch Library ${status} stays generic in ${preview ? 'preview' : 'development'}`, async () => { + const app = fixture('rocketLaunchesProxy', { fetch: async () => new Response(detail.repeat(1000), { status }) }, preview); + const res = await app.request(); + assert.equal(res.status, status); + assert.deepEqual(JSON.parse(res.body), { error: 'Launch Library 2 unavailable' }); + assert.equal(res.headers['Cache-Control'], 'no-store'); + assert.equal(res.headers['X-GEV-Cache'], 'NONE'); + assert.equal(app.logs.length, 1); + assert.match(app.logs[0], new RegExp(`HTTP ${status}`)); + assert.ok(app.logs[0].length < 100); + }); + } +} + +for (const [label, fetch] of [ + ['network error', async () => { throw new Error(detail); }], + ['malformed JSON', async () => new Response(detail)], + ['invalid feed', async () => new Response('{}')], + ['oversized response', async () => new Response(detail, { headers: { 'content-length': String(13 * 1024 * 1024) } })], +]) { + test(`Launch Library ${label} returns 502`, async () => { + const res = await fixture('rocketLaunchesProxy', { fetch }).request(); + assert.equal(res.status, 502); + assert.deepEqual(JSON.parse(res.body), { error: 'Launch Library 2 unavailable' }); + }); +} + +test('Launch Library retains single-flight, fresh cache, stale fallback, and method guard', async () => { + let now = Date.now(); + let calls = 0; + let release; + const gate = new Promise(resolve => { release = resolve; }); + class Clock extends Date { static now() { return now; } } + const app = fixture('rocketLaunchesProxy', { + Date: Clock, + fetch: async () => { calls += 1; await gate; if (calls > 1) throw new Error(detail); return new Response('{"results":[]}'); }, + }); + assert.equal((await app.request('/', 'POST')).status, 405); + const first = app.request(); + const second = app.request(); + release(); + const pair = await Promise.all([first, second]); + assert.deepEqual(pair.map(res => res.headers['X-GEV-Cache']).sort(), ['INFLIGHT', 'MISS']); + assert.equal(calls, 1); + assert.equal((await app.request()).headers['X-GEV-Cache'], 'HIT'); + now += 16 * 60_000; + const stale = await app.request(); + assert.equal(stale.status, 200); + assert.equal(stale.body, '{"results":[]}'); + assert.equal(stale.headers['X-GEV-Cache'], 'STALE-ERROR'); + assert.equal(calls, 2); +}); + +test('CelesTrak unexpected failures hide details', async () => { + const app = fixture('celestrakProxy', { Date: { now() { throw new Error(detail); } } }); + const res = await app.request('/active'); + assert.equal(res.status, 500); + assert.equal(res.body, 'celestrak proxy error'); + assert.equal(res.headers['x-tle-cache'], 'ERROR'); +}); + +test('CelesTrak retains invalid-group and unavailable responses', async () => { + const app = fixture('celestrakProxy'); + assert.equal((await app.request('/../')).status, 400); + const res = await app.request('/active'); + assert.equal(res.status, 502); + assert.equal(res.headers['x-tle-cache'], 'NONE'); +}); + +test('CelesTrak retains fresh and stale TLE caches', async () => { + let now = Date.now(); + let calls = 0; + const app = fixture('celestrakProxy', { + Date: { now: () => now }, + fetch: async () => { if (++calls > 1) throw new Error(detail); return new Response('1 valid-fixture-TLE'); }, + }); + assert.equal((await app.request('/active')).headers['x-tle-cache'], 'MISS'); + assert.equal((await app.request('/active')).headers['x-tle-cache'], 'HIT'); + now += 7 * 3600_000; + const stale = await app.request('/active'); + assert.equal(stale.status, 200); + assert.equal(stale.body, '1 valid-fixture-TLE'); + assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR'); +}); + +test('terrain unexpected failures hide details', async () => { + const res = await fixture('terrainHeightsProxy').request('/?points=1,2'); + assert.equal(res.status, 500); + assert.deepEqual(JSON.parse(res.body), { error: 'terrain heights proxy error' }); +}); + +test('terrain validation and resolver outcomes remain intact', async () => { + const invalid = await fixture('terrainHeightsProxy', { parseTerrainPoints: () => null }).request(); + assert.equal(invalid.status, 400); + const body = { results: [{ height: 12 }] }; + const app = fixture('terrainHeightsProxy', { resolveTerrainHeightRequest: async () => ({ status: 200, body, upstreamError: new Error(detail) }) }); + const res = await app.request('/?points=1,2'); + assert.equal(res.status, 200); + assert.deepEqual(JSON.parse(res.body), body); + assert.equal(app.logs.length, 1); +}); + +test('ADSBDB unexpected failures hide details', async () => { + const badUrl = { toString() { throw new Error(detail); } }; + const res = await fixture('adsbdbProxy').request(badUrl); + assert.equal(res.status, 500); + assert.deepEqual(JSON.parse(res.body), { error: 'adsbdb proxy error' }); +}); + +test('ADSBDB retains validation and missing-aircraft semantics', async () => { + const app = fixture('adsbdbProxy'); + assert.equal((await app.request('/route/!')).status, 400); + assert.equal((await app.request('/type/nope')).status, 400); + assert.equal((await app.request('/unknown')).status, 404); + const absent = await app.request('/type/abcdef'); + assert.equal(absent.status, 200); + assert.deepEqual(JSON.parse(absent.body), { found: false }); +}); diff --git a/src/ui.js b/src/ui.js index 668e752d4..b2840fff5 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1016,8 +1016,34 @@ class CockpitViewController { onKeyDown(event) { if (event.repeat || event.isComposing) return; if (event.key === 'Escape' && this.active) { + // The credit lightbox owns Escape while its Close control or links hold + // focus. Its target handler closes the overlay and restores attribution + // focus; Cockpit must stay active behind it. + if (event.target?.closest?.('.cesium-credit-lightbox')) return; if (document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')) return; if (document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')) return; + if (this.context?.contains(event.target) && !this.contextCollapsed) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.setContextCollapsed(true); + if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) { + this.contextToggle?.blur?.(); + } else { + this.contextToggle?.focus({ preventScroll: true }); + } + return; + } + if (this.signalStream?.contains(event.target) && !this.signalCollapsed) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.setSignalCollapsed(true, { user: true }); + if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) { + this.signalToggle?.blur?.(); + } else { + this.signalToggle?.focus({ preventScroll: true }); + } + return; + } event.preventDefault(); event.stopImmediatePropagation(); this.exit(); @@ -1851,40 +1877,89 @@ class CockpitViewController { renderCockpitSignals() { if (!this.signalList) return; - this.signalList.replaceChildren(...this.signalItems.map((item) => { - const entry = document.createElement('li'); - entry.className = item.tone; - const time = document.createElement('time'); - time.textContent = new Date(item.timestamp).toISOString().slice(11, 19) + 'Z'; - const body = document.createElement('div'); - const heading = item.target ? document.createElement('button') : document.createElement('strong'); + const existing = [...this.signalList.children]; + const focusedEntry = existing.find((entry) => entry.contains(document.activeElement)); + const entriesByKey = new Map(); + for (const entry of existing) { + const key = entry.dataset.signalKey; + if (!entriesByKey.has(key)) entriesByKey.set(key, []); + entriesByKey.get(key).push(entry); + } + const setText = (element, value) => { + if (element.textContent !== value) element.textContent = value; + }; + const entries = this.signalItems.map((item) => { + // Identity includes the action target: a reused status key must never + // silently turn a focused flight button into a different selection. + const key = JSON.stringify([ + item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''), + ]); + let entry = entriesByKey.get(key)?.shift(); + if (!entry) { + entry = document.createElement('li'); + entry.dataset.signalKey = key; + const time = document.createElement('time'); + const body = document.createElement('div'); + const heading = document.createElement(item.target ? 'button' : 'strong'); + if (item.target) { + heading.type = 'button'; + heading.className = 'cockpit-signal-target'; + const label = document.createElement('span'); + label.className = 'cockpit-signal-target-label'; + const rule = document.createElement('span'); + rule.className = 'cockpit-signal-target-rule'; + rule.setAttribute('aria-hidden', 'true'); + const chevron = document.createElement('span'); + chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron'; + chevron.setAttribute('aria-hidden', 'true'); + chevron.textContent = 'chevron_right'; + heading.append(label, rule, chevron); + } + body.append(heading, document.createElement('span')); + entry.append(time, body); + } + const [time, body] = entry.children; + const [heading, copy] = body.children; + const className = item.target ? `${item.tone} actionable` : item.tone; + if (entry.className !== className) entry.className = className; + setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z'); if (item.target) { - heading.type = 'button'; - heading.className = 'cockpit-signal-target'; heading.dataset.signalLayer = item.target.layerId; heading.dataset.signalId = item.target.id; - heading.setAttribute('aria-label', `Select flight ${item.title}`); - const label = document.createElement('span'); - label.className = 'cockpit-signal-target-label'; - label.textContent = item.title; - const rule = document.createElement('span'); - rule.className = 'cockpit-signal-target-rule'; - rule.setAttribute('aria-hidden', 'true'); - const chevron = document.createElement('span'); - chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron'; - chevron.setAttribute('aria-hidden', 'true'); - chevron.textContent = 'chevron_right'; - heading.append(label, rule, chevron); - entry.classList.add('actionable'); + const label = `Select flight ${item.title}`; + if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label); + setText(heading.children[0], item.title); } else { - heading.textContent = item.title; + setText(heading, item.title); } - const copy = document.createElement('span'); - copy.textContent = item.detail; - body.append(heading, copy); - entry.append(time, body); + setText(copy, item.detail); return entry; - })); + }); + const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null; + if (focusedEntry && !retainedFocus) { + // A departed contact cannot remain selectable. Continue from the stable + // briefing footer instead of dropping keyboard traversal to the page top. + (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true }); + } + for (const entry of existing) if (!entries.includes(entry)) entry.remove(); + + // Ordinary insertBefore moves disconnect an element and lose its focus. + // Reorder the other rows around the focused row, which stays connected. + const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1; + if (retainedFocus) { + let anchor = retainedFocus; + for (let index = focusIndex - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor); + anchor = entry; + } + } + let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild; + for (let index = focusIndex + 1; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry === anchor) anchor = anchor.nextElementSibling; + else this.signalList.insertBefore(entry, anchor); + } this.scheduleContextLayout(); } @@ -3946,6 +4021,10 @@ export class StyleManager { }); for (const targetId of targets) { + const panelEl = document.getElementById(targetId); + panelEl?.addEventListener('keydown', (event) => { + this._collapsePanelOnEscape(event, targetId); + }); this._restorePanelCollapsedState(targetId, { allowStored: !this._initialShareState, }); @@ -3961,6 +4040,46 @@ export class StyleManager { this._maybeNotifyLayoutReset(); } + /** + * Collapses the nearest expanded panel that owns keyboard focus on Escape. + * Nested panels consume the event first, so one key closes one level and + * returns focus to that level's disclosure. If Escape was pressed on the + * disclosure itself, remove focus after closing so the collapsed button does + * not keep a stale keyboard ring. + * @param {KeyboardEvent} event - Candidate Escape key event. + * @param {string} panelId - Collapsible panel containing the listener. + * @returns {boolean} Whether this panel handled the key. + */ + _collapsePanelOnEscape(event, panelId) { + if (event.key !== 'Escape' || event.defaultPrevented) return false; + const panelEl = document.getElementById(panelId); + if (!panelEl || panelEl.classList.contains('collapsed') || !panelEl.contains(event.target)) { + return false; + } + const focusedPanel = event.target?.closest?.( + '.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)', + ); + if (focusedPanel && focusedPanel !== panelEl) return false; + event.preventDefault(); + event.stopPropagation(); + if (panelId === 'location-bar' && this._locationSearch) { + // The document-level Escape cleanup cannot run after this panel consumes + // the event. Mirror that cleanup here so reopening Location never reveals + // a hidden draft query or expanded search field. + this._locationSearch.classList.remove('expanded'); + this._locationSearch.value = ''; + this._locationSearch.blur(); + } + this.setPanelCollapsed(panelId, true, { explicit: true }); + const disclosure = panelEl.querySelector(`[data-dock-toggle-target="${panelId}"]`) + || panelEl.querySelector(`[data-collapse-target="${panelId}"]`); + const escapedFromDisclosure = event.target === disclosure + || disclosure?.contains?.(event.target); + if (escapedFromDisclosure) disclosure?.blur?.(); + else disclosure?.focus?.({ preventScroll: true }); + return true; + } + /** * Allows either command-dock tray to remain open until explicitly unpinned. * Both trays may be pinned; transient and error trays stack above them. @@ -4107,6 +4226,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,24 +4320,52 @@ export class StyleManager { }); panelEl.addEventListener('pointerdown', () => { + cancelMapSourceFocus(); clearOpen(); clearClose(); }); 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') + || 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 + // 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(() => { + 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')) focusMapSource(); - }, 240); + 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); + }; + disclosureFocusTimer = window.setTimeout(attemptFocus, 240); }; const toggleDisclosure = ({ focusSource = false } = {}) => { + cancelMapSourceFocus(); clearOpen(); clearClose(); const shouldOpen = panelEl.classList.contains('collapsed'); @@ -4224,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; @@ -4236,16 +4393,16 @@ 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; - event.preventDefault(); + if (event.key !== 'Escape') return; + if (!event.defaultPrevented) this._collapsePanelOnEscape(event, panelId); + cancelMapSourceFocus(); clearOpen(); clearClose(); - this.setPanelCollapsed(panelId, true, { explicit: true }); - disclosure?.focus?.({ preventScroll: true }); }); } @@ -4615,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( @@ -4632,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( @@ -4651,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', @@ -4666,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'); }); }); } @@ -4881,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 }); @@ -5036,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(); } } @@ -5284,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; @@ -5532,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, { @@ -5550,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 }); } }; @@ -5602,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; @@ -5905,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); @@ -5917,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); @@ -5929,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) { @@ -7433,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}`); @@ -7677,6 +7864,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); @@ -8930,6 +9118,7 @@ export class StyleManager { const slider = document.createElement('input'); slider.type = 'range'; slider.className = 'param-slider'; + slider.setAttribute('aria-label', uMeta.label); slider.min = uMeta.min; slider.max = uMeta.max; slider.step = uMeta.max <= 1 ? '0.01' : '0.1'; @@ -9650,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({ @@ -9684,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; @@ -10123,6 +10310,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; diff --git a/src/voice/gevRealtime.js b/src/voice/gevRealtime.js index ab566fec1..45ebed526 100644 --- a/src/voice/gevRealtime.js +++ b/src/voice/gevRealtime.js @@ -20,6 +20,7 @@ const STATUS = { error: 'ERROR', }; const CALL_DEDUPE_MS = 2500; +export const PUSH_TO_TALK_HOLD_DELAY_MS = 500; // WebRTC 'disconnected' is frequently momentary (a brief network blip that ICE // recovers on its own). Give it this long to return to 'connected' before we // treat it as a real drop (H8). @@ -307,6 +308,11 @@ export class GevRealtimeController { this.pushToTalkMode = false; this.pushToTalkKeyHeld = false; this.spaceKeyHeld = false; + this.pushToTalkHoldTimer = null; + this.pushToTalkHoldGeneration = 0; + this.pushToTalkHoldFocusOwner = null; + this.pushToTalkHoldControl = null; + this.pushToTalkHoldPreservesNative = false; this.shortcutKeyDownHandler = null; this.shortcutKeyUpHandler = null; this.shortcutBlurHandler = null; @@ -568,65 +574,122 @@ export class GevRealtimeController { } } - /** - * Registers hold-Space push-to-talk without hijacking typing or modified shortcuts. - * @returns {void} - */ + /** Registers delayed hold-Space push-to-talk while preserving short control taps. */ bindPushToTalkShortcut() { if (this.shortcutKeyDownHandler) return; this.shortcutKeyDownHandler = (event) => { if (!shouldHandlePushToTalkKeyDown(event)) return; if (event.repeat) { - if (this.spaceKeyHeld) event.preventDefault(); + if (this.spaceKeyHeld && !this.pushToTalkHoldPreservesNative) event.preventDefault(); + if (this.pushToTalkKeyHeld) event.preventDefault(); return; } this.spaceKeyHeld = true; - // Space must not generate the focused mic button's native click on keyup. - event.preventDefault(); - this.pauseRadioForVoice(); - if (this.pushToTalkKeyHeld) return; + this.pushToTalkHoldFocusOwner = document.activeElement; + this.pushToTalkHoldControl = isInteractiveSpaceTarget(document.activeElement) + ? document.activeElement + : (isInteractiveSpaceTarget(event.target) + ? event.target.closest?.(SPACE_INTERACTIVE_SELECTOR) || event.target + : null); + this.pushToTalkHoldPreservesNative = Boolean(this.pushToTalkHoldControl); + // Background Space is reserved immediately to avoid scrolling. A focused + // control keeps its native keydown and release unless the hold is claimed. + if (!this.pushToTalkHoldPreservesNative) event.preventDefault(); // A click-started session is intentionally open-mic. Space only claims an // idle session (or a session it already started) so releasing the key can // never surprise the user by muting a click-started conversation. if (this.isActive() && !this.pushToTalkMode) return; - this.pushToTalkKeyHeld = true; - this.ui.root.dataset.pushToTalk = 'held'; - if (this.isActive()) { - this.setMicrophoneEnabled(true); - if (this.status === 'listening') this.setStatus('listening', 'Release Space to send'); - } else { - this.start({ pushToTalk: true }); - } + this.cancelPushToTalkHold(); + const holdGeneration = ++this.pushToTalkHoldGeneration; + this.pushToTalkHoldTimer = setTimeout(() => { + this.pushToTalkHoldTimer = null; + if (holdGeneration !== this.pushToTalkHoldGeneration) return; + if (!this.spaceKeyHeld || this.pushToTalkKeyHeld) return; + // A pointer or Tab focus change during the delay cancels the original + // gesture rather than letting voice claim a key held for another owner. + if (document.activeElement !== this.pushToTalkHoldFocusOwner) return; + if ( + document.visibilityState === 'hidden' + || (typeof document.hasFocus === 'function' && !document.hasFocus()) + ) return; + if (this.isActive() && !this.pushToTalkMode) return; + // Blur before voice starts. This removes the focused state and ensures + // the eventual Space release cannot activate the old control. + if ( + this.pushToTalkHoldControl + && document.activeElement === this.pushToTalkHoldControl + && typeof this.pushToTalkHoldControl.blur === 'function' + ) { + this.pushToTalkHoldControl.blur(); + } + this.pauseRadioForVoice(); + this.pushToTalkKeyHeld = true; + if (this.isActive()) { + this.ui.root.dataset.pushToTalk = 'held'; + this.setMicrophoneEnabled(true); + if (this.status === 'listening') this.setStatus('listening', 'Release Space to send'); + } else { + this.start({ pushToTalk: true }); + // start() performs a controlled stop() before connecting. Restore the + // marker it clears so the UI reflects this still-held gesture. + if (this.pushToTalkKeyHeld) this.ui.root.dataset.pushToTalk = 'held'; + } + }, PUSH_TO_TALK_HOLD_DELAY_MS); }; this.shortcutKeyUpHandler = (event) => { if (!isPushToTalkKey(event)) return; const wasHoldingSpace = this.spaceKeyHeld; + const preservedNativeActivation = this.pushToTalkHoldPreservesNative; this.spaceKeyHeld = false; + this.cancelPushToTalkHold(); if (!this.pushToTalkKeyHeld) { - if (wasHoldingSpace) event.preventDefault(); + if (wasHoldingSpace && !preservedNativeActivation) event.preventDefault(); + this.resetPushToTalkGesture(); return; } event.preventDefault(); this.releasePushToTalkKey(); + this.resetPushToTalkGesture(); }; this.shortcutBlurHandler = () => { this.spaceKeyHeld = false; + this.cancelPushToTalkHold(); this.releasePushToTalkKey(); + this.resetPushToTalkGesture(); }; this.shortcutVisibilityHandler = () => { if (document.visibilityState === 'hidden') this.shortcutBlurHandler(); }; - document.addEventListener('keydown', this.shortcutKeyDownHandler); - document.addEventListener('keyup', this.shortcutKeyUpHandler); + // Observe before custom controls call preventDefault for their own Space + // behavior. This lets the same physical hold cross the 500ms voice + // threshold while short taps still reach the control unchanged. + document.addEventListener('keydown', this.shortcutKeyDownHandler, true); + document.addEventListener('keyup', this.shortcutKeyUpHandler, true); window.addEventListener('blur', this.shortcutBlurHandler); document.addEventListener('visibilitychange', this.shortcutVisibilityHandler); } + /** Cancels an unclaimed Space hold before it starts voice. */ + cancelPushToTalkHold() { + this.pushToTalkHoldGeneration++; + if (this.pushToTalkHoldTimer === null) return; + clearTimeout(this.pushToTalkHoldTimer); + this.pushToTalkHoldTimer = null; + } + + /** Clears the owner metadata for the current physical Space gesture. */ + resetPushToTalkGesture() { + this.pushToTalkHoldFocusOwner = null; + this.pushToTalkHoldControl = null; + this.pushToTalkHoldPreservesNative = false; + } + /** * Mutes a keyboard-started microphone while leaving WebRTC alive for the reply. * @returns {void} */ releasePushToTalkKey() { + this.cancelPushToTalkHold(); if (!this.pushToTalkKeyHeld) return; this.pushToTalkKeyHeld = false; delete this.ui.root.dataset.pushToTalk; @@ -773,6 +836,7 @@ export class GevRealtimeController { // releases its own resources instead of promoting them onto a stopped // controller (H7). this.startEpoch++; + this.cancelPushToTalkHold(); this.radioHandoffEpoch++; for (const controller of this.activeToolAbortControllers) controller.abort(); this.activeToolAbortControllers.clear(); @@ -844,6 +908,7 @@ export class GevRealtimeController { this.pushToTalkMode = false; this.pushToTalkKeyHeld = false; this.spaceKeyHeld = false; + this.resetPushToTalkGesture(); if (this.ui?.root) { delete this.ui.root.dataset.pushToTalk; delete this.ui.root.dataset.microphone; @@ -857,8 +922,8 @@ export class GevRealtimeController { this.tierHandler = null; } if (removeUi) { - if (this.shortcutKeyDownHandler) document.removeEventListener('keydown', this.shortcutKeyDownHandler); - if (this.shortcutKeyUpHandler) document.removeEventListener('keyup', this.shortcutKeyUpHandler); + if (this.shortcutKeyDownHandler) document.removeEventListener('keydown', this.shortcutKeyDownHandler, true); + if (this.shortcutKeyUpHandler) document.removeEventListener('keyup', this.shortcutKeyUpHandler, true); if (this.shortcutBlurHandler) window.removeEventListener('blur', this.shortcutBlurHandler); if (this.shortcutVisibilityHandler) { document.removeEventListener('visibilitychange', this.shortcutVisibilityHandler); @@ -2454,18 +2519,77 @@ export function isPushToTalkKey(event) { return event?.code === 'Space' || event?.key === ' '; } +const SPACE_INTERACTIVE_SELECTOR = [ + 'button', + 'input', + 'textarea', + 'select', + 'option', + 'a[href]', + 'summary', + 'audio[controls]', + 'video[controls]', + '[contenteditable]', + '[tabindex]', + '[role="button"]', + '[role="checkbox"]', + '[role="combobox"]', + '[role="link"]', + '[role="listbox"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="option"]', + '[role="radio"]', + '[role="searchbox"]', + '[role="slider"]', + '[role="spinbutton"]', + '[role="switch"]', + '[role="tab"]', + '[role="textbox"]', + '[role="treeitem"]', +].join(', '); + /** - * Protects text entry and modified shortcuts from the global push-to-talk key. - * @param {KeyboardEvent|object|null} event - * @returns {boolean} + * Returns whether Space began on the map surface reserved for push-to-talk. + * Cesium gives its canvas `tabindex="0"` for camera input, which otherwise + * makes the generic focus guard treat a map click like a button activation. */ +export function isPushToTalkSurface(target) { + if (target?.tagName?.toUpperCase?.() !== 'CANVAS') return false; + if (target.id === 'world-overlay-canvas') return true; + return Boolean(target.closest?.('#cesiumContainer')); +} + +/** Returns whether a focused target owns Space for UI interaction. */ +export function isInteractiveSpaceTarget(target) { + if (!target) return false; + if (isPushToTalkSurface(target)) return false; + if (target.isContentEditable) return true; + return Boolean(target.closest?.(SPACE_INTERACTIVE_SELECTOR)); +} + +/** Returns whether Space belongs to a text-entry surface. */ +export function isEditingSpaceTarget(target) { + if (!target) return false; + if (target.isContentEditable || target.closest?.('[contenteditable]')) return true; + if (target.closest?.('textarea, [role="textbox"], [role="searchbox"], [role="spinbutton"]')) { + return true; + } + const input = target.closest?.('input'); + if (!input) return false; + const type = String(input.type || 'text').toLowerCase(); + return [ + 'text', 'search', 'email', 'url', 'tel', 'password', 'number', + 'date', 'datetime-local', 'month', 'week', 'time', + ].includes(type); +} + +/** Protects text entry and modified shortcuts from push-to-talk arbitration. */ export function shouldHandlePushToTalkKeyDown(event) { if (!isPushToTalkKey(event) || event.defaultPrevented) return false; if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false; - const target = event.target; - if (target?.isContentEditable) return false; - const editingControl = target?.closest?.('input, textarea, select, [contenteditable], [role="textbox"]'); - return !editingControl; + return !isEditingSpaceTarget(event.target); } /** @@ -2510,7 +2634,7 @@ export function resolveVoiceVisualizerSpeaker(currentSpeaker, nextSpeaker, keepC export function resolveVoiceControlHint(pushToTalkMode, pushToTalkKeyHeld) { return pushToTalkMode && pushToTalkKeyHeld ? 'Release Space to send' - : 'Hold Space to speak · click mic to toggle voice'; + : 'Hold Space to speak · tap Space to activate focused controls'; } /** @@ -2559,7 +2683,7 @@ function createVoiceControl({ reset = false } = {}) { ~$0.00
- @@ -2571,7 +2695,7 @@ function createVoiceControl({ reset = false } = {}) {