From b11390404abb2306efef1266e121898c222594b8 Mon Sep 17 00:00:00 2001 From: jibraaan Date: Wed, 7 Oct 2026 16:19:45 +0500 Subject: [PATCH 001/154] feat(cctv): add the Autostrada del Brennero (A22) motorway webcams Closes bilawalsidhu/gods-eye-view#155. The first Italian cameras: the 13 webcams along the A22 from the Brenner Pass to Modena. The camera list is one keyless request to the Open Data Hub (NOI Techpark) tourism API, which republishes Autobrennero's own list with coordinates and kilometre markers. Frames are the operator's stills, refreshed about once a minute, and only each camera's own WebCamImg/km.jpg is registered. The catalog fetch refuses redirects and is size-capped. The road climbs from ~20 m in the Po valley to ~1,370 m at the Brenner Pass, so ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM instead of one flat prior. Headings use the id-hash fallback at low confidence. CCTV_A22_ENABLED=0 disables the pack. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 + CHANGELOG.md | 9 ++ DATA_SOURCES.md | 2 + README.md | 2 +- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 33 +++++ server/providers/cctv/normalize.js | 11 ++ server/providers/cctv/sources.js | 126 ++++++++++++++++++ src/data/cctvA22.test.mjs | 199 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 7 + 12 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 src/data/cctvA22.test.mjs diff --git a/.env.example b/.env.example index ee7620be9..ec431b03a 100644 --- a/.env.example +++ b/.env.example @@ -196,6 +196,8 @@ AISSTREAM_API_KEY= # CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000) # CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras # CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL +# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams +# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..4f03ac311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +- Add the Autostrada del Brennero (A22, Brenner–Modena) motorway webcams as a + CCTV pack, the first Italian cameras. The 13-camera list is one keyless + request to the Open Data Hub (NOI Techpark) tourism API, which republishes + Autobrennero's own list with coordinates; frames are the operator's stills, + refreshed about once a minute and pinned to each camera's own `km.jpg`. + Each camera's ground elevation comes from a per-kilometre DEM table, since + the road climbs from the Po valley to the Brenner Pass. `CCTV_A22_ENABLED=0` + disables the pack. Closes #155. + - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 734e35a69..d90cb8030 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -55,6 +55,7 @@ How to read this: | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | +| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub as open data (`ClosedData: false`, licence holder Autobrennero, no licence named) | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | @@ -90,6 +91,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. +- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub marks the list as open (`ClosedData: false`) with Autobrennero as licence holder but names no licence, so the images are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/README.md b/README.md index 724ea18cb..c84a67af8 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer a | 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Italy (A22 Brennero motorway). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d4fe022a8..11a97f55f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2919,7 +2919,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 662b2a43a..c1733a555 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -523,6 +523,7 @@ "src/data/cctvWarendorf.test.mjs", "src/data/cctvCalgary.test.mjs", "src/data/cctvVegvesen.test.mjs", + "src/data/cctvA22.test.mjs", "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 9bc3b70ec..c438f4075 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -19,6 +19,7 @@ import { loadCalgarySourcesFromOpenData, loadDelDOTSourcesFromOpenData, loadVegvesenSourcesFromOpenData, + loadA22SourcesFromOpenDataHub, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -98,6 +99,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'), load: loadVegvesenSourcesFromOpenData, }, + { + name: 'a22', + enabled: () => envEnabled('CCTV_A22_ENABLED'), + load: loadA22SourcesFromOpenDataHub, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 6298e86a0..531a00051 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -267,6 +267,39 @@ export const NORWAY_ANCHORS = [ { lat: 69.6492, lon: 18.9553 }, // Tromsø ]; +/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open + * Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's + * own camera list with coordinates, one request for the whole motorway + * (13 cameras). Frames are stills on the operator's host, refreshed about + * once a minute. */ +export const DEFAULT_A22_CCTV_URL = + 'https://tourism.api.opendatahub.com/v1/WebcamInfo?source=a22&pagesize=200&removenullvalues=true'; +/** The only origin A22 camera frames may come from: `km.jpg` per camera. */ +export const A22_IMAGE_ORIGIN = 'https://www.autobrennero.it/WebCamImg/'; +/** Hard ceiling on the catalog body. The whole motorway is ~45 KB. */ +export const A22_MAX_CATALOG_BYTES = 2 * 1024 * 1024; +/** Orthometric ground elevation (m) at each camera, keyed by its motorway + * kilometre, from the Copernicus GLO-90 DEM via Open-Meteo's elevation API. + * The feed's coordinates are 2D and the road climbs from the Po valley (~20 m) + * to the Brenner Pass (~1,370 m), so one flat prior would bury the Alpine + * cameras. A camera not listed here uses A22_DEFAULT_GROUND_ELEVATION_M. */ +export const A22_GROUND_ELEVATION_BY_KM = Object.freeze({ + 1: 1365, + 68: 375, + 77: 301, + 100: 219, + 129: 206, + 138: 204, + 159: 175, + 205: 188, + 228: 72, + 240: 38, + 272: 16, + 285: 19, + 313: 40, +}); +export const A22_DEFAULT_GROUND_ELEVATION_M = 200; + /** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */ export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json'; export const DEFAULT_DELDOT_MAX_SOURCES = 300; diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index e62ba33f3..9339c2166 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -378,6 +378,17 @@ export function isLikelyNorwayCoordinate(lat, lon) { ); } +/** The A22 corridor, Brenner Pass to Modena, with slack either side. */ +export function isLikelyA22Coordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 44.5 && + lat <= 47.1 && + lon >= 10.5 && + lon <= 11.8 + ); +} + export function isLikelyFinlandCoordinate(lat, lon) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32; diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 7ecd3434d..89d051420 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -65,6 +65,11 @@ import { DEFAULT_VEGVESEN_MAX_SOURCES, VEGVESEN_MAX_CATALOG_BYTES, NORWAY_ANCHORS, + DEFAULT_A22_CCTV_URL, + A22_IMAGE_ORIGIN, + A22_MAX_CATALOG_BYTES, + A22_GROUND_ELEVATION_BY_KM, + A22_DEFAULT_GROUND_ELEVATION_M, } from './constants.js'; import { toFiniteNumber, @@ -83,6 +88,7 @@ import { isLikelyNswCoordinate, isLikelyCalgaryCoordinate, isLikelyNorwayCoordinate, + isLikelyA22Coordinate, cameraDisplayCode, rowArrayToObject, prioritizeSources, @@ -1863,3 +1869,123 @@ export async function loadVegvesenSourcesFromOpenData() { return []; } } + +/** + * One Open Data Hub `WebcamInfo` item (source `a22`) -> one catalog source, + * or null. + * + * The frame must be the operator's own `WebCamImg/km.jpg` for the + * camera's motorway kilometre. The feed names the place and the kilometre but + * no bearing, so the heading uses the shared id-hash fallback at low + * confidence, like Calgary and Vegvesen; the ground elevation comes from the + * per-kilometre DEM table because the road spans ~1,350 m of altitude. + * + * @param {object} item - One element of the API's `Items` array. + * @returns {?object} + */ +export function a22WebcamToSource(item) { + if (!item || typeof item !== 'object') return null; + if (item.Active === false) return null; + if (String(item.Source ?? '').toLowerCase() !== 'a22') return null; + + const gps = Array.isArray(item.GpsInfo) ? item.GpsInfo[0] : null; + const lat = toFiniteNumber(gps?.Latitude); + const lon = toFiniteNumber(gps?.Longitude); + if (!isLikelyA22Coordinate(lat, lon)) return null; + + const km = String(item.Mapping?.a22?.km ?? '').trim(); + if (!/^\d{1,3}$/.test(km)) return null; + const imageUrl = `${A22_IMAGE_ORIGIN}km${Number(km)}.jpg`; + let image; + try { + image = new URL(String(item.Webcamurl ?? '')); + } catch { + return null; + } + if (image.username || image.password || image.href !== imageUrl) return null; + + const place = + String(item.Shortname ?? item.Webcamname?.it ?? '') + .replace(/\s+/g, ' ') + .trim() || `Km ${Number(km)}`; + const cameraId = `it-a22-km${Number(km)}`; + const ground = A22_GROUND_ELEVATION_BY_KM[Number(km)]; + + return { + id: cameraId, + name: `A22 ${place} (km ${Number(km)})`, + // One motorway-wide category in the camera picker; the place is already + // in the name. + city: 'A22 Brennero', + cityId: 'a22', + provider: 'Autostrada del Brennero', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -15, + fovDeg: 50, + rangeM: 160, + mountHeightM: 8, + groundElevationM: Number.isFinite(ground) + ? ground + : A22_DEFAULT_GROUND_ELEVATION_M, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'a22-opendatahub', + license: + 'Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)', + code: cameraDisplayCode(place.toUpperCase()), + }; +} + +/** + * Fetch the Autostrada del Brennero (A22) webcams from the keyless Open Data + * Hub tourism API. Frames are stills on www.autobrennero.it. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadA22SourcesFromOpenDataHub() { + try { + const endpoint = process.env.CCTV_A22_URL || DEFAULT_A22_CCTV_URL; + const resp = await fetch(endpoint, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn('[CCTV] A22 catalog redirected; redirects are not followed'); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] A22 camera download failed:', resp.status); + return discard(); + } + const payload = await readResponseJsonCapped(resp, A22_MAX_CATALOG_BYTES); + const items = Array.isArray(payload?.Items) ? payload.Items : []; + const cameras = []; + const seen = new Set(); + for (const item of items) { + const camera = a22WebcamToSource(item); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + // North to south along the motorway, so the picker reads in road order. + cameras.sort((a, b) => b.lat - a.lat); + console.log(`[CCTV] Loaded A22 camera sources: ${cameras.length}`); + return cameras; + } catch (error) { + console.warn('[CCTV] A22 camera download error:', error?.message || error); + return []; + } +} diff --git a/src/data/cctvA22.test.mjs b/src/data/cctvA22.test.mjs new file mode 100644 index 000000000..fc483bf9d --- /dev/null +++ b/src/data/cctvA22.test.mjs @@ -0,0 +1,199 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + a22WebcamToSource, + loadA22SourcesFromOpenDataHub, +} from '../../server/providers/cctv/sources.js'; +import { + A22_DEFAULT_GROUND_ELEVATION_M, + A22_IMAGE_ORIGIN, + DEFAULT_A22_CCTV_URL, +} from '../../server/providers/cctv/constants.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */ +const item = ( + patch = {}, + { km = '68', lat = 46.53591, lon = 11.49772 } = {}, +) => ({ + Id: `A22_${km}`, + Active: true, + Source: 'a22', + Shortname: 'ADS Sciliar', + Webcamname: { it: 'ADS Sciliar' }, + Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`, + Mapping: { a22: { km } }, + GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }], + LicenseInfo: { + ClosedData: false, + LicenseHolder: 'https://www.autobrennero.it/', + }, + ...patch, +}); + +test('an A22 webcam maps to a source on the pinned operator frame', () => { + const source = a22WebcamToSource(item()); + assert.equal(source.id, 'it-a22-km68'); + assert.equal(source.name, 'A22 ADS Sciliar (km 68)'); + assert.equal(source.city, 'A22 Brennero'); + assert.equal(source.cityId, 'a22'); + assert.equal(source.provider, 'Autostrada del Brennero'); + assert.equal(source.lat, 46.53591); + assert.equal(source.lon, 11.49772); + assert.equal(source.headingConfidence, 'low'); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'a22-opendatahub'); + assert.equal(source.code, 'ADS SCILIAR'); + assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`); + assert.equal(source.snapshotUrl, source.url); +}); + +test('ground elevation follows the motorway from the Brenner Pass to the Po', () => { + const brenner = a22WebcamToSource( + item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }), + ); + const ponte = a22WebcamToSource( + item( + { Shortname: 'Ponte Po' }, + { km: '272', lat: 45.03145, lon: 10.85369 }, + ), + ); + assert.ok(brenner.groundElevationM > 1300); + assert.ok(ponte.groundElevationM < 50); + // A kilometre the table does not know falls back to the corridor prior. + const unknown = a22WebcamToSource( + item({}, { km: '150', lat: 45.95, lon: 11.05 }), + ); + assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M); +}); + +test('inactive cameras, off-host frames and bad geometry are dropped', () => { + assert.equal(a22WebcamToSource(item({ Active: false })), null); + assert.equal(a22WebcamToSource(item({ Source: 'other' })), null); + assert.equal( + a22WebcamToSource( + item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }), + ), + null, + ); + // The frame must be exactly the camera's own kilometre image. + assert.equal( + a22WebcamToSource( + item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }), + ), + null, + ); + assert.equal( + a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })), + null, + ); + // Milan: plausible, but off the Brenner–Modena corridor. + assert.equal( + a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })), + null, + ); + assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null); + assert.equal(a22WebcamToSource(null), null); +}); + +test('the loader dedupes, orders north to south, and degrades to empty', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + const requested = []; + const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return Response.json({ + TotalResults: 3, + Items: [ + item( + { Shortname: 'Ponte Po' }, + { km: '272', lat: 45.03145, lon: 10.85369 }, + ), + item(), + item(), + item( + { Shortname: 'Brennero' }, + { km: '1', lat: 46.99682, lon: 11.50218 }, + ), + ], + }); + }); + const cameras = await loadA22SourcesFromOpenDataHub(); + assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]); + assert.deepEqual( + cameras.map((c) => c.id), + ['it-a22-km1', 'it-a22-km68', 'it-a22-km272'], + ); + + fetchMock.mock.mockImplementation( + async () => new Response('nope', { status: 503 }), + ); + assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []); + fetchMock.mock.mockImplementation( + async () => + new Response(null, { + status: 302, + headers: { Location: 'https://evil.test/' }, + }), + ); + assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []); + fetchMock.mock.mockImplementation(async () => { + throw new Error('offline'); + }); + assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []); +}); + +const runCatalog = async (t) => { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url) => { + const href = String(url); + requested.push(href); + if (href === DEFAULT_A22_CCTV_URL) { + return Response.json({ Items: [item()] }); + } + return Response.json([]); + }); + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; +}; + +const withEnv = async (patch, fn) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + delete process.env.CCTV_A22_ENABLED; + Object.assign(process.env, patch); + await fn(); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}; + +test('the A22 lane is wired into the catalog', async (t) => { + await withEnv({}, async () => { + const { requested, sources } = await runCatalog(t); + assert.ok(requested.includes(DEFAULT_A22_CCTV_URL)); + assert.deepEqual( + sources.filter((s) => s.cityId === 'a22').map((s) => s.id), + ['it-a22-km68'], + ); + }); +}); + +test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => { + await withEnv({ CCTV_A22_ENABLED: '0' }, async () => { + const { requested, sources } = await runCatalog(t); + assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false); + assert.deepEqual( + sources.filter((s) => s.cityId === 'a22'), + [], + ); + }); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..d3cdf74e2 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -247,6 +247,13 @@ export const DATA_CREDITS = [ 'Norwegian licence for Open Government data (NLOD) ' + 'distributed by Statens vegvesen', }, + { + key: 'a22-cctv', + html: + 'Motorway webcams (A22 Brennero–Modena): images © ' + + 'Autostrada del Brennero S.p.A., ' + + 'camera list via the Open Data Hub (NOI Techpark)', + }, { key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', From 166aef58db448788d58eb6db078055d1eef79af2 Mon Sep 17 00:00:00 2001 From: Jeff Blakely <7987380+jeffblakely@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:53:33 -0400 Subject: [PATCH 002/154] fix(firms): retry over IPv4 when every address fails to connect Node's network-family auto-selection gives each resolved address a short attempt timeout. On hosts where IPv6 is unreachable, a slow IPv4 handshake is abandoned for the IPv6 address, which fails at once, and the request reports only `fetch failed`. Retry such failures once with a single IPv4 connection within the same deadline, and log the per-address connection errors (never the keyed URL). Node 26 wraps the AggregateError one level deeper, so both shapes are recognised. Fixes #68 --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 9 +++ server/providers/firms.js | 119 +++++++++++++++++++++++++++++++++-- src/data/firmsProxy.test.mjs | 118 +++++++++++++++++++++++++++++++++- 4 files changed, 245 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..8901fa255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- FIRMS fire data loads on hosts where IPv6 is unreachable and IPv4 is slow to + connect: a request whose every address fails to connect retries once over + IPv4, and FIRMS warnings name the underlying connection errors instead of + only `fetch failed`. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..bf26418ad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1393,6 +1393,15 @@ 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. +FIRMS requests retry once over IPv4 when every resolved address fails to +connect. Node's network-family auto-selection gives each address a short +attempt timeout, so on hosts where IPv6 is unreachable a slow IPv4 handshake +was abandoned and the request reported only `fetch failed` (#68). The retry +makes a single IPv4 connection within the same deadline; HTTP errors, +timeouts and DNS failures are not retried. FIRMS warnings include the +per-address connection errors and never the request URL, which embeds the +MAP_KEY. + ## Installations and map-source guidance - Keyless mapped installations use OpenFreeMap `landuse` military polygons, diff --git a/server/providers/firms.js b/server/providers/firms.js index 636968348..531c9a99f 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -1,8 +1,117 @@ import path from 'node:path'; +import https from 'node:https'; import { promises as fsp } from 'node:fs'; +import { Readable } from 'node:stream'; import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js'; +/** + * The per-address connection errors behind a fetch failure, or null. Node 24 + * puts the AggregateError directly on `err.cause`; Node 26's fetch wraps it in + * a ConnectTimeoutError one level deeper. + * @param {unknown} err + * @returns {?Array} + */ +function connectAttemptErrors(err) { + let cause = err?.cause; + for (let depth = 0; cause && depth < 3; depth++) { + if (Array.isArray(cause.errors)) return cause.errors; + cause = cause.cause; + } + return null; +} + +/** + * Describe a fetch failure with its socket-level cause. Node reports every + * connection failure as `fetch failed`; the cause carries the error code and + * the address that failed (never the URL, which embeds the MAP_KEY). + * @param {unknown} err + * @returns {string} + */ +export function describeFetchError(err) { + const message = err?.message || String(err); + const cause = err?.cause; + if (!cause) return message; + const attempts = connectAttemptErrors(err); + const parts = attempts?.length + ? attempts.map((inner) => inner?.message || inner?.code || String(inner)) + : [cause.message || cause.code || String(cause)]; + return `${message} (${parts.join('; ')})`; +} + +/** + * Whether a fetch failed because every address it tried failed to connect. + * Node's network-family auto-selection gives each address a short attempt + * timeout, so a slow IPv4 handshake is abandoned in favour of an unreachable + * IPv6 address and the whole request fails (#68). + * @param {unknown} err + * @returns {boolean} + */ +export function isAddressSelectionFailure(err) { + return err instanceof TypeError && connectAttemptErrors(err)?.length > 0; +} + +/** + * GET over IPv4 only, with one connection attempt bounded by the caller's + * signal rather than by the auto-selection attempt timeout. + * @param {string} url + * @param {{signal?: AbortSignal}} [options] + * @returns {Promise} + */ +export function fetchIpv4(url, { signal } = {}) { + return new Promise((resolve, reject) => { + const request = https.request( + url, + { + method: 'GET', + signal, + family: 4, + autoSelectFamily: false, + agent: false, + }, + (response) => { + const headers = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) + value.forEach((item) => headers.append(name, item)); + else if (value !== undefined) headers.set(name, String(value)); + } + resolve( + new Response(Readable.toWeb(response), { + status: response.statusCode || 500, + statusText: response.statusMessage || '', + headers, + }), + ); + }, + ); + request.on('error', reject); + request.end(); + }); +} + +/** + * Fetch a FIRMS URL, retrying once over IPv4 when address selection fails. + * Other failures (HTTP status, timeout, DNS) are returned or thrown as is. + * @param {string} url + * @param {{signal?: AbortSignal}} options + * @param {{fetchImpl?: typeof fetch, fetchIpv4Impl?: typeof fetchIpv4, warn?: Function}} [deps] + * @returns {Promise} + */ +export async function fetchFirms( + url, + options, + { fetchImpl = fetch, fetchIpv4Impl = fetchIpv4, warn = console.warn } = {}, +) { + try { + return await fetchImpl(url, options); + } catch (err) { + if (!isAddressSelectionFailure(err) || options?.signal?.aborted) throw err; + warn(`[firms-proxy] ${describeFetchError(err)} — retrying over IPv4`); + return fetchIpv4Impl(url, options); + } +} + /** * NASA FIRMS live active-fire proxy with a memory + disk cache. * Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2 @@ -83,7 +192,7 @@ export function firmsProxy() { */ async function fetchSource(key, source) { const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`; - const res = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + const res = await fetchFirms(url, { signal: AbortSignal.timeout(60_000) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const records = parseFirmsCsv(await res.text()); if (records === null) throw new Error('non-CSV upstream response'); @@ -111,7 +220,7 @@ export function firmsProxy() { } catch (err) { console.warn( `[firms-proxy] ${source} fetch failed:`, - err?.message || err, + describeFetchError(err), ); sources.push({ source, count: 0, ok: false }); } @@ -146,7 +255,9 @@ export function firmsProxy() { statusInflight = (async () => { try { const url = `https://firms.modaps.eosdis.nasa.gov/mapserver/mapkey_status/?MAP_KEY=${encodeURIComponent(key)}`; - const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + const res = await fetchFirms(url, { + signal: AbortSignal.timeout(10_000), + }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); const used = Number(body?.current_transactions); @@ -157,7 +268,7 @@ export function firmsProxy() { } catch (err) { console.warn( '[firms-proxy] mapkey status failed:', - err?.message || err, + describeFetchError(err), ); return null; } diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index a54bd42fe..f24d93349 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -2,6 +2,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js'; +import net from 'node:net'; +import { + describeFetchError, + fetchFirms, + fetchIpv4, +} from '../../server/providers/firms.js'; const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8'); const start = config.indexOf(' async function refreshUpstream(key) {'); @@ -17,8 +23,8 @@ const recent = { acqDate: '2026-09-11', acqTime: '1100' }; // 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() {} }); + return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'describeFetchError', 'Date', 'console', + `return (${refreshSource});`)(SOURCES, fetchSource, filter, describeFetchError, { now: () => NOW }, { warn() {} }); } test('FIRMS retains large sources in order and filters expired rows', async () => { @@ -78,3 +84,111 @@ test('FIRMS distinguishes all-source failure from successful empty sources', asy assert.deepEqual(result.fires, []); assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true }))); }); + +// #68: Node's network-family auto-selection gives each address a short attempt +// timeout, abandons a slow IPv4 handshake for an unreachable IPv6 address, and +// reports only `fetch failed`. This is the shape Node 24 throws for it. +function addressSelectionFailure() { + const v4 = Object.assign(new Error('connect ETIMEDOUT 198.118.194.34:443'), { code: 'ETIMEDOUT' }); + const v6 = Object.assign(new Error('connect ENETUNREACH 2001:4d0:241a:40c0::34:443'), { code: 'ENETUNREACH' }); + const cause = Object.assign(new AggregateError([v4, v6]), { code: 'ETIMEDOUT' }); + return new TypeError('fetch failed', { cause }); +} +// Node 26's fetch wraps the same AggregateError in a ConnectTimeoutError. +function wrappedAddressSelectionFailure() { + const inner = addressSelectionFailure().cause; + const cause = Object.assign(new Error('Connect Timeout Error (attempted addresses: 198.118.194.34:443, 2001:4d0:241a:40c0::34:443, timeout: 10000ms)'), + { name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner }); + return new TypeError('fetch failed', { cause }); +} +const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2'; + +test('FIRMS failures log the socket cause, never the keyed URL', () => { + const described = describeFetchError(addressSelectionFailure()); + assert.equal(described, + 'fetch failed (connect ETIMEDOUT 198.118.194.34:443; connect ENETUNREACH 2001:4d0:241a:40c0::34:443)'); + assert.ok(!described.includes('SECRET')); + assert.equal(describeFetchError(wrappedAddressSelectionFailure()), described, 'Node 26 reports the same attempts'); + const single = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }); + assert.equal(describeFetchError(single), 'fetch failed (getaddrinfo ENOTFOUND x)'); + assert.equal(describeFetchError(new Error('HTTP 503')), 'HTTP 503'); +}); + +test('FIRMS retries once over IPv4 when every address fails to connect', async () => { + for (const shape of [addressSelectionFailure, wrappedAddressSelectionFailure]) { + const retried = []; + await fetchFirms(URL_WITH_KEY, {}, { + fetchImpl: async () => { throw shape(); }, + fetchIpv4Impl: async (url) => { retried.push(url); return new Response('csv'); }, + warn() {}, + }); + assert.deepEqual(retried, [URL_WITH_KEY], `${shape.name} is retried`); + } + const signal = new AbortController().signal; + const ok = new Response('csv'); + const retried = []; + const warnings = []; + const response = await fetchFirms(URL_WITH_KEY, { signal }, { + fetchImpl: async () => { throw addressSelectionFailure(); }, + fetchIpv4Impl: async (url, options) => { retried.push([url, options]); return ok; }, + warn: (message) => warnings.push(message), + }); + assert.equal(response, ok); + assert.deepEqual(retried, [[URL_WITH_KEY, { signal }]], 'one retry, same URL and deadline'); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /ETIMEDOUT 198\.118\.194\.34:443; .*ENETUNREACH .* retrying over IPv4/); + assert.ok(!warnings[0].includes('SECRET')); + + const failure = new Error('connect ETIMEDOUT 198.118.194.34:443'); + await assert.rejects(fetchFirms(URL_WITH_KEY, {}, { + fetchImpl: async () => { throw addressSelectionFailure(); }, + fetchIpv4Impl: async () => { throw failure; }, + warn() {}, + }), (err) => err === failure, 'a failed retry reports its own error'); +}); + +test('FIRMS does not retry anything but an address-selection failure', async () => { + const deps = (fetchImpl) => ({ + fetchImpl, + fetchIpv4Impl: async () => assert.fail('must not retry'), + warn: () => assert.fail('must not warn'), + }); + const serverError = new Response('down', { status: 503 }); + assert.equal(await fetchFirms(URL_WITH_KEY, {}, deps(async () => serverError)), serverError); + for (const thrown of [ + new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }), + new TypeError('fetch failed', { cause: new AggregateError([]) }), + new TypeError('fetch failed'), + new DOMException('The operation was aborted due to timeout', 'TimeoutError'), + ]) { + await assert.rejects(fetchFirms(URL_WITH_KEY, {}, deps(async () => { throw thrown; })), (err) => err === thrown); + } + const controller = new AbortController(); + controller.abort(); + const aborted = addressSelectionFailure(); + await assert.rejects(fetchFirms(URL_WITH_KEY, { signal: controller.signal }, + deps(async () => { throw aborted; })), (err) => err === aborted, 'an expired deadline is not retried'); +}); + +test('the IPv4 retry connects to an IPv4 address only', async (t) => { + // `localhost` resolves to both ::1 and 127.0.0.1. Listen on both with the + // same port and record which one the retry reaches; the TLS handshake then + // fails against a plain socket, which is fine — only the connection matters. + const reached = []; + const listen = (host, port) => new Promise((resolve, reject) => { + const server = net.createServer((socket) => { reached.push(host); socket.destroy(); }); + server.once('error', reject).listen(port, host, () => resolve(server)); + }); + const v4 = await listen('127.0.0.1', 0); + t.after(() => v4.close()); + const { port } = v4.address(); + let v6 = null; + try { + v6 = await listen('::1', port); + t.after(() => v6.close()); + } catch { + // No IPv6 loopback on this host; the IPv4 assertion still holds. + } + await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) })); + assert.deepEqual(reached, ['127.0.0.1']); +}); From d1bbfdbca5736900bbdbf54a8d57d773b7ec3191 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:10:52 +0530 Subject: [PATCH 003/154] =?UTF-8?q?fix(draw):=20show=20a=209,999.6=20m?= =?UTF-8?q?=C2=B2=20area=20as=201.0=20ha,=20not=2010000=20m=C2=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draw hint picked m², ha or km² from the raw area and then rounded, so 9,999.5 m² up to a hectare read "10000 m²" and about 999,500 m² up to a square kilometre read "100.0 ha". It now rounds first, as the line measure already does. --- docs/CURRENT-STATE.md | 1 + src/annotations/drawMode.js | 9 ++++++--- src/annotations/drawMode.test.mjs | 27 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..2578eb84a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5350,6 +5350,7 @@ request viewbox and continues searching without that geographic preference. - Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. +- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`. - Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. - **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing. - **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed. diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index 538c2e5da..93f75dbdc 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -215,9 +215,12 @@ export function formatMeasure(session) { } if (session.shape === 'area') { const m2 = ringAreaM2(session.vertices); - if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`; - if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`; - return `${Math.round(m2)} m²`; + // Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²". + const whole = Math.round(m2); + const ha = (m2 / 1e4).toFixed(1); + if (whole < 1e4) return `${whole} m²`; + if (Number(ha) < 100) return `${ha} ha`; + return `${(m2 / 1e6).toFixed(2)} km²`; } return ''; } diff --git a/src/annotations/drawMode.test.mjs b/src/annotations/drawMode.test.mjs index be9894dda..51e3b854e 100644 --- a/src/annotations/drawMode.test.mjs +++ b/src/annotations/drawMode.test.mjs @@ -345,3 +345,30 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => { assert.equal(formatMeasure(line), '1.0 km'); assert.match(drawHint(line), /^1\.0 km · /); }); + +test('an area that rounds up to the next unit reads in that unit', () => { + // A square on the equator whose shoelace area is about `m2`. + const square = (m2) => { + const side = Math.sqrt(m2) / 111320; + const area = createDrawSession('area'); + for (const [lon, lat] of [ + [0, 0], + [side, 0], + [side, side], + [0, side], + ]) + addVertex(area, { lon, lat }); + return area; + }; + const almostHectare = square(9999.6); + const metres = ringAreaM2(almostHectare.vertices); + assert.ok( + metres >= 9999.5 && metres < 1e4, + `area should round to 10000 m², got ${metres}`, + ); + assert.equal(formatMeasure(almostHectare), '1.0 ha'); + assert.match(drawHint(almostHectare), /^1\.0 ha · /); + assert.equal(formatMeasure(square(9999.4)), '9999 m²'); + assert.equal(formatMeasure(square(999_960)), '1.00 km²'); + assert.equal(formatMeasure(square(999_400)), '99.9 ha'); +}); From 76a3d47e563d03f0d90415ae67d65c879effc18f Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:42 +0530 Subject: [PATCH 004/154] docs(changelog): add #1446 fragment --- changelog.d/1446-draw-area-units.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1446-draw-area-units.md diff --git a/changelog.d/1446-draw-area-units.md b/changelog.d/1446-draw-area-units.md new file mode 100644 index 000000000..ef82d6628 --- /dev/null +++ b/changelog.d/1446-draw-area-units.md @@ -0,0 +1,3 @@ +- Show a drawn area of 9,999.5 m² or more as 1.0 ha, and of 999,500 m² or + more as 1.00 km². The draw hint picked the unit before rounding, so it read + "10000 m²" and "100.0 ha" (Aditya, @Rohilalala, #1446). From da2a3c3379461c335a211fccd5dd99f248e5f819 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:08:10 -0700 Subject: [PATCH 005/154] fix(sky): read the Milky Way faces by file path in the paint-out test The test passed URL.pathname to sharp, which is /D:/... on Windows, so the faces were not found and npm test failed on every Windows clone. fileURLToPath gives a path that works on every platform. Co-Authored-By: Claude Opus 5.5 --- src/tooling/skyAssets.paintOut.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tooling/skyAssets.paintOut.test.mjs b/src/tooling/skyAssets.paintOut.test.mjs index ddb57589a..78a91519e 100644 --- a/src/tooling/skyAssets.paintOut.test.mjs +++ b/src/tooling/skyAssets.paintOut.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; import { CUBE_FACES, @@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn })); for (const face of CUBE_FACES) { const { data, info } = await sharp( - new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname, + fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)), ) .raw() .toBuffer({ resolveWithObject: true }); From d2647f35ba055c8aa81a59d8aef7ae070b143e10 Mon Sep 17 00:00:00 2001 From: Said Turksever <28983926+asturksever@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:40:14 +0000 Subject: [PATCH 006/154] Give the side panels one title style, hover and toggle inset Every rail panel title uses the command dock's label type and colour, collapsed or open, instead of three sizes at 30% opacity. Panels answer hover as the dock trays do, with a blue edge, a soft glow and an accent title. In the default theme each panel's title and collapse button sit 19px from its edge; Context's toggle no longer shrinks beside the radio button. The cyber theme keeps its own colours and insets. --- src/ui/styles/controls.css | 105 +++++++++++++++++++++++++++---- src/ui/styles/layers.css | 8 ++- src/ui/styles/recent-imagery.css | 4 -- src/ui/styles/scenes.css | 8 --- src/ui/styles/weather.css | 4 -- 5 files changed, 99 insertions(+), 30 deletions(-) diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 59f275109..58f756039 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -81,6 +81,23 @@ white-space: nowrap; } +/* Side panel titles read like the command dock's labels: one type for every + panel, collapsed or open, and bright enough to read over the globe. */ +:is(#left-panel-stack, #right-context-rail) + :is(.panel-header > .panel-title, .pp-header-label) { + font-family: var(--font-mono); + font-size: 0.58rem; + font-weight: 500; + letter-spacing: 0.13rem; + text-transform: uppercase; +} + +:root:not([data-ui-theme='cyber']) + :is(#left-panel-stack, #right-context-rail) + :is(.panel-header > .panel-title, .pp-header-label, .panel-collapse-btn) { + color: rgba(206, 232, 239, 0.72); +} + /* A longer translated title in a collapsed rail launcher tightens its tracking rather than pushing the disclosure button out of the card. */ html:not([lang='en']) @@ -109,6 +126,8 @@ html:not([lang='en']) font-family: var(--font-mono); font-size: 13px; line-height: 1; + /* A long title or a header action must not squeeze the toggle. */ + flex-shrink: 0; cursor: pointer; transition: background-color var(--transition-fast), @@ -116,17 +135,72 @@ html:not([lang='en']) color var(--transition-fast); } -/* A long translated title must not squeeze the toggle into a sliver. */ -html:not([lang='en']) .panel-collapse-btn { - flex-shrink: 0; -} - .panel-collapse-btn:hover { border-color: var(--glass-border-hover); color: var(--text-primary); background: rgba(255, 255, 255, 0.07); } +/* A side panel answers the pointer as the command dock does: a blue edge, + a soft glow and an accent title. */ +:root:not([data-ui-theme='cyber']) + :is(#left-panel-stack, #right-context-rail) + :is( + .data-panel-inner, + .scene-panel-inner, + .cctv-panel-inner, + .radio-panel-inner, + .weather-panel-inner, + .recent-imagery-panel-inner, + .street-level-panel-inner, + .global-context-panel-inner, + #pp-toggles:not(.collapsed), + #pp-toggles.collapsed > .pp-header-row + ) { + transition: + border-color 160ms ease, + box-shadow 160ms ease; +} + +:root:not([data-ui-theme='cyber']) + :is(#left-panel-stack, #right-context-rail) + :is( + .data-panel-inner, + .scene-panel-inner, + .cctv-panel-inner, + .radio-panel-inner, + .weather-panel-inner, + .recent-imagery-panel-inner, + .street-level-panel-inner, + .global-context-panel-inner, + #pp-toggles:not(.collapsed), + #pp-toggles.collapsed > .pp-header-row + ):hover { + border-color: rgba(0, 212, 255, 0.55); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(0, 212, 255, 0.08) inset, + 0 0 1rem rgba(0, 212, 255, 0.12); +} + +:root:not([data-ui-theme='cyber']) + :is(#left-panel-stack, #right-context-rail) + :is( + .data-panel-inner, + .scene-panel-inner, + .cctv-panel-inner, + .radio-panel-inner, + .weather-panel-inner, + .recent-imagery-panel-inner, + .street-level-panel-inner, + .global-context-panel-inner, + #pp-toggles:not(.collapsed), + #pp-toggles.collapsed > .pp-header-row + ):hover + :is(.panel-title, .pp-header-label, .panel-collapse-btn) { + color: var(--accent); +} + /* ── Button Grid ──────────────────────────────── */ .button-grid { display: flex; @@ -431,17 +505,26 @@ html:not([lang='en']) .panel-collapse-btn { } #pp-toggles .pp-header-label { - font-family: var(--font-mono); - font-size: 8px; - letter-spacing: 1.4px; color: var(--text-dim); } +/* Display's title and toggle sit where the other panels' do: 14px of panel + padding plus a 4px header inset. The cyber theme sets its own insets. */ +:root:not([data-ui-theme='cyber']) #pp-toggles.collapsed > .pp-header-row { + padding: 8px 18px; +} + +:root:not([data-ui-theme='cyber']) + #pp-toggles:not(.collapsed) + > .pp-header-row { + padding: 0 10px; +} + #pp-toggles > .pp-header-row .pp-collapse-btn { - width: 20px; - height: 20px; + width: 22px; + height: 22px; border-radius: 6px; - font-size: 12px; + font-size: 13px; padding: 0; } diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index ea64eefc5..0c17d372f 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -58,7 +58,7 @@ background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--panel-radius); - padding: 14px 16px 12px; + padding: 14px 14px 12px; backdrop-filter: blur(24px) saturate(1.4); -webkit-backdrop-filter: blur(24px) saturate(1.4); box-shadow: @@ -249,10 +249,12 @@ box-sizing: border-box; justify-content: center; overflow: visible; - padding: 8px 10px 8px 14px; + padding: 8px 14px; } #right-context-rail #global-context-panel.collapsed .panel-header { margin: 0; + /* Room for the radio button between the title and the toggle. */ + gap: 8px; } #right-context-rail.layout-focus { height: var(--right-stack-max-height); @@ -1002,7 +1004,7 @@ display: flex; min-height: 0; flex-direction: column; - padding: 14px 16px 12px; + padding: 14px 14px 12px; border: 1px solid var(--glass-border); border-radius: var(--panel-radius); background: var(--glass-bg); diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css index e13e17b95..70ee7a9c2 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -56,10 +56,6 @@ #recent-imagery-split-line[hidden] { display: none !important; } -#recent-imagery-panel.collapsed .panel-title { - font-size: 8px; - letter-spacing: 1.8px; -} #recent-imagery-panel.collapsed .panel-header { margin-bottom: 2px; } diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 29250a636..da17a9c80 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -378,14 +378,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { width: 100%; } -#data-panel.collapsed .panel-title, -#cctv-panel.collapsed .panel-title, -#radio-panel.collapsed .panel-title, -#scene-panel.collapsed .panel-title { - font-size: 8px; - letter-spacing: 1.8px; -} - #control-panel.collapsed .panel-inner, #data-panel.collapsed .data-panel-inner, #cctv-panel.collapsed .cctv-panel-inner, diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css index f7cd999d3..b2c052e0e 100644 --- a/src/ui/styles/weather.css +++ b/src/ui/styles/weather.css @@ -50,10 +50,6 @@ .weather-readout[hidden] { display: none !important; } -#weather-panel.collapsed .panel-title { - font-size: 8px; - letter-spacing: 1.8px; -} #weather-panel.collapsed .panel-header { margin-bottom: 2px; } From 361f7de3bdaf5503ed4997970739890e1a84f95a Mon Sep 17 00:00:00 2001 From: Said Turksever <28983926+asturksever@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:40:43 +0000 Subject: [PATCH 007/154] Add the changelog entry for the panel chrome change --- changelog.d/1466-panel-chrome-consistency.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1466-panel-chrome-consistency.md diff --git a/changelog.d/1466-panel-chrome-consistency.md b/changelog.d/1466-panel-chrome-consistency.md new file mode 100644 index 000000000..64aefd607 --- /dev/null +++ b/changelog.d/1466-panel-chrome-consistency.md @@ -0,0 +1,4 @@ +- Give the side panels one look. Every panel title now uses the command dock's + label type in a brighter colour, collapsed or open; a panel lights up with a + blue edge and an accent title on hover, as the dock does; and every collapse + button sits the same distance from its panel's edge (asturksever, #1466). From 8b2c6993702d01cfcbf162379171b684c665364e Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 10:41:58 +0000 Subject: [PATCH 008/154] Remove the Operator HUD layout Display > HUD > Layout and the set_hud voice action now offer Tactical, Minimal and Cyber. The Operator option, its CSS and its QA coverage are removed, and the unused "Operator" translations are pruned. Share-link codes are append-only, so operator keeps its hudVariant code. A link, scene or saved state that names it restores the default Tactical layout. The voice schema pins keep their legacy digests by restoring the retired value before comparing; the full-payload digest is re-derived. --- docs/CURRENT-STATE.md | 11 +++- docs/SHARE-LINK-FORMAT.md | 3 ++ scripts/qa-cockpit-utility.mjs | 4 +- scripts/qa-radio.mjs | 2 +- scripts/qa-scope-hud.mjs | 15 ++---- server/providers/openai/instructions.js | 4 +- src/firstRunExperience.test.mjs | 12 ++--- src/hud.js | 4 +- src/hudLayoutPolicy.js | 7 +-- src/i18n/locales/es.json | 1 - src/i18n/locales/ru.json | 1 - src/i18n/locales/zh-CN.json | 1 - src/i18n/locales/zh-Hant.json | 1 - src/keySetup.test.mjs | 2 +- src/radioMarkup.test.mjs | 7 +-- src/share/viewCodec.test.mjs | 9 ++-- src/ui/applicationShell.js | 2 +- src/ui/cyberPanelDisclosure.test.mjs | 2 +- src/ui/cyberSonarControls.test.mjs | 2 +- src/ui/cyberTheme.test.mjs | 37 +++++++------ src/ui/panelRails.test.mjs | 2 +- src/ui/styles/command-dock-trays.css | 9 +--- src/ui/styles/overlays.css | 71 ++++--------------------- src/ui/styles/responsive.css | 7 --- src/ui/templates/display-controls.html | 1 - src/voice/actionSchemas.test.mjs | 8 +-- 26 files changed, 78 insertions(+), 147 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 146e157d8..bdb0d97f3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -221,7 +221,7 @@ from 0–200%. OFF disables the slider while retaining its value. HUD layout changes preserve both settings; share links and scenes restore them. The historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge. -With Scope OFF, Tactical, Operator and Minimal add text-sized black backplates +With Scope OFF, Tactical and Minimal add text-sized black backplates at 70% opacity behind telemetry, vertical metadata and scene chrome. Original HUD text color, glow and opacity are preserved. Scope OFF brightens the tagline and Active Style label with the shared secondary text color. The title plate @@ -231,6 +231,13 @@ the original HUD presentation; Cyber retains its existing skin. `node scripts/qa-scope-hud.mjs` checks native input, range, layout persistence, restoration and both Scope presentations. `QA_SCREENSHOT_DIR` saves screenshots. +## HUD layouts — October 29, 2026 + +Display > HUD > Layout offers Tactical, Minimal and Cyber; the `set_hud` voice +action accepts the same three. The Operator layout is retired. Share links +keep its enum code so older links still parse, and a link, scene or saved state +that names Operator restores the default Tactical layout. + ## Cyber HUD — September 30, 2026 Display > HUD > Layout includes Cyber, also available through the HUD voice @@ -2927,7 +2934,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > corridor). Its minimal Display popover exposes Intel HUD, > Detection, Parameters, and 3D aircraft. During Cockpit, those existing > standard Display controls move into the Cockpit popover and retain their normal -> nested interaction: HUD plus Tactical/Operator/Minimal layout, Detection plus +> nested interaction: HUD plus Tactical/Minimal/Cyber layout, Detection plus > Density/Allocation/Fade/Outside tuning, and 3D plus Proximity/All mode. The > same nodes and state return to the map Display on exit, so Cockpit does not > maintain a second control state. **Detection is owned by the CONTACTS diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index cfb6d50fb..6e1129830 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -129,6 +129,9 @@ table learns its code. A new style's settings are encoded against its own defaults, while a decoder that does not know the style uses Normal's; adding a style also extends the pinned style-defaults table. +`hudVariant` code 1 (`operator`) is retired: the app no longer offers that +layout and restores it as `tactical`. The code stays reserved. + Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 `celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index cb3515ed2..a601aa929 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -1751,7 +1751,7 @@ try { return state; }, { variantName: variant, kind: openKind }); - for (const variant of ['off', 'minimal', 'tactical', 'operator']) { + for (const variant of ['off', 'minimal', 'tactical']) { for (const kind of ['display', 'radio']) { const state = await desktopState(variant, kind); check( @@ -2081,7 +2081,7 @@ try { await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) }); } } - await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('operator')); + await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('tactical')); const signalTransition = await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index d78c3dbcc..98aa95fda 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -2159,7 +2159,7 @@ async function main() { recordLayoutStep(); if (index < 4) { if (index < 2) { - hudLayout.value = index === 0 ? 'operator' : 'minimal'; + hudLayout.value = index === 0 ? 'minimal' : 'tactical'; hudLayout.dispatchEvent(new Event('change', { bubbles: true })); } else { hudToggle.click(); diff --git a/scripts/qa-scope-hud.mjs b/scripts/qa-scope-hud.mjs index 7fece45ee..38444ca18 100644 --- a/scripts/qa-scope-hud.mjs +++ b/scripts/qa-scope-hud.mjs @@ -345,7 +345,7 @@ try { (await readScope()).feather === 2, ); await page.click('#scope-toggle'); - for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) { + for (const layout of ['minimal', 'cyber', 'tactical']) { await page.select('#hud-layout-select', layout); const state = await readScope(); check( @@ -395,7 +395,7 @@ try { restored, ); - for (const layout of ['minimal', 'operator', 'tactical']) { + for (const layout of ['minimal', 'tactical']) { await page.select('#hud-layout-select', layout); await page.evaluate(() => window.__godsEyeView.styleManager.applyVisualState({ @@ -429,20 +429,13 @@ try { wrappersTransparent: wrappers.every( (el) => getComputedStyle(el).display === 'contents', ), - operatorFrame: - hud.dataset.variant !== 'operator' || - (getComputedStyle(hud.querySelector('.hud-corner')) - .backgroundColor === 'rgba(0, 0, 0, 0.36)' && - getComputedStyle(hud.querySelector('.hud-top-bar')) - .backgroundImage !== 'none'), }; }); check( `${layout}: Scope ON retains the original presentation without new backplates`, original.markerAbsent && original.unbacked && - original.wrappersTransparent && - original.operatorFrame, + original.wrappersTransparent, original, ); check( @@ -656,7 +649,7 @@ try { }), ); check('narrow HUD text backings stay within the viewport', narrow); - for (const layout of ['minimal', 'operator', 'tactical']) { + for (const layout of ['minimal', 'tactical']) { await page.select('#hud-layout-select', layout); await page.evaluate(() => window.__godsEyeView.styleManager.applyVisualState({ diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index c9c4a43b4..c6182eb8c 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -48,13 +48,13 @@ function realtimeInstructions( 'For visual filter requests, call set_visual_style with one of the allowed style IDs.', 'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, snow, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.', 'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any other mention of "satellite" or "satellites" means the satellites DATA LAYER via set_layer_visibility, never a basemap — except "recent/latest satellite imagery" (the recent-imagery layer) and "weather satellite"/clouds (weather-satellite). "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.', - 'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.', + 'HUD requests ("hud on/off", "switch to minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.', 'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.', 'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.', 'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.', '"Track/follow " (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.', '"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.', - 'Confirmations echo the RESULTING state, never the request: "HUD operator layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.', + 'Confirmations echo the RESULTING state, never the request: "HUD minimal layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.', 'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.', 'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.', 'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.', diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index d76a48458..bc9a22894 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1022,14 +1022,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping ); const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud') .parameters.properties.layout; - assert.deepEqual(hudLayout.enum, [ - 'tactical', - 'operator', - 'minimal', - 'cyber', - ]); - // Cyber deliberately adds one layout; first-run missions still change no tools. - hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + assert.deepEqual(hudLayout.enum, ['tactical', 'minimal', 'cyber']); + // Cyber deliberately adds one layout and Operator was retired; first-run + // missions still change no tools. + hudLayout.enum = ['tactical', 'operator', 'minimal']; const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera') .parameters.properties.motion; assert.deepEqual(cameraMotion.enum, [ diff --git a/src/hud.js b/src/hud.js index 011b53691..ce432a03f 100644 --- a/src/hud.js +++ b/src/hud.js @@ -9,7 +9,7 @@ import { applicationServices } from './services/application.js'; * real-time at configurable cadences. * * The HUD auto-activates when a military-style shader (NVG, FLIR, CRT) is - * selected and supports three layout variants: tactical, operator, minimal. + * selected and supports the layout variants: tactical, minimal, cyber. * * Color theming is driven by the active shader mode via CSS custom properties. */ @@ -1063,7 +1063,7 @@ export class IntelHUD { * Switch the HUD layout variant. Falls back to `'tactical'` if the * name is unrecognized. Cyber also applies a coordinated skin to the full * application shell; leaving Cyber restores the standard shell tokens. - * @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`, `'cyber'`. + * @param {string} variantName - One of `'tactical'`, `'minimal'`, `'cyber'`. */ setVariant(variantName) { this._variant = normalizeHudLayout(variantName); diff --git a/src/hudLayoutPolicy.js b/src/hudLayoutPolicy.js index 6b3e2287b..81a64b782 100644 --- a/src/hudLayoutPolicy.js +++ b/src/hudLayoutPolicy.js @@ -1,7 +1,2 @@ /** Browser-free layout vocabulary shared by UI and server-side voice tools. */ -export const HUD_LAYOUTS = Object.freeze([ - 'tactical', - 'operator', - 'minimal', - 'cyber', -]); +export const HUD_LAYOUTS = Object.freeze(['tactical', 'minimal', 'cyber']); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 696233f86..f1a0a5ad8 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -76,7 +76,6 @@ "Layout": "Disposición", "HUD layout": "Disposición del HUD", "Tactical": "Táctica", - "Operator": "De operador", "Minimal": "Mínima", "Detection Overlay (D)": "Superposición de detección (D)", "Detection overlay": "Superposición de detección", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 8764d9d06..6c2e9d4d7 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -777,7 +777,6 @@ "Fade": "Затухание", "Outside": "Снаружи", "Tactical": "Тактический", - "Operator": "Операторский", "Minimal": "Минимальный", "HUD layout": "Макет HUD", "Layout": "Макет", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 2e7db6b8f..044aca844 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2616,7 +2616,6 @@ "Layout": "布局", "HUD layout": "HUD 布局", "Tactical": "战术", - "Operator": "操作员", "Minimal": "精简", "Cyber": "赛博", "Sonar": "声呐", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 1477acd51..16705376d 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2604,7 +2604,6 @@ "Layout": "佈局", "HUD layout": "HUD 佈局", "Tactical": "戰術", - "Operator": "操作員", "Minimal": "精簡", "Cyber": "賽博", "Sonar": "聲吶", diff --git a/src/keySetup.test.mjs b/src/keySetup.test.mjs index e7e6c70ae..013dbbd87 100644 --- a/src/keySetup.test.mjs +++ b/src/keySetup.test.mjs @@ -48,7 +48,7 @@ test('setup placement moves the same button only in Cyber and disconnects on tea chip, ); assert.equal(chip.parentNode, home); - for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) { + for (const variant of ['cyber', 'tactical', 'cyber', 'minimal']) { theme.dataset.uiTheme = variant; notify(); assert.equal(chip.parentNode, variant === 'cyber' ? toolbar : home); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index dfe5b4257..a60524b5e 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -206,10 +206,11 @@ test('no unchanged Realtime tool definition drifts silently', () => { .sort((a, b) => a.name.localeCompare(b.name)) .map((tool) => structuredClone(tool)) .filter((tool) => tool.name !== 'set_cyber_sonar'); - // Cyber adds one HUD choice and the sonar tool; retain the existing pin for every legacy field. + // Cyber adds one HUD choice and the sonar tool, and Operator was retired; + // retain the existing pin for every legacy field. const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout; - assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']); - hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + assert.deepEqual(hudLayout.enum, ['tactical', 'minimal', 'cyber']); + hudLayout.enum = ['tactical', 'operator', 'minimal']; // Globe rotation adds one motion and clarifies the two affected descriptions. // Check those edits explicitly, then keep the legacy pin for every other field. const camera = unchanged.find((tool) => tool.name === 'move_camera'); diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs index 4ec2b967c..4bd685047 100644 --- a/src/share/viewCodec.test.mjs +++ b/src/share/viewCodec.test.mjs @@ -437,9 +437,12 @@ test('defaults travel as frozen meanings that match today’s app defaults', () assert.equal(defaults.bloomIntensity, BLOOM_INTENSITY_DEFAULT); assert.equal(defaults.bloomVersion, BLOOM_SCALE_VERSION); assert.equal(defaults.hudVariant, HUD_LAYOUTS[0]); - assert.deepEqual(SHARE_VIEW_ENUMS.hudVariant.slice(0, HUD_LAYOUTS.length), [ - ...HUD_LAYOUTS, - ]); + // Codes are append-only: the retired Operator layout keeps its code so old + // links still parse, and the app restores them as Tactical. + assert.deepEqual( + SHARE_VIEW_ENUMS.hudVariant.filter((variant) => variant !== 'operator'), + [...HUD_LAYOUTS], + ); assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES]); assert.deepEqual(SHARE_VIEW_ENUMS.mapStack, [...VIEW_MAPS]); assert.deepEqual(SHARE_VIEW_ENUMS.detectionAllocation, [ diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 4b999a75f..4e29fca4e 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1337,7 +1337,7 @@ export class StyleManager extends ShellFacade { /** * Switches the HUD layout variant. - * @param {'tactical'|'operator'|'minimal'|'cyber'} variantName - Layout variant. + * @param {'tactical'|'minimal'|'cyber'} variantName - Layout variant. * @returns {{ok: boolean, layout?: string, visible?: boolean, error?: string}} */ setHudLayout(variantName) { diff --git a/src/ui/cyberPanelDisclosure.test.mjs b/src/ui/cyberPanelDisclosure.test.mjs index ae0256d4c..e2a666ba5 100644 --- a/src/ui/cyberPanelDisclosure.test.mjs +++ b/src/ui/cyberPanelDisclosure.test.mjs @@ -72,7 +72,7 @@ test('opening already-expanded Radio reveals Context and closes Display', () => }); test('non-Cyber explicit panels and restored preferences retain their existing policy', () => { - for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) { + for (const theme of ['tactical', 'minimal', 'cyber']) { const f = fixture(theme), prior = globalThis.document; globalThis.document = f.doc; try { diff --git a/src/ui/cyberSonarControls.test.mjs b/src/ui/cyberSonarControls.test.mjs index b819d6f3d..7636d76b7 100644 --- a/src/ui/cyberSonarControls.test.mjs +++ b/src/ui/cyberSonarControls.test.mjs @@ -132,7 +132,7 @@ test('rejects the whole invalid request before any mutation', () => { }); test('requires Cyber and preserves off state and every omitted setting', () => { - for (const theme of ['tactical', 'operator', 'minimal']) { + for (const theme of ['tactical', 'minimal']) { const f = fixture(theme); assert.equal(setCyberSonarControls(f.owner, { enabled: true }, f.doc).ok, false); assert.deepEqual(f.calls, []); diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 03ee75f08..340dd0204 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -54,7 +54,8 @@ function styleOwner(t, initialVariant = 'cyber') { const hud = { getVariant: () => variant, setVariant: (value) => { - variant = value; + // Normalize like the real HUD so retired layouts fall back. + variant = normalizeHudLayout(value); applyHudUiTheme(root, value); }, setMode: (value) => calls.push(['mode', value]), @@ -131,7 +132,7 @@ test('visual styles retain Cyber through real preset application without droppin test('non-Cyber style presets and explicit HUD choices remain authoritative', (t) => { const { owner, hud, root } = styleOwner(t); - for (const variant of ['tactical', 'operator', 'minimal']) { + for (const variant of ['tactical', 'minimal']) { owner._setHudVariant(variant); assert.equal(hud.getVariant(), variant); assert.equal(root.dataset.uiTheme, undefined); @@ -150,8 +151,8 @@ test('leaving Cyber without a preset choice restores the prior visual preset', ( assert.equal(owner._preCyberStyle, 'noir'); assert.equal(owner._cyberStyleUserSelected, false); - owner._setHudVariant('operator', { applyVisualDefaults: true }); - assert.equal(hud.getVariant(), 'operator'); + owner._setHudVariant('minimal', { applyVisualDefaults: true }); + assert.equal(hud.getVariant(), 'minimal'); assert.equal(owner.activeStyle, 'noir'); assert.equal(owner._preCyberStyle, null); assert.equal(owner._cyberStyleUserSelected, false); @@ -170,8 +171,8 @@ test('a preset explicitly selected in Cyber survives exit and resets for re-entr // Reselecting the active mode must not erase the user's preset intent. owner._setHudVariant('cyber', { applyVisualDefaults: true }); assert.equal(owner._preCyberStyle, 'noir'); - owner._setHudVariant('operator', { applyVisualDefaults: true }); - assert.equal(hud.getVariant(), 'operator'); + owner._setHudVariant('minimal', { applyVisualDefaults: true }); + assert.equal(hud.getVariant(), 'minimal'); assert.equal(owner.activeStyle, 'snow'); assert.equal(owner._preCyberStyle, null); assert.equal(owner._cyberStyleUserSelected, false); @@ -192,7 +193,7 @@ test('an explicit same-value FLIR choice is preserved on Cyber exit', (t) => { owner.setStyle('thermal', { userInitiated: true }); assert.equal(owner.activeStyle, 'thermal'); assert.equal(owner._cyberStyleUserSelected, true); - owner._setHudVariant('operator', { applyVisualDefaults: true }); + owner._setHudVariant('minimal', { applyVisualDefaults: true }); assert.equal(owner.activeStyle, 'thermal'); }); @@ -207,7 +208,7 @@ test('an authoritative same-Cyber reset clears local preset ownership', (t) => { assert.equal(owner.activeStyle, 'snow'); assert.equal(owner._preCyberStyle, null); assert.equal(owner._cyberStyleUserSelected, false); - owner._setHudVariant('operator', { applyVisualDefaults: true }); + owner._setHudVariant('minimal', { applyVisualDefaults: true }); assert.equal(owner.activeStyle, 'snow'); }); @@ -241,9 +242,9 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author assert.equal(display.owner.activeStyle, 'thermal'); snowButton.dispatchEvent(new Event('click')); assert.equal(display.owner.activeStyle, 'snow'); - hudLayout.value = 'operator'; + hudLayout.value = 'minimal'; hudLayout.dispatchEvent(new Event('change')); - assert.equal(display.hud.getVariant(), 'operator'); + assert.equal(display.hud.getVariant(), 'minimal'); assert.equal(display.owner.activeStyle, 'snow'); controls.destroy(); @@ -266,9 +267,9 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author const selected = await runVoiceAction('set_visual_style', { style: 'snow' }); assert.equal(selected.ok, true); assert.equal(voice.owner.activeStyle, 'snow'); - const exited = await runVoiceAction('set_hud', { layout: 'operator' }); + const exited = await runVoiceAction('set_hud', { layout: 'minimal' }); assert.equal(exited.ok, true); - assert.equal(voice.hud.getVariant(), 'operator'); + assert.equal(voice.hud.getVariant(), 'minimal'); assert.equal(voice.owner.activeStyle, 'snow'); }); @@ -284,7 +285,7 @@ test('scope choice and expanded feather survive every HUD layout and both restor assert.equal(owner._scopeBtn.checked, false); assert.equal(owner._scopeFeatherSlider.disabled, true); assert.equal(owner._scopeFeatherSlider.value, '200'); - for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) { + for (const layout of ['minimal', 'cyber', 'tactical']) { owner._setHudVariant(layout, { applyVisualDefaults: true }); assert.equal(isScopeMaskEnabled(), false, layout); assert.equal(getScopeMaskFeather(), 2, layout); @@ -313,7 +314,7 @@ test('scene and share HUD transitions do not restore a local pre-Cyber preset', assert.equal(owner.activeStyle, 'retro'); assert.equal(owner._preCyberStyle, null); assert.equal(owner._cyberStyleUserSelected, false); - owner._setHudVariant('operator', { applyVisualDefaults: true }); + owner._setHudVariant('minimal', { applyVisualDefaults: true }); assert.equal(owner.activeStyle, 'retro'); owner.setStyle('surveillance'); @@ -342,7 +343,8 @@ test('scene and share restoration override Cyber and preserve restored Cyber par style: 'surveillance', hudVariant: 'operator', }); - assert.equal(hud.getVariant(), 'operator'); + // A link saved with the retired Operator layout restores as Tactical. + assert.equal(hud.getVariant(), 'tactical'); await owner.restoreShareState({ style: 'thermal', hudVariant: 'cyber', @@ -564,8 +566,9 @@ test('Cyber side panels share one width and one framed surface material', () => }); test('Cyber is a canonical HUD layout with a safe Tactical fallback', () => { - assert.deepEqual(HUD_LAYOUTS, ['tactical', 'operator', 'minimal', 'cyber']); + assert.deepEqual(HUD_LAYOUTS, ['tactical', 'minimal', 'cyber']); assert.equal(normalizeHudLayout('CYBER'), 'cyber'); + assert.equal(normalizeHudLayout('operator'), 'tactical'); assert.equal(normalizeHudLayout('unknown'), 'tactical'); }); @@ -597,7 +600,7 @@ test('an explicit transition into Cyber selects FLIR with Ironbow 0.42 once', () ); assert.equal(cyberVisualDefaultsForHudTransition('tactical', 'cyber'), null); assert.equal( - cyberVisualDefaultsForHudTransition('cyber', 'operator', { + cyberVisualDefaultsForHudTransition('cyber', 'minimal', { explicit: true, }), null, diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 270ba61f5..f144ec0e7 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -39,7 +39,7 @@ test('Cyber restores one expanded owner, keeps launchers, and restores other the true, 'layout does not reopen a peer', ); - f.options.hud.variant = 'operator'; + f.options.hud.variant = 'minimal'; f.run(); assert.equal(f.first.classList.contains('collapsed'), false); assert.equal( diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css index 547037de7..e3607a8ce 100644 --- a/src/ui/styles/command-dock-trays.css +++ b/src/ui/styles/command-dock-trays.css @@ -463,17 +463,10 @@ body:not(.ui-clean-view):not(.recording-mode):has( } #intel-hud[data-variant='tactical'] .hud-bottom-left, -#intel-hud[data-variant='tactical'] .hud-bottom-right, -#intel-hud[data-variant='operator'] .hud-bottom-left, -#intel-hud[data-variant='operator'] .hud-bottom-right { +#intel-hud[data-variant='tactical'] .hud-bottom-right { bottom: calc(2vh + 6.5rem); } -#intel-hud[data-variant='operator'] .hud-bottom-bar { - bottom: calc(2vh + 9.8rem); - width: min(48vw, 680px); -} - #intel-hud[data-variant='minimal'] .hud-bottom-left { bottom: calc(2vh + 5.4rem); } diff --git a/src/ui/styles/overlays.css b/src/ui/styles/overlays.css index 7f491439b..f406b1294 100644 --- a/src/ui/styles/overlays.css +++ b/src/ui/styles/overlays.css @@ -303,49 +303,11 @@ display: none; } -#intel-hud[data-variant='operator'] .hud-top-bar, -#intel-hud[data-variant='operator'] .hud-bottom-bar { - display: flex; -} - -#intel-hud[data-variant='operator'] .hud-top-bar { - top: max(10vh, 90px); - left: 50%; - right: auto; - width: min(52vw, 720px); - transform: translateX(-50%); - border-color: rgba(255, 255, 255, 0.24); - background: linear-gradient(90deg, rgba(0, 0, 0, 0.56), rgba(8, 26, 34, 0.5)); - box-shadow: 0 0 24px rgba(0, 255, 255, 0.08); -} - #intel-hud[data-variant='tactical'] .hud-top-left, -#intel-hud[data-variant='tactical'] .hud-top-right, -#intel-hud[data-variant='operator'] .hud-top-left, -#intel-hud[data-variant='operator'] .hud-top-right { +#intel-hud[data-variant='tactical'] .hud-top-right { top: max(14vh, 136px); } -#intel-hud[data-variant='operator'] .hud-bottom-bar { - border-color: rgba(255, 255, 255, 0.2); - background: linear-gradient( - 90deg, - rgba(0, 0, 0, 0.52), - rgba(8, 22, 18, 0.52) - ); -} - -#intel-hud[data-variant='operator'] .hud-corner { - padding: 8px 10px; - border: 1px solid rgba(255, 255, 255, 0.16); - border-radius: 8px; - background: rgba(0, 0, 0, 0.36); -} - -#intel-hud[data-variant='operator'] .hud-bracket { - display: none; -} - #intel-hud[data-variant='minimal'] .hud-top-right, #intel-hud[data-variant='minimal'] .hud-bottom-right, #intel-hud[data-variant='minimal'] .hud-left-edge, @@ -378,11 +340,7 @@ } :root[data-scope-mask='off'] - #intel-hud:is( - [data-variant='minimal'], - [data-variant='operator'], - [data-variant='tactical'] - ) + #intel-hud:is([data-variant='minimal'], [data-variant='tactical']) .hud-readout, :root[data-scope-mask='off']:not([data-ui-theme='cyber']) .scene-text-holdout { background: rgba(0, 0, 0, 0.7); @@ -399,11 +357,7 @@ } :root[data-scope-mask='off'] - #intel-hud:is( - [data-variant='minimal'], - [data-variant='operator'], - [data-variant='tactical'] - ) + #intel-hud:is([data-variant='minimal'], [data-variant='tactical']) .hud-readout { display: block; width: max-content; @@ -413,11 +367,7 @@ } :root[data-scope-mask='off'] - #intel-hud:is( - [data-variant='minimal'], - [data-variant='operator'], - [data-variant='tactical'] - ) + #intel-hud:is([data-variant='minimal'], [data-variant='tactical']) :is(.hud-top-right, .hud-bottom-right) .hud-readout, :root[data-scope-mask='off']:not([data-ui-theme='cyber']) @@ -427,29 +377,26 @@ } /* Preserve glyph opacity without dimming the vertical backplates. */ -:root[data-scope-mask='off'] - #intel-hud:is([data-variant='operator'], [data-variant='tactical']) - .hud-edge { +:root[data-scope-mask='off'] #intel-hud[data-variant='tactical'] .hud-edge { opacity: 1; } :root[data-scope-mask='off'] - #intel-hud:is([data-variant='operator'], [data-variant='tactical']) + #intel-hud[data-variant='tactical'] .hud-edge .hud-readout > * { opacity: 0.5; } -:root[data-scope-mask='off'] #intel-hud[data-variant='operator'] .hud-corner, :root[data-scope-mask='off'] - #intel-hud:is([data-variant='minimal'], [data-variant='operator']) + #intel-hud[data-variant='minimal'] :is(.hud-top-bar, .hud-bottom-bar) { background: transparent; } :root[data-scope-mask='off'] - #intel-hud:is([data-variant='minimal'], [data-variant='operator']) + #intel-hud[data-variant='minimal'] :is(.hud-top-bar, .hud-bottom-bar) { width: fit-content; max-width: calc(100vw - 120px); @@ -457,7 +404,7 @@ } :root[data-scope-mask='off'] - #intel-hud:is([data-variant='minimal'], [data-variant='operator']) + #intel-hud[data-variant='minimal'] :is(.hud-top-bar, .hud-bottom-bar) .hud-readout { overflow: hidden; diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 8278b51a7..001c3ab3a 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -110,13 +110,6 @@ font-size: 9px; } - #intel-hud[data-variant='operator'] .hud-top-bar { - left: 12px; - right: auto; - width: calc(100vw - 24px); - transform: none; - } - .hud-bottom-bar { bottom: 90px; font-size: 9px; diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index f6af31127..458a2fcd8 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -15,7 +15,6 @@ Layout diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 97e5004b5..e08505146 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -45,7 +45,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // S2 GhostMaps extends the generated layer enums. // Panel adds named voice targets and timeout; shared Panel/Cockpit guidance // is consolidated without changing either feature's arguments. - '9ee82c4214499280c208c93751b19d7b724c81b7727a2d0c6dab0184749fbe15', + // The Operator HUD layout leaves set_hud's layout enum. + '8c3c26336e4820b4b0828ab2bedf70050eca91ea3b0517a62cd8b69ea4fefc92', ); }); @@ -166,9 +167,10 @@ test('all legacy action arguments are byte-identical after removing the delibera delete center.properties[axis].minimum; delete center.properties[axis].maximum; } - // Cyber adds one HUD layout. + // Cyber adds one HUD layout and Operator was retired. const hud = property('set_hud').layout; - hud.enum = hud.enum.filter((layout) => layout !== 'cyber'); + assert.deepEqual(hud.enum, ['tactical', 'minimal', 'cyber']); + hud.enum = ['tactical', 'operator', 'minimal']; // Derived by applying the same removals to the 4b56d0e9 actionSchemas. assert.equal( createHash('sha256').update(JSON.stringify(legacy)).digest('hex'), From e693e665a6a2d83b0524f6e5cd31b792c77c0726 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 10:42:38 +0000 Subject: [PATCH 009/154] Add the changelog entry for removing the Operator HUD layout --- changelog.d/1467-remove-operator-hud-layout.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1467-remove-operator-hud-layout.md diff --git a/changelog.d/1467-remove-operator-hud-layout.md b/changelog.d/1467-remove-operator-hud-layout.md new file mode 100644 index 000000000..556d551ee --- /dev/null +++ b/changelog.d/1467-remove-operator-hud-layout.md @@ -0,0 +1,3 @@ +- Remove the Operator HUD layout. Display > HUD > Layout and the HUD voice + action now offer Tactical, Minimal and Cyber; share links, scenes and saved + views that name Operator open in Tactical (asturksever, #1467). From 66e19f0c50c70584b23bf01741361a58eb5a79d7 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 11:28:47 +0000 Subject: [PATCH 010/154] Drop the Radio icon from Context and make the panel easier to read The Context header carried a Radio icon that opened a second, compact Radio player on top of the panel, duplicating the Radio section that already lives inside Context. Remove the icon, the compact player and everything that kept the two in step. Radio stays in Context as its own section with one Enable control; Cockpit's compact Radio is unchanged. Context itself was hard to read: 7-8 px labels at low contrast, a dashed placeholder box, and a glow halo. The two modes are now one segmented control, labels and guidance are 10 px at higher contrast, the standby hint is plain text, section titles use --text-secondary, and the panel draws no glow. Unused translations are pruned, tests and QA harnesses that pinned the compact player are updated or retired, and CURRENT-STATE and the changelog describe the new header. --- CHANGELOG.md | 9 + docs/CURRENT-STATE.md | 13 +- scripts/qa-firstrun-mutations.mjs | 10 +- scripts/qa-firstrun.mjs | 47 --- scripts/qa-radio.mjs | 363 +++------------------ src/cockpitMarkup.test.mjs | 8 +- src/contextTabKeyboard.test.mjs | 4 +- src/firstRunExperience.test.mjs | 6 +- src/i18n/locales/es.json | 12 - src/i18n/locales/ru.json | 12 - src/i18n/locales/zh-CN.json | 12 - src/i18n/locales/zh-Hant.json | 12 - src/panelEscape.test.mjs | 5 - src/radioMarkup.test.mjs | 67 +--- src/ui/applicationShell.js | 19 -- src/ui/cockpitInput.js | 6 - src/ui/panelChrome.js | 34 -- src/ui/panelChrome.radioOwnership.test.mjs | 200 ------------ src/ui/panelRails.test.mjs | 1 - src/ui/radioBindings.js | 90 ----- src/ui/radioControls.js | 102 ------ src/ui/radioControls.test.mjs | 1 - src/ui/radioPanelPlacement.js | 8 +- src/ui/radioPanelPlacement.test.mjs | 36 +- src/ui/radioPresentation.js | 53 --- src/ui/shellElements.js | 30 -- src/ui/shellFacade.js | 8 - src/ui/shellToasts.i18n.test.mjs | 2 - src/ui/styles/layers.css | 237 +++----------- src/ui/styles/radio.css | 12 +- src/ui/styles/responsive.css | 4 - src/ui/templates/context.html | 30 -- 32 files changed, 127 insertions(+), 1326 deletions(-) delete mode 100644 src/ui/panelChrome.radioOwnership.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 356289841..5e5ec8e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +- Remove the Radio icon from the Context panel header, along with the compact + Radio player it opened. Radio stays in Context as its own section, with one + Enable control and full playback. Cockpit's compact Radio is unchanged. + +- Make the Context panel easier to read: the Contacts / Space Missions modes + are one segmented control, labels and guidance are larger and higher + contrast, the standby hint is plain text instead of a dashed box, and the + panel no longer draws a glow halo. + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 146e157d8..4d656502b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1508,10 +1508,6 @@ Automatic collapse remains presentation only, prioritizes the latest explicit panel, and honors keyboard focus on the right. Mobile layout still releases desktop allocation styles. Stable Display allocation avoids unnecessary style writes. This extraction does not change panel positions or layout defaults. -Context expansion through the panel owner or adaptive right-rail restoration -closes an open compact Radio disclosure before Context/Space Missions becomes -visible. The mini remains available while Context is collapsed. This fixes the -Radio-overlap path reported in #804 without changing z-index or layout policy. The right rail skips panels hidden by other CSS (computed `display: none`), but keeps the collapsed panels its own exclusive presentation hides, so automatic collapse can still be undone. @@ -2246,8 +2242,9 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > to yield to: whoever handles ESC first calls `preventDefault()` **and** > `stopImmediatePropagation()`, and the launcher skips `defaultPrevented` events. > `stopPropagation()` alone does **not** stop later listeners on the same -> `document` — that is exactly how the compact Radio disclosure made one key -> close the disclosure _and_ dismiss the launcher. +> `document` — that is exactly how a disclosure that called it alone made one +> key close the disclosure _and_ dismiss the launcher. Cockpit's utility +> disclosures (Display, Radio) claim the key the immediate way. > > **Accepted:** a surface class that never clears means no launcher for that page > load, with no timeout. None of the four classes is restored at startup, so an @@ -4800,6 +4797,8 @@ silently demoting every later lookup for the session. - The expanded `CONTEXT` view offers mutually exclusive `CONTACTS` and `SPACE MISSIONS` modes. Selecting `CONTACTS` enables the context-owned Flights, Military Flights, AIS Vessels, and Mapped Installations dependencies only when they are not already user-enabled; selecting `SPACE MISSIONS` enables the recent-launch layer and its Satellite dependency. `CONTACTS` cycles the nearest supported contact of whatever type is selected. Satellites are deliberately excluded from those Awareness cohorts and keep their own tracking UX. Selecting the active mode again returns to the neutral chooser and releases only mode-owned dependencies. - If a civilian or military aircraft is already tracked when `CONTACTS` becomes operational, that source-owned track is adopted as the Context subject before nearest-contact autofocus. Context rechecks the tracker after its dependencies settle, so a newer selection wins, while an explicit clear during activation prevents fallback from silently selecting a replacement. Cockpit entry remains unavailable until that Context transaction has settled, so its camera takeover cannot clear Cesium tracking before adoption. Adoption does not recreate tracking or transfer camera ownership; it initializes the normal 250 km ring, history, proximity results, and Cockpit Previous/Next state for the original aircraft. - `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available. A view across the antimeridian is searched around its own centre, with a radius from its real width. +- The Context header holds only its title and collapse control. It no longer carries a Radio shortcut or a compact Radio player: Radio is the `RADIO` section inside the panel, with its own disclosure and the single `ENABLE` power control. Cockpit keeps its own compact Radio utility. +- Context is set for reading: the two modes form one segmented control with 10 px labels, the standby guidance is plain left-aligned text rather than a dashed placeholder, action buttons use 10 px labels, section titles use `--text-secondary`, and the panel draws no glow halo. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. - Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. @@ -5590,7 +5589,7 @@ apply to `public/` assets or built-preview serving. - The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored. - The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded). - Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. DISPLAY may remain open beside one contextual panel; CCTV and Context are mutually exclusive. In Tactical HUD, expanding CCTV or Context hides the other contextual launcher while DISPLAY remains independently available. The most recently opened right-rail panel owns the constrained lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. Minimal and other HUD layouts retain the collapsed launchers; when their active panel exceeds the measured corridor, the rail reserves sibling heights and gaps and scrolls the active panel internally. -- At 720px and below both panel stacks (`#left-panel-stack`, `#right-context-rail`) go edge-to-edge and scroll vertically. Every hosted panel's decorative `.panel-glow` is pinned to its panel box there (`inset: 0` in `src/ui/styles/responsive.css`): on desktop the glow overhangs the panel by 18–20 px as harmless paint, but inside a scroll container that overhang is scrollable overflow on both axes and drew a horizontal scrollbar band plus a vertical scrollbar that scrolled nothing but glow whenever CCTV, Context, Data Layers or Scenes was expanded. The glow's blur is ink overflow and never scrolls, so the look is unchanged. Do not fix this by clipping the stacks: `overflow-x: clip` would also clip the Context radio popover and would not remove the vertical scrollbar. Pinned by `src/ui/panelRails.test.mjs` against every `(max-width: 720px)` block in the flattened stylesheet. +- At 720px and below both panel stacks (`#left-panel-stack`, `#right-context-rail`) go edge-to-edge and scroll vertically. Every hosted panel's decorative `.panel-glow` is pinned to its panel box there (`inset: 0` in `src/ui/styles/responsive.css`): on desktop the glow overhangs the panel by 18–20 px as harmless paint, but inside a scroll container that overhang is scrollable overflow on both axes and drew a horizontal scrollbar band plus a vertical scrollbar that scrolled nothing but glow whenever CCTV, Context, Data Layers or Scenes was expanded. The glow's blur is ink overflow and never scrolls, so the look is unchanged. Do not fix this by clipping the stacks: `overflow-x: clip` would not remove the vertical scrollbar. Context draws no glow at all (`display: none`), so it is not part of that pin. Pinned by `src/ui/panelRails.test.mjs` against every `(max-width: 720px)` block in the flattened stylesheet. - `STYLE PRESETS` and `LOCATIONS` start collapsed, expand on intentional hover/click, and auto-collapse after hover leave delay. - Collapsed mini-status indicators show active style and active location/landmark. - Detection mode is user-controlled and should persist when switching styles. Since 2026-08-22 it also STARTS on — Dense @ 75% for every style on a first run, Normal included — as a `GLOBAL_POST_DEFAULTS` baseline that does NOT set `_detectionUserOverridden`. Exception (unchanged): selecting a military style (CRT/NVG/FLIR) auto-enables the same Dense preset, but only until the user manually changes detection this session (`_detectionUserOverridden` gate), after which style switches never touch it. diff --git a/scripts/qa-firstrun-mutations.mjs b/scripts/qa-firstrun-mutations.mjs index 3ef331abd..cdca7a55c 100644 --- a/scripts/qa-firstrun-mutations.mjs +++ b/scripts/qa-firstrun-mutations.mjs @@ -175,12 +175,12 @@ const MUTATIONS = [ to: ' /* belt removed */', }, { - // Anchored past the call so it cannot land on the cockpit disclosure's own - // stopImmediatePropagation() a few lines below and prove nothing about this. - defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things', + // Anchored past the call so it lands on the Cockpit utility disclosure's + // Escape claim and nowhere else. + defect: 'a Cockpit utility disclosure returns to stopPropagation, so one ESC does two things', file: 'radio', - from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||', - to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||', + from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';", + to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';", }, { defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind', diff --git a/scripts/qa-firstrun.mjs b/scripts/qa-firstrun.mjs index 24d82bd2b..c8291ed62 100644 --- a/scripts/qa-firstrun.mjs +++ b/scripts/qa-firstrun.mjs @@ -357,53 +357,6 @@ async function runArbitrationSection(page, { shots, consoleErrors }) { `classVisible=${afterUncoveredEsc.classVisible} session=${afterUncoveredEsc.session}`, ); - // ── Blocker repro: a control that claims only the KEY ─────────────────── - // The compact Radio disclosure closes on ESC from a capture listener bound - // long before this module exists. It called stopPropagation(), which does - // NOT stop later listeners on the same document — so one key closed the - // disclosure AND dismissed the launcher. The card is not hiding behind this - // one and must not yield to it: the key is simply already spoken for. - await open(page, { query: "?welcome=1", errorSink: consoleErrors }); - const beforeRadio = await launcherState(); - record('the launcher is up before the radio disclosure opens', beforeRadio.onScreen); - const radioOpened = await page.evaluate(() => { - document.getElementById('context-radio-toggle-btn')?.click(); - return !!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open'); - }); - await sleep(250); - record('the compact Radio disclosure opens', radioOpened); - const withRadio = await launcherState(); - record( - 'a small disclosure is a key contest, not a screen takeover — no yield', - withRadio.classVisible && withRadio.topmost, - `classVisible=${withRadio.classVisible} topmost=${withRadio.topmost}`, - ); - - await page.keyboard.press('Escape'); - await sleep(400); - const radioAfterEsc = await page.evaluate(() => ( - !!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open') - )); - const launcherAfterRadioEsc = await launcherState(); - record('ESC closes the disclosure', radioOpened && !radioAfterEsc, - `disclosure still open=${radioAfterEsc}`); - record( - 'that SAME ESC does not also dismiss the launcher', - beforeRadio.onScreen && launcherAfterRadioEsc.classVisible - && launcherAfterRadioEsc.session !== 'dismissed', - `classVisible=${launcherAfterRadioEsc.classVisible} session=${launcherAfterRadioEsc.session} — one key, one action`, - ); - // ...and the NEXT press is the launcher's, so nothing was permanently taken. - await page.keyboard.press('Escape'); - await sleep(600); - const escAfterRadioClosed = await launcherState(); - record( - 'the next ESC belongs to the launcher again', - beforeRadio.onScreen && !escAfterRadioClosed.classVisible - && escAfterRadioClosed.session === 'dismissed', - `classVisible=${escAfterRadioClosed.classVisible} session=${escAfterRadioClosed.session}`, - ); - // ── Deferred reveal: a surface already up when the launcher would show ── // The class goes on as soon as the app exists, which is well before the // launcher's reveal (~T+1.9s), so init genuinely sees a screen it does not diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index d78c3dbcc..03f402363 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -286,25 +286,7 @@ async function main() { }); const enableStartedAt = Date.now(); - const initialDisclosure = await page.evaluate(() => { - const button = document.getElementById('context-radio-toggle-btn'); - const mini = document.getElementById('context-radio-mini'); - const enabledBefore = window.__godsEyeView.dataManager.isEnabled('radio'); - button.click(); - return { - enabledBefore, - enabledAfterDisclosure: window.__godsEyeView.dataManager.isEnabled('radio'), - expanded: button.getAttribute('aria-expanded'), - miniHidden: mini.hidden, - }; - }); - check( - 'Radio launcher discloses compact controls without changing power', - !initialDisclosure.enabledBefore && !initialDisclosure.enabledAfterDisclosure - && initialDisclosure.expanded === 'true' && !initialDisclosure.miniHidden, - JSON.stringify(initialDisclosure), - ); - await page.$eval('#context-radio-mini-enable-btn', (button) => button.click()); + await page.$eval('#radio-enable-btn', (button) => button.click()); await page.waitForFunction(() => { const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState(); return state.enabled && !state.loading && state.stationCount === 750; @@ -328,9 +310,6 @@ async function main() { color: entry.dataset.radioColor, })), enablePressed: document.getElementById('radio-enable-btn').getAttribute('aria-pressed'), - launcherExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'), - launcherActive: document.getElementById('context-radio-toggle-btn').classList.contains('active'), - compactEnablePressed: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-pressed'), fullPlayEnabled: !document.getElementById('radio-play-btn').disabled, fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'), tunerVisible: !document.getElementById('radio-tuner').hidden, @@ -341,9 +320,8 @@ async function main() { check('750-station catalog renders within the browser budget', initial.state.stationCount === 750 && enableElapsedMs < 5000, `${enableElapsedMs}ms`); check('enable does not autoplay', initial.plays === 0 && initial.state.audioState === 'stopped'); check( - 'compact and detailed Radio power controls reflect the layer manager independently of disclosure', - initial.enablePressed === 'true' && initial.compactEnablePressed === 'true' - && initial.launcherExpanded === 'true' && initial.launcherActive, + 'detailed Radio power control reflects the layer manager while the panel is collapsed', + initial.enablePressed === 'true', ); check('dynamic canonical and genre categories reach the UI', initial.filterOptions >= 10, `${initial.filterOptions} options`); const musicOption = initial.filterPalette.find((entry) => entry.id === 'music'); @@ -419,14 +397,10 @@ async function main() { prevDisabled: document.getElementById('radio-prev-btn').disabled, playDisabled: document.getElementById('radio-play-btn').disabled, volumeDisabled: document.getElementById('radio-volume').disabled, - disclosureLabel: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'), - compactPowerLabel: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-label'), - compactPowerText: document.getElementById('context-radio-mini-enable-btn').textContent, fullLabel: document.getElementById('radio-enable-btn').getAttribute('aria-label'), fullText: document.getElementById('radio-enable-btn').textContent, layerState: document.getElementById('radio-layer-state').textContent, playbackMessage: document.getElementById('radio-playback-state').textContent, - miniStation: document.getElementById('context-radio-mini-station').textContent, dataRailText: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.textContent, dataRailState: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.dataset.feedState, dataRailLabel: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.getAttribute('aria-label'), @@ -468,14 +442,10 @@ async function main() { && uncertainPresentation.prevDisabled && uncertainPresentation.playDisabled && uncertainPresentation.volumeDisabled - && /compact Radio controls/i.test(uncertainPresentation.disclosureLabel) - && /uncertain/i.test(uncertainPresentation.compactPowerLabel) - && uncertainPresentation.compactPowerText === 'RECONCILE' && /uncertain/i.test(uncertainPresentation.fullLabel) && uncertainPresentation.fullText === 'RECONCILE' && uncertainPresentation.layerState === 'UNCERTAIN' && /uncertain/i.test(uncertainPresentation.playbackMessage) - && uncertainPresentation.miniStation === 'RADIO STATE UNCERTAIN' && uncertainPresentation.dataRailText === 'UNCERTAIN' && uncertainPresentation.dataRailState === 'uncertain' && /uncertain/i.test(uncertainPresentation.dataRailLabel) @@ -517,10 +487,6 @@ async function main() { presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive, markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show, full: document.getElementById('radio-enable-btn').textContent, - disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'), - disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'), - disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'), - compactPower: document.getElementById('context-radio-mini-enable-btn').textContent, layerState: document.getElementById('radio-layer-state').textContent, message: document.getElementById('radio-playback-state').textContent, stopDisabled: document.getElementById('radio-stop-btn').disabled, @@ -534,8 +500,6 @@ async function main() { && disablingPresentation.presentationActive === false && disablingPresentation.markerSourceVisible === false && disablingPresentation.full === 'DISABLING' - && /compact Radio controls/i.test(disablingPresentation.disclosure) - && disablingPresentation.compactPower === 'DISABLING' && disablingPresentation.layerState === 'DISABLING' && /disabling/i.test(disablingPresentation.message) && disablingPresentation.stopDisabled @@ -592,10 +556,6 @@ async function main() { presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive, markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show, full: document.getElementById('radio-enable-btn').textContent, - disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'), - disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'), - disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'), - compactPower: document.getElementById('context-radio-mini-enable-btn').textContent, layerState: document.getElementById('radio-layer-state').textContent, message: document.getElementById('radio-playback-state').textContent, tunerHidden: document.getElementById('radio-tuner').hidden, @@ -610,10 +570,6 @@ async function main() { && enablingPresentation.presentationActive === false && enablingPresentation.markerSourceVisible === false && enablingPresentation.full === 'ENABLING' - && enablingPresentation.disclosure === 'Go to expanded Radio section' - && enablingPresentation.disclosureControls === 'radio-panel' - && enablingPresentation.disclosureExpanded === 'true' - && enablingPresentation.compactPower === 'ENABLING' && enablingPresentation.layerState === 'ENABLING' && /enabling/i.test(enablingPresentation.message) && enablingPresentation.tunerHidden @@ -1581,112 +1537,9 @@ async function main() { JSON.stringify(directoryRefreshContinuity), ); await page.select('#radio-filter', 'news'); - check('launcher enables Radio without forcing Context open', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext); + check('collapsed Radio power enables Radio without forcing Context open', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext); check('full Play is available before a station is selected', !initial.state.selected && initial.fullPlayEnabled && initial.fullPlayLabel === 'Play nearest radio station'); - const hoverOnlyDisclosure = await page.evaluate(() => { - const button = document.getElementById('context-radio-toggle-btn'); - if (button.getAttribute('aria-expanded') === 'true') button.click(); - return { - expanded: button.getAttribute('aria-expanded'), - hidden: document.getElementById('context-radio-mini').hidden, - }; - }); - await page.hover('#context-radio-toggle-btn'); - await sleep(100); - const hoverOnlyVisible = await page.$eval('#context-radio-mini', (mini) => ( - !mini.hidden && getComputedStyle(mini).display !== 'none' - )); - check( - 'hover alone does not disclose compact Radio controls', - hoverOnlyDisclosure.expanded === 'false' && hoverOnlyDisclosure.hidden && !hoverOnlyVisible, - JSON.stringify({ hoverOnlyDisclosure, hoverOnlyVisible }), - ); - await page.click('#context-radio-toggle-btn'); - await page.waitForFunction(() => { - const button = document.getElementById('context-radio-toggle-btn'); - const mini = document.getElementById('context-radio-mini'); - return button.getAttribute('aria-expanded') === 'true' - && !mini.hidden && getComputedStyle(mini).display !== 'none'; - }); - const compact = await page.evaluate(() => { - const mini = document.getElementById('context-radio-mini'); - const toggle = document.getElementById('context-radio-toggle-btn'); - const dock = document.getElementById('context-radio-dock'); - const miniRect = mini.getBoundingClientRect(); - const toggleRect = toggle.getBoundingClientRect(); - const miniStyle = getComputedStyle(mini); - return { - visible: miniStyle.display !== 'none' && miniRect.width > 0 && miniRect.height > 0, - display: miniStyle.display, - visibility: miniStyle.visibility, - opacity: miniStyle.opacity, - miniRect: { top: miniRect.top, left: miniRect.left, width: miniRect.width, height: miniRect.height }, - toggleRect: { top: toggleRect.top, left: toggleRect.left, width: toggleRect.width, height: toggleRect.height }, - dockRect: (() => { - const rect = dock.getBoundingClientRect(); - return { top: rect.top, left: rect.left, width: rect.width, height: rect.height }; - })(), - offsetParent: mini.offsetParent?.id || mini.offsetParent?.className || null, - prevEnabled: !document.getElementById('context-radio-mini-prev-btn').disabled, - playEnabled: !document.getElementById('context-radio-mini-play-btn').disabled, - playLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'), - nextEnabled: !document.getElementById('context-radio-mini-next-btn').disabled, - volumeEnabled: !document.getElementById('context-radio-mini-volume').disabled, - offsetBelowToggle: Math.round(miniRect.top - toggleRect.bottom), - }; - }); - check( - 'explicit launcher disclosure reveals enabled compact Radio controls', - compact.visible && compact.prevEnabled && compact.playEnabled && compact.nextEnabled && compact.volumeEnabled, - JSON.stringify(compact), - ); - check('compact Play is available before a station is selected', compact.playLabel === 'Play nearest radio station'); - check('compact Radio controls sit 20px below the launcher', compact.offsetBelowToggle === 20, `${compact.offsetBelowToggle}px`); - const compactCloseDisclosure = await page.evaluate(() => { - const launcher = document.getElementById('context-radio-toggle-btn'); - const close = document.getElementById('context-radio-mini-close-btn'); - close.focus(); - close.click(); - const result = { - launcherExpanded: launcher.getAttribute('aria-expanded'), - compactHidden: document.getElementById('context-radio-mini').hidden, - focusRestored: document.activeElement === launcher, - radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'), - }; - launcher.click(); - return result; - }); - check( - 'dedicated compact close hides only the compact Radio disclosure and restores launcher focus', - compactCloseDisclosure.launcherExpanded === 'false' - && compactCloseDisclosure.compactHidden - && compactCloseDisclosure.focusRestored - && compactCloseDisclosure.radioCollapsed, - JSON.stringify(compactCloseDisclosure), - ); - const compactTouchDisclosure = await page.evaluate(() => { - const button = document.getElementById('context-radio-toggle-btn'); - button.click(); - const closed = button.getAttribute('aria-expanded') === 'false' - && document.getElementById('context-radio-mini').hidden; - button.dispatchEvent(new PointerEvent('pointerdown', { - bubbles: true, - pointerType: 'touch', - isPrimary: true, - })); - button.click(); - return { - closed, - reopened: button.getAttribute('aria-expanded') === 'true' - && !document.getElementById('context-radio-mini').hidden, - }; - }); - check( - 'touch/coarse-pointer activation can close and reopen compact Radio controls', - compactTouchDisclosure.closed && compactTouchDisclosure.reopened, - JSON.stringify(compactTouchDisclosure), - ); const multiExpandedPanelLayout = await page.evaluate(async () => { const manager = window.__godsEyeView.styleManager; const ids = ['data-panel', 'scene-panel', 'pp-toggles', 'cctv-panel', 'global-context-panel']; @@ -2062,7 +1915,6 @@ async function main() { hudVariant: manager.hud.getVariant(), displayExpanded: display.getAttribute('aria-expanded') === 'true', radioExpanded: radio.getAttribute('aria-expanded') === 'true', - contextRadioExpanded: document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === 'true', focusId: document.activeElement?.id || null, detectionMode: manager.getDetectionState().detectionMode, models3dEnabled: manager._models3dEnabled, @@ -2287,8 +2139,7 @@ async function main() { document.getElementById('cockpit-radio-next-btn'), ].every((button) => button?.isConnected && !button.disabled); const detailsExcluded = !document.querySelector( - '#cockpit-radio-panel #context-radio-details-btn, ' - + '#cockpit-radio-panel #radio-filter, #cockpit-radio-panel #radio-tuner', + '#cockpit-radio-panel #radio-filter, #cockpit-radio-panel #radio-tuner', ); const scenesHidden = getComputedStyle(scene).display === 'none'; radio.click(); @@ -2381,7 +2232,6 @@ async function main() { manager.setPanelCollapsed('global-context-panel', prior.contextCollapsed); if (display.getAttribute('aria-expanded') !== String(prior.displayExpanded)) display.click(); if (radio.getAttribute('aria-expanded') !== String(prior.radioExpanded)) radio.click(); - manager._setRadioDisclosure(prior.contextRadioExpanded); hud.hidden = prior.hudHidden; signal.hidden = prior.signalHidden; if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop); @@ -2416,7 +2266,6 @@ async function main() { && document.getElementById('global-context-panel').classList.contains('collapsed') === prior.contextCollapsed && display.getAttribute('aria-expanded') === String(prior.displayExpanded) && radio.getAttribute('aria-expanded') === String(prior.radioExpanded) - && document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === String(prior.contextRadioExpanded) && hud.hidden === prior.hudHidden && signal.hidden === prior.signalHidden && intelHud.style.getPropertyValue('transition') === priorHudTransition @@ -2472,17 +2321,14 @@ async function main() { check('first Play scenario starts without a leaked Radio selection', firstPlayPrecondition === null, `selected=${firstPlayPrecondition}`); await page.select('#radio-filter', 'all'); - await page.$eval('#context-radio-toggle-btn', (button) => { - if (button.getAttribute('aria-expanded') !== 'true') button.click(); - }); await page.evaluate(() => { const camera = window.__godsEyeView.viewer.camera; window.__qaFirstPlayFlyToCalls = []; window.__qaFirstPlayOriginalFlyTo = camera.flyTo; camera.flyTo = (options) => window.__qaFirstPlayFlyToCalls.push(options); - document.getElementById('context-radio-toggle-btn').focus(); + document.getElementById('radio-play-btn').focus(); }); - await page.$eval('#context-radio-mini-play-btn', (button) => button.click()); + await page.$eval('#radio-play-btn', (button) => button.click()); await page.waitForFunction(() => { const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState(); return state.audioState === 'playing' || state.audioState === 'error'; @@ -2593,59 +2439,19 @@ async function main() { ); await page.select('#radio-filter', 'news'); - await page.$eval('#context-radio-mini-volume', (input) => { + await page.$eval('#radio-volume', (input) => { input.value = '35'; input.dispatchEvent(new Event('input', { bubbles: true })); }); - const compactVolume = await page.evaluate(() => ({ + const radioVolume = await page.evaluate(() => ({ volume: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().volume, full: document.getElementById('radio-volume').value, - mini: document.getElementById('context-radio-mini-volume').value, + label: document.getElementById('radio-volume-value').textContent, })); - check('compact volume shares the full Radio player state', Math.abs(compactVolume.volume - 0.35) < 0.001 && compactVolume.full === '35' && compactVolume.mini === '35'); - - const compactDisclosureContinuity = await page.evaluate(async () => { - const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - const manager = window.__godsEyeView.styleManager; - manager.setPanelCollapsed('global-context-panel', true); - manager._setRadioDisclosure(true); - await radio.play({ origin: 'user' }); - const before = radio.getUIState(); - const disclosure = document.getElementById('context-radio-toggle-btn'); - if (disclosure.getAttribute('aria-expanded') !== 'true') disclosure.click(); - disclosure.click(); - const closed = { - expanded: disclosure.getAttribute('aria-expanded'), - hidden: document.getElementById('context-radio-mini').hidden, - state: radio.getUIState(), - }; - disclosure.click(); - return { - before, - closed, - reopened: disclosure.getAttribute('aria-expanded'), - miniHidden: document.getElementById('context-radio-mini').hidden, - after: radio.getUIState(), - }; - }); check( - 'compact disclosure preserves playback, station, filter, and volume continuity', - compactDisclosureContinuity.closed.expanded === 'false' - && compactDisclosureContinuity.closed.hidden - && compactDisclosureContinuity.reopened === 'true' - && !compactDisclosureContinuity.miniHidden - && compactDisclosureContinuity.before.audioState === 'playing' - && compactDisclosureContinuity.closed.state.audioState === 'playing' - && compactDisclosureContinuity.after.audioState === 'playing' - && compactDisclosureContinuity.after.selected?.id === compactDisclosureContinuity.before.selected?.id - && compactDisclosureContinuity.after.filter === compactDisclosureContinuity.before.filter - && compactDisclosureContinuity.after.volume === compactDisclosureContinuity.before.volume, - JSON.stringify({ - before: compactDisclosureContinuity.before, - closed: compactDisclosureContinuity.closed, - reopened: compactDisclosureContinuity.reopened, - after: compactDisclosureContinuity.after, - }), + 'full Radio volume drives the Radio player state', + Math.abs(radioVolume.volume - 0.35) < 0.001 && radioVolume.full === '35' && radioVolume.label === '35%', + JSON.stringify(radioVolume), ); const voicePause = await page.evaluate(async () => { @@ -2709,11 +2515,13 @@ async function main() { idleVoice.audioState === 'paused', idleVoice.audioState, ); - const manualPlayTarget = await page.evaluate(() => { + const manualPlayTarget = await page.evaluate(async () => { const manager = window.__godsEyeView.styleManager; - manager.setPanelCollapsed('global-context-panel', true); - manager._setRadioDisclosure(true); - const button = document.getElementById('context-radio-mini-play-btn'); + manager.setPanelCollapsed('global-context-panel', false); + manager.setPanelCollapsed('radio-panel', false); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const button = document.getElementById('radio-play-btn'); + button.scrollIntoView({ block: 'nearest' }); const rect = button.getBoundingClientRect(); const style = getComputedStyle(button); return { @@ -2728,12 +2536,12 @@ async function main() { }; }); check( - 'manual voice takeover targets the visible compact Radio Play control', + 'manual voice takeover targets the visible full Radio Play control', manualPlayTarget.visible && manualPlayTarget.enabled, JSON.stringify(manualPlayTarget), ); if (manualPlayTarget.visible && manualPlayTarget.enabled) { - await page.click('#context-radio-mini-play-btn'); + await page.click('#radio-play-btn'); await page.waitForFunction(() => ( window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing' && window.__godsEyeView.voiceCommands.status === 'idle' @@ -2781,7 +2589,6 @@ async function main() { await page.evaluate(() => window.__godsEyeView.voiceCommands.setStatus('idle', 'QA complete')); await page.evaluate(async () => { const manager = window.__godsEyeView.styleManager; - manager._setRadioDisclosure(false); manager.setPanelCollapsed('global-context-panel', false); manager.setPanelCollapsed('radio-panel', false); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); @@ -3835,22 +3642,22 @@ async function main() { gev.dataManager.layers.get('flights').enabled = true; }); await sleep(500); - await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true)); await page.evaluate(() => { - const button = document.getElementById('context-radio-toggle-btn'); - if (button.getAttribute('aria-expanded') !== 'true') button.click(); + const manager = window.__godsEyeView.styleManager; + manager.setPanelCollapsed('global-context-panel', false); + manager.setPanelCollapsed('radio-panel', false); }); - await page.waitForSelector('#context-radio-mini', { visible: true }); + await page.waitForSelector('#radio-next-btn', { visible: true }); await page.evaluate(() => { window.__qaRadioCameraHeight = window.__godsEyeView.viewer.camera.positionCartographic.height; }); const radioOnlyNextDispatched = await page.evaluate(() => { - const button = document.getElementById('context-radio-mini-next-btn'); + const button = document.getElementById('radio-next-btn'); if (!button) return false; button.click(); return true; }); - if (!radioOnlyNextDispatched) throw new Error('Radio compact next button unavailable'); + if (!radioOnlyNextDispatched) throw new Error('Radio next button unavailable'); await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function'); await page.evaluate(() => { const viewer = window.__godsEyeView.viewer; @@ -3885,7 +3692,7 @@ async function main() { flyToCalls: window.__qaRadioFlyToCalls.length, flightTargets, cameraHeight: window.__qaRadioCameraHeight, - compactPlayLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'), + playLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'), regularMarkerSize: Array.from({ length: window.__godsEyeView.viewer.dataSources.length }, (_, index) => window.__godsEyeView.viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations')?.entities.values[0]?.point?.pixelSize?.getValue(), selectedMarkerSize: selectedEntity?.point?.pixelSize?.getValue(), @@ -3898,7 +3705,7 @@ async function main() { }; }); check( - 'compact Next follows stable directory order and retries its next available station', + 'Radio Next follows stable directory order and retries its next available station', playing.state.selected?.id === playing.expectedFallback?.id && playing.calls[0] === playing.expectedPrimary?.streamUrl && playing.calls[1] === playing.expectedFallback?.streamUrl, @@ -3909,9 +3716,9 @@ async function main() { fallback: playing.expectedFallback?.id, }), ); - check('compact next is explicit playback and updates play/pause state', playing.calls.length === 2 && playing.compactPlayLabel.startsWith('Pause')); + check('Radio next is explicit playback and updates play/pause state', playing.calls.length === 2 && playing.playLabel.startsWith('Pause')); check( - 'compact next rotates toward its station without changing zoom, including fallback', + 'Radio next rotates toward its station without changing zoom, including fallback', playing.flyToCalls === 2 && Math.abs(playing.flightTargets[0]?.lat - playing.expectedPrimary?.lat) < 0.001 && Math.abs(playing.flightTargets[0]?.lon - playing.expectedPrimary?.lon) < 0.001 @@ -3950,11 +3757,11 @@ async function main() { }; return [ await run('flights', 'radio-next-btn'), - await run('military', 'context-radio-mini-prev-btn'), + await run('military', 'radio-prev-btn'), ]; }); check( - 'full and compact Radio navigation preserve Flights/Military camera ownership', + 'full Radio Next and Prev navigation preserve Flights/Military camera ownership', trackedRadioControls.every((sample) => sample.afterStationId && sample.afterStationId !== sample.beforeStationId && sample.flyToDelta === 0 @@ -3968,7 +3775,7 @@ async function main() { viewer.trackedEntity = undefined; window.__qaRadioDelayNextFailure = true; const beforeFlyToCalls = window.__qaRadioFlyToCalls.length; - document.getElementById('context-radio-mini-next-btn')?.click(); + document.getElementById('radio-next-btn')?.click(); return { beforeFlyToCalls }; }); await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function'); @@ -4244,7 +4051,7 @@ async function main() { window.__godsEyeView.dataManager.layers.get('flights').enabled = false; }); const radioOnlyNextClicked = await page.evaluate(() => { - const button = document.getElementById('context-radio-mini-next-btn'); + const button = document.getElementById('radio-next-btn'); if (!(button instanceof HTMLButtonElement)) return false; button.click(); return true; @@ -4508,106 +4315,12 @@ async function main() { && companion.radioCollapsed && !companion.contextCollapsed && companion.nested, ); - const expandedContextRadioBefore = await page.evaluate(() => { - const gev = window.__godsEyeView; - const scroller = document.querySelector('#global-context-panel .global-context-panel-inner'); - scroller.scrollTop = 0; - const camera = gev.viewer.camera.positionWC; - return { - scrollTop: scroller.scrollTop, - pageY: window.scrollY, - camera: { x: camera.x, y: camera.y, z: camera.z }, - plays: window.__qaRadioPlayCalls.length, - }; - }); - await page.click('#context-radio-toggle-btn'); - await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed')); - await page.waitForFunction((priorScroll) => ( - document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel' - && document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll - ), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop); - const expandedContextRadioAfter = await page.evaluate(() => { - const gev = window.__godsEyeView; - gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState()); - const launcher = document.getElementById('context-radio-toggle-btn'); - const radio = document.getElementById('radio-panel'); - const scroller = document.querySelector('#global-context-panel .global-context-panel-inner'); - const camera = gev.viewer.camera.positionWC; - return { - ariaControls: launcher.getAttribute('aria-controls'), - expanded: launcher.getAttribute('aria-expanded'), - label: launcher.getAttribute('aria-label'), - radioCollapsed: radio.classList.contains('collapsed'), - compactHidden: document.getElementById('context-radio-mini').hidden, - scrollTop: scroller.scrollTop, - focusTarget: document.activeElement?.getAttribute('data-collapse-target'), - pageY: window.scrollY, - camera: { x: camera.x, y: camera.y, z: camera.z }, - plays: window.__qaRadioPlayCalls.length, - state: gev.dataManager.layers.get('radio').module.getUIState().audioState, - }; - }); - const expandedContextRadioCameraDelta = Math.hypot( - expandedContextRadioAfter.camera.x - expandedContextRadioBefore.camera.x, - expandedContextRadioAfter.camera.y - expandedContextRadioBefore.camera.y, - expandedContextRadioAfter.camera.z - expandedContextRadioBefore.camera.z, - ); - check( - 'expanded Context Radio icon reveals and scrolls to embedded Radio without an overlay or playback/camera theft', - expandedContextRadioAfter.ariaControls === 'radio-panel' - && expandedContextRadioAfter.expanded === 'true' - && expandedContextRadioAfter.label === 'Go to expanded Radio section' - && !expandedContextRadioAfter.radioCollapsed - && expandedContextRadioAfter.compactHidden - && expandedContextRadioAfter.scrollTop > expandedContextRadioBefore.scrollTop - && expandedContextRadioAfter.focusTarget === 'radio-panel' - && expandedContextRadioAfter.pageY === expandedContextRadioBefore.pageY - && expandedContextRadioCameraDelta < 0.01 - && expandedContextRadioAfter.plays === expandedContextRadioBefore.plays - && expandedContextRadioAfter.state === 'playing', - JSON.stringify({ - before: expandedContextRadioBefore, - after: expandedContextRadioAfter, - cameraDelta: expandedContextRadioCameraDelta, - }), - ); - await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; - manager.setPanelCollapsed('global-context-panel', true); - manager._setRadioDisclosure(true); + manager.setPanelCollapsed('global-context-panel', false); + manager.setPanelCollapsed('radio-panel', false); }); - await page.waitForFunction(() => { - const close = document.getElementById('context-radio-mini-close-btn'); - const rect = close.getBoundingClientRect(); - return !document.getElementById('context-radio-mini').hidden && rect.width > 0 && rect.height > 0; - }); - await page.click('#context-radio-mini-close-btn'); - const closedCompact = await page.evaluate(() => { - const launcher = document.getElementById('context-radio-toggle-btn'); - return { - radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'), - compactHidden: document.getElementById('context-radio-mini').hidden, - focusRestored: document.activeElement === launcher, - }; - }); - await page.click('#context-radio-toggle-btn'); - await page.waitForFunction(() => !document.getElementById('context-radio-mini').hidden); - await page.click('#context-radio-details-btn'); - const repeatedOpen = await page.evaluate(() => ({ - radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'), - compactHidden: document.getElementById('context-radio-mini').hidden, - })); - const splitDetailActions = { closedCompact, repeatedOpen }; - check( - 'collapsed Context keeps compact close and full-panel open as separate stable actions', - !splitDetailActions.closedCompact.radioCollapsed - && splitDetailActions.closedCompact.compactHidden - && splitDetailActions.closedCompact.focusRestored - && !splitDetailActions.repeatedOpen.radioCollapsed - && splitDetailActions.repeatedOpen.compactHidden, - JSON.stringify(splitDetailActions), - ); + await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed')); await page.click('#global-context-flights-btn'); await page.waitForFunction(() => !document.getElementById('context-flights-view').hidden); diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 0b376d9fc..56ff53ef5 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -227,13 +227,9 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc ); }); -test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => { +test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => { const keydown = cockpitKeyDown.toString(); assert.ok(keydown, 'Cockpit keyboard handler is missing'); - assert.match( - keydown, - /document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/, - ); assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/); assert.match( keydown, @@ -898,7 +894,7 @@ test('Cockpit Radio station changes preserve first-person camera ownership', () ); assert.match( radioBindings, - /listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/, + /listen\(this\._radioNextBtn, 'click', \(\) => cycleRadio\(1\)\)/, ); assert.match( radioBindings, diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 6994459a7..bfb1c283f 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -129,7 +129,7 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs', test('Context async action buttons remain focused while busy', () => { const init = _initGlobalContextPanel.toString(); - const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,')); + const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._cockpitRadioToggleBtn,')); const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)')); const clear = clearSelectedLayers.toString(); @@ -137,7 +137,7 @@ test('Context async action buttons remain focused while busy', () => { 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']) { + for (const name of ['_radioEnableBtn', '_cockpitRadioEnableBtn']) { assert.match(radioSync, new RegExp(`${name}\\.disabled = false`)); } assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index d76a48458..4ab6b2bf7 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -374,7 +374,7 @@ test('an overlay with NO class to watch still disarms the launcher', () => { assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/); }); -test('one ESC does one thing — the radio disclosure stops the launcher outright', () => { +test('one ESC does one thing — a Cockpit utility disclosure stops the launcher outright', () => { const ui = fs.readFileSync( new URL('./ui/radioBindings.js', import.meta.url), 'utf8', @@ -384,8 +384,8 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh // disclosure's earlier capture handler closed the disclosure and the launcher // dismissed itself off the same key. The earlier listener is the only one that // can stop the later one — and only the immediate form does it. - const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||")); - const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false')); + const utilityEsc = ui.slice(ui.indexOf('if (!displayOpen && !radioOpen) return;')); + const claim = utilityEsc.slice(0, utilityEsc.indexOf('setCockpitDisclosure(kind, false')); assert.match(claim, /event\.preventDefault\(\);/); assert.match(claim, /event\.stopImmediatePropagation\(\);/); assert.doesNotMatch( diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 696233f86..86aa8799b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -198,21 +198,13 @@ "AVAILABLE MISSIONS": "MISIONES DISPONIBLES", "SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR", "LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS", - "Open compact Radio controls": "Abrir los controles compactos de Radio", - "Compact Radio controls": "Controles compactos de Radio", "RADIO": "RADIO", - "RADIO READY": "RADIO LISTA", - "Open detailed Radio controls": "Abrir los controles detallados de Radio", - "Close compact Radio controls": "Cerrar los controles compactos de Radio", "ENABLE": "ACTIVAR", "Previous filtered radio station": "Emisora de radio filtrada anterior", - "Previous station": "Emisora anterior", "Play selected radio station": "Reproducir la emisora de radio seleccionada", "Play": "Reproducir", "Next filtered radio station": "Siguiente emisora de radio filtrada", - "Next station": "Siguiente emisora", "VOLUME": "VOLUMEN", - "Compact Radio volume": "Volumen de la Radio compacta", "Cockpit display and Radio controls": "Controles de visualización y Radio de la cabina", "Expand Cockpit display options": "Expandir las opciones de visualización de la cabina", "Cockpit display options": "Opciones de visualización de la cabina", @@ -357,8 +349,6 @@ "selected": "seleccionada", "nearest": "más cercana", "{action} {target} radio station": "{action} la emisora de radio {target}", - "RADIO STATE UNCERTAIN": "ESTADO DE RADIO SIN CONFIRMAR", - "SYNCING DIRECTORY": "SINCRONIZANDO DIRECTORIO", "SYNCING": "SINCRONIZANDO", "station": "emisora", "Ready": "Listo", @@ -2442,8 +2432,6 @@ "Exit cockpit to fly to a route": "Sal de la cabina para volar a una ruta", "Exit cockpit to fly to a file": "Sal de la cabina para volar a un archivo", "Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena", - "Go to expanded Radio section": "Ir a la sección de Radio expandida", - "Expand Radio section in Context": "Expandir la sección de Radio en Contexto", "New scene name": "Nombre de la nueva escena", "Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?", "Detailed outline unavailable": "Contorno detallado no disponible", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 8764d9d06..7cc126794 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -553,8 +553,6 @@ "Expand Cockpit display options": "Развернуть параметры экрана кабины", "Collapse Cockpit Radio controls": "Свернуть управление радио кабины", "Expand Cockpit Radio controls": "Развернуть управление радио кабины", - "Close compact Radio controls": "Закрыть компактное управление радио", - "Open compact Radio controls": "Открыть компактное управление радио", "DIRECTORY BAND": "ДИАПАЗОН КАТАЛОГА", "{category} BAND": "ДИАПАЗОН {category}", "NO STATION SELECTED": "СТАНЦИЯ НЕ ВЫБРАНА", @@ -568,9 +566,6 @@ "selected": "выбранную", "nearest": "ближайшую", "{action} {target} radio station": "{action} {target} радиостанцию", - "RADIO STATE UNCERTAIN": "СОСТОЯНИЕ РАДИО НЕОПРЕДЕЛЁННО", - "SYNCING DIRECTORY": "СИНХРОНИЗАЦИЯ КАТАЛОГА", - "RADIO READY": "РАДИО ГОТОВО", "SYNCING": "СИНХРОНИЗАЦИЯ", "READY": "ГОТОВО", "station": "станция", @@ -710,9 +705,6 @@ "FLIR": "FLIR", "Style": "Стиль", "STATION SITE": "САЙТ СТАНЦИИ", - "Open detailed Radio controls": "Открыть подробное управление радио", - "Previous station": "Предыдущая станция", - "Next station": "Следующая станция", "Expand panel": "Развернуть панель", "CONTACTS": "КОНТАКТЫ", "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Перебирает ближайшие контакты выбранного типа — самолёты, суда, объекты. Спутники отслеживаются отдельно.", @@ -724,13 +716,11 @@ "Next filtered station": "Следующая отфильтрованная станция", "STOP": "СТОП", "Stop radio playback": "Остановить воспроизведение радио", - "Compact Radio controls": "Компактное управление радио", "Context mode": "Режим контекста", "Contact Context actions": "Действия контекста контакта", "Available Space Missions": "Доступные космические миссии", "LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ", "Radio playback": "Воспроизведение радио", - "Compact Radio volume": "Громкость компактного радио", "Radio volume": "Громкость радио", "STATION TAG": "ТЕГ СТАНЦИИ", "DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ", @@ -2483,8 +2473,6 @@ "Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту", "Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу", "Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене", - "Go to expanded Radio section": "Перейти к развёрнутому разделу радио", - "Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»", "New scene name": "Название новой сцены", "Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?", "Detailed outline unavailable": "Подробный контур недоступен", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 2e7db6b8f..e3b8b1ccf 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1860,10 +1860,6 @@ "STATION UNAVAILABLE": "电台不可用", "Station unavailable after directory refresh": "目录刷新后该电台不可用", "Radio could not {action} cleanly": "电台未能正常{action}", - "Go to expanded Radio section": "前往展开的电台区域", - "Expand Radio section in Context": "在态势面板中展开电台区域", - "Close compact Radio controls": "关闭精简电台控件", - "Open compact Radio controls": "打开精简电台控件", "SYNC": "同步", "RECONCILE": "核对", "DISABLE": "停用", @@ -1885,9 +1881,6 @@ "selected": "所选", "nearest": "最近", "{action} {target} radio station": "{action}{target}电台", - "RADIO STATE UNCERTAIN": "电台状态不确定", - "SYNCING DIRECTORY": "正在同步目录", - "RADIO READY": "电台就绪", "SYNCING": "同步中", "READY": "就绪", "station": "电台", @@ -2551,11 +2544,6 @@ "RECENT IMAGERY": "近期影像", "Imagery days in the box": "视野内的影像天数", "CONTEXT": "态势", - "Compact Radio controls": "精简电台控件", - "Open detailed Radio controls": "打开详细电台控件", - "Previous station": "上一个电台", - "Next station": "下一个电台", - "Compact Radio volume": "精简电台音量", "Expand panel": "展开面板", "Context mode": "态势模式", "CONTACTS": "目标", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 1477acd51..827ec5b8d 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1848,10 +1848,6 @@ "STATION UNAVAILABLE": "電臺不可用", "Station unavailable after directory refresh": "目錄刷新後該電臺不可用", "Radio could not {action} cleanly": "電臺未能正常{action}", - "Go to expanded Radio section": "前往展開的電臺區域", - "Expand Radio section in Context": "在態勢面板中展開電臺區域", - "Close compact Radio controls": "關閉精簡電臺控件", - "Open compact Radio controls": "打開精簡電臺控件", "SYNC": "同步", "RECONCILE": "核對", "DISABLE": "停用", @@ -1873,9 +1869,6 @@ "selected": "所選", "nearest": "最近", "{action} {target} radio station": "{action}{target}電臺", - "RADIO STATE UNCERTAIN": "電臺狀態不確定", - "SYNCING DIRECTORY": "正在同步目錄", - "RADIO READY": "電臺就緒", "SYNCING": "同步中", "READY": "就緒", "station": "電臺", @@ -2539,11 +2532,6 @@ "RECENT IMAGERY": "近期影像", "Imagery days in the box": "視野內的影像天數", "CONTEXT": "態勢", - "Compact Radio controls": "精簡電臺控件", - "Open detailed Radio controls": "打開詳細電臺控件", - "Previous station": "上一個電臺", - "Next station": "下一個電臺", - "Compact Radio volume": "精簡電臺音量", "Expand panel": "展開面板", "Context mode": "態勢模式", "CONTACTS": "目標", diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index d3d5cfc6d..b906ecf67 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -166,11 +166,6 @@ test('Cockpit utility Escape leaves an expanded nested Parameters panel to the s /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( - radioBindings, - /_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/, - 'compact Radio disclosure clears its own focus when Escape closes it', - ); }); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index dfe5b4257..ebd40da4f 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -249,7 +249,7 @@ test('no unchanged Realtime tool definition drifts silently', () => { }); test('Radio volume and mission speed share the Sharpen slider visual language', () => { - for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume', 'sdr-volume']) { + for (const id of ['cockpit-radio-volume', 'radio-volume', 'sdr-volume']) { assert.match( html, new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`), @@ -269,22 +269,17 @@ test('Radio volume and mission speed share the Sharpen slider visual language', assert.doesNotMatch(css, /#space-mission-panel \[data-mission-replay-speed\]::-webkit-slider-thumb/); }); -test('Radio is nested inside Context with separate disclosure and power controls', () => { +test('Radio is nested inside Context with its own disclosure and power controls', () => { const contextStart = html.indexOf('id="global-context-panel"'); const radioStart = html.indexOf('id="radio-panel"'); const contextEnd = html.indexOf('\n ', contextStart); assert.ok(contextStart >= 0 && radioStart > contextStart && radioStart < contextEnd); assert.match(html, /id="radio-panel"[^>]*data-panel-id="radio-panel"/); assert.match(html, /aria-label="Radio playback"/); - assert.match(html, /id="context-radio-toggle-btn"[^>]*aria-expanded="false"[^>]*aria-controls="context-radio-mini"/); - assert.doesNotMatch(html, /id="context-radio-toggle-btn"[^>]*aria-pressed=/); - assert.match(html, /id="context-radio-mini"[^>]*aria-label="Compact Radio controls"[^>]*hidden/); - assert.match(html, /id="context-radio-mini-enable-btn"[^>]*aria-pressed="false"/); - assert.match(html, /id="context-radio-details-btn"[^>]*aria-expanded="false"[^>]*aria-controls="radio-panel"/); - assert.match(html, /id="context-radio-details-btn"[\s\S]*?