mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into chore/prettier
This commit is contained in:
63 files changed
+5218
-1863
No files matched your search
@@ -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
|
||||
|
||||
+118
-4
@@ -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.
|
||||
+10
-7
@@ -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. -->
|
||||
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" title="Scope edge feather as a percentage of the keyhole radius" />
|
||||
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" />
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -460,7 +460,7 @@
|
||||
<span class="pp-label">Bloom</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="bloom-slider-row">
|
||||
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" />
|
||||
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" />
|
||||
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,7 +470,7 @@
|
||||
<span class="pp-label">Sharpen</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="sharpen-slider-row">
|
||||
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" />
|
||||
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" />
|
||||
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -548,14 +548,16 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="location-inner">
|
||||
<div class="location-toolbar">
|
||||
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
|
||||
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION">
|
||||
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
|
||||
</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel">+</button>
|
||||
</div>
|
||||
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
|
||||
<div id="location-mini-city" class="location-mini-city">📍 Location: --</div>
|
||||
<div id="location-mini-poi" class="location-mini-poi">Landmark: --</div>
|
||||
</div>
|
||||
<div class="dock-popover-content">
|
||||
<div id="location-bar-popover" class="dock-popover-content">
|
||||
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open"><img src="/pin.svg" alt="" /></button>
|
||||
<div id="poi-row" class="poi-row-container"></div>
|
||||
<div class="location-bar-divider" id="location-bar-divider"></div>
|
||||
@@ -565,7 +567,7 @@
|
||||
<button id="search-toggle" class="search-toggle-btn" title="Search any location">
|
||||
<span>🔎</span>
|
||||
</button>
|
||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" />
|
||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -727,7 +729,7 @@
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
|
||||
<span>CONTACTS</span>
|
||||
</button>
|
||||
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="-1" aria-selected="false" aria-controls="context-missions-view">
|
||||
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-missions-view">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
|
||||
<span>SPACE MISSIONS</span>
|
||||
</button>
|
||||
@@ -765,6 +767,7 @@
|
||||
<div class="space-mission-roster-list" data-mission-roster-list>
|
||||
<div class="space-mission-roster-empty">LOADING 30-DAY MISSION INDEX</div>
|
||||
</div>
|
||||
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Generated
+360
-1272
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
|
||||
+7
-208
@@ -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"
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+287
-147
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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; } });
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
+883
-20
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
|
||||
|
||||
const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
const renderStart = source.indexOf(' renderCockpitSignals() {');
|
||||
const renderEnd = source.indexOf(' setContextCollapsed(', renderStart);
|
||||
assert.ok(renderStart >= 0 && renderEnd > renderStart);
|
||||
const clickSource = source.match(/this\._listen\(this\.signalList, 'click', (\(event\) => \{[\s\S]*?\n \})\);/)?.[1];
|
||||
assert.ok(clickSource, 'the installed signal click route exists');
|
||||
|
||||
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
|
||||
// object reference alone is insufficient if reconciliation disconnects it.
|
||||
function fixture() {
|
||||
const document = { activeElement: null };
|
||||
let focusLosses = 0;
|
||||
let focusCalls = 0;
|
||||
class Node {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.dataset = {};
|
||||
this.attributes = new Map();
|
||||
this.className = '';
|
||||
this._text = '';
|
||||
this.classList = {
|
||||
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
|
||||
};
|
||||
}
|
||||
get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); }
|
||||
get firstElementChild() { return this.children[0] || null; }
|
||||
get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; }
|
||||
get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
|
||||
set textContent(value) { this.replaceChildren(); this._text = String(value); }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
contains(node) { return this === node || this.children.some((child) => child.contains(node)); }
|
||||
append(...nodes) { for (const node of nodes) this.insertBefore(node, null); }
|
||||
insertBefore(node, anchor) {
|
||||
assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent');
|
||||
if (node === anchor) return node;
|
||||
node.remove();
|
||||
const index = anchor === null ? this.children.length : this.children.indexOf(anchor);
|
||||
this.children.splice(index, 0, node);
|
||||
node.parentNode = this;
|
||||
return node;
|
||||
}
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
if (this.contains(document.activeElement)) {
|
||||
document.activeElement = document.body;
|
||||
focusLosses += 1;
|
||||
}
|
||||
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
|
||||
this.parentNode = null;
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
for (const child of [...this.children]) child.remove();
|
||||
this._text = '';
|
||||
this.append(...nodes);
|
||||
}
|
||||
matches(selector) {
|
||||
if (selector === 'button[data-signal-layer][data-signal-id]') {
|
||||
return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null;
|
||||
}
|
||||
return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1))
|
||||
: this.tagName === selector.toUpperCase();
|
||||
}
|
||||
closest(selector) {
|
||||
for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node;
|
||||
return null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]);
|
||||
}
|
||||
focus() {
|
||||
assert.ok(this.isConnected, 'a focus destination must still be connected');
|
||||
focusCalls += 1;
|
||||
document.activeElement = this;
|
||||
}
|
||||
}
|
||||
document.body = new Node('body');
|
||||
document.createElement = (tag) => new Node(tag);
|
||||
document.activeElement = document.body;
|
||||
const controller = new (new Function('document', 'formatAwarenessLabel',
|
||||
`return class { ${source.slice(renderStart, renderEnd)} };`)(document, formatAwarenessLabel))();
|
||||
controller.signalList = new Node('ol');
|
||||
controller.signalToggle = new Node('button');
|
||||
controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
|
||||
controller.briefPageIndex = 0;
|
||||
controller.signalItems = [];
|
||||
controller.signalSignatures = new Map();
|
||||
controller.scheduleContextLayout = () => {};
|
||||
const display = new Node('button');
|
||||
const radio = new Node('button');
|
||||
const outside = new Node('button');
|
||||
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
|
||||
const selected = [];
|
||||
const click = new Function('militaryAwarenessLayer', `return ${clickSource};`)({
|
||||
focusTarget: (...args) => selected.push(args),
|
||||
});
|
||||
const buttons = () => controller.signalList.querySelectorAll('button');
|
||||
const tab = () => {
|
||||
const nodes = document.body.querySelectorAll('button');
|
||||
const index = nodes.indexOf(document.activeElement);
|
||||
nodes[(index + 1) % nodes.length].focus();
|
||||
};
|
||||
return { controller, document, buttons, display, radio, outside, selected, click, tab,
|
||||
losses: () => focusLosses, focusCalls: () => focusCalls };
|
||||
}
|
||||
|
||||
const item = (id, overrides = {}) => ({
|
||||
key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM',
|
||||
target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides,
|
||||
});
|
||||
const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); };
|
||||
const snapshot = (distanceM = 2000) => ({
|
||||
evaluatedAt: 1_700_000_000_000,
|
||||
subject: { layerId: 'flights', id: 'current', label: 'CURRENT' },
|
||||
cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }],
|
||||
});
|
||||
|
||||
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
|
||||
const f = fixture();
|
||||
f.controller.updateCockpitSignals(snapshot(), 0);
|
||||
const target = f.buttons()[1]; target.focus();
|
||||
const calls = f.focusCalls();
|
||||
for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0);
|
||||
assert.equal(f.buttons()[1], target);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it');
|
||||
assert.match(f.controller.signalList.textContent, /2\.5 KM/);
|
||||
});
|
||||
|
||||
test('signal content and accessible name update without replacing the active target', () => {
|
||||
const f = fixture(); render(f, [item('a')]);
|
||||
const target = f.buttons()[0]; target.focus();
|
||||
render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]);
|
||||
assert.equal(f.buttons()[0], target);
|
||||
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
|
||||
assert.match(target.textContent, /RENAMED/);
|
||||
assert.match(f.controller.signalList.textContent, /CURRENT/);
|
||||
assert.equal(f.controller.signalList.children[0].className, 'track actionable');
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
});
|
||||
|
||||
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
|
||||
const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id)));
|
||||
const target = f.buttons()[1]; target.focus();
|
||||
const calls = f.focusCalls();
|
||||
for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) {
|
||||
render(f, order.map((id) => item(id)));
|
||||
assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
}
|
||||
assert.equal(f.focusCalls(), calls);
|
||||
});
|
||||
|
||||
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
|
||||
const f = fixture(); render(f, [item('a'), item('b')]);
|
||||
const target = f.buttons()[1]; target.focus();
|
||||
f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE');
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } };
|
||||
f.click(event);
|
||||
assert.equal(event.prevented, true);
|
||||
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
|
||||
});
|
||||
|
||||
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
|
||||
const f = fixture(); render(f, [item('a'), item('b')]);
|
||||
const removed = f.buttons()[1]; removed.focus();
|
||||
const calls = f.focusCalls();
|
||||
render(f, [item('a')]);
|
||||
assert.equal(removed.isConnected, false);
|
||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||
assert.equal(f.focusCalls(), calls + 1);
|
||||
f.tab(); f.tab(); f.tab();
|
||||
assert.equal(f.document.activeElement, f.display);
|
||||
render(f, [item('a', { detail: 'UPDATED' })]);
|
||||
assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back');
|
||||
f.tab();
|
||||
assert.equal(f.document.activeElement, f.radio);
|
||||
});
|
||||
|
||||
test('a changed target cannot reuse a focused action for another flight', () => {
|
||||
const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]);
|
||||
const removed = f.buttons()[0]; removed.focus();
|
||||
render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]);
|
||||
const target = f.buttons()[0];
|
||||
assert.notEqual(target, removed);
|
||||
assert.equal(removed.isConnected, false);
|
||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||
f.click({ target, preventDefault() {} });
|
||||
assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]);
|
||||
});
|
||||
|
||||
test('empty, informational and duplicate-key rows have no stale or aliased actions', () => {
|
||||
const f = fixture();
|
||||
render(f, [item('a'), item('a')]);
|
||||
const [first, second] = f.buttons();
|
||||
assert.notEqual(first, second);
|
||||
second.focus();
|
||||
render(f, [item('a'), item('a')]);
|
||||
assert.deepEqual(f.buttons(), [first, second]);
|
||||
assert.equal(f.document.activeElement, second);
|
||||
render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
|
||||
assert.equal(f.buttons().length, 0);
|
||||
assert.match(f.controller.signalList.textContent, /UNKNOWN/);
|
||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||
f.outside.focus();
|
||||
render(f, []);
|
||||
assert.equal(f.controller.signalList.children.length, 0);
|
||||
render(f, [item('a')]);
|
||||
assert.notEqual(f.buttons()[0], first);
|
||||
assert.equal(f.document.activeElement, f.outside);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||
|
||||
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
|
||||
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
|
||||
const button = html.match(new RegExp(`<button id="${id}"[\\s\\S]*?</button>`));
|
||||
assert.ok(button, `${id} is missing`);
|
||||
assert.match(button[0], /role="tab"/);
|
||||
assert.match(button[0], /tabindex="0"/);
|
||||
}
|
||||
|
||||
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
|
||||
assert.ok(sync, 'Context mode sync is missing');
|
||||
assert.match(sync[1], /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/);
|
||||
assert.match(sync[1], /button\.tabIndex = 0/);
|
||||
assert.doesNotMatch(sync[1], /tabIndex\s*=\s*[^;]*\?\s*-1/);
|
||||
});
|
||||
|
||||
test('Context transition state preserves focus and Tab availability until settle', () => {
|
||||
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
|
||||
assert.ok(sync, 'Context mode sync is missing');
|
||||
|
||||
const attributes = () => new Map();
|
||||
const makeButton = () => {
|
||||
const attrs = attributes();
|
||||
let disabled = false;
|
||||
const button = {
|
||||
attrs,
|
||||
tabIndex: -1,
|
||||
classList: { toggle() {} },
|
||||
setAttribute(name, value) { attrs.set(name, String(value)); },
|
||||
get disabled() { return disabled; },
|
||||
set disabled(value) {
|
||||
disabled = Boolean(value);
|
||||
// Model the browser behavior that exposed this regression: native
|
||||
// disabled drops focus immediately.
|
||||
if (disabled && globalThis.document?.activeElement === button) {
|
||||
globalThis.document.activeElement = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
return button;
|
||||
};
|
||||
const contacts = makeButton();
|
||||
const missions = makeButton();
|
||||
const panel = { classList: { toggle() {} }, setAttribute() {} };
|
||||
const priorDocument = globalThis.document;
|
||||
globalThis.document = { activeElement: missions, getElementById: () => panel };
|
||||
const owner = {
|
||||
_contextMode: null,
|
||||
_contextModeChanging: true,
|
||||
_globalContextFlightsBtn: contacts,
|
||||
_globalContextMissionsBtn: missions,
|
||||
_contextModeStandby: {},
|
||||
_contextFlightsView: {},
|
||||
_contextMissionsView: {},
|
||||
cockpitView: { syncEntry() {} },
|
||||
_syncContactsDetection() {},
|
||||
_scheduleRightPanelLayout() {},
|
||||
};
|
||||
try {
|
||||
Function(sync[1]).call(owner);
|
||||
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
|
||||
for (const button of [contacts, missions]) {
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.tabIndex, 0);
|
||||
assert.equal(button.attrs.get('aria-disabled'), 'true');
|
||||
assert.equal(button.attrs.get('aria-busy'), 'true');
|
||||
}
|
||||
|
||||
owner._contextModeChanging = false;
|
||||
owner._contextMode = 'space-missions';
|
||||
Function(sync[1]).call(owner);
|
||||
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
|
||||
for (const button of [contacts, missions]) {
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.tabIndex, 0);
|
||||
assert.equal(button.attrs.get('aria-disabled'), 'false');
|
||||
assert.equal(button.attrs.get('aria-busy'), 'false');
|
||||
}
|
||||
} finally {
|
||||
globalThis.document = priorDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('Context activation and Clear All never native-disable tabs and guard repeated clicks', () => {
|
||||
const init = ui.slice(
|
||||
ui.indexOf('_initGlobalContextPanel() {'),
|
||||
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
|
||||
);
|
||||
const select = ui.slice(
|
||||
ui.indexOf('async _selectContextMode('),
|
||||
ui.indexOf('async _deactivateContextForLayerChange(', ui.indexOf('async _selectContextMode(')),
|
||||
);
|
||||
const clear = ui.slice(
|
||||
ui.indexOf('clearSelectedLayers() {'),
|
||||
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
|
||||
);
|
||||
assert.equal((init.match(/if \(this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2);
|
||||
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
|
||||
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
|
||||
});
|
||||
|
||||
test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
|
||||
const init = ui.match(/_initGlobalContextPanel\(\) \{([\s\S]*?)\n this\._globalContextFlightsBtn\?\.addEventListener/);
|
||||
assert.ok(init, 'Context panel initialization is missing');
|
||||
assert.match(init[1], /event\.key === 'ArrowRight'/);
|
||||
assert.match(init[1], /event\.key === 'ArrowLeft'/);
|
||||
assert.match(init[1], /event\.key === 'Home'/);
|
||||
assert.match(init[1], /event\.key === 'End'/);
|
||||
assert.match(init[1], /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
|
||||
assert.match(init[1], /contextTabs\[nextIndex\]\.click\(\)/);
|
||||
});
|
||||
|
||||
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
|
||||
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
|
||||
const focusRule = rules.find(([, selector, body]) => (
|
||||
selector.trim().endsWith('.context-mode-button:focus-visible')
|
||||
&& /outline:\s*2px solid var\(--text-primary\)/.test(body)
|
||||
));
|
||||
assert.ok(focusRule, 'Context focus-visible rule must draw a two-pixel outline');
|
||||
assert.match(focusRule[2], /outline-offset:\s*-3px/);
|
||||
|
||||
const activeRule = rules.find(([, selector]) => selector.trim().endsWith('.context-mode-button.active'));
|
||||
assert.ok(activeRule, 'Context active-state rule is missing');
|
||||
assert.doesNotMatch(activeRule[2], /outline:\s*none/);
|
||||
});
|
||||
|
||||
test('Context async action buttons remain focused while busy', () => {
|
||||
const init = ui.slice(
|
||||
ui.indexOf('_initGlobalContextPanel() {'),
|
||||
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
|
||||
);
|
||||
const radio = ui.slice(
|
||||
ui.indexOf('const toggleRadio = async (trigger) => {'),
|
||||
ui.indexOf('this._contextRadioToggleBtn?.addEventListener', ui.indexOf('const toggleRadio = async (trigger) => {')),
|
||||
);
|
||||
const radioSync = ui.slice(
|
||||
ui.indexOf('_renderRadioState(state) {'),
|
||||
ui.indexOf('if (this._radioFilter)', ui.indexOf('_renderRadioState(state) {')),
|
||||
);
|
||||
const clear = ui.slice(
|
||||
ui.indexOf('clearSelectedLayers() {'),
|
||||
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
|
||||
);
|
||||
|
||||
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
|
||||
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
|
||||
for (const name of ['_radioEnableBtn', '_contextRadioMiniEnableBtn', '_cockpitRadioEnableBtn']) {
|
||||
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
|
||||
}
|
||||
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
|
||||
assert.match(clear, /_clearSelectedLayersBtn\.setAttribute\('aria-busy', 'true'\)/);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
|
||||
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
||||
*/
|
||||
export function configureCreditKeyboardAccess(root = document) {
|
||||
const expand = root?.querySelector?.('#cesium-credits .cesium-credit-expand-link');
|
||||
const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
|
||||
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
|
||||
if (!expand || !lightbox || !close) return false;
|
||||
const overlay = lightbox.parentElement;
|
||||
|
||||
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
|
||||
expand.setAttribute('role', 'button');
|
||||
expand.setAttribute('tabindex', '0');
|
||||
expand.setAttribute('aria-haspopup', 'dialog');
|
||||
expand.setAttribute('aria-controls', lightbox.id);
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
close.setAttribute('role', 'button');
|
||||
close.setAttribute('tabindex', '0');
|
||||
close.setAttribute('aria-label', 'Close data attribution');
|
||||
|
||||
if (expand.dataset.gevKeyboardReady === 'true') return true;
|
||||
expand.dataset.gevKeyboardReady = 'true';
|
||||
|
||||
const installKeyboardActivation = (control) => {
|
||||
let spacePressed = false;
|
||||
control.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (!event.repeat) control.click();
|
||||
return;
|
||||
}
|
||||
if (event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
if (!event.repeat) spacePressed = true;
|
||||
});
|
||||
control.addEventListener('keyup', (event) => {
|
||||
if (event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
if (!spacePressed) return;
|
||||
spacePressed = false;
|
||||
control.click();
|
||||
});
|
||||
// A long Space hold may hand control to push-to-talk and move focus away.
|
||||
// Cancelling the armed release prevents that eventual keyup from also
|
||||
// activating the attribution control.
|
||||
control.addEventListener('blur', () => { spacePressed = false; });
|
||||
};
|
||||
const closeAndRestoreFocus = () => {
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
expand.focus();
|
||||
};
|
||||
installKeyboardActivation(expand);
|
||||
installKeyboardActivation(close);
|
||||
lightbox.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close.click();
|
||||
});
|
||||
expand.addEventListener('click', () => {
|
||||
expand.setAttribute('aria-expanded', 'true');
|
||||
close.focus();
|
||||
});
|
||||
close.addEventListener('click', closeAndRestoreFocus);
|
||||
overlay?.addEventListener?.('click', (event) => {
|
||||
if (!lightbox.contains(event.target)) closeAndRestoreFocus();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
|
||||
|
||||
function fakeElement() {
|
||||
const attributes = new Map();
|
||||
const listeners = new Map();
|
||||
return {
|
||||
dataset: {},
|
||||
focused: false,
|
||||
id: '',
|
||||
setAttribute(name, value) { attributes.set(name, String(value)); },
|
||||
getAttribute(name) { return attributes.get(name) ?? null; },
|
||||
addEventListener(type, listener) {
|
||||
if (!listeners.has(type)) listeners.set(type, []);
|
||||
listeners.get(type).push(listener);
|
||||
},
|
||||
dispatch(type, event = {}) {
|
||||
const dispatched = {
|
||||
currentTarget: this,
|
||||
target: this,
|
||||
defaultPrevented: false,
|
||||
propagationStopped: false,
|
||||
preventDefault() { this.defaultPrevented = true; },
|
||||
stopPropagation() { this.propagationStopped = true; },
|
||||
...event,
|
||||
};
|
||||
for (const listener of listeners.get(type) || []) listener(dispatched);
|
||||
return dispatched;
|
||||
},
|
||||
click() {
|
||||
this.onclick?.();
|
||||
this.dispatch('click');
|
||||
},
|
||||
focus() { this.focused = true; },
|
||||
listenerCount(type) { return listeners.get(type)?.length || 0; },
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const expand = fakeElement();
|
||||
const close = fakeElement();
|
||||
const lightbox = fakeElement();
|
||||
lightbox.querySelector = () => close;
|
||||
lightbox.contains = (target) => target === lightbox || target === close;
|
||||
const overlay = fakeElement();
|
||||
lightbox.parentElement = overlay;
|
||||
const root = {
|
||||
querySelector(selector) {
|
||||
if (selector === '#cesium-credits .cesium-credit-expand-link') return expand;
|
||||
if (selector === '.cesium-credit-lightbox') return lightbox;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return { close, expand, lightbox, overlay, root };
|
||||
}
|
||||
|
||||
test('Cesium Data attribution and close controls join the keyboard tab order', () => {
|
||||
const f = fixture();
|
||||
assert.equal(configureCreditKeyboardAccess(f.root), true);
|
||||
assert.equal(f.expand.getAttribute('role'), 'button');
|
||||
assert.equal(f.expand.getAttribute('tabindex'), '0');
|
||||
assert.equal(f.expand.getAttribute('aria-haspopup'), 'dialog');
|
||||
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(f.close.getAttribute('role'), 'button');
|
||||
assert.equal(f.close.getAttribute('tabindex'), '0');
|
||||
assert.equal(f.close.getAttribute('aria-label'), 'Close data attribution');
|
||||
});
|
||||
|
||||
test('Enter opens and closes attribution on initial keydown', () => {
|
||||
const f = fixture();
|
||||
let opened = 0;
|
||||
let closed = 0;
|
||||
f.expand.onclick = () => { opened++; };
|
||||
f.close.onclick = () => { closed++; };
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
const openEvent = f.expand.dispatch('keydown', { key: 'Enter' });
|
||||
assert.equal(openEvent.defaultPrevented, true);
|
||||
assert.equal(opened, 1);
|
||||
assert.equal(f.expand.getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(f.close.focused, true);
|
||||
const closeEvent = f.close.dispatch('keydown', { key: 'Enter' });
|
||||
assert.equal(closeEvent.defaultPrevented, true);
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(f.expand.focused, true);
|
||||
});
|
||||
|
||||
test('Space opens and closes attribution only on key release', () => {
|
||||
const f = fixture();
|
||||
let opened = 0;
|
||||
let closed = 0;
|
||||
f.expand.onclick = () => { opened++; };
|
||||
f.close.onclick = () => { closed++; };
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
|
||||
const openDown = f.expand.dispatch('keydown', { key: ' ' });
|
||||
assert.equal(openDown.defaultPrevented, true);
|
||||
assert.equal(opened, 0);
|
||||
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
|
||||
const openUp = f.expand.dispatch('keyup', { key: ' ' });
|
||||
assert.equal(openUp.defaultPrevented, true);
|
||||
assert.equal(opened, 1);
|
||||
assert.equal(f.expand.getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(f.close.focused, true);
|
||||
|
||||
const closeDown = f.close.dispatch('keydown', { key: ' ' });
|
||||
assert.equal(closeDown.defaultPrevented, true);
|
||||
assert.equal(closed, 0);
|
||||
const closeUp = f.close.dispatch('keyup', { key: ' ' });
|
||||
assert.equal(closeUp.defaultPrevented, true);
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(f.expand.focused, true);
|
||||
});
|
||||
|
||||
test('held activation keys do not repeat open or close actions', () => {
|
||||
const f = fixture();
|
||||
let opened = 0;
|
||||
f.expand.onclick = () => { opened++; };
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
|
||||
f.expand.dispatch('keydown', { key: ' ' });
|
||||
const repeated = f.expand.dispatch('keydown', { key: ' ', repeat: true });
|
||||
assert.equal(repeated.defaultPrevented, true);
|
||||
assert.equal(opened, 0);
|
||||
f.expand.dispatch('keyup', { key: ' ' });
|
||||
assert.equal(opened, 1);
|
||||
f.expand.dispatch('keyup', { key: ' ' });
|
||||
assert.equal(opened, 1);
|
||||
|
||||
const g = fixture();
|
||||
let enterOpened = 0;
|
||||
g.expand.onclick = () => { enterOpened++; };
|
||||
configureCreditKeyboardAccess(g.root);
|
||||
g.expand.dispatch('keydown', { key: 'Enter' });
|
||||
g.expand.dispatch('keydown', { key: 'Enter', repeat: true });
|
||||
assert.equal(enterOpened, 1);
|
||||
});
|
||||
|
||||
test('losing focus during a Space hold cancels release activation', () => {
|
||||
const f = fixture();
|
||||
let opened = 0;
|
||||
f.expand.onclick = () => { opened++; };
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
|
||||
f.expand.dispatch('keydown', { key: ' ' });
|
||||
f.expand.dispatch('blur');
|
||||
const release = f.expand.dispatch('keyup', { key: ' ' });
|
||||
assert.equal(release.defaultPrevented, true);
|
||||
assert.equal(opened, 0);
|
||||
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
|
||||
});
|
||||
|
||||
test('Escape and backdrop dismissal close attribution and restore its disclosure', () => {
|
||||
const f = fixture();
|
||||
let closed = 0;
|
||||
f.close.onclick = () => { closed++; };
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
f.expand.click();
|
||||
const escape = f.lightbox.dispatch('keydown', { key: 'Escape', target: f.close });
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
assert.equal(escape.propagationStopped, true);
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(f.expand.focused, true);
|
||||
|
||||
f.expand.focused = false;
|
||||
f.expand.click();
|
||||
f.overlay.dispatch('click', { target: f.overlay });
|
||||
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(f.expand.focused, true);
|
||||
});
|
||||
|
||||
test('credit keyboard setup is idempotent and safely declines incomplete markup', () => {
|
||||
const f = fixture();
|
||||
assert.equal(configureCreditKeyboardAccess(f.root), true);
|
||||
assert.equal(configureCreditKeyboardAccess(f.root), true);
|
||||
assert.equal(f.expand.listenerCount('keydown'), 1);
|
||||
assert.equal(f.expand.listenerCount('keyup'), 1);
|
||||
assert.equal(f.expand.listenerCount('blur'), 1);
|
||||
assert.equal(f.close.listenerCount('keydown'), 1);
|
||||
assert.equal(f.close.listenerCount('keyup'), 1);
|
||||
assert.equal(f.close.listenerCount('blur'), 1);
|
||||
assert.equal(f.lightbox.listenerCount('keydown'), 1);
|
||||
assert.equal(f.overlay.listenerCount('click'), 1);
|
||||
assert.equal(configureCreditKeyboardAccess({ querySelector: () => null }), false);
|
||||
});
|
||||
+38
-14
@@ -121,6 +121,35 @@ export function mapAnalystRecord(raw, index = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate a complete feed before replacing the last good earthquake snapshot. */
|
||||
export function normalizeEarthquakeSnapshot(geojson) {
|
||||
if (!Array.isArray(geojson?.features)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const [index, feature] of geojson.features.entries()) {
|
||||
const coordinates = feature?.geometry?.coordinates;
|
||||
const properties = feature?.properties;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2 || !properties
|
||||
|| typeof properties !== 'object' || Array.isArray(properties)
|
||||
|| (feature.geometry.type != null && feature.geometry.type !== 'Point')) return null;
|
||||
const [lon, lat, depthKm] = coordinates;
|
||||
const mag = properties.mag;
|
||||
if (!Number.isFinite(lon) || Math.abs(lon) > 180
|
||||
|| !Number.isFinite(lat) || Math.abs(lat) > 90
|
||||
|| (depthKm != null && !Number.isFinite(depthKm))
|
||||
|| (mag != null && (!Number.isFinite(mag) || mag > 10))) return null;
|
||||
// A missing magnitude cannot establish that this event meets M2.5+.
|
||||
if (mag == null || mag < 2.5) continue;
|
||||
const stableId = feature.id == null || feature.id === '' ? `event-${index + 1}` : String(feature.id);
|
||||
if (ids.has(stableId)) return null;
|
||||
ids.add(stableId);
|
||||
rows.push({ stableId, usgsId: feature.id ?? null, lon, lat, depthKm: depthKm ?? null,
|
||||
mag, place: typeof properties.place === 'string' ? properties.place : null,
|
||||
time: Number.isFinite(properties.time) ? properties.time : null });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = {}) {
|
||||
let _dataSource = null;
|
||||
let _count = 0;
|
||||
@@ -172,23 +201,17 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
|
||||
}
|
||||
|
||||
const geojson = await response.json();
|
||||
if (!geojson || !Array.isArray(geojson.features)) {
|
||||
const rows = normalizeEarthquakeSnapshot(geojson);
|
||||
if (!rows) {
|
||||
_lastError = 'Malformed USGS response';
|
||||
return false;
|
||||
}
|
||||
|
||||
_dataSource.entities.removeAll();
|
||||
const nextEntities = [];
|
||||
let count = 0;
|
||||
const overlayEntries = [];
|
||||
|
||||
for (const feature of geojson.features) {
|
||||
const [lon, lat, depthKm] = feature.geometry.coordinates;
|
||||
const mag = feature.properties.mag;
|
||||
const place = feature.properties.place;
|
||||
const time = feature.properties.time;
|
||||
|
||||
if (mag < 2.5) continue; // Skip micro-quakes
|
||||
|
||||
for (const { stableId, usgsId, lon, lat, depthKm, mag, place, time } of rows) {
|
||||
count++;
|
||||
const baseRadius = Math.pow(2, mag) * 1000;
|
||||
const color = depthColor(depthKm || 0);
|
||||
@@ -197,8 +220,7 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
|
||||
const outlineAlpha = isSignificant ? 1.0 : 0.8;
|
||||
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat);
|
||||
const stableId = feature.id || `event-${count}`;
|
||||
_dataSource.entities.add({
|
||||
nextEntities.push(new Cesium.Entity({
|
||||
id: `earthquake:${stableId}`,
|
||||
position,
|
||||
ellipse: {
|
||||
@@ -216,13 +238,13 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
|
||||
},
|
||||
properties: {
|
||||
// Analyst seam (additive): the USGS event id (e.g. "us7000abcd").
|
||||
usgsId: feature.id ?? null,
|
||||
usgsId,
|
||||
mag,
|
||||
place,
|
||||
time,
|
||||
depth: depthKm,
|
||||
},
|
||||
});
|
||||
}));
|
||||
overlayEntries.push(createEarthquakeOverlayEntry({
|
||||
id: String(stableId),
|
||||
position,
|
||||
@@ -231,6 +253,8 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
|
||||
}));
|
||||
}
|
||||
|
||||
_dataSource.entities.removeAll();
|
||||
for (const entity of nextEntities) _dataSource.entities.add(entity);
|
||||
if (_enabled) {
|
||||
overlayHost.setEntries(
|
||||
EARTHQUAKE_OVERLAY_SOURCE_ID,
|
||||
|
||||
@@ -338,3 +338,66 @@ test('earthquake refresh reports failure and clears it only after a successful r
|
||||
layer.destroy(viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed earthquake refresh preserves entities, overlays, count and timestamp', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const dataSources = [];
|
||||
const publications = [];
|
||||
const viewer = { dataSources: {
|
||||
add(source) { dataSources.push(source); }, remove() { return true; },
|
||||
} };
|
||||
const layer = createEarthquakesLayer({ overlayHost: {
|
||||
setEntries(...args) { publications.push(args); }, setVisible() {}, clearSource() {},
|
||||
} });
|
||||
const good = { id: 'good', geometry: { type: 'Point', coordinates: [10, 20, 5] },
|
||||
properties: { mag: 4, time: 100, place: 'Fixture' } };
|
||||
const respond = (features) => { globalThis.fetch = async () => ({ ok: true, json: async () => ({ features }) }); };
|
||||
try {
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
respond([good]);
|
||||
assert.equal(await layer.update(viewer), true);
|
||||
const entity = dataSources[0].entities.values[0];
|
||||
const stats = layer.getStats();
|
||||
for (const properties of ['invalid', 123, [], true]) {
|
||||
const bad = { ...good, id: 'bad-properties', properties };
|
||||
for (const features of [[bad], [good, bad]]) {
|
||||
respond(features);
|
||||
assert.equal(await layer.update(viewer), false);
|
||||
assert.equal(dataSources[0].entities.values.length, 1);
|
||||
assert.equal(dataSources[0].entities.values[0], entity);
|
||||
assert.equal(layer.getStats().count, stats.count);
|
||||
assert.equal(layer.getStats().lastUpdate, stats.lastUpdate);
|
||||
assert.equal(layer.getStats().error, 'Malformed USGS response');
|
||||
assert.equal(publications.length, 1);
|
||||
}
|
||||
}
|
||||
for (const bad of [null, { ...good, geometry: null },
|
||||
{ ...good, geometry: { type: 'LineString', coordinates: [1, 2] } },
|
||||
{ ...good, geometry: { coordinates: [181, 20] } },
|
||||
{ ...good, geometry: { coordinates: [10, -91] } },
|
||||
{ ...good, geometry: { coordinates: [null, 20] } },
|
||||
{ ...good, properties: { mag: '4' } },
|
||||
{ ...good, properties: { mag: Infinity } }, good]) {
|
||||
respond([good, bad]);
|
||||
assert.equal(await layer.update(viewer), false);
|
||||
assert.equal(dataSources[0].entities.values.length, 1);
|
||||
assert.equal(dataSources[0].entities.values[0], entity);
|
||||
assert.equal(layer.getStats().count, stats.count);
|
||||
assert.equal(layer.getStats().lastUpdate, stats.lastUpdate);
|
||||
assert.equal(layer.getStats().error, 'Malformed USGS response');
|
||||
assert.equal(publications.length, 1);
|
||||
}
|
||||
respond([{ ...good, properties: { mag: null } }]);
|
||||
assert.equal(await layer.update(viewer), true);
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assert.equal(publications.at(-1)[1].length, 0);
|
||||
respond([good]);
|
||||
assert.equal(await layer.update(viewer), true);
|
||||
assert.equal(dataSources[0].entities.values.length, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
layer.destroy(viewer);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
|
||||
|
||||
const config = fs.readFileSync(new URL('../../vite.config.js', import.meta.url), 'utf8');
|
||||
const start = config.indexOf(' async function refreshUpstream(key) {');
|
||||
assert.notEqual(start, -1, 'FIRMS refresh function exists');
|
||||
const end = config.indexOf('\n }', start);
|
||||
assert.notEqual(end, -1, 'FIRMS refresh function closes');
|
||||
const refreshSource = config.slice(start, end + 4);
|
||||
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT'];
|
||||
const NOW = Date.UTC(2026, 8, 11, 12);
|
||||
const recent = { acqDate: '2026-09-11', acqTime: '1100' };
|
||||
|
||||
// Exercise the production refresh without opening a server or using a MAP_KEY.
|
||||
// Inject only its upstream, clock and filter dependencies; keep its aggregation
|
||||
// and source-status code intact, including failures while consuming records.
|
||||
function createRefresh(fetchSource, filter = filterTrailing24h) {
|
||||
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console',
|
||||
`return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} });
|
||||
}
|
||||
|
||||
test('FIRMS retains large sources in order and filters expired rows', async () => {
|
||||
const header = 'latitude,longitude,acq_date,acq_time,confidence,frp\n';
|
||||
const large = parseFirmsCsv(header + '1,2,2026-09-11,1100,n,4\n'.repeat(200_000));
|
||||
const expired = { ...recent, acqDate: '2026-09-09' };
|
||||
const last = { ...recent, marker: 'last' };
|
||||
const calls = [];
|
||||
let active = 0;
|
||||
const refresh = createRefresh(async (key, source) => {
|
||||
assert.equal(active++, 0, 'sources must be fetched sequentially');
|
||||
calls.push(source);
|
||||
await Promise.resolve();
|
||||
active--;
|
||||
return source === SOURCES[0] ? [...large, expired] : source === SOURCES[1] ? [last] : [];
|
||||
});
|
||||
const result = await refresh('fixture');
|
||||
assert.deepEqual(calls, SOURCES);
|
||||
assert.equal(result.fires.length, 200_001);
|
||||
assert.equal(result.fires[0], large[0]);
|
||||
assert.equal(result.fires[199_999], large.at(-1));
|
||||
assert.equal(result.fires.at(-1), last);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: [200_000, 1, 0][index], ok: true,
|
||||
})));
|
||||
});
|
||||
|
||||
test('FIRMS keeps successful sources when another upstream fails', async () => {
|
||||
const result = await createRefresh(async (key, source) => {
|
||||
if (source === SOURCES[1]) throw new Error('upstream unavailable');
|
||||
return [recent];
|
||||
})('fixture');
|
||||
assert.equal(result.fires.length, 2);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: index === 1 ? 0 : 1, ok: index !== 1,
|
||||
})));
|
||||
});
|
||||
|
||||
test('FIRMS reports one failure if consuming a source throws before append', async () => {
|
||||
const failing = [];
|
||||
const result = await createRefresh(async (key, source) => source === SOURCES[0] ? failing : [recent],
|
||||
(records, now) => {
|
||||
if (records !== failing) return filterTrailing24h(records, now);
|
||||
// Fault injection for aggregation; ordinary parsed CSV returns an array.
|
||||
return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } };
|
||||
})('fixture');
|
||||
assert.equal(result.fires.length, 2);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: index === 0 ? 0 : 1, ok: index !== 0,
|
||||
})));
|
||||
});
|
||||
|
||||
test('FIRMS distinguishes all-source failure from successful empty sources', async () => {
|
||||
await assert.rejects(createRefresh(async () => { throw new Error('upstream unavailable'); })('fixture'),
|
||||
/all FIRMS sources failed/);
|
||||
const result = await createRefresh(async () => [])('fixture');
|
||||
assert.deepEqual(result.fires, []);
|
||||
assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
|
||||
});
|
||||
+15
-3
@@ -2043,16 +2043,23 @@ export class DataLayerManager {
|
||||
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
|
||||
|
||||
const toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
|
||||
this._syncToggleButton(toggle, layer);
|
||||
toggle.addEventListener('click', async () => {
|
||||
toggle.disabled = true;
|
||||
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
|
||||
// the lifecycle control focusable while it is busy, and enforce the
|
||||
// same single-flight interaction contract through ARIA instead.
|
||||
if (toggle.getAttribute('aria-disabled') === 'true') return;
|
||||
toggle.setAttribute('aria-disabled', 'true');
|
||||
toggle.setAttribute('aria-busy', 'true');
|
||||
try {
|
||||
await this.setEnabled(layer.id, !this.isEnabled(layer.id), { origin: 'user' });
|
||||
} catch (error) {
|
||||
console.warn(`[Data] ${layer.id} toggle error:`, error);
|
||||
} finally {
|
||||
toggle.disabled = false;
|
||||
const current = this.getAll().find(({ id }) => id === layer.id);
|
||||
if (current) this._syncToggleButton(toggle, current);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2266,7 +2273,12 @@ export class DataLayerManager {
|
||||
button.dataset.feedState = transitioning
|
||||
? layer.lifecycleState
|
||||
: (uncertain ? 'uncertain' : feedState);
|
||||
button.disabled = transitioning;
|
||||
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
|
||||
// click guard above prevents repeat activation without the focus loss caused
|
||||
// by native `disabled`.
|
||||
button.disabled = false;
|
||||
button.setAttribute('aria-disabled', String(transitioning));
|
||||
button.setAttribute('aria-busy', String(transitioning));
|
||||
button.textContent = transitioning
|
||||
? layer.lifecycleState.toUpperCase()
|
||||
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
|
||||
|
||||
@@ -2757,6 +2757,7 @@ function makeControlElement() {
|
||||
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
|
||||
addEventListener(name, handler) { this.listeners[name] = handler; },
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||
getAttribute(name) { return this.attributes[name] ?? null; },
|
||||
closest(selector) {
|
||||
const className = selector.slice(1);
|
||||
return String(this.className).split(/\s+/).includes(className) ? this : null;
|
||||
@@ -2783,6 +2784,163 @@ function makeControlElement() {
|
||||
return element;
|
||||
}
|
||||
|
||||
test('keyboard focus survives Data Layer enabling and disabling transitions', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
||||
const mgr = new DataLayerManager({});
|
||||
let releaseEnable;
|
||||
let releaseDisable;
|
||||
const calls = { enable: 0, disable: 0 };
|
||||
mgr.register({
|
||||
id: 'focus-layer',
|
||||
name: 'Focus layer',
|
||||
icon: '',
|
||||
source: 'Local focus fixture',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {
|
||||
calls.enable += 1;
|
||||
return new Promise((resolve) => { releaseEnable = resolve; });
|
||||
},
|
||||
disable() {
|
||||
calls.disable += 1;
|
||||
return new Promise((resolve) => { releaseDisable = resolve; });
|
||||
},
|
||||
update() {},
|
||||
destroy() {},
|
||||
getStats() { return { count: 1, lastUpdate: Date.now() }; },
|
||||
});
|
||||
const container = makeControlElement();
|
||||
|
||||
const waitFor = async (predicate, label) => {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (predicate()) return;
|
||||
await Promise.resolve();
|
||||
}
|
||||
assert.fail(`timed out waiting for ${label}`);
|
||||
};
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
const row = container.querySelector('[data-layer-id="focus-layer"]');
|
||||
const button = row.querySelector('.data-toggle-btn');
|
||||
button.focus();
|
||||
|
||||
const enablingClick = button.listeners.click();
|
||||
await waitFor(() => typeof releaseEnable === 'function', 'enable transition');
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(button.textContent, 'ENABLING');
|
||||
await button.listeners.click();
|
||||
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
|
||||
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
|
||||
|
||||
releaseEnable();
|
||||
await enablingClick;
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(mgr.isEnabled('focus-layer'), true);
|
||||
|
||||
const disablingClick = button.listeners.click();
|
||||
await waitFor(() => typeof releaseDisable === 'function', 'disable transition');
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(button.disabled, false, 'DISABLING never uses native disabled');
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(button.textContent, 'DISABLING');
|
||||
await button.listeners.click();
|
||||
assert.equal(calls.disable, 1, 'repeat activation is inert while disabling');
|
||||
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
|
||||
|
||||
releaseDisable();
|
||||
await disablingClick;
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(mgr.isEnabled('focus-layer'), false);
|
||||
} finally {
|
||||
releaseEnable?.();
|
||||
releaseDisable?.();
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed Data Layer transition clears busy state without losing keyboard focus', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWarn = console.warn;
|
||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
||||
console.warn = () => {};
|
||||
const mgr = new DataLayerManager({});
|
||||
let rejectEnable;
|
||||
let enableCalls = 0;
|
||||
mgr.register({
|
||||
id: 'focus-failure-layer',
|
||||
name: 'Focus failure layer',
|
||||
icon: '',
|
||||
source: 'Local focus fixture',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {
|
||||
enableCalls += 1;
|
||||
if (enableCalls > 1) return true;
|
||||
return new Promise((resolve, reject) => { rejectEnable = reject; });
|
||||
},
|
||||
disable() { return true; },
|
||||
update() {},
|
||||
destroy() {},
|
||||
getStats() { return { count: 0, lastUpdate: null }; },
|
||||
});
|
||||
const container = makeControlElement();
|
||||
|
||||
try {
|
||||
mgr.buildTogglePanel(container);
|
||||
const button = container
|
||||
.querySelector('[data-layer-id="focus-failure-layer"]')
|
||||
.querySelector('.data-toggle-btn');
|
||||
button.focus();
|
||||
|
||||
const failedClick = button.listeners.click();
|
||||
for (let attempt = 0; attempt < 20 && typeof rejectEnable !== 'function'; attempt += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
assert.equal(typeof rejectEnable, 'function');
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||
|
||||
rejectEnable(new Error('QA enable rejected'));
|
||||
await failedClick;
|
||||
assert.equal(globalThis.document.activeElement, button, 'failure settlement preserves focus');
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(button.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(button.textContent, 'OFF');
|
||||
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
|
||||
enabled: false,
|
||||
lifecycleState: 'disabled',
|
||||
uncertain: false,
|
||||
});
|
||||
|
||||
await button.listeners.click();
|
||||
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
|
||||
assert.equal(globalThis.document.activeElement, button);
|
||||
assert.equal(mgr.isEnabled('focus-failure-layer'), true);
|
||||
} finally {
|
||||
rejectEnable?.(new Error('test cleanup'));
|
||||
console.warn = originalWarn;
|
||||
await mgr.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
/** Collect every node in a rendered subtree carrying `className`. */
|
||||
function collectByClass(node, className) {
|
||||
const found = [];
|
||||
|
||||
@@ -982,6 +982,43 @@ function navigationState() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable identity for a delegated Contacts-panel button across live repaints. */
|
||||
export function awarenessPanelControlKey(element) {
|
||||
const control = element?.closest?.(
|
||||
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||
);
|
||||
if (!control) return null;
|
||||
if (control.dataset.awarenessAction) return `action:${control.dataset.awarenessAction}`;
|
||||
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
|
||||
}
|
||||
|
||||
/** Capture stable identity so a live repaint can restore only the same control. */
|
||||
export function captureAwarenessPanelFocus(panel, activeElement = document.activeElement) {
|
||||
if (!panel?.contains?.(activeElement)) return null;
|
||||
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
|
||||
return { key: 'continuation' };
|
||||
}
|
||||
const key = awarenessPanelControlKey(activeElement);
|
||||
if (!key) return null;
|
||||
return { key };
|
||||
}
|
||||
|
||||
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
|
||||
export function restoreAwarenessPanelFocus(panel, snapshot) {
|
||||
if (!panel || !snapshot) return null;
|
||||
const continuation = panel.querySelector('[data-awareness-focus-continuation]');
|
||||
const controls = [...panel.querySelectorAll(
|
||||
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||
)].filter((control) => !control.disabled);
|
||||
const retained = snapshot.key === 'continuation'
|
||||
? continuation
|
||||
: controls.find((control) => awarenessPanelControlKey(control) === snapshot.key);
|
||||
const target = retained
|
||||
|| continuation;
|
||||
target?.focus?.({ preventScroll: true });
|
||||
return target || null;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[char]);
|
||||
}
|
||||
@@ -993,11 +1030,13 @@ function renderResults() {
|
||||
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
|
||||
${navigationControlsHtml()}
|
||||
${cohorts.map(rowHtml).join('')}
|
||||
<p class="military-awareness-note">Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
|
||||
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
|
||||
panel.hidden = false;
|
||||
if (state.panelMarkup !== markup) {
|
||||
const focusSnapshot = captureAwarenessPanelFocus(panel);
|
||||
panel.innerHTML = markup;
|
||||
state.panelMarkup = markup;
|
||||
restoreAwarenessPanelFocus(panel, focusSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,17 @@ import militaryAwarenessLayer, {
|
||||
awarenessRefreshIntervalMs,
|
||||
awarenessRefreshRequired,
|
||||
awarenessResultsAreLive,
|
||||
awarenessPanelControlKey,
|
||||
buildAwarenessContextSnapshot,
|
||||
canNavigateAwarenessNext,
|
||||
contactsWindowFromSnapshot,
|
||||
contextTargetFlyToAllowed,
|
||||
captureAwarenessPanelFocus,
|
||||
summarizeInstallationViewport,
|
||||
findCompatibleHistoryIndex,
|
||||
AWARENESS_QUERY_LIMIT,
|
||||
historySubjectSnapshot,
|
||||
restoreAwarenessPanelFocus,
|
||||
} from './militaryAwareness.js';
|
||||
import flightsLayer, {
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
@@ -212,6 +215,96 @@ function installAwarenessRuntime({
|
||||
};
|
||||
}
|
||||
|
||||
function awarenessControl(dataset, { disabled = false } = {}) {
|
||||
return {
|
||||
dataset,
|
||||
disabled,
|
||||
focusCalls: 0,
|
||||
closest(selector) {
|
||||
if (selector === '[data-awareness-focus-continuation]') {
|
||||
return this.isContinuation ? this : null;
|
||||
}
|
||||
return selector.includes('button') && !this.isContinuation ? this : null;
|
||||
},
|
||||
matches(selector) {
|
||||
return selector === '[data-awareness-focus-continuation]' && this.isContinuation;
|
||||
},
|
||||
focus(options) {
|
||||
this.focusCalls += 1;
|
||||
this.focusOptions = options;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function awarenessPanel(controls) {
|
||||
const continuation = awarenessControl({});
|
||||
continuation.isContinuation = true;
|
||||
return {
|
||||
controls,
|
||||
continuation,
|
||||
contains(element) { return controls.includes(element) || element === continuation; },
|
||||
querySelectorAll() { return this.controls; },
|
||||
querySelector(selector) {
|
||||
return selector === '[data-awareness-focus-continuation]' ? continuation : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Contacts live repaint restores only stable identity and never retargets another contact', () => {
|
||||
const previous = awarenessControl({ awarenessAction: 'previous' });
|
||||
const focus = awarenessControl({ awarenessAction: 'focus' });
|
||||
const next = awarenessControl({ awarenessAction: 'next' });
|
||||
const alpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' });
|
||||
const bravo = awarenessControl({ awarenessLayer: 'military', awarenessId: 'def456' });
|
||||
const before = awarenessPanel([previous, focus, next, alpha, bravo]);
|
||||
const alphaSnapshot = captureAwarenessPanelFocus(before, alpha);
|
||||
|
||||
assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' });
|
||||
assert.equal(awarenessPanelControlKey(next), 'action:next');
|
||||
|
||||
const retainedAlpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' });
|
||||
const retained = awarenessPanel([previous, focus, next, bravo, retainedAlpha]);
|
||||
assert.equal(restoreAwarenessPanelFocus(retained, alphaSnapshot), retainedAlpha);
|
||||
assert.equal(retainedAlpha.focusCalls, 1, 'identity wins even when live distance reorders rows');
|
||||
assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true });
|
||||
|
||||
const forward = awarenessControl({ awarenessLayer: 'ais-live-vessels', awarenessId: '789' });
|
||||
const departed = awarenessPanel([previous, focus, next, forward]);
|
||||
assert.equal(restoreAwarenessPanelFocus(departed, alphaSnapshot), departed.continuation);
|
||||
assert.equal(forward.focusCalls, 0, 'automatic paging must not transfer focus to another contact');
|
||||
assert.equal(departed.continuation.focusCalls, 1);
|
||||
|
||||
const bravoSnapshot = captureAwarenessPanelFocus(before, bravo);
|
||||
const noRemainingRow = awarenessPanel([previous, focus, next, alpha]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot),
|
||||
noRemainingRow.continuation,
|
||||
);
|
||||
assert.equal(noRemainingRow.continuation.focusCalls, 1, 'the end sentinel continues Tab beyond the list');
|
||||
|
||||
const continuationSnapshot = captureAwarenessPanelFocus(departed, departed.continuation);
|
||||
assert.deepEqual(continuationSnapshot, { key: 'continuation' });
|
||||
const repaintedAgain = awarenessPanel([previous, focus, next, forward]);
|
||||
assert.equal(
|
||||
restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot),
|
||||
repaintedAgain.continuation,
|
||||
);
|
||||
assert.equal(
|
||||
repaintedAgain.continuation.focusCalls,
|
||||
1,
|
||||
'later live repaints keep focus on the continuation point until the user tabs onward',
|
||||
);
|
||||
});
|
||||
|
||||
test('Contacts continuation target is the visible explanatory note', () => {
|
||||
const source = fs.readFileSync(new URL('./militaryAwareness.js', import.meta.url), 'utf8');
|
||||
assert.match(
|
||||
source,
|
||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped\/observed context\./,
|
||||
);
|
||||
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
|
||||
});
|
||||
|
||||
test('awareness disable settles every owned dependency release before resolving', async () => {
|
||||
let releasing = false;
|
||||
let releaseDependencies;
|
||||
|
||||
@@ -55,6 +55,22 @@ for (const [name, code] of Object.entries({
|
||||
brunei: 'BN',
|
||||
'cape verde': 'CV',
|
||||
'the netherlands': 'NL',
|
||||
// Common English names and exonyms whose code is not reachable through the
|
||||
// Intl.DisplayNames primary label, so they would otherwise fail closed
|
||||
// (verified against ICU 77): TR resolves only as 'Türkiye', MM only as
|
||||
// 'Myanmar (Burma)', AE only as 'United Arab Emirates', SZ as 'Eswatini',
|
||||
// TL as 'Timor-Leste'. Each maps to exactly one country; ambiguous names
|
||||
// (e.g. bare "Congo") are deliberately left to fail closed.
|
||||
turkey: 'TR',
|
||||
turkiye: 'TR',
|
||||
myanmar: 'MM',
|
||||
burma: 'MM',
|
||||
uae: 'AE',
|
||||
holland: 'NL',
|
||||
swaziland: 'SZ',
|
||||
'east timor': 'TL',
|
||||
'cabo verde': 'CV',
|
||||
vatican: 'VA',
|
||||
})) COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
|
||||
|
||||
function canonicalCountryName(code) {
|
||||
|
||||
@@ -18,6 +18,37 @@ test('Radio country normalization maps ISO codes and bounded common names', () =
|
||||
}
|
||||
});
|
||||
|
||||
test('Radio country normalization resolves common English names/exonyms ICU misses', () => {
|
||||
// Each of these fails closed against the Intl.DisplayNames primary label
|
||||
// alone (e.g. TR is "Türkiye", MM is "Myanmar (Burma)", AE is "United Arab
|
||||
// Emirates"), so a request like "play radio in Turkey" would return nothing.
|
||||
for (const [input, code] of [
|
||||
['Turkey', 'TR'],
|
||||
['Turkiye', 'TR'],
|
||||
['Myanmar', 'MM'],
|
||||
['Burma', 'MM'],
|
||||
['UAE', 'AE'],
|
||||
['U.A.E.', 'AE'],
|
||||
['Holland', 'NL'],
|
||||
['Swaziland', 'SZ'],
|
||||
['East Timor', 'TL'],
|
||||
['Cabo Verde', 'CV'],
|
||||
['Vatican', 'VA'],
|
||||
]) {
|
||||
const result = normalizeRadioCountryInput(input);
|
||||
assert.equal(result.valid, true, input);
|
||||
assert.equal(result.code, code, input);
|
||||
}
|
||||
});
|
||||
|
||||
test('Ambiguous country names still fail closed (no broadened selection)', () => {
|
||||
// Two states share the name "Congo" (CD/CG), so a bare mention must not
|
||||
// resolve to either — it stays low-confidence and fails closed.
|
||||
for (const input of ['Congo', 'Korea']) {
|
||||
assert.equal(normalizeRadioCountryInput(input).valid, false, input);
|
||||
}
|
||||
});
|
||||
|
||||
test('Radio country normalization rejects malformed, non-ISO, and oversized values', () => {
|
||||
for (const input of [
|
||||
'ZZ',
|
||||
|
||||
+151
-27
@@ -61,6 +61,7 @@ let _missionPanel = null;
|
||||
let _missionRoster = null;
|
||||
let _missionRosterHoverTimer = null;
|
||||
let _hoveredRosterLaunchId = null;
|
||||
let _missionRosterPreviewOwnership = null;
|
||||
let _missionHoverReticleImage = null;
|
||||
let _replayVehicleOverlay = null;
|
||||
let _replayVehicleOverlayText = '';
|
||||
@@ -786,6 +787,115 @@ export function missionHoverPreviewRange(cameraHeight) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinate pointer and keyboard ownership of the temporary roster preview.
|
||||
* The most recent input owns the preview until it leaves, then any remaining
|
||||
* input resumes ownership. This lets keyboard focus take over from a pointer
|
||||
* resting on another row without losing that pointer preview on blur.
|
||||
* @param {{preview: (index: number) => void, clear: () => void}} handlers
|
||||
* @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void,
|
||||
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void,
|
||||
* reset: () => void}}
|
||||
*/
|
||||
export function createMissionRosterPreviewOwnership({ preview, clear }) {
|
||||
let pointerIndex = null;
|
||||
let focusIndex = null;
|
||||
let latestOwner = null;
|
||||
let activeIndex = null;
|
||||
|
||||
const sync = () => {
|
||||
const nextIndex = latestOwner === 'pointer'
|
||||
? pointerIndex ?? focusIndex
|
||||
: focusIndex ?? pointerIndex;
|
||||
if (nextIndex === activeIndex) return;
|
||||
activeIndex = nextIndex;
|
||||
if (Number.isInteger(nextIndex)) preview(nextIndex);
|
||||
else clear();
|
||||
};
|
||||
|
||||
return {
|
||||
pointerEnter(index) {
|
||||
pointerIndex = index;
|
||||
latestOwner = 'pointer';
|
||||
sync();
|
||||
},
|
||||
pointerLeave() {
|
||||
pointerIndex = null;
|
||||
if (latestOwner === 'pointer') latestOwner = focusIndex === null ? null : 'focus';
|
||||
sync();
|
||||
},
|
||||
focus(index) {
|
||||
focusIndex = index;
|
||||
latestOwner = 'focus';
|
||||
sync();
|
||||
},
|
||||
blur(nextIndex = null) {
|
||||
focusIndex = Number.isInteger(nextIndex) ? nextIndex : null;
|
||||
if (latestOwner === 'focus') latestOwner = focusIndex === null
|
||||
? (pointerIndex === null ? null : 'pointer')
|
||||
: 'focus';
|
||||
sync();
|
||||
},
|
||||
reset() {
|
||||
pointerIndex = null;
|
||||
focusIndex = null;
|
||||
latestOwner = null;
|
||||
activeIndex = null;
|
||||
clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind native keyboard focus events for one rendered roster row.
|
||||
* @param {Element} button Mission roster button.
|
||||
* @param {number} index Source-array mission index.
|
||||
* @param {{pointerEnter: (index: number) => void, pointerLeave: () => void,
|
||||
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership
|
||||
* @param {Element} roster Roster root used to reject focus targets outside the list.
|
||||
*/
|
||||
export function bindMissionRosterItemKeyboardPreview(button, index, ownership, roster) {
|
||||
button.addEventListener('mouseenter', () => ownership.pointerEnter(index));
|
||||
button.addEventListener('mouseleave', () => ownership.pointerLeave());
|
||||
button.addEventListener('focus', () => ownership.focus(index));
|
||||
button.addEventListener('blur', (event) => {
|
||||
const nextButton = event.relatedTarget?.closest?.('[data-mission-roster-index]');
|
||||
const nextIndex = nextButton && roster.contains(nextButton)
|
||||
? Number(nextButton.dataset.missionRosterIndex)
|
||||
: null;
|
||||
ownership.blur(nextIndex);
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture the exact mission identity owned by keyboard focus before a refresh. */
|
||||
export function captureMissionRosterFocus(list, activeElement = globalThis.document?.activeElement) {
|
||||
const button = activeElement?.closest?.('[data-mission-roster-id]');
|
||||
if (!button || !list?.contains(button)) return null;
|
||||
const launchId = button.dataset.missionRosterId;
|
||||
return launchId ? { launchId } : null;
|
||||
}
|
||||
|
||||
/** Restore focus by mission identity, or continue after the list if it departed. */
|
||||
export function restoreMissionRosterFocus(list, snapshot, continuation) {
|
||||
if (!snapshot?.launchId) return 'none';
|
||||
const button = Array.from(list?.querySelectorAll?.('[data-mission-roster-id]') || [])
|
||||
.find((candidate) => candidate.dataset.missionRosterId === snapshot.launchId);
|
||||
if (button) {
|
||||
button.focus({ preventScroll: true });
|
||||
return 'restored';
|
||||
}
|
||||
if (continuation?.focus) {
|
||||
continuation.focus({ preventScroll: true });
|
||||
return 'continued';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** Resolve a pending preview against the current refresh, never a stale object. */
|
||||
export function resolveMissionRosterPreviewLaunch(launches, launchId) {
|
||||
return (launches || []).find((candidate) => candidate.id === launchId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a mission epoch for compact on-globe replay labels.
|
||||
* @param {string|Date|null} launchTime ISO-8601 mission time or Date.
|
||||
@@ -2106,13 +2216,15 @@ function normalizePayloadFlights(launch) {
|
||||
const payload = flight.payload || flight;
|
||||
return {
|
||||
id: String(flight.id || payload.id || `payload-${index}`),
|
||||
name: payload.name || flight.name || 'Undisclosed payload',
|
||||
name: payload.name || flight.name || 'Unnamed payload',
|
||||
type: payload.type?.name || flight.type?.name || null,
|
||||
manufacturer: payload.manufacturer?.name || null,
|
||||
operator: payload.operator?.name || null,
|
||||
destination: flight.destination || payload.destination || null,
|
||||
amount: Number.isFinite(Number(flight.amount)) ? Number(flight.amount) : 1,
|
||||
massKg: Number.isFinite(Number(payload.mass)) ? Number(payload.mass) : null,
|
||||
massKg: (typeof payload.mass === 'number' || (typeof payload.mass === 'string' && payload.mass.trim() !== ''))
|
||||
&& Number.isFinite(Number(payload.mass)) && Number(payload.mass) >= 0
|
||||
? Number(payload.mass) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -2288,7 +2400,7 @@ function renderMissionPanel() {
|
||||
_missionPanel.querySelector('[data-mission-payloads]').innerHTML = missionTableRows(
|
||||
payloadRows,
|
||||
3,
|
||||
'CLASSIFIED / MULTI-PAYLOAD',
|
||||
'PAYLOAD DATA UNAVAILABLE',
|
||||
);
|
||||
const stageRows = launch.recoveryStages.map((stage) => {
|
||||
const endpoint = stage.endpoint;
|
||||
@@ -2325,9 +2437,16 @@ function renderMissionRoster() {
|
||||
const count = _missionRoster.querySelector('[data-mission-roster-count]');
|
||||
if (count) count.textContent = `${_launches.length} / 30D`;
|
||||
if (!list) return;
|
||||
const focusSnapshot = captureMissionRosterFocus(list);
|
||||
_missionRosterPreviewOwnership?.reset();
|
||||
const entries = missionRosterEntries(_launches);
|
||||
if (!entries.length) {
|
||||
list.innerHTML = '<div class="space-mission-roster-empty">NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW</div>';
|
||||
restoreMissionRosterFocus(
|
||||
list,
|
||||
focusSnapshot,
|
||||
_missionRoster.querySelector('[data-mission-roster-focus-continuation]'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
list.innerHTML = entries.map(({ launch, index }) => {
|
||||
@@ -2335,8 +2454,22 @@ function renderMissionRoster() {
|
||||
const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE';
|
||||
const provider = launch.provider || 'UNSPECIFIED OPERATOR';
|
||||
const label = shortMissionLabel(launch.name, 27).toUpperCase();
|
||||
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
}).join('');
|
||||
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
|
||||
const index = Number(button.dataset.missionRosterIndex);
|
||||
bindMissionRosterItemKeyboardPreview(
|
||||
button,
|
||||
index,
|
||||
_missionRosterPreviewOwnership,
|
||||
_missionRoster,
|
||||
);
|
||||
});
|
||||
restoreMissionRosterFocus(
|
||||
list,
|
||||
focusSnapshot,
|
||||
_missionRoster.querySelector('[data-mission-roster-focus-continuation]'),
|
||||
);
|
||||
}
|
||||
|
||||
function escapeMissionText(value) {
|
||||
@@ -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);
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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(`<input\\b[^>]*\\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, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span>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\)/);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||
|
||||
function ruleBody(selector) {
|
||||
const start = css.indexOf(selector);
|
||||
assert.ok(start >= 0, `${selector} rule exists`);
|
||||
const open = css.indexOf('{', start);
|
||||
const close = css.indexOf('}', open);
|
||||
return css.slice(open + 1, close);
|
||||
}
|
||||
|
||||
function ruleText(selector) {
|
||||
const start = css.indexOf(selector);
|
||||
assert.ok(start >= 0, `${selector} rule exists`);
|
||||
const close = css.indexOf('}', css.indexOf('{', start));
|
||||
return css.slice(start, close + 1);
|
||||
}
|
||||
|
||||
function ruleBodyContaining(selector, declaration) {
|
||||
let start = -1;
|
||||
while ((start = css.indexOf(selector, start + 1)) >= 0) {
|
||||
const open = css.indexOf('{', start);
|
||||
const close = css.indexOf('}', open);
|
||||
const body = css.slice(open + 1, close);
|
||||
if (body.includes(declaration)) return body;
|
||||
}
|
||||
assert.fail(`${selector} rule containing ${declaration} exists`);
|
||||
}
|
||||
|
||||
test('the global keyboard ring survives local active and outline-reset rules', () => {
|
||||
const rule = ruleText(':where(\n button,');
|
||||
assert.match(rule, /outline:\s*2px solid var\(--text-primary\) !important;/);
|
||||
for (const selector of [
|
||||
"[role='button']",
|
||||
"[role='radio']",
|
||||
"[role='slider']",
|
||||
"[role='tab']",
|
||||
'[tabindex]',
|
||||
'a[href]',
|
||||
]) assert.ok(rule.includes(selector), `${selector} receives the global ring`);
|
||||
});
|
||||
|
||||
test('Location city, POI, search toggle and search field use an inset ring', () => {
|
||||
const rule = ruleText('.location-pill:focus-visible,');
|
||||
assert.match(rule, /\.poi-pill:focus-visible/);
|
||||
assert.match(rule, /\.search-toggle-btn:focus-visible/);
|
||||
assert.match(rule, /#location-search:focus-visible/);
|
||||
assert.match(rule, /outline:\s*2px solid var\(--text-primary\)/);
|
||||
assert.match(rule, /outline-offset:\s*-3px/);
|
||||
for (const selector of ['.location-pill {', '.poi-pill {', '.search-toggle-btn {']) {
|
||||
assert.doesNotMatch(ruleBody(selector), /transition:\s*all\b/);
|
||||
}
|
||||
});
|
||||
|
||||
test('Cockpit Display and Radio launcher glyphs have a complete inset ring', () => {
|
||||
const body = ruleBody('.cockpit-utility-glyph:focus-visible');
|
||||
assert.match(body, /outline:\s*2px solid var\(--text-primary\)/);
|
||||
assert.match(body, /outline-offset:\s*-3px/);
|
||||
assert.doesNotMatch(body, /outline:\s*none/);
|
||||
});
|
||||
|
||||
test('focusable controls do not animate the global outline', () => {
|
||||
for (const selector of [
|
||||
'.panel-collapse-btn {',
|
||||
'#top-center-actions button {',
|
||||
'.data-toggle-chip {',
|
||||
'.scene-btn {',
|
||||
'.scene-shot-btn {',
|
||||
]) {
|
||||
assert.doesNotMatch(
|
||||
ruleBody(selector),
|
||||
/transition:\s*all\b/,
|
||||
`${selector} must leave the focus outline immediate`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('opening a dock popover makes its controls keyboard-reachable immediately', () => {
|
||||
const base = ruleBodyContaining('#command-dock .dock-popover-content {', 'visibility: hidden');
|
||||
assert.match(base, /visibility\s+0s\s+linear\s+180ms/);
|
||||
const open = ruleText('#command-dock #location-bar:not(.collapsed) .dock-popover-content,');
|
||||
assert.match(open, /transition-delay:\s*0s/);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import militaryAwarenessLayer from './data/militaryAwareness.js';
|
||||
import localDataLayers from './data/localLayers.js';
|
||||
import { LAYER_STATE_REGISTRY } from './data/layerState.js';
|
||||
import { registerDataCredits } from './data/dataCredits.js';
|
||||
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
|
||||
import { SceneDirector } from './scenes/director.js';
|
||||
import { initGevVoiceCommands } from './voice/gevRealtime.js';
|
||||
import { MapStackController } from './mapStackController.js';
|
||||
@@ -131,6 +132,7 @@ async function init() {
|
||||
// expandable bottom-left credit lightbox (showOnScreen=false), so they never
|
||||
// clutter the on-globe line. See docs/pre-ship-audit-2026-07-01.md H11.
|
||||
registerDataCredits(viewer);
|
||||
configureCreditKeyboardAccess(document);
|
||||
|
||||
// Hide Cesium's default globe — Google Photorealistic 3D Tiles provide their own
|
||||
// globe at all LODs (street level → orbital). The default globe's 2D imagery
|
||||
|
||||
@@ -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('<div id="location-bar"'), markup.indexOf('<div id="left-panel-stack"'));
|
||||
const locationToggleMarkup = locationMarkup.match(/<button\b([^>]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/);
|
||||
const locationToggleAttributes = Object.fromEntries(
|
||||
[...(locationToggleMarkup?.[1] || '').matchAll(/([\w-]+)="([^"]*)"/g)].map((match) => [match[1], match[2]]),
|
||||
);
|
||||
const initStart = source.indexOf(' _initAutoHoverPanel(');
|
||||
const initEnd = source.indexOf(' /**\n * Sets up drag-to-reposition', initStart);
|
||||
const escapeStart = source.indexOf(' _collapsePanelOnEscape(');
|
||||
const escapeEnd = source.indexOf(' _initCommandDockPins(', escapeStart);
|
||||
const closeStart = source.indexOf(' setPanelCollapsed(panelId, collapsed, {');
|
||||
const closeEnd = source.indexOf(' /**\n * Toggles "clean view"', closeStart);
|
||||
const syncStart = source.indexOf(' _syncPanelCollapseButton(panelEl) {');
|
||||
const syncEnd = source.indexOf(' /**\n * Converts a panel', syncStart);
|
||||
assert.ok(initStart >= 0 && initEnd > initStart && 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], /<button\b/);
|
||||
assert.match(locationMarkup.slice(locationToggleMarkup.index + locationToggleMarkup[0].length),
|
||||
/^\s*<button class="panel-collapse-btn" data-collapse-target="location-bar"/);
|
||||
assert.match(locationMarkup, /<div id="location-bar-popover" class="dock-popover-content">/);
|
||||
});
|
||||
|
||||
test('Location Enter stays immediate while Space waits for native key release', () => {
|
||||
for (const key of ['Enter', ' ']) {
|
||||
const h = harness(); h.focus(h.locationDisclosure); h.drain();
|
||||
const event = h.locationKey(key);
|
||||
assert.equal(event.defaultPrevented, key === 'Enter');
|
||||
assert.equal(event.propagationStopped, key === 'Enter');
|
||||
if (key === ' ') {
|
||||
assert.equal(h.location.classList.contains('collapsed'), true);
|
||||
h.locationKeyUp(key);
|
||||
}
|
||||
assert.equal(h.location.classList.contains('collapsed'), false);
|
||||
assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION');
|
||||
assert.equal(h.document.activeElement, h.locationDisclosure);
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.calls(), 0);
|
||||
const repeated = h.locationKey(key, true);
|
||||
assert.equal(repeated.defaultPrevented, key === 'Enter');
|
||||
assert.equal(repeated.propagationStopped, key === 'Enter');
|
||||
assert.equal(h.location.classList.contains('collapsed'), false);
|
||||
assert.equal(h.timers.size, 0);
|
||||
const release = key === ' ' ? h.locationKeyUp(key, true) : h.emit(h.locationDisclosure, 'keyup', { key });
|
||||
assert.equal(release.defaultPrevented, false, 'global voice release must remain reachable');
|
||||
assert.equal(release.propagationStopped, false);
|
||||
h.locationKey(key);
|
||||
if (key === ' ') h.locationKeyUp(key);
|
||||
assert.equal(h.location.classList.contains('collapsed'), true);
|
||||
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Expand LOCATION');
|
||||
}
|
||||
});
|
||||
|
||||
test('Location synthesized click and Escape retain focus and its accessible name', () => {
|
||||
const h = harness(); h.focus(h.locationDisclosure); h.drain();
|
||||
h.emit(h.locationDisclosure, 'click', { detail: 0 });
|
||||
assert.equal(h.location.classList.contains('collapsed'), false);
|
||||
assert.equal(h.calls(), 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
h.focus(h.locationSearch);
|
||||
const escape = h.locationEscape();
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
assert.equal(h.location.classList.contains('collapsed'), true);
|
||||
assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Expand LOCATION');
|
||||
assert.equal(h.locationDisclosure.title, 'Expand LOCATION');
|
||||
assert.equal(h.document.activeElement, h.locationDisclosure);
|
||||
h.drain();
|
||||
assert.equal(h.calls(), 0);
|
||||
});
|
||||
|
||||
test('Location keyboard opening cancels the unpinned sibling and cannot revive its old callback', () => {
|
||||
const h = harness({ hidden: true }); h.key(); h.tick();
|
||||
const stale = [...h.timers.values()][0].callback;
|
||||
h.focus(h.locationDisclosure); h.locationKey();
|
||||
assert.equal(h.panel.classList.contains('collapsed'), true);
|
||||
assert.equal(h.location.classList.contains('collapsed'), false);
|
||||
const attemptsBefore = h.calls();
|
||||
h.show(); stale(); h.drain();
|
||||
assert.equal(h.calls(), attemptsBefore);
|
||||
assert.equal(h.document.activeElement, h.locationDisclosure);
|
||||
});
|
||||
|
||||
test('Location keyboard opening preserves a pinned sibling and a pinned Location survives sibling opening', () => {
|
||||
const h = harness(); h.panel.classes.add('dock-pinned'); h.key(); h.tick();
|
||||
h.focus(h.locationDisclosure); h.locationKey(); h.drain();
|
||||
assert.equal(h.panel.classList.contains('collapsed'), false);
|
||||
assert.equal(h.location.classList.contains('collapsed'), false);
|
||||
assert.equal(h.document.activeElement, h.locationDisclosure);
|
||||
h.location.classes.add('dock-pinned');
|
||||
h.manager.setPanelCollapsed('control-panel', true);
|
||||
h.focus(h.disclosure); h.key(); h.tick();
|
||||
assert.equal(h.location.classList.contains('collapsed'), false);
|
||||
assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(h.document.activeElement, h.active);
|
||||
});
|
||||
|
||||
test('Location restoration syncs its real name without claiming focus, collapsing its sibling or persisting', () => {
|
||||
const h = harness(); h.key(); h.tick();
|
||||
const saves = h.saves.length;
|
||||
const claims = h.claims.length;
|
||||
const shareSyncs = h.shareSyncs();
|
||||
h.manager.setPanelCollapsed('location-bar', false, { restore: true, persist: false, syncShare: false });
|
||||
assert.equal(h.panel.classList.contains('collapsed'), false);
|
||||
assert.equal(h.location.classList.contains('collapsed'), false);
|
||||
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION');
|
||||
assert.equal(h.document.activeElement, h.active);
|
||||
assert.equal(h.saves.length, saves);
|
||||
assert.equal(h.claims.length, claims);
|
||||
assert.equal(h.shareSyncs(), shareSyncs);
|
||||
h.locationDisclosure.setAttribute('aria-label', 'stale');
|
||||
h.manager.setPanelCollapsed('location-bar', false, { restore: true, persist: false, syncShare: false });
|
||||
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION', 'same-state synchronization keeps the Location name');
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test('disposed UI cannot complete a pending handoff', () => {
|
||||
const h = harness({ hidden: true }); h.key(); h.tick();
|
||||
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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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 <html>';
|
||||
|
||||
// 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|<html>/);
|
||||
assert.doesNotMatch(logs.join('\n'), /fixture-secret-token|internal\/example|<html>/);
|
||||
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 });
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
+156
-32
@@ -20,6 +20,7 @@ const STATUS = {
|
||||
error: 'ERROR',
|
||||
};
|
||||
const CALL_DEDUPE_MS = 2500;
|
||||
export const PUSH_TO_TALK_HOLD_DELAY_MS = 500;
|
||||
// WebRTC 'disconnected' is frequently momentary (a brief network blip that ICE
|
||||
// recovers on its own). Give it this long to return to 'connected' before we
|
||||
// treat it as a real drop (H8).
|
||||
@@ -307,6 +308,11 @@ export class GevRealtimeController {
|
||||
this.pushToTalkMode = false;
|
||||
this.pushToTalkKeyHeld = false;
|
||||
this.spaceKeyHeld = false;
|
||||
this.pushToTalkHoldTimer = null;
|
||||
this.pushToTalkHoldGeneration = 0;
|
||||
this.pushToTalkHoldFocusOwner = null;
|
||||
this.pushToTalkHoldControl = null;
|
||||
this.pushToTalkHoldPreservesNative = false;
|
||||
this.shortcutKeyDownHandler = null;
|
||||
this.shortcutKeyUpHandler = null;
|
||||
this.shortcutBlurHandler = null;
|
||||
@@ -568,65 +574,122 @@ export class GevRealtimeController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers hold-Space push-to-talk without hijacking typing or modified shortcuts.
|
||||
* @returns {void}
|
||||
*/
|
||||
/** Registers delayed hold-Space push-to-talk while preserving short control taps. */
|
||||
bindPushToTalkShortcut() {
|
||||
if (this.shortcutKeyDownHandler) return;
|
||||
this.shortcutKeyDownHandler = (event) => {
|
||||
if (!shouldHandlePushToTalkKeyDown(event)) return;
|
||||
if (event.repeat) {
|
||||
if (this.spaceKeyHeld) event.preventDefault();
|
||||
if (this.spaceKeyHeld && !this.pushToTalkHoldPreservesNative) event.preventDefault();
|
||||
if (this.pushToTalkKeyHeld) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
this.spaceKeyHeld = true;
|
||||
// Space must not generate the focused mic button's native click on keyup.
|
||||
event.preventDefault();
|
||||
this.pauseRadioForVoice();
|
||||
if (this.pushToTalkKeyHeld) return;
|
||||
this.pushToTalkHoldFocusOwner = document.activeElement;
|
||||
this.pushToTalkHoldControl = isInteractiveSpaceTarget(document.activeElement)
|
||||
? document.activeElement
|
||||
: (isInteractiveSpaceTarget(event.target)
|
||||
? event.target.closest?.(SPACE_INTERACTIVE_SELECTOR) || event.target
|
||||
: null);
|
||||
this.pushToTalkHoldPreservesNative = Boolean(this.pushToTalkHoldControl);
|
||||
// Background Space is reserved immediately to avoid scrolling. A focused
|
||||
// control keeps its native keydown and release unless the hold is claimed.
|
||||
if (!this.pushToTalkHoldPreservesNative) event.preventDefault();
|
||||
// A click-started session is intentionally open-mic. Space only claims an
|
||||
// idle session (or a session it already started) so releasing the key can
|
||||
// never surprise the user by muting a click-started conversation.
|
||||
if (this.isActive() && !this.pushToTalkMode) return;
|
||||
this.pushToTalkKeyHeld = true;
|
||||
this.ui.root.dataset.pushToTalk = 'held';
|
||||
if (this.isActive()) {
|
||||
this.setMicrophoneEnabled(true);
|
||||
if (this.status === 'listening') this.setStatus('listening', 'Release Space to send');
|
||||
} else {
|
||||
this.start({ pushToTalk: true });
|
||||
}
|
||||
this.cancelPushToTalkHold();
|
||||
const holdGeneration = ++this.pushToTalkHoldGeneration;
|
||||
this.pushToTalkHoldTimer = setTimeout(() => {
|
||||
this.pushToTalkHoldTimer = null;
|
||||
if (holdGeneration !== this.pushToTalkHoldGeneration) return;
|
||||
if (!this.spaceKeyHeld || this.pushToTalkKeyHeld) return;
|
||||
// A pointer or Tab focus change during the delay cancels the original
|
||||
// gesture rather than letting voice claim a key held for another owner.
|
||||
if (document.activeElement !== this.pushToTalkHoldFocusOwner) return;
|
||||
if (
|
||||
document.visibilityState === 'hidden'
|
||||
|| (typeof document.hasFocus === 'function' && !document.hasFocus())
|
||||
) return;
|
||||
if (this.isActive() && !this.pushToTalkMode) return;
|
||||
// Blur before voice starts. This removes the focused state and ensures
|
||||
// the eventual Space release cannot activate the old control.
|
||||
if (
|
||||
this.pushToTalkHoldControl
|
||||
&& document.activeElement === this.pushToTalkHoldControl
|
||||
&& typeof this.pushToTalkHoldControl.blur === 'function'
|
||||
) {
|
||||
this.pushToTalkHoldControl.blur();
|
||||
}
|
||||
this.pauseRadioForVoice();
|
||||
this.pushToTalkKeyHeld = true;
|
||||
if (this.isActive()) {
|
||||
this.ui.root.dataset.pushToTalk = 'held';
|
||||
this.setMicrophoneEnabled(true);
|
||||
if (this.status === 'listening') this.setStatus('listening', 'Release Space to send');
|
||||
} else {
|
||||
this.start({ pushToTalk: true });
|
||||
// start() performs a controlled stop() before connecting. Restore the
|
||||
// marker it clears so the UI reflects this still-held gesture.
|
||||
if (this.pushToTalkKeyHeld) this.ui.root.dataset.pushToTalk = 'held';
|
||||
}
|
||||
}, PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
};
|
||||
this.shortcutKeyUpHandler = (event) => {
|
||||
if (!isPushToTalkKey(event)) return;
|
||||
const wasHoldingSpace = this.spaceKeyHeld;
|
||||
const preservedNativeActivation = this.pushToTalkHoldPreservesNative;
|
||||
this.spaceKeyHeld = false;
|
||||
this.cancelPushToTalkHold();
|
||||
if (!this.pushToTalkKeyHeld) {
|
||||
if (wasHoldingSpace) event.preventDefault();
|
||||
if (wasHoldingSpace && !preservedNativeActivation) event.preventDefault();
|
||||
this.resetPushToTalkGesture();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.releasePushToTalkKey();
|
||||
this.resetPushToTalkGesture();
|
||||
};
|
||||
this.shortcutBlurHandler = () => {
|
||||
this.spaceKeyHeld = false;
|
||||
this.cancelPushToTalkHold();
|
||||
this.releasePushToTalkKey();
|
||||
this.resetPushToTalkGesture();
|
||||
};
|
||||
this.shortcutVisibilityHandler = () => {
|
||||
if (document.visibilityState === 'hidden') this.shortcutBlurHandler();
|
||||
};
|
||||
document.addEventListener('keydown', this.shortcutKeyDownHandler);
|
||||
document.addEventListener('keyup', this.shortcutKeyUpHandler);
|
||||
// Observe before custom controls call preventDefault for their own Space
|
||||
// behavior. This lets the same physical hold cross the 500ms voice
|
||||
// threshold while short taps still reach the control unchanged.
|
||||
document.addEventListener('keydown', this.shortcutKeyDownHandler, true);
|
||||
document.addEventListener('keyup', this.shortcutKeyUpHandler, true);
|
||||
window.addEventListener('blur', this.shortcutBlurHandler);
|
||||
document.addEventListener('visibilitychange', this.shortcutVisibilityHandler);
|
||||
}
|
||||
|
||||
/** Cancels an unclaimed Space hold before it starts voice. */
|
||||
cancelPushToTalkHold() {
|
||||
this.pushToTalkHoldGeneration++;
|
||||
if (this.pushToTalkHoldTimer === null) return;
|
||||
clearTimeout(this.pushToTalkHoldTimer);
|
||||
this.pushToTalkHoldTimer = null;
|
||||
}
|
||||
|
||||
/** Clears the owner metadata for the current physical Space gesture. */
|
||||
resetPushToTalkGesture() {
|
||||
this.pushToTalkHoldFocusOwner = null;
|
||||
this.pushToTalkHoldControl = null;
|
||||
this.pushToTalkHoldPreservesNative = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes a keyboard-started microphone while leaving WebRTC alive for the reply.
|
||||
* @returns {void}
|
||||
*/
|
||||
releasePushToTalkKey() {
|
||||
this.cancelPushToTalkHold();
|
||||
if (!this.pushToTalkKeyHeld) return;
|
||||
this.pushToTalkKeyHeld = false;
|
||||
delete this.ui.root.dataset.pushToTalk;
|
||||
@@ -773,6 +836,7 @@ export class GevRealtimeController {
|
||||
// releases its own resources instead of promoting them onto a stopped
|
||||
// controller (H7).
|
||||
this.startEpoch++;
|
||||
this.cancelPushToTalkHold();
|
||||
this.radioHandoffEpoch++;
|
||||
for (const controller of this.activeToolAbortControllers) controller.abort();
|
||||
this.activeToolAbortControllers.clear();
|
||||
@@ -844,6 +908,7 @@ export class GevRealtimeController {
|
||||
this.pushToTalkMode = false;
|
||||
this.pushToTalkKeyHeld = false;
|
||||
this.spaceKeyHeld = false;
|
||||
this.resetPushToTalkGesture();
|
||||
if (this.ui?.root) {
|
||||
delete this.ui.root.dataset.pushToTalk;
|
||||
delete this.ui.root.dataset.microphone;
|
||||
@@ -857,8 +922,8 @@ export class GevRealtimeController {
|
||||
this.tierHandler = null;
|
||||
}
|
||||
if (removeUi) {
|
||||
if (this.shortcutKeyDownHandler) document.removeEventListener('keydown', this.shortcutKeyDownHandler);
|
||||
if (this.shortcutKeyUpHandler) document.removeEventListener('keyup', this.shortcutKeyUpHandler);
|
||||
if (this.shortcutKeyDownHandler) document.removeEventListener('keydown', this.shortcutKeyDownHandler, true);
|
||||
if (this.shortcutKeyUpHandler) document.removeEventListener('keyup', this.shortcutKeyUpHandler, true);
|
||||
if (this.shortcutBlurHandler) window.removeEventListener('blur', this.shortcutBlurHandler);
|
||||
if (this.shortcutVisibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', this.shortcutVisibilityHandler);
|
||||
@@ -2454,18 +2519,77 @@ export function isPushToTalkKey(event) {
|
||||
return event?.code === 'Space' || event?.key === ' ';
|
||||
}
|
||||
|
||||
const SPACE_INTERACTIVE_SELECTOR = [
|
||||
'button',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'option',
|
||||
'a[href]',
|
||||
'summary',
|
||||
'audio[controls]',
|
||||
'video[controls]',
|
||||
'[contenteditable]',
|
||||
'[tabindex]',
|
||||
'[role="button"]',
|
||||
'[role="checkbox"]',
|
||||
'[role="combobox"]',
|
||||
'[role="link"]',
|
||||
'[role="listbox"]',
|
||||
'[role="menuitem"]',
|
||||
'[role="menuitemcheckbox"]',
|
||||
'[role="menuitemradio"]',
|
||||
'[role="option"]',
|
||||
'[role="radio"]',
|
||||
'[role="searchbox"]',
|
||||
'[role="slider"]',
|
||||
'[role="spinbutton"]',
|
||||
'[role="switch"]',
|
||||
'[role="tab"]',
|
||||
'[role="textbox"]',
|
||||
'[role="treeitem"]',
|
||||
].join(', ');
|
||||
|
||||
/**
|
||||
* Protects text entry and modified shortcuts from the global push-to-talk key.
|
||||
* @param {KeyboardEvent|object|null} event
|
||||
* @returns {boolean}
|
||||
* Returns whether Space began on the map surface reserved for push-to-talk.
|
||||
* Cesium gives its canvas `tabindex="0"` for camera input, which otherwise
|
||||
* makes the generic focus guard treat a map click like a button activation.
|
||||
*/
|
||||
export function isPushToTalkSurface(target) {
|
||||
if (target?.tagName?.toUpperCase?.() !== 'CANVAS') return false;
|
||||
if (target.id === 'world-overlay-canvas') return true;
|
||||
return Boolean(target.closest?.('#cesiumContainer'));
|
||||
}
|
||||
|
||||
/** Returns whether a focused target owns Space for UI interaction. */
|
||||
export function isInteractiveSpaceTarget(target) {
|
||||
if (!target) return false;
|
||||
if (isPushToTalkSurface(target)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
return Boolean(target.closest?.(SPACE_INTERACTIVE_SELECTOR));
|
||||
}
|
||||
|
||||
/** Returns whether Space belongs to a text-entry surface. */
|
||||
export function isEditingSpaceTarget(target) {
|
||||
if (!target) return false;
|
||||
if (target.isContentEditable || target.closest?.('[contenteditable]')) return true;
|
||||
if (target.closest?.('textarea, [role="textbox"], [role="searchbox"], [role="spinbutton"]')) {
|
||||
return true;
|
||||
}
|
||||
const input = target.closest?.('input');
|
||||
if (!input) return false;
|
||||
const type = String(input.type || 'text').toLowerCase();
|
||||
return [
|
||||
'text', 'search', 'email', 'url', 'tel', 'password', 'number',
|
||||
'date', 'datetime-local', 'month', 'week', 'time',
|
||||
].includes(type);
|
||||
}
|
||||
|
||||
/** Protects text entry and modified shortcuts from push-to-talk arbitration. */
|
||||
export function shouldHandlePushToTalkKeyDown(event) {
|
||||
if (!isPushToTalkKey(event) || event.defaultPrevented) return false;
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false;
|
||||
const target = event.target;
|
||||
if (target?.isContentEditable) return false;
|
||||
const editingControl = target?.closest?.('input, textarea, select, [contenteditable], [role="textbox"]');
|
||||
return !editingControl;
|
||||
return !isEditingSpaceTarget(event.target);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2510,7 +2634,7 @@ export function resolveVoiceVisualizerSpeaker(currentSpeaker, nextSpeaker, keepC
|
||||
export function resolveVoiceControlHint(pushToTalkMode, pushToTalkKeyHeld) {
|
||||
return pushToTalkMode && pushToTalkKeyHeld
|
||||
? 'Release Space to send'
|
||||
: 'Hold Space to speak · click mic to toggle voice';
|
||||
: 'Hold Space to speak · tap Space to activate focused controls';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2559,7 +2683,7 @@ function createVoiceControl({ reset = false } = {}) {
|
||||
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="Estimated session cost">~$0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="gev-voice-button" type="button" aria-label="Voice control — hold Space to speak; click to toggle voice" aria-describedby="gev-voice-help">
|
||||
<button id="gev-voice-button" type="button" aria-label="Voice control — activate to toggle voice; hold Space to speak" aria-describedby="gev-voice-help">
|
||||
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
|
||||
<span class="gev-mic-label">ON/OFF</span>
|
||||
</button>
|
||||
@@ -2571,7 +2695,7 @@ function createVoiceControl({ reset = false } = {}) {
|
||||
</div>
|
||||
<div id="gev-voice-help" class="gev-voice-help-tray" role="tooltip">
|
||||
<span class="gev-voice-help-kicker">VOICE CONTROL</span>
|
||||
<span class="gev-voice-help-detail">Hold Space to speak · click mic to toggle voice</span>
|
||||
<span class="gev-voice-help-detail">Hold Space to speak · tap Space to activate focused controls</span>
|
||||
</div>
|
||||
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
|
||||
<div class="gev-voice-error-header">
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
GevRealtimeController,
|
||||
gateVoiceVisualizerLevel,
|
||||
isBenignViewportDeleteError,
|
||||
isEditingSpaceTarget,
|
||||
isInteractiveSpaceTarget,
|
||||
isPushToTalkKey,
|
||||
isPushToTalkSurface,
|
||||
PUSH_TO_TALK_HOLD_DELAY_MS,
|
||||
resolveVoiceControlHint,
|
||||
resolveVoiceVisualizerSpeaker,
|
||||
selectVoiceVisualizerSignal,
|
||||
@@ -30,23 +34,442 @@ import {
|
||||
import { createVoiceCostTracker } from './voiceCost.js';
|
||||
|
||||
test('push-to-talk recognizes Space by code or key', () => {
|
||||
assert.equal(PUSH_TO_TALK_HOLD_DELAY_MS, 500);
|
||||
assert.equal(isPushToTalkKey({ code: 'Space', key: 'Unidentified' }), true);
|
||||
assert.equal(isPushToTalkKey({ code: '', key: ' ' }), true);
|
||||
assert.equal(isPushToTalkKey({ code: 'KeyM', key: 'm' }), false);
|
||||
});
|
||||
|
||||
test('push-to-talk ignores typing targets and modified shortcuts', () => {
|
||||
test('push-to-talk protects text entry but arbitrates non-editing controls', () => {
|
||||
const plainTarget = { isContentEditable: false, closest: () => null };
|
||||
assert.equal(shouldHandlePushToTalkKeyDown({ code: 'Space', target: plainTarget }), true);
|
||||
assert.equal(shouldHandlePushToTalkKeyDown({ code: 'Space', target: plainTarget, metaKey: true }), false);
|
||||
assert.equal(shouldHandlePushToTalkKeyDown({
|
||||
code: 'Space',
|
||||
target: { isContentEditable: false, closest: () => ({ tagName: 'INPUT' }) },
|
||||
target: pushToTalkTarget({ tagName: 'INPUT', type: 'text' }),
|
||||
}), false);
|
||||
assert.equal(shouldHandlePushToTalkKeyDown({
|
||||
code: 'Space',
|
||||
target: { isContentEditable: true, closest: () => null },
|
||||
}), false);
|
||||
assert.equal(shouldHandlePushToTalkKeyDown({
|
||||
code: 'Space',
|
||||
target: pushToTalkTarget({ tagName: 'BUTTON' }),
|
||||
}), true);
|
||||
assert.equal(isEditingSpaceTarget(pushToTalkTarget({ tagName: 'INPUT', type: 'range' })), false);
|
||||
});
|
||||
|
||||
// Dispatch through the installed document/window listeners, keeping WebRTC at
|
||||
// the start boundary. Native button activation itself belongs to browser QA.
|
||||
function createPushToTalkFixture(t) {
|
||||
const savedGlobals = new Map(['document', 'window', 'setTimeout', 'clearTimeout'].map((name) => (
|
||||
[name, Object.getOwnPropertyDescriptor(globalThis, name)]
|
||||
)));
|
||||
const eventHub = () => {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
addEventListener(type, listener) {
|
||||
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||
listeners.get(type).add(listener);
|
||||
},
|
||||
removeEventListener(type, listener) { listeners.get(type)?.delete(listener); },
|
||||
dispatch(type, event = {}) {
|
||||
for (const listener of listeners.get(type) || []) listener(event);
|
||||
return event;
|
||||
},
|
||||
count(type) { return listeners.get(type)?.size || 0; },
|
||||
};
|
||||
};
|
||||
let clock = 0;
|
||||
let nextTimerId = 1;
|
||||
const timers = new Map();
|
||||
const setTimeout = (callback, delay = 0) => {
|
||||
const id = nextTimerId++;
|
||||
timers.set(id, { callback, due: clock + Number(delay || 0) });
|
||||
return id;
|
||||
};
|
||||
const clearTimeout = (id) => timers.delete(id);
|
||||
const advance = (milliseconds) => {
|
||||
const end = clock + milliseconds;
|
||||
while (true) {
|
||||
const due = [...timers.entries()]
|
||||
.filter(([, timer]) => timer.due <= end)
|
||||
.sort((a, b) => a[1].due - b[1].due || a[0] - b[0])[0];
|
||||
if (!due) break;
|
||||
const [id, timer] = due;
|
||||
timers.delete(id);
|
||||
clock = timer.due;
|
||||
timer.callback();
|
||||
}
|
||||
clock = end;
|
||||
};
|
||||
const document = { ...eventHub(), visibilityState: 'visible', activeElement: null };
|
||||
const window = eventHub();
|
||||
Object.assign(globalThis, { document, window, setTimeout, clearTimeout });
|
||||
const starts = [];
|
||||
const radio = [];
|
||||
const controllers = [];
|
||||
const makeController = () => {
|
||||
const microphone = { enabled: false, stopped: false, stop() { this.stopped = true; } };
|
||||
const controller = new GevRealtimeController({
|
||||
ui: {
|
||||
root: { dataset: {}, querySelectorAll: () => [], remove() {} },
|
||||
status: {}, detail: {},
|
||||
},
|
||||
runner: async () => ({}),
|
||||
radioLayer: {
|
||||
pause: () => radio.push('pause'),
|
||||
setVoiceDucked: (ducked) => radio.push(ducked ? 'duck' : 'unduck'),
|
||||
},
|
||||
});
|
||||
controller.debugLog = () => {};
|
||||
controller.start = ({ pushToTalk }) => {
|
||||
starts.push({ pushToTalk });
|
||||
controller.pushToTalkMode = pushToTalk;
|
||||
controller.status = 'listening';
|
||||
controller.stream = {
|
||||
getAudioTracks: () => [microphone],
|
||||
getTracks: () => [microphone],
|
||||
};
|
||||
controller.setMicrophoneEnabled(true);
|
||||
};
|
||||
controllers.push(controller);
|
||||
controller.bindPushToTalkShortcut();
|
||||
return { controller, microphone };
|
||||
};
|
||||
t.after(() => {
|
||||
for (const controller of controllers) controller.stop({ removeUi: true });
|
||||
for (const [name, descriptor] of savedGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else delete globalThis[name];
|
||||
}
|
||||
});
|
||||
const key = (type, target, options = {}) => {
|
||||
document.activeElement = options.activeElement === undefined ? target : options.activeElement;
|
||||
return document.dispatch(type, {
|
||||
code: 'Space', key: ' ', target, defaultPrevented: false,
|
||||
preventDefault() { this.defaultPrevented = true; },
|
||||
...options,
|
||||
});
|
||||
};
|
||||
return { advance, document, window, starts, radio, key, makeController, timers, ...makeController() };
|
||||
}
|
||||
|
||||
function pushToTalkTarget({
|
||||
tagName = null,
|
||||
id = null,
|
||||
style = null,
|
||||
parentElement = null,
|
||||
role = null,
|
||||
tabIndex = null,
|
||||
href = null,
|
||||
controls = false,
|
||||
type = null,
|
||||
contentEditable = false,
|
||||
mapSurface = false,
|
||||
} = {}) {
|
||||
const resolvedTagName = (tagName || (style ? 'BUTTON' : 'DIV')).toUpperCase();
|
||||
return {
|
||||
tagName: resolvedTagName,
|
||||
id,
|
||||
style,
|
||||
parentElement,
|
||||
role,
|
||||
tabIndex,
|
||||
href,
|
||||
controls,
|
||||
type,
|
||||
blurred: 0,
|
||||
blur() { this.blurred += 1; },
|
||||
isContentEditable: contentEditable,
|
||||
closest(selector) {
|
||||
for (let node = this; node; node = node.parentElement) {
|
||||
if (selector === '#cesiumContainer' && (node.id === 'cesiumContainer' || node.mapSurface)) {
|
||||
return node;
|
||||
}
|
||||
for (const part of selector.split(/,\s*/)) {
|
||||
if (part.toUpperCase() === node.tagName) return node;
|
||||
if (part === 'a[href]' && node.tagName === 'A' && node.href !== null) return node;
|
||||
if (part === 'audio[controls]' && node.tagName === 'AUDIO' && node.controls) return node;
|
||||
if (part === 'video[controls]' && node.tagName === 'VIDEO' && node.controls) return node;
|
||||
if (part === '[contenteditable]' && node.isContentEditable) return node;
|
||||
if (part === '[tabindex]' && node.tabIndex !== null) return node;
|
||||
const roleMatch = part.match(/^\[role="([^"]+)"\]$/);
|
||||
if (roleMatch && node.role === roleMatch[1]) return node;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
mapSurface,
|
||||
};
|
||||
}
|
||||
|
||||
test('push-to-talk starts from Cesium canvases and the document background', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const cesiumContainer = pushToTalkTarget({ tagName: 'DIV', id: 'cesiumContainer' });
|
||||
const cesiumCanvas = pushToTalkTarget({
|
||||
tagName: 'CANVAS',
|
||||
tabIndex: 0,
|
||||
parentElement: cesiumContainer,
|
||||
});
|
||||
const overlayCanvas = pushToTalkTarget({
|
||||
tagName: 'CANVAS',
|
||||
id: 'world-overlay-canvas',
|
||||
tabIndex: 0,
|
||||
});
|
||||
|
||||
for (const [index, canvas] of [cesiumCanvas, overlayCanvas].entries()) {
|
||||
assert.equal(isPushToTalkSurface(canvas), true);
|
||||
assert.equal(isInteractiveSpaceTarget(canvas), false);
|
||||
assert.equal(f.key('keydown', canvas).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.starts.length, 1, 'the first map hold opens one reusable voice session');
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||||
assert.equal(f.microphone.enabled, true, `map hold ${index + 1} enables the microphone`);
|
||||
assert.equal(f.key('keyup', canvas).defaultPrevented, true);
|
||||
assert.equal(f.microphone.enabled, false, `map release ${index + 1} mutes the microphone`);
|
||||
}
|
||||
const background = pushToTalkTarget();
|
||||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||||
assert.equal(f.key('keyup', background).defaultPrevented, true);
|
||||
});
|
||||
|
||||
test('short Space on a focused control preserves release activation', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const button = pushToTalkTarget({ tagName: 'BUTTON' });
|
||||
let activations = 0;
|
||||
assert.equal(f.key('keydown', button).defaultPrevented, false);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
|
||||
const release = f.key('keyup', button);
|
||||
if (!release.defaultPrevented) activations += 1;
|
||||
assert.equal(release.defaultPrevented, false);
|
||||
assert.equal(activations, 1);
|
||||
assert.equal(button.blurred, 0);
|
||||
assert.deepEqual(f.starts, []);
|
||||
});
|
||||
|
||||
test('long Space blurs a focused control before voice and consumes release', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const button = pushToTalkTarget({ tagName: 'BUTTON' });
|
||||
const order = [];
|
||||
button.blur = () => {
|
||||
button.blurred += 1;
|
||||
order.push('blur');
|
||||
f.document.activeElement = null;
|
||||
};
|
||||
f.controller.pauseRadioForVoice = () => order.push('voice');
|
||||
assert.equal(f.key('keydown', button).defaultPrevented, false);
|
||||
assert.equal(f.key('keydown', button, { repeat: true }).defaultPrevented, false);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.deepEqual(order, ['blur', 'voice']);
|
||||
assert.equal(button.blurred, 1);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||||
assert.equal(f.key('keydown', button, { repeat: true, activeElement: null }).defaultPrevented, true);
|
||||
let activations = 0;
|
||||
const release = f.key('keyup', button, { activeElement: null });
|
||||
if (!release.defaultPrevented) activations += 1;
|
||||
assert.equal(release.defaultPrevented, true);
|
||||
assert.equal(activations, 0);
|
||||
assert.equal(f.microphone.enabled, false);
|
||||
});
|
||||
|
||||
test('push-to-talk still ignores modified, already-handled and typing keydowns', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const target = pushToTalkTarget();
|
||||
for (const modifier of ['altKey', 'ctrlKey', 'metaKey', 'shiftKey']) {
|
||||
assert.equal(f.key('keydown', target, { [modifier]: true }).defaultPrevented, false);
|
||||
}
|
||||
f.key('keydown', target, { defaultPrevented: true });
|
||||
f.key('keydown', target, { code: 'Enter', key: 'Enter' });
|
||||
for (const tagName of ['INPUT', 'TEXTAREA']) f.key('keydown', pushToTalkTarget({ tagName }));
|
||||
f.key('keydown', pushToTalkTarget({ role: 'textbox' }));
|
||||
f.key('keydown', { isContentEditable: true });
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
});
|
||||
|
||||
test('push-to-talk starts once at 500ms and repeat does not reset the threshold', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const background = pushToTalkTarget();
|
||||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, true);
|
||||
f.advance(1);
|
||||
assert.deepEqual(f.starts, [{ pushToTalk: true }]);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||||
assert.equal(f.microphone.enabled, true);
|
||||
const radioAfterStart = f.radio.slice();
|
||||
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS * 2);
|
||||
assert.deepEqual(f.radio, radioAfterStart);
|
||||
assert.equal(f.starts.length, 1);
|
||||
assert.equal(
|
||||
f.key('keyup', pushToTalkTarget({ tagName: 'BUTTON' })).defaultPrevented,
|
||||
true,
|
||||
'release follows a voice-owned hold after focus moves',
|
||||
);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.microphone.enabled, false);
|
||||
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
|
||||
assert.equal(f.controller.ui.detail.textContent, 'Hold Space to talk');
|
||||
});
|
||||
|
||||
test('push-to-talk treats a Space release before 500ms as a background tap', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const background = pushToTalkTarget();
|
||||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
|
||||
assert.equal(f.key('keyup', background).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test('push-to-talk rechecks focus before the hold threshold claims voice', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const background = pushToTalkTarget();
|
||||
const control = pushToTalkTarget({ tagName: 'BUTTON' });
|
||||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||||
f.document.activeElement = control;
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.key('keyup', control).defaultPrevented, true, 'release follows the background gesture owner');
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
});
|
||||
|
||||
test('push-to-talk never claims a control-started hold after focus moves outside', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const control = pushToTalkTarget({ tagName: 'BUTTON' });
|
||||
const background = pushToTalkTarget();
|
||||
assert.equal(f.key('keydown', control).defaultPrevented, false);
|
||||
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, false);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
|
||||
assert.equal(f.key('keyup', background).defaultPrevented, false);
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
});
|
||||
|
||||
test('push-to-talk cancels pending and active holds on blur or hidden document', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const background = pushToTalkTarget();
|
||||
for (const exit of ['blur', 'hidden']) {
|
||||
const startsBeforePending = f.starts.length;
|
||||
const radioBeforePending = f.radio.length;
|
||||
f.key('keydown', background);
|
||||
const staleCallback = [...f.timers.values()][0].callback;
|
||||
if (exit === 'blur') f.window.dispatch('blur');
|
||||
else {
|
||||
f.document.visibilityState = 'hidden';
|
||||
f.document.dispatch('visibilitychange');
|
||||
}
|
||||
staleCallback();
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.starts.length, startsBeforePending);
|
||||
assert.equal(f.radio.length, radioBeforePending);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
f.document.visibilityState = 'visible';
|
||||
|
||||
f.key('keydown', background);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.microphone.enabled, true);
|
||||
if (exit === 'blur') f.window.dispatch('blur');
|
||||
else {
|
||||
f.document.visibilityState = 'hidden';
|
||||
f.document.dispatch('visibilitychange');
|
||||
}
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.microphone.enabled, false);
|
||||
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
|
||||
assert.equal(f.key('keyup', background).defaultPrevented, false);
|
||||
f.document.visibilityState = 'visible';
|
||||
}
|
||||
});
|
||||
|
||||
test('push-to-talk timer refuses hidden or unfocused documents before lifecycle events arrive', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const background = pushToTalkTarget();
|
||||
for (const state of ['hidden', 'unfocused']) {
|
||||
f.key('keydown', background);
|
||||
if (state === 'hidden') f.document.visibilityState = 'hidden';
|
||||
else f.document.hasFocus = () => false;
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.key('keyup', background).defaultPrevented, true);
|
||||
f.document.visibilityState = 'visible';
|
||||
delete f.document.hasFocus;
|
||||
}
|
||||
});
|
||||
|
||||
test('focused controls and background Space preserve a click-started open mic', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
f.controller.start({ pushToTalk: false });
|
||||
f.starts.length = 0;
|
||||
const style = pushToTalkTarget({ style: 'retro' });
|
||||
f.key('keydown', style);
|
||||
f.key('keydown', style, { repeat: true });
|
||||
assert.equal(f.key('keyup', style).defaultPrevented, false);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.microphone.enabled, true);
|
||||
assert.equal(f.controller.pushToTalkMode, false);
|
||||
assert.equal(f.key('keydown', pushToTalkTarget()).defaultPrevented, true);
|
||||
assert.equal(f.controller.spaceKeyHeld, true);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.timers.size, 0);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
|
||||
assert.equal(f.key('keyup', style).defaultPrevented, true);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.microphone.enabled, true, 'an outside Space gesture must not claim or mute an open-mic session');
|
||||
assert.deepEqual(f.starts, []);
|
||||
});
|
||||
|
||||
test('push-to-talk teardown removes installed listeners and replacement binds once', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const outside = pushToTalkTarget();
|
||||
f.controller.bindPushToTalkShortcut();
|
||||
assert.equal(f.document.count('keydown'), 1);
|
||||
f.key('keydown', outside);
|
||||
const staleCallback = [...f.timers.values()][0].callback;
|
||||
f.controller.stop({ removeUi: true });
|
||||
staleCallback();
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.microphone.stopped, false);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkMode, false);
|
||||
assert.equal(f.document.count('keydown'), 0);
|
||||
assert.equal(f.document.count('keyup'), 0);
|
||||
assert.equal(f.document.count('visibilitychange'), 0);
|
||||
assert.equal(f.window.count('blur'), 0);
|
||||
f.key('keydown', outside);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.starts.length, 0, 'the removed controller must not respond');
|
||||
const replacement = f.makeController();
|
||||
replacement.controller.bindPushToTalkShortcut();
|
||||
assert.equal(f.document.count('keydown'), 1);
|
||||
assert.equal(f.document.count('keyup'), 1);
|
||||
assert.equal(f.document.count('visibilitychange'), 1);
|
||||
assert.equal(f.window.count('blur'), 1);
|
||||
f.key('keydown', pushToTalkTarget({ tagName: 'INPUT', type: 'text' }));
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.starts.length, 0);
|
||||
f.key('keydown', outside);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.starts.length, 1);
|
||||
assert.equal(replacement.microphone.enabled, true);
|
||||
f.key('keyup', outside);
|
||||
assert.equal(replacement.microphone.enabled, false);
|
||||
});
|
||||
|
||||
test('mic clicks are ignored while Space is physically held', () => {
|
||||
@@ -57,12 +480,12 @@ test('mic clicks are ignored while Space is physically held', () => {
|
||||
test('voice control help tray reflects the push-to-talk key state', () => {
|
||||
assert.equal(
|
||||
resolveVoiceControlHint(false, false),
|
||||
'Hold Space to speak · click mic to toggle voice',
|
||||
'Hold Space to speak · tap Space to activate focused controls',
|
||||
);
|
||||
assert.equal(resolveVoiceControlHint(true, true), 'Release Space to send');
|
||||
assert.equal(
|
||||
resolveVoiceControlHint(true, false),
|
||||
'Hold Space to speak · click mic to toggle voice',
|
||||
'Hold Space to speak · tap Space to activate focused controls',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,39 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Keyboard focus is an app-wide state and must survive selected/active button
|
||||
skins. Individual controls can choose an inset/outset offset, but no local
|
||||
`outline: none` rule may erase the ring. */
|
||||
:where(
|
||||
button,
|
||||
a[href],
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
summary,
|
||||
[tabindex],
|
||||
[role='button'],
|
||||
[role='checkbox'],
|
||||
[role='combobox'],
|
||||
[role='link'],
|
||||
[role='listbox'],
|
||||
[role='menuitem'],
|
||||
[role='menuitemcheckbox'],
|
||||
[role='menuitemradio'],
|
||||
[role='option'],
|
||||
[role='radio'],
|
||||
[role='searchbox'],
|
||||
[role='slider'],
|
||||
[role='spinbutton'],
|
||||
[role='switch'],
|
||||
[role='tab'],
|
||||
[role='textbox'],
|
||||
[role='treeitem']
|
||||
):focus-visible {
|
||||
outline: 2px solid var(--text-primary) !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -703,7 +736,10 @@ body.cockpit-mode #cockpit-cloud-effects.active {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.panel-collapse-btn:hover {
|
||||
@@ -739,11 +775,23 @@ body.cockpit-mode #cockpit-cloud-effects.active {
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: var(--btn-radius);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
/* Keep the keyboard focus outline immediate. */
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Keep keyboard focus visible inside the scrolling preset row. */
|
||||
.style-btn:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.style-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -788,7 +836,9 @@ body.cockpit-mode #cockpit-cloud-effects.active {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
text-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-label {
|
||||
@@ -915,7 +965,12 @@ body.cockpit-mode #cockpit-cloud-effects.active {
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
border-radius var(--transition-fast);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -1056,10 +1111,6 @@ body.cockpit-mode #cockpit-cloud-effects.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pp-toggle-group .pp-toggle-btn {
|
||||
transition: all var(--transition-fast), border-radius var(--transition-fast);
|
||||
}
|
||||
|
||||
.pp-toggle-group .pp-slider-row.visible ~ .pp-toggle-btn,
|
||||
.pp-toggle-group:has(.pp-slider-row.visible) .pp-toggle-btn {
|
||||
border-radius: 8px 8px 0 0;
|
||||
@@ -1372,12 +1423,31 @@ body.cockpit-mode #cockpit-cloud-effects.active {
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
.pp-mode-btn + .pp-mode-btn { border-left: 1px solid rgba(255, 255, 255, 0.14); }
|
||||
.pp-mode-btn:hover { background: rgba(255, 255, 255, 0.08); color: var(--text-primary); }
|
||||
.pp-mode-btn.active { background: var(--accent); color: #000; }
|
||||
|
||||
/* These controls also move into Cockpit Display; keep focus on the control
|
||||
itself and inside clipped button groups, independent of selection color. */
|
||||
.pp-toggle-btn:focus-visible,
|
||||
.pp-select:focus-visible,
|
||||
.pp-mode-btn:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.pp-mode-btn:focus-visible {
|
||||
box-shadow: inset 0 0 0 4px #061014;
|
||||
}
|
||||
|
||||
.pp-slider:focus-visible,
|
||||
.param-slider:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
#clean-view-toggle {
|
||||
width: var(--pp-expanded-width);
|
||||
}
|
||||
@@ -1695,7 +1765,12 @@ body.ui-clean-view #scene-runtime.active {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-sans);
|
||||
@@ -1705,6 +1780,14 @@ body.ui-clean-view #scene-runtime.active {
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.location-pill:focus-visible,
|
||||
.poi-pill:focus-visible,
|
||||
.search-toggle-btn:focus-visible,
|
||||
#location-search:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.location-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--glass-border-hover);
|
||||
@@ -1767,7 +1850,12 @@ body.ui-clean-view #scene-runtime.active {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
@@ -1831,7 +1919,11 @@ body.ui-clean-view #scene-runtime.active {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
@@ -1895,7 +1987,12 @@ body.ui-clean-view #scene-runtime.active {
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
opacity var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -3389,7 +3486,8 @@ body.cockpit-mode #view-switcher button {
|
||||
.cockpit-utility-glyph:focus-visible {
|
||||
border-color: var(--glass-border-hover);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
.cockpit-utility-popover {
|
||||
position: static;
|
||||
@@ -4766,6 +4864,14 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
|
||||
letter-spacing: .09em;
|
||||
text-align: center;
|
||||
}
|
||||
#space-mission-panel-host .space-mission-roster-focus-continuation {
|
||||
margin: 0;
|
||||
padding: 3px;
|
||||
color: rgba(190, 230, 230, .58);
|
||||
font: 7px/1.4 'JetBrains Mono', monospace;
|
||||
letter-spacing: .08em;
|
||||
text-align: center;
|
||||
}
|
||||
#space-mission-panel .space-mission-view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -5351,6 +5457,12 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
/* Keep focus distinct when the selected tab owns the accent colors. */
|
||||
.context-mode-button:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.context-mode-button.active {
|
||||
border-color: rgba(0, 212, 255, .75);
|
||||
color: #eaffff;
|
||||
@@ -5849,12 +5961,24 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
min-width var(--transition-fast),
|
||||
letter-spacing var(--transition-fast);
|
||||
outline: none;
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* An inset ring stays visible inside the scrolling layer list. */
|
||||
.data-toggle-btn:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.data-toggle-btn.feed-loading,
|
||||
.data-toggle-btn.feed-degraded,
|
||||
.data-toggle-btn.feed-stale,
|
||||
@@ -5933,7 +6057,11 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -6141,6 +6269,11 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#cctv-camera-select:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
#cctv-camera-select:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@@ -6480,7 +6613,11 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
|
||||
letter-spacing: 1.2px;
|
||||
padding: 6px 9px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 72px;
|
||||
@@ -6574,7 +6711,10 @@ body.cockpit-mode #intel-hud .hud-right-edge { right: 22px; }
|
||||
letter-spacing: 0.8px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.scene-shot-btn:hover {
|
||||
@@ -7746,6 +7886,26 @@ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#command-dock #location-bar .dock-tray-toggle {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#command-dock #location-bar .dock-tray-toggle:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#command-dock #control-panel .dock-tray-toggle {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -8193,7 +8353,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(#intel-hud[data-variant='minim
|
||||
transform-origin: bottom left;
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
visibility 180ms ease,
|
||||
visibility 0s linear 180ms,
|
||||
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
@@ -8215,6 +8375,9 @@ body:not(.ui-clean-view):not(.recording-mode):has(#intel-hud[data-variant='minim
|
||||
pointer-events: auto;
|
||||
transform: translateY(0) scale(1);
|
||||
margin-bottom: 10px;
|
||||
/* Make newly revealed controls enter the Tab order immediately. The base
|
||||
rule still delays visibility on close so its opacity fade can finish. */
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
#command-dock #location-bar.collapsed .dock-popover-content,
|
||||
|
||||
+17
-13
@@ -1572,7 +1572,7 @@ function celestrakProxy() {
|
||||
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
||||
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
|
||||
} catch (err) {
|
||||
console.warn(`[celestrak-proxy] cache write failed for ${group}:`, err?.message || err);
|
||||
console.warn('[celestrak-proxy] cache write failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1631,7 +1631,7 @@ function celestrakProxy() {
|
||||
return fresh;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(`[celestrak-proxy] ${group} refresh failed (${err?.message || err}) — serving cache if any`);
|
||||
console.warn('[celestrak-proxy] refresh failed — serving cache if any');
|
||||
return null;
|
||||
})
|
||||
.finally(() => inflight.delete(group)));
|
||||
@@ -1645,7 +1645,8 @@ function celestrakProxy() {
|
||||
send(502, 'celestrak fetch failed and no cache available', 'NONE');
|
||||
}
|
||||
} catch (err) {
|
||||
send(500, `celestrak proxy error: ${err?.message || err}`, 'ERROR');
|
||||
console.error('[celestrak-proxy] request failed');
|
||||
send(500, 'celestrak proxy error', 'ERROR');
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -1692,7 +1693,7 @@ function rocketLaunchesProxy() {
|
||||
await fsp.mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await fsp.writeFile(cachePath, JSON.stringify(entry), 'utf8');
|
||||
} catch (error) {
|
||||
console.warn(`[launch-library-proxy] cache write failed: ${error?.message || error}`);
|
||||
console.warn('[launch-library-proxy] cache write failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1721,7 +1722,6 @@ function rocketLaunchesProxy() {
|
||||
if (!upstream.ok) {
|
||||
const error = new Error(`upstream HTTP ${upstream.status}`);
|
||||
error.upstreamStatus = upstream.status;
|
||||
error.upstreamBody = body;
|
||||
throw error;
|
||||
}
|
||||
const parsed = JSON.parse(body);
|
||||
@@ -1750,15 +1750,17 @@ function rocketLaunchesProxy() {
|
||||
const fresh = await request.promise;
|
||||
send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS');
|
||||
} catch (error) {
|
||||
// Log only a bounded status, never upstream bodies, URLs, or credentials.
|
||||
const status = Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus : 502;
|
||||
if (!request.shared) console.warn(`[launch-library-proxy] refresh failed (HTTP ${status})${stale ? ' — serving stale cache' : ''}`);
|
||||
if (stale) {
|
||||
if (!request.shared) console.warn(`[launch-library-proxy] refresh failed (${error?.message || error}) — serving stale cache`);
|
||||
send(res, 200, stale.body, 'STALE-ERROR');
|
||||
return;
|
||||
}
|
||||
send(
|
||||
res,
|
||||
Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus : 502,
|
||||
error?.upstreamBody || JSON.stringify({ error: 'Launch Library 2 unavailable' }),
|
||||
status,
|
||||
JSON.stringify({ error: 'Launch Library 2 unavailable' }),
|
||||
'NONE',
|
||||
);
|
||||
}
|
||||
@@ -2094,11 +2096,11 @@ function firmsProxy() {
|
||||
for (const source of SOURCES) {
|
||||
try {
|
||||
const records = filterTrailing24h(await fetchSource(key, source), now);
|
||||
sources.push({ source, count: records.length, ok: true });
|
||||
// NOT fires.push(...records): spread passes each record as an argument,
|
||||
// and a world/2 VIIRS pull exceeds V8's argument limit (~125k) at
|
||||
// ~131k records — RangeError, and the whole source is silently dropped.
|
||||
for (const record of records) fires.push(record);
|
||||
sources.push({ source, count: records.length, ok: true });
|
||||
} catch (err) {
|
||||
console.warn(`[firms-proxy] ${source} fetch failed:`, err?.message || err);
|
||||
sources.push({ source, count: 0, ok: false });
|
||||
@@ -2296,7 +2298,7 @@ function terrainHeightsProxy() {
|
||||
await fsp.writeFile(CACHE_PATH, JSON.stringify(obj), 'utf8');
|
||||
} catch (err) {
|
||||
diskDirty = true; // retry next tick
|
||||
console.warn('[terrain-heights-proxy] cache write failed:', err?.message || err);
|
||||
console.warn('[terrain-heights-proxy] cache write failed');
|
||||
}
|
||||
}, 15_000).unref?.();
|
||||
}
|
||||
@@ -2367,13 +2369,14 @@ function terrainHeightsProxy() {
|
||||
if (outcome.cacheChanged) diskDirty = true;
|
||||
if (outcome.upstreamError) {
|
||||
console.warn(
|
||||
`[terrain-heights-proxy] refresh incomplete (${outcome.upstreamError?.message || outcome.upstreamError})`
|
||||
'[terrain-heights-proxy] refresh incomplete'
|
||||
+ ' — serving stale points when available'
|
||||
);
|
||||
}
|
||||
send(outcome.status, outcome.body);
|
||||
} catch (err) {
|
||||
send(500, { error: `terrain heights proxy error: ${err?.message || err}` });
|
||||
console.error('[terrain-heights-proxy] request failed');
|
||||
send(500, { error: 'terrain heights proxy error' });
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -2494,7 +2497,8 @@ function adsbdbProxy() {
|
||||
}
|
||||
return send(404, { error: 'unknown endpoint' });
|
||||
} catch (err) {
|
||||
return send(500, { error: String(err?.message || err) });
|
||||
console.error('[adsbdb-proxy] request failed');
|
||||
return send(500, { error: 'adsbdb proxy error' });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user