mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge remote-tracking branch 'origin/main' into claude/hal-627-starfield-cb6fa2
This commit is contained in:
commit
a1a9a36f54
48 files changed
+2368
-277
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
- Prevent malformed Overpass filters from blocking the local server, and reject
|
||||||
|
cross-site or excessive query requests before reading their bodies
|
||||||
|
(asturksever, #1264; fixes #1248).
|
||||||
|
- Release unread responses from the optional Google-token probe so keyless
|
||||||
|
browser validation can settle; the Overpass gate recognizes only that probe's
|
||||||
|
expected 404 as an unavailable capability (asturksever, #1264).
|
||||||
|
- Make tracking arrival checks respect the aircraft update cadence while checking
|
||||||
|
orientation on the first reveal, avoiding false failures at faster render
|
||||||
|
rates (asturksever, #1264).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Give ordinary Scene and CCTV actions shared native buttons with consistent
|
||||||
|
default, destructive and compact styling while preserving their existing
|
||||||
|
behavior and specialized controls (mazy1998, #1302).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Share native dropdown and numeric-field styling across Scene, CCTV, Display
|
||||||
|
and Radio controls, with a shared CCTV number-input builder that preserves
|
||||||
|
existing precision, validation and commit behavior (mazy1998, #1305).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Share read-only status badge presentation across CCTV source/frame labels,
|
||||||
|
calibration provenance and Weather coverage, preserving status meanings,
|
||||||
|
palettes, translations and announcements while preparing the view contract
|
||||||
|
for a future Vue migration (mazy1998, #1306).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Clarify shared field CSS ownership: CCTV owns calibration editor sizing,
|
||||||
|
while Radio and Cyber supply explicit field palettes without changing the
|
||||||
|
current appearance or native control behavior (mazy1998, #1309).
|
||||||
@@ -374,6 +374,15 @@ renderer, persistence or services. The facade retains panel visibility, share
|
|||||||
restore claims and render scheduling. Both controls are destroyed before the
|
restore claims and render scheduling. Both controls are destroyed before the
|
||||||
facade's asynchronous teardown can yield.
|
facade's asynchronous teardown can yield.
|
||||||
|
|
||||||
|
## Ordinary action buttons
|
||||||
|
|
||||||
|
`src/ui/actionButton.js` is a native, listener-free view helper used by Scene
|
||||||
|
presentation and sharing-toolbar owners. It receives translated text and explicit
|
||||||
|
presentation/accessibility properties. Feature controllers own action dispatch,
|
||||||
|
state, persistence and cleanup. Static Scenes/CCTV templates use the same CSS
|
||||||
|
surface and preserve IDs and layout hooks. The helper imports no application,
|
||||||
|
renderer, storage or services; it belongs to the consuming package closures.
|
||||||
|
|
||||||
## Display controls
|
## Display controls
|
||||||
|
|
||||||
`ui/display` owns Display button, selector and slider subscriptions. It receives
|
`ui/display` owns Display button, selector and slider subscriptions. It receives
|
||||||
@@ -729,3 +738,32 @@ sources supply `{records, droppedCount, status, saturated}`; viewport filtering,
|
|||||||
exact-bound retry and rendering stay in the layer. Legacy cache saturation is
|
exact-bound retry and rendering stay in the layer. Legacy cache saturation is
|
||||||
decoded in the source. Source and geometry modules remain independent of Cesium,
|
decoded in the source. Source and geometry modules remain independent of Cesium,
|
||||||
DOM and platform middleware.
|
DOM and platform middleware.
|
||||||
|
|
||||||
|
### Native form fields
|
||||||
|
|
||||||
|
`src/ui/formField.js` constructs native numeric inputs without listeners or
|
||||||
|
application state. Static selects and number inputs use the same `gev-field`
|
||||||
|
surface, with explicit compact and inline densities. Feature owners retain
|
||||||
|
option identity/order, bounds, parsing, draft values, input/change timing and
|
||||||
|
teardown. CCTV effective-pose conversion and tuner/device operations remain
|
||||||
|
feature responsibilities. A Vue replacement can reuse this contract and CSS;
|
||||||
|
it replaces DOM construction, rather than adding a second state owner.
|
||||||
|
|
||||||
|
The shared stylesheet owns field surfaces and disabled opacity; feature and theme
|
||||||
|
palettes use `--gev-field-background`, `--gev-field-border-color`,
|
||||||
|
`--gev-field-color` and `--gev-field-disabled-opacity`. Radio retains its own
|
||||||
|
compact density and arrow space. CCTV owns calibration editor width in
|
||||||
|
`cctv.css`; the shared inline density does not impose a feature-specific width.
|
||||||
|
Cyber supplies a shared-field palette explicitly and keeps its legacy native
|
||||||
|
field rules separate, so Radio's palette remains an intentional exception.
|
||||||
|
|
||||||
|
### Read-only status badges
|
||||||
|
|
||||||
|
`src/ui/statusBadge.js` constructs/updates ordinary badges without listeners,
|
||||||
|
status inference or live-region policy. It consumes translated text and controlled
|
||||||
|
visibility; appearance variants share CSS rather than mapping machine states to
|
||||||
|
universal severity. CCTV owns frame/source and calibration rules and their data
|
||||||
|
attributes, while Weather owns coverage labels. The keyed rail-card owner keeps
|
||||||
|
node identity, header focus, disclosure and teardown. The shared adapter never
|
||||||
|
creates another state store or announces all card updates. A Vue view can replace
|
||||||
|
this adapter and retain these presentation inputs and styles.
|
||||||
+86
-6
@@ -1251,6 +1251,58 @@ Feature owners retain bounds, steps, formatted values, input/change timing and
|
|||||||
listener cleanup. The radio tuning dial, imagery comparison divider and event
|
listener cleanup. The radio tuning dial, imagery comparison divider and event
|
||||||
comparison/progression controls keep their specialized presentation and behavior.
|
comparison/progression controls keep their specialized presentation and behavior.
|
||||||
|
|
||||||
|
## Shared control CSS ownership
|
||||||
|
|
||||||
|
Shared styles own ordinary control surfaces and interaction states; feature CSS
|
||||||
|
owns surrounding layout and context-specific sizing. CCTV's calibration editor
|
||||||
|
width lives in `ui/styles/cctv.css`, while the shared inline field variant owns
|
||||||
|
only density and spinner treatment. Radio supplies its field palette and disabled
|
||||||
|
opacity through the `--gev-field-*` variables, retaining compact typography and
|
||||||
|
arrow space locally. Cyber supplies shared-field palette variables explicitly;
|
||||||
|
its legacy native-field palette rules exclude `.gev-field`. These ownership
|
||||||
|
boundaries preserve the current Tactical/Cyber appearance and native behavior.
|
||||||
|
|
||||||
|
## Ordinary status badges
|
||||||
|
|
||||||
|
CCTV source/frame labels, calibration provenance and Weather coverage labels use
|
||||||
|
`ui/statusBadge.js` and the `gev-status-badge` CSS surface. The listener-free
|
||||||
|
adapter accepts already-translated `label` and `hidden` values; pill, box and plain
|
||||||
|
appearances describe shape/density only. Static CCTV markup uses the same classes
|
||||||
|
as generated rail-card badges. Repeated identical updates do not rewrite text or
|
||||||
|
replace nodes. Empty coverage remains hidden.
|
||||||
|
|
||||||
|
Feature owners retain the status rules, machine-state attributes, translations
|
||||||
|
and announcement policy. CCTV frame acquisition (`idle/loading/error/ready`)
|
||||||
|
remains separate from provider status: retained pixels do not imply a healthy
|
||||||
|
feed. Calibration still distinguishes manually saved, catalog-curated and raw
|
||||||
|
prior poses; unsaved edits override the label until save/reset. Coverage labels
|
||||||
|
remain geographic scope, not health claims. Existing colors and the source
|
||||||
|
badge's polite live region are preserved; coverage and calibration do not gain
|
||||||
|
live regions. Interactive layer toggles, progress indicators and world overlays
|
||||||
|
retain their specialized ownership. Vue can reuse the view contract and CSS while
|
||||||
|
replacing DOM construction. Browser acceptance:
|
||||||
|
`QA_BASE_URL=http://127.0.0.1:4189 node scripts/qa-status-badges.mjs`.
|
||||||
|
|
||||||
|
## Ordinary action buttons
|
||||||
|
|
||||||
|
Scene panel actions, shot LOAD/DEL actions, the EDIT DETAILS / SHARE SCENE
|
||||||
|
bar and one-shot CCTV actions use `ui/actionButton.js` and the shared
|
||||||
|
`gev-action-button` surface in `ui/styles/action-button.css`. Static templates
|
||||||
|
apply the same classes as generated buttons. The native builder accepts translated
|
||||||
|
text, default/danger variants, default/compact sizes and disabled/accessibility
|
||||||
|
properties; it installs no listeners and owns no application state. Feature
|
||||||
|
controllers retain callbacks, enable/disable rules, confirmation prompts,
|
||||||
|
persistence and teardown. The smaller shot actions retain their existing sizing
|
||||||
|
and hover treatment. Feature CSS still owns panel layout and translated wrapping.
|
||||||
|
|
||||||
|
CCTV enable, coverage, auto-hop, projection and adjustment toggles, editable
|
||||||
|
calibration-value chips and the scene authoring dialogs retain their specialized
|
||||||
|
controls. Existing `scene-btn` hooks remain compatible with Street Level and
|
||||||
|
legacy toggles. The shared CSS, explicit input/action boundary and behavior tests
|
||||||
|
can carry to a Vue view while Vue replaces native DOM construction.
|
||||||
|
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs`;
|
||||||
|
`qa-scene-controls.mjs` also covers real Scene authoring/playback with native Space capture.
|
||||||
|
|
||||||
## Segmented selectors
|
## Segmented selectors
|
||||||
|
|
||||||
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
|
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
|
||||||
@@ -2817,11 +2869,16 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
||||||
> camera batches still wait for their sequential requests, and unresolved
|
> camera batches still wait for their sequential requests, and unresolved
|
||||||
> placement continues to use the existing prior until real heights arrive.
|
> placement continues to use the existing prior until real heights arrive.
|
||||||
> - **Overpass cache admission:** `/api/overpass` parses and sanitizes requests,
|
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
|
||||||
> then checks fresh memory, identical in-flight work, and fresh disk entries
|
> POSTs through the shared local-request gate, then limits requests to 300/min
|
||||||
> before invoking its local 90/min limiter. Cache and single-flight responses
|
> per socket peer and 900/min globally before reading or validating bodies.
|
||||||
> therefore do not spend quota; upstream-bound misses retain the existing
|
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
|
||||||
> limiter, configured-upstream, stale, and sanitization behavior. An unconfigured transport serves dated stale data or a non-retryable capability error.
|
> It then checks fresh memory, identical in-flight work, and fresh disk entries
|
||||||
|
> before invoking its separate local 90/min upstream limiter. Cache and
|
||||||
|
> single-flight responses therefore do not spend upstream quota; upstream-bound
|
||||||
|
> misses retain the existing limiter, configured-upstream, stale, and sanitization
|
||||||
|
> behavior. An unconfigured transport serves dated stale data or a non-retryable
|
||||||
|
> capability error.
|
||||||
> - **CCTV world-click focus:** clicking an in-world CCTV icon or ambient card
|
> - **CCTV world-click focus:** clicking an in-world CCTV icon or ambient card
|
||||||
> activates it and routes the camera flight through the panel FOCUS policy.
|
> activates it and routes the camera flight through the panel FOCUS policy.
|
||||||
> Aircraft/satellite tracking releases outside cockpit; cockpit retains the
|
> Aircraft/satellite tracking releases outside cockpit; cockpit retains the
|
||||||
@@ -4637,6 +4694,12 @@ Retry-After; absent delays back off from 30 seconds to five minutes. Empty
|
|||||||
remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized.
|
remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized.
|
||||||
Consumer capability misses never schedule automatic retries.
|
Consumer capability misses never schedule automatic retries.
|
||||||
|
|
||||||
|
Keyless browser validation also probes the optional Google tiles-token route.
|
||||||
|
The token client cancels rejected response bodies before returning unavailable,
|
||||||
|
so they do not remain pending in browser network observation. The Overpass gate
|
||||||
|
accepts only that same-origin route's expected 404 console diagnostic; other
|
||||||
|
HTTP and JavaScript errors still fail acceptance.
|
||||||
|
|
||||||
Operator-configured Overpass area and focus-footprint queries use body-level
|
Operator-configured Overpass area and focus-footprint queries use body-level
|
||||||
`out geom` (with `center` for focus), preserving relation member outlines.
|
`out geom` (with `center` for focus), preserving relation member outlines.
|
||||||
Member geometry is printed only inside a box around the point (6 km for
|
Member geometry is printed only inside a box around the point (6 km for
|
||||||
@@ -5085,7 +5148,7 @@ pins and share links already supply complete boxes.
|
|||||||
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
||||||
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
||||||
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
||||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||||
|
|
||||||
### TR-3B conversion Easter egg (August 2026)
|
### TR-3B conversion Easter egg (August 2026)
|
||||||
|
|
||||||
@@ -5956,3 +6019,20 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye
|
|||||||
|
|
||||||
Military fragments stitch by source identity. Different mapped parcels merge only
|
Military fragments stitch by source identity. Different mapped parcels merge only
|
||||||
when their footprints touch or contain one another, never across a seam tolerance gap.
|
when their footprints touch or contain one another, never across a seam tolerance gap.
|
||||||
|
|
||||||
|
### Shared native dropdowns and numeric fields
|
||||||
|
|
||||||
|
- Scene and CCTV selects share the default `gev-field` surface. HUD layout and
|
||||||
|
draw colour retain compact density. Radio filter, tuner gain and FM frequency
|
||||||
|
use the same base while retaining their feature-specific skins and layout.
|
||||||
|
- The CCTV inline number editor uses `createNumberInput` with its existing
|
||||||
|
precision and inline density. Enter/blur commit, Escape cancellation, frozen
|
||||||
|
camera base, camera-switch guards and disposal remain with CCTV controls.
|
||||||
|
- Options, values, native keyboard/typeahead, disabled attributes and browser
|
||||||
|
validity remain native. Frequency bounds, tuner commit timing and calibration
|
||||||
|
parsing are unchanged. Specialized tuner graphics and calibration chips
|
||||||
|
retain their own controls. Provider settings and scene-authoring dialogs are
|
||||||
|
outside this change.
|
||||||
|
- `scripts/qa-form-fields.mjs` exercises native selections, number validity,
|
||||||
|
calibration commit/cancel and stale-camera/disposal guards against an installed
|
||||||
|
controller with fixture ports, plus default/Cyber and narrow translated layouts.
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
".prettierrc.json",
|
".prettierrc.json",
|
||||||
"package.json",
|
"package.json",
|
||||||
"scripts/format-scope.json",
|
"scripts/format-scope.json",
|
||||||
|
"src/ui/styles/form-field.css",
|
||||||
|
"scripts/qa-form-fields.mjs",
|
||||||
"scripts/package-boundaries.json",
|
"scripts/package-boundaries.json",
|
||||||
"scripts/format.mjs",
|
"scripts/format.mjs",
|
||||||
"scripts/next-layer-state-token.mjs",
|
"scripts/next-layer-state-token.mjs",
|
||||||
@@ -281,6 +283,7 @@
|
|||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
"src/ui/sceneControls.test.mjs",
|
"src/ui/sceneControls.test.mjs",
|
||||||
"scripts/qa-scene-controls.mjs",
|
"scripts/qa-scene-controls.mjs",
|
||||||
|
"scripts/qa-action-buttons.mjs",
|
||||||
"src/ui.js",
|
"src/ui.js",
|
||||||
"src/standalone/ui.js",
|
"src/standalone/ui.js",
|
||||||
"src/ui/applicationShell.js",
|
"src/ui/applicationShell.js",
|
||||||
@@ -305,6 +308,7 @@
|
|||||||
"src/ui/styles/layers.css",
|
"src/ui/styles/layers.css",
|
||||||
"src/ui/styles/controls.css",
|
"src/ui/styles/controls.css",
|
||||||
"src/ui/styles/slider.css",
|
"src/ui/styles/slider.css",
|
||||||
|
"src/ui/styles/action-button.css",
|
||||||
"src/ui/styles/cctv.css",
|
"src/ui/styles/cctv.css",
|
||||||
"src/ui/styles/foundation.css",
|
"src/ui/styles/foundation.css",
|
||||||
"src/ui/styles/first-run.css",
|
"src/ui/styles/first-run.css",
|
||||||
@@ -1076,5 +1080,11 @@
|
|||||||
"src/tooling/skyAssets.test.mjs",
|
"src/tooling/skyAssets.test.mjs",
|
||||||
"src/tooling/skyAssets.paintOut.test.mjs",
|
"src/tooling/skyAssets.paintOut.test.mjs",
|
||||||
"scripts/qa-anime-sky.mjs",
|
"scripts/qa-anime-sky.mjs",
|
||||||
"scripts/qa-sky-model-lighting.mjs"
|
"scripts/qa-sky-model-lighting.mjs",
|
||||||
|
"server/providers/overpass/requestAdmission.test.mjs",
|
||||||
|
"src/tooling/trackArrivalObservation.test.mjs",
|
||||||
|
"src/ui/styles/status-badge.css",
|
||||||
|
"src/ui/statusBadge.test.mjs",
|
||||||
|
"src/ui/cctvBadges.test.mjs",
|
||||||
|
"scripts/qa-status-badges.mjs"
|
||||||
]
|
]
|
||||||
@@ -259,6 +259,7 @@
|
|||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
"server/providers/common/rate-limit.js",
|
"server/providers/common/rate-limit.js",
|
||||||
"server/providers/common/request.js",
|
"server/providers/common/request.js",
|
||||||
|
"server/providers/common/same-site.js",
|
||||||
"server/providers/overpass.js",
|
"server/providers/overpass.js",
|
||||||
"server/providers/overpass/cache.js",
|
"server/providers/overpass/cache.js",
|
||||||
"server/providers/overpass/constants.js",
|
"server/providers/overpass/constants.js",
|
||||||
@@ -268,6 +269,7 @@
|
|||||||
"server/providers/places/routes.js",
|
"server/providers/places/routes.js",
|
||||||
"src/data/placeProviderPayloads.js",
|
"src/data/placeProviderPayloads.js",
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
|
"src/localRequestGate.mjs",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/sources/overpass.js",
|
"src/sources/overpass.js",
|
||||||
@@ -505,7 +507,8 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/ui/unitLabels.js",
|
"src/ui/unitLabels.js",
|
||||||
"src/ui/keyGuidance.js",
|
"src/ui/keyGuidance.js",
|
||||||
"src/ui/statusText.js"
|
"src/ui/statusText.js",
|
||||||
|
"src/ui/statusBadge.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -546,6 +549,7 @@
|
|||||||
"src/ui/cctv.js",
|
"src/ui/cctv.js",
|
||||||
"src/ui/cctvBindings.js",
|
"src/ui/cctvBindings.js",
|
||||||
"src/ui/cctvCalibration.js",
|
"src/ui/cctvCalibration.js",
|
||||||
|
"src/ui/formField.js",
|
||||||
"src/ui/cctvControls.js",
|
"src/ui/cctvControls.js",
|
||||||
"src/ui/cctvFrames.js",
|
"src/ui/cctvFrames.js",
|
||||||
"src/ui/cctvVideo.js",
|
"src/ui/cctvVideo.js",
|
||||||
@@ -554,7 +558,8 @@
|
|||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/ui/unitLabels.js"
|
"src/ui/unitLabels.js",
|
||||||
|
"src/ui/statusBadge.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -621,6 +626,7 @@
|
|||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
|
"src/ui/actionButton.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
@@ -682,6 +688,7 @@
|
|||||||
"src/ui/cctv.js",
|
"src/ui/cctv.js",
|
||||||
"src/ui/cctvBindings.js",
|
"src/ui/cctvBindings.js",
|
||||||
"src/ui/cctvCalibration.js",
|
"src/ui/cctvCalibration.js",
|
||||||
|
"src/ui/formField.js",
|
||||||
"src/ui/cctvControls.js",
|
"src/ui/cctvControls.js",
|
||||||
"src/ui/cctvFrames.js",
|
"src/ui/cctvFrames.js",
|
||||||
"src/ui/cctvVideo.js",
|
"src/ui/cctvVideo.js",
|
||||||
@@ -744,6 +751,7 @@
|
|||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
|
"src/ui/actionButton.js",
|
||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
"src/ui/shareRestoration.js",
|
"src/ui/shareRestoration.js",
|
||||||
"src/ui/shellElements.js",
|
"src/ui/shellElements.js",
|
||||||
@@ -778,7 +786,8 @@
|
|||||||
"src/ui/unitLabels.js",
|
"src/ui/unitLabels.js",
|
||||||
"src/ui/keyGuidance.js",
|
"src/ui/keyGuidance.js",
|
||||||
"src/ui/layerNames.js",
|
"src/ui/layerNames.js",
|
||||||
"src/ui/statusText.js"
|
"src/ui/statusText.js",
|
||||||
|
"src/ui/statusBadge.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -856,6 +865,7 @@
|
|||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
|
"src/ui/actionButton.js",
|
||||||
"src/contextModePolicy.js",
|
"src/contextModePolicy.js",
|
||||||
"src/director/clock.js",
|
"src/director/clock.js",
|
||||||
"src/director/timeline.js",
|
"src/director/timeline.js",
|
||||||
@@ -2160,6 +2170,7 @@
|
|||||||
"src/ui/cctv.js",
|
"src/ui/cctv.js",
|
||||||
"src/ui/cctvBindings.js",
|
"src/ui/cctvBindings.js",
|
||||||
"src/ui/cctvCalibration.js",
|
"src/ui/cctvCalibration.js",
|
||||||
|
"src/ui/formField.js",
|
||||||
"src/ui/cctvControls.js",
|
"src/ui/cctvControls.js",
|
||||||
"src/ui/cctvFrames.js",
|
"src/ui/cctvFrames.js",
|
||||||
"src/ui/cctvVideo.js",
|
"src/ui/cctvVideo.js",
|
||||||
@@ -2223,6 +2234,7 @@
|
|||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
|
"src/ui/actionButton.js",
|
||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
"src/ui/shareRestoration.js",
|
"src/ui/shareRestoration.js",
|
||||||
"src/ui/shellElements.js",
|
"src/ui/shellElements.js",
|
||||||
@@ -2515,7 +2527,8 @@
|
|||||||
"src/voice/providerCapabilities.js",
|
"src/voice/providerCapabilities.js",
|
||||||
"src/voice/geminiSession.js",
|
"src/voice/geminiSession.js",
|
||||||
"src/voice/geminiAudio.js",
|
"src/voice/geminiAudio.js",
|
||||||
"src/voice/geminiCaptureWorklet.js"
|
"src/voice/geminiCaptureWorklet.js",
|
||||||
|
"src/ui/statusBadge.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Scenes/CCTV action-button acceptance against dev or production preview:
|
||||||
|
* QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs
|
||||||
|
* Uses the installed CCTV controller with fixture camera/action ports to prove
|
||||||
|
* native dispatch/disabled/teardown without providers or world calibration writes.
|
||||||
|
* Real Scene authoring/playback remains covered by qa-scene-controls.mjs.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
let failures = 0;
|
||||||
|
const check = (name, passed, detail) => {
|
||||||
|
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||||
|
if (!passed) {
|
||||||
|
failures++;
|
||||||
|
console.log(JSON.stringify(detail));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const shots = 'qa-shots/action-buttons';
|
||||||
|
fs.mkdirSync(shots, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
await page.goto(
|
||||||
|
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4179'}/?welcome=0`,
|
||||||
|
{ waitUntil: 'domcontentloaded' },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
const inventory = await page.evaluate(() => {
|
||||||
|
const ids = [
|
||||||
|
'scene-new-btn',
|
||||||
|
'scene-delete-btn',
|
||||||
|
'scene-capture-btn',
|
||||||
|
'scene-update-shot-btn',
|
||||||
|
'scene-start-btn',
|
||||||
|
'scene-stop-btn',
|
||||||
|
'scene-next-btn',
|
||||||
|
'scene-export-btn',
|
||||||
|
'scene-import-btn',
|
||||||
|
'scene-download-btn',
|
||||||
|
'cctv-nearest-btn',
|
||||||
|
'cctv-prev-btn',
|
||||||
|
'cctv-next-btn',
|
||||||
|
'cctv-focus-btn',
|
||||||
|
'cctv-calib-save-btn',
|
||||||
|
'cctv-calib-reset-btn',
|
||||||
|
];
|
||||||
|
const ordinary = ids.map((id) => document.getElementById(id));
|
||||||
|
const generated = [
|
||||||
|
...document.querySelectorAll(
|
||||||
|
'.scene-shot-btn, [data-director-authoring] button',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const toggles = [
|
||||||
|
'cctv-enable-btn',
|
||||||
|
'cctv-coverage-btn',
|
||||||
|
'cctv-auto-hop-btn',
|
||||||
|
'cctv-projection-btn',
|
||||||
|
'cctv-adjust-btn',
|
||||||
|
].map((id) => document.getElementById(id));
|
||||||
|
return {
|
||||||
|
shared: [...ordinary, ...generated].every(
|
||||||
|
(node) =>
|
||||||
|
node?.type === 'button' &&
|
||||||
|
node.classList.contains('gev-action-button'),
|
||||||
|
),
|
||||||
|
generated: generated.length,
|
||||||
|
layout: ordinary.every((node) => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
return style.minWidth === '72px' && style.flexGrow === '1';
|
||||||
|
}),
|
||||||
|
togglesSeparate: toggles.every(
|
||||||
|
(node) => node && !node.classList.contains('gev-action-button'),
|
||||||
|
),
|
||||||
|
compactDanger: !!document.querySelector(
|
||||||
|
'.scene-shot-danger.gev-action-button--compact.gev-action-button--danger',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'static and generated actions share the native component',
|
||||||
|
inventory.shared && inventory.generated >= 4 && inventory.compactDanger,
|
||||||
|
inventory,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'persistent CCTV toggles retain their existing controls',
|
||||||
|
inventory.togglesSeparate,
|
||||||
|
);
|
||||||
|
check('ordinary actions retain their panel sizing', inventory.layout);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
const owner = manager._cctvControls;
|
||||||
|
owner._cctvUnsubscribe?.();
|
||||||
|
owner._cctvUnsubscribe = null;
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
const record = (...args) => window.__qaButtonCalls.push(args);
|
||||||
|
owner.actions = {
|
||||||
|
...owner.actions,
|
||||||
|
isEnabled: () => true,
|
||||||
|
toggleEnabled: async () => {
|
||||||
|
record('enable');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
runExplicitFocus: (select, focus) => focus(select()),
|
||||||
|
setParams: (params, options) => record('params', params, options),
|
||||||
|
showToast: () => {},
|
||||||
|
};
|
||||||
|
owner.cctv = {
|
||||||
|
focusNearest: () => {
|
||||||
|
record('nearest');
|
||||||
|
return 'a';
|
||||||
|
},
|
||||||
|
cycleCamera: (direction) => {
|
||||||
|
record('cycle', direction);
|
||||||
|
return 'b';
|
||||||
|
},
|
||||||
|
focusCamera: (id, seconds) => record('focus', id, seconds),
|
||||||
|
};
|
||||||
|
window.__qaCameraState = {
|
||||||
|
enabled: true,
|
||||||
|
activeCameraId: 'a',
|
||||||
|
cameras: [
|
||||||
|
{ id: 'a', city: 'Fixture', name: 'A' },
|
||||||
|
{ id: 'b', city: 'Fixture', name: 'B' },
|
||||||
|
],
|
||||||
|
activeCamera: { id: 'a', name: 'A', basePose: {} },
|
||||||
|
};
|
||||||
|
owner._renderCctvState(window.__qaCameraState);
|
||||||
|
manager.setPanelCollapsed('cctv-panel', false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.focus('#cctv-next-btn');
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||||
|
check(
|
||||||
|
'native Space dispatches Next and its focus once',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
JSON.stringify(window.__qaButtonCalls) ===
|
||||||
|
JSON.stringify([['enable'], ['cycle', 1], ['focus', 'b', 1.4]]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'native keyboard activation keeps a visible focus ring',
|
||||||
|
await page.$eval(
|
||||||
|
'#cctv-next-btn',
|
||||||
|
(node) =>
|
||||||
|
document.activeElement === node &&
|
||||||
|
getComputedStyle(node).outlineStyle === 'solid',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
});
|
||||||
|
await page.focus('#cctv-prev-btn');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||||
|
check(
|
||||||
|
'native Enter dispatches Previous once',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
JSON.stringify(window.__qaButtonCalls) ===
|
||||||
|
JSON.stringify([['enable'], ['cycle', -1], ['focus', 'b', 1.4]]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [id, expected] of [
|
||||||
|
['cctv-nearest-btn', [['enable'], ['nearest'], ['focus', 'a', 1.8]]],
|
||||||
|
[
|
||||||
|
'cctv-focus-btn',
|
||||||
|
[
|
||||||
|
['enable'],
|
||||||
|
['focus', 'a', 1.9],
|
||||||
|
['params', { selectedCameraId: 'a' }, { origin: 'user' }],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
});
|
||||||
|
await page.click(`#${id}`);
|
||||||
|
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||||
|
const calls = await page.evaluate(() => window.__qaButtonCalls);
|
||||||
|
check(
|
||||||
|
`${id} retains its existing action and focus timing`,
|
||||||
|
JSON.stringify(calls) === JSON.stringify(expected),
|
||||||
|
calls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
});
|
||||||
|
await page.click('#cctv-calib-save-btn');
|
||||||
|
await page.click('#cctv-calib-reset-btn');
|
||||||
|
const calibration = await page.evaluate(() => window.__qaButtonCalls);
|
||||||
|
check(
|
||||||
|
'Save and Reset retain camera identity and user-origin dispatch',
|
||||||
|
JSON.stringify(calibration) ===
|
||||||
|
JSON.stringify([
|
||||||
|
[
|
||||||
|
'params',
|
||||||
|
{ selectedCameraId: 'a', calibration: { cameraId: 'a', save: true } },
|
||||||
|
{ origin: 'user' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'params',
|
||||||
|
{
|
||||||
|
selectedCameraId: 'a',
|
||||||
|
calibration: { cameraId: 'a', reset: true },
|
||||||
|
},
|
||||||
|
{ origin: 'user' },
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
calibration,
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
window.__godsEyeView.styleManager._cctvControls._renderCctvState({
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.click('#cctv-next-btn');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const node = document.getElementById('cctv-next-btn');
|
||||||
|
return node.disabled && Number(getComputedStyle(node).opacity) === 0.45;
|
||||||
|
},
|
||||||
|
{ timeout: 3000 },
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'disabled actions stay inert and keep disabled presentation',
|
||||||
|
await page.$eval(
|
||||||
|
'#cctv-next-btn',
|
||||||
|
(node) =>
|
||||||
|
node.disabled &&
|
||||||
|
getComputedStyle(node).opacity === '0.45' &&
|
||||||
|
window.__qaButtonCalls.length === 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const width of [1440, 620, 390]) {
|
||||||
|
await page.setViewport({ width, height: 900 });
|
||||||
|
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||||
|
await page.evaluate((panel) => {
|
||||||
|
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
}, panel);
|
||||||
|
const fit = await page.$eval(`#${panel}`, (root) => {
|
||||||
|
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||||
|
const rect = root.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
count: buttons.length,
|
||||||
|
fits:
|
||||||
|
buttons.length > 0 &&
|
||||||
|
buttons.every((button) => {
|
||||||
|
const box = button.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
box.width > 0 &&
|
||||||
|
box.left >= rect.left &&
|
||||||
|
box.right <= rect.right + 1 &&
|
||||||
|
box.left >= 0 &&
|
||||||
|
box.right <= innerWidth + 1 &&
|
||||||
|
button.scrollWidth <= button.clientWidth
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(`${panel} actions fit at ${width}px`, fit.fits, fit);
|
||||||
|
if (width !== 620)
|
||||||
|
await page.screenshot({ path: `${shots}/${panel}-${width}.png` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||||
|
owner.destroy();
|
||||||
|
document.getElementById('cctv-next-btn').disabled = false;
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
});
|
||||||
|
await page.click('#cctv-next-btn');
|
||||||
|
check(
|
||||||
|
'destroyed controllers revoke action listeners',
|
||||||
|
await page.evaluate(() => window.__qaButtonCalls.length === 0),
|
||||||
|
);
|
||||||
|
const localized = new URL(page.url());
|
||||||
|
localized.searchParams.set('lang', 'ru');
|
||||||
|
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||||
|
await page.evaluate((panel) => {
|
||||||
|
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
}, panel);
|
||||||
|
const translated = await page.$eval(`#${panel}`, (root) => {
|
||||||
|
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||||
|
return (
|
||||||
|
document.documentElement.lang === 'ru' &&
|
||||||
|
buttons.length > 0 &&
|
||||||
|
buttons.every((node) => node.scrollWidth <= node.clientWidth)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
check(`Russian ${panel} action labels fit at 390px`, translated);
|
||||||
|
await page.screenshot({ path: `${shots}/${panel}-ru-390.png` });
|
||||||
|
}
|
||||||
|
check('no uncaught browser errors', errors.length === 0, errors);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
console.log(`RESULT: ${failures} failures`);
|
||||||
|
process.exitCode = failures ? 1 : 0;
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Native form-field regression gate. CCTV ports are fixtures, never live writes. */
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
page.setDefaultNavigationTimeout(60000);
|
||||||
|
let failures = 0;
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const check = (name, passed, detail) => {
|
||||||
|
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||||
|
if (!passed) {
|
||||||
|
failures++;
|
||||||
|
console.log(JSON.stringify(detail));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fs.mkdirSync('qa-shots/form-fields', { recursive: true });
|
||||||
|
try {
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
await page.goto(
|
||||||
|
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4183'}/?welcome=0`,
|
||||||
|
{
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
const inventory = await page.evaluate(() => {
|
||||||
|
const selects = [
|
||||||
|
'scene-select',
|
||||||
|
'cctv-camera-select',
|
||||||
|
'hud-layout-select',
|
||||||
|
'draw-color-select',
|
||||||
|
'radio-filter',
|
||||||
|
'sdr-gain-select',
|
||||||
|
].map((id) => document.getElementById(id));
|
||||||
|
return {
|
||||||
|
ids: selects.map((node) => node.id),
|
||||||
|
shared:
|
||||||
|
selects.length === 6 &&
|
||||||
|
selects.every((node) => node.classList.contains('gev-field')),
|
||||||
|
native: selects.every(
|
||||||
|
(node) =>
|
||||||
|
node instanceof HTMLSelectElement && !node.hasAttribute('role'),
|
||||||
|
),
|
||||||
|
dark: selects.every(
|
||||||
|
(node) => getComputedStyle(node).colorScheme === 'dark',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'all six panel dropdowns retain native select semantics and shared surface',
|
||||||
|
inventory.shared && inventory.native,
|
||||||
|
inventory,
|
||||||
|
);
|
||||||
|
check('native option menus keep the dark scheme', inventory.dark);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
manager.setPanelCollapsed('pp-toggles', false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
manager.setPanelCollapsed('scene-panel', false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
manager.setPanelCollapsed('cctv-panel', false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
window.__qaFormChanges = [];
|
||||||
|
document
|
||||||
|
.getElementById('scene-select')
|
||||||
|
.addEventListener('change', (event) =>
|
||||||
|
window.__qaFormChanges.push(event.target.value),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const ownerSetup = await page.evaluate(() => {
|
||||||
|
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||||
|
owner._cctvUnsubscribe?.();
|
||||||
|
owner._cctvUnsubscribe = null;
|
||||||
|
window.__qaFormCalls = [];
|
||||||
|
owner.actions = {
|
||||||
|
...owner.actions,
|
||||||
|
isEnabled: () => true,
|
||||||
|
toggleEnabled: async () => true,
|
||||||
|
setParams: (...args) => window.__qaFormCalls.push(args),
|
||||||
|
runExplicitFocus: (select, focus) => focus(select()),
|
||||||
|
showToast: () => {},
|
||||||
|
};
|
||||||
|
owner.cctv = {
|
||||||
|
setParams() {},
|
||||||
|
focusCamera: (id) => window.__qaFormCalls.push(['focus', id]),
|
||||||
|
selectCamera: (id) => id,
|
||||||
|
};
|
||||||
|
owner._cctvState = {
|
||||||
|
enabled: true,
|
||||||
|
activeCameraId: 'a',
|
||||||
|
cameras: [
|
||||||
|
{ id: 'a', name: 'A' },
|
||||||
|
{ id: 'b', name: 'B' },
|
||||||
|
],
|
||||||
|
activeCamera: {
|
||||||
|
id: 'a',
|
||||||
|
headingDeg: 30,
|
||||||
|
pitchDeg: -10,
|
||||||
|
fovDeg: 45,
|
||||||
|
rangeM: 120,
|
||||||
|
mountHeightM: 8,
|
||||||
|
calibration: {},
|
||||||
|
basePose: {
|
||||||
|
headingDeg: 20,
|
||||||
|
pitchDeg: -10,
|
||||||
|
fovDeg: 45,
|
||||||
|
rangeM: 120,
|
||||||
|
mountHeightM: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
owner._renderCctvState(owner._cctvState);
|
||||||
|
const input = document.getElementById('sdr-frequency-input');
|
||||||
|
return {
|
||||||
|
min: input.min,
|
||||||
|
max: input.max,
|
||||||
|
step: input.step,
|
||||||
|
type: input.type,
|
||||||
|
shared: input.classList.contains('gev-field'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'FM frequency retains native bounds, precision and shared surface',
|
||||||
|
ownerSetup.min === '87.5' &&
|
||||||
|
ownerSetup.max === '108' &&
|
||||||
|
ownerSetup.step === '0.1' &&
|
||||||
|
ownerSetup.type === 'number' &&
|
||||||
|
ownerSetup.shared,
|
||||||
|
ownerSetup,
|
||||||
|
);
|
||||||
|
const validity = await page.$eval('#sdr-frequency-input', (input) => {
|
||||||
|
const original = input.value;
|
||||||
|
input.value = '87.4';
|
||||||
|
const below = input.validity.rangeUnderflow;
|
||||||
|
input.value = '108.1';
|
||||||
|
const above = input.validity.rangeOverflow;
|
||||||
|
input.value = '98.55';
|
||||||
|
const step = input.validity.stepMismatch;
|
||||||
|
input.value = '';
|
||||||
|
const empty = input.validity.valid;
|
||||||
|
input.value = original;
|
||||||
|
return below && above && step && empty;
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'native numeric validity preserves underflow, overflow, steps and empty drafts',
|
||||||
|
validity,
|
||||||
|
);
|
||||||
|
await page.select('#cctv-camera-select', 'b');
|
||||||
|
await page.waitForFunction(() => window.__qaFormCalls.length === 2);
|
||||||
|
const cameraSelection = await page.evaluate(() => window.__qaFormCalls);
|
||||||
|
check(
|
||||||
|
'camera dropdown retains selection, focus and user-origin dispatch',
|
||||||
|
cameraSelection[0][0] === 'focus' &&
|
||||||
|
cameraSelection[0][1] === 'b' &&
|
||||||
|
cameraSelection[1][0].selectedCameraId === 'b' &&
|
||||||
|
cameraSelection[1][1].origin === 'user',
|
||||||
|
cameraSelection,
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaFormCalls = [];
|
||||||
|
document.getElementById('cctv-camera-select').disabled = true;
|
||||||
|
});
|
||||||
|
await page.focus('#scene-select');
|
||||||
|
await page.focus('#cctv-camera-select');
|
||||||
|
check(
|
||||||
|
'disabled native selects cannot receive keyboard focus',
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.activeElement.id !== 'cctv-camera-select',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.getElementById('cctv-camera-select').disabled = false;
|
||||||
|
});
|
||||||
|
const begin = async (field) => {
|
||||||
|
await page.evaluate((field) => {
|
||||||
|
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||||
|
owner._beginCctvCalValueEdit(
|
||||||
|
document.querySelector(`[data-cal-field="${field}"]`),
|
||||||
|
);
|
||||||
|
}, field);
|
||||||
|
};
|
||||||
|
for (const [field, step, value] of [
|
||||||
|
['heading', '0.1', '30.0'],
|
||||||
|
['pitch', '0.1', '-10.0'],
|
||||||
|
['fov', '1', '45'],
|
||||||
|
['range', '1', '120'],
|
||||||
|
['height', '1', '8'],
|
||||||
|
['north', '0.1', '0.0'],
|
||||||
|
['east', '0.1', '0.0'],
|
||||||
|
]) {
|
||||||
|
await begin(field);
|
||||||
|
const result = await page.$eval('.cctv-cal-input', (input) => ({
|
||||||
|
step: input.step,
|
||||||
|
value: input.value,
|
||||||
|
native: input.type === 'number',
|
||||||
|
shared: input.classList.contains('gev-field'),
|
||||||
|
label: input.getAttribute('aria-label'),
|
||||||
|
focus: document.activeElement === input,
|
||||||
|
}));
|
||||||
|
check(
|
||||||
|
`${field} editor retains precision, initial value and focus`,
|
||||||
|
result.step === step &&
|
||||||
|
result.value === value &&
|
||||||
|
result.native &&
|
||||||
|
result.shared &&
|
||||||
|
!!result.label &&
|
||||||
|
result.focus,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
'Escape cancels each field without a calibration write',
|
||||||
|
await page.evaluate(() => window.__qaFormCalls.length === 0),
|
||||||
|
);
|
||||||
|
await begin('heading');
|
||||||
|
await page.keyboard.press('Backspace');
|
||||||
|
await page.type('.cctv-cal-input', '100');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCamera.basePose.headingDeg = 60;
|
||||||
|
});
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
const commit = await page.evaluate(() => window.__qaFormCalls);
|
||||||
|
check(
|
||||||
|
'Enter commits once against the frozen camera base',
|
||||||
|
commit.length === 1 &&
|
||||||
|
commit[0][0].calibration.patch.headingDeg === 80 &&
|
||||||
|
commit[0][0].calibration.cameraId === 'a' &&
|
||||||
|
commit[0][1].origin === 'user',
|
||||||
|
commit,
|
||||||
|
);
|
||||||
|
await begin('north');
|
||||||
|
await page.keyboard.press('Backspace');
|
||||||
|
await page.type('.cctv-cal-input', '12.5');
|
||||||
|
await page.$eval('.cctv-cal-input', (input) => input.blur());
|
||||||
|
const blur = await page.evaluate(() => window.__qaFormCalls);
|
||||||
|
check(
|
||||||
|
'blur commits a decimal offset exactly once',
|
||||||
|
blur.length === 2 && blur[1][0].calibration.patch.offsetNorthM === 12.5,
|
||||||
|
blur,
|
||||||
|
);
|
||||||
|
await begin('east');
|
||||||
|
await page.keyboard.press('Backspace');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
check(
|
||||||
|
'an empty numeric draft does not write calibration',
|
||||||
|
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||||
|
);
|
||||||
|
await begin('heading');
|
||||||
|
await page.type('.cctv-cal-input', '90');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCameraId =
|
||||||
|
'b';
|
||||||
|
});
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
check(
|
||||||
|
'a stale camera editor cannot commit to the next camera',
|
||||||
|
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||||
|
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
await page.$eval(
|
||||||
|
'#draw-toggle',
|
||||||
|
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await page.click('#draw-toggle');
|
||||||
|
for (const lang of ['en']) {
|
||||||
|
await page.evaluate((lang) => {
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}, lang);
|
||||||
|
for (const width of [1440, 620, 390]) {
|
||||||
|
await page.setViewport({ width, height: 900 });
|
||||||
|
const layout = await page.evaluate(() => {
|
||||||
|
const ids = [
|
||||||
|
'scene-select',
|
||||||
|
'cctv-camera-select',
|
||||||
|
'hud-layout-select',
|
||||||
|
'draw-color-select',
|
||||||
|
];
|
||||||
|
return ids.map((id) => {
|
||||||
|
const node = document.getElementById(id),
|
||||||
|
rect = node.getBoundingClientRect(),
|
||||||
|
panel = node.closest('.panel-collapsible')?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
width: rect.width,
|
||||||
|
fits: rect.width > 0 && rect.width <= (panel?.width || innerWidth),
|
||||||
|
radius: getComputedStyle(node).borderRadius,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
`${lang} fields fit their panels at ${width}px`,
|
||||||
|
layout.every((item) => item.fits),
|
||||||
|
layout,
|
||||||
|
);
|
||||||
|
if (width === 390)
|
||||||
|
await page.screenshot({
|
||||||
|
path: `qa-shots/form-fields/${lang}-${width}.png`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.lang = 'en';
|
||||||
|
document.documentElement.dataset.uiTheme = 'cyber';
|
||||||
|
});
|
||||||
|
const cyber = await page.$eval(
|
||||||
|
'#scene-select',
|
||||||
|
(node) => getComputedStyle(node).backgroundColor,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'Cyber keeps its native field theme',
|
||||||
|
cyber === 'rgba(26, 35, 39, 0.96)',
|
||||||
|
cyber,
|
||||||
|
);
|
||||||
|
await begin('heading');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||||
|
window.__qaDetachedInput = document.querySelector('.cctv-cal-input');
|
||||||
|
owner.destroy();
|
||||||
|
window.__qaDetachedInput.dispatchEvent(new Event('blur'));
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'disposal removes the editor and prevents late commits',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
!document.querySelector('.cctv-cal-input') &&
|
||||||
|
window.__qaFormCalls.length === 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Scene selection can restore application settings. Exercise it after the
|
||||||
|
// fixture-owned CCTV phase so those asynchronous effects cannot replace ports.
|
||||||
|
page.once('dialog', (dialog) => dialog.accept('Baseline selection fixture'));
|
||||||
|
await page.click('#scene-new-btn');
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
[...document.querySelectorAll('#scene-select option')].some(
|
||||||
|
(option) => option.textContent === 'Baseline selection fixture',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
page.once('dialog', (dialog) => dialog.accept('QA selection fixture'));
|
||||||
|
await page.click('#scene-new-btn');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelectorAll('#scene-select option').length > 1,
|
||||||
|
);
|
||||||
|
const targetSceneId = await page.$eval(
|
||||||
|
'#scene-select',
|
||||||
|
(node) =>
|
||||||
|
[...node.options].find(
|
||||||
|
(option) => option.textContent === 'QA selection fixture',
|
||||||
|
).value,
|
||||||
|
);
|
||||||
|
const baselineSceneId = await page.$eval(
|
||||||
|
'#scene-select',
|
||||||
|
(node) =>
|
||||||
|
[...node.options].find(
|
||||||
|
(option) => option.textContent === 'Baseline selection fixture',
|
||||||
|
).value,
|
||||||
|
);
|
||||||
|
await page.select('#scene-select', baselineSceneId);
|
||||||
|
await page.waitForFunction(
|
||||||
|
(id) => window.__godsEyeView.sceneDirector._getSelectedScene().id !== id,
|
||||||
|
{},
|
||||||
|
targetSceneId,
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaFormChanges = [];
|
||||||
|
});
|
||||||
|
await page.focus('#scene-select');
|
||||||
|
// Typeahead is native even when headless Chrome does not drive OS popups.
|
||||||
|
await page.keyboard.press('q');
|
||||||
|
const sceneSelection = await page.evaluate(() => ({
|
||||||
|
id: document.getElementById('scene-select').value,
|
||||||
|
selected: window.__godsEyeView.sceneDirector._getSelectedScene().id,
|
||||||
|
changes: window.__qaFormChanges,
|
||||||
|
focus: document.activeElement.id,
|
||||||
|
outline: getComputedStyle(document.activeElement).outlineStyle,
|
||||||
|
}));
|
||||||
|
check(
|
||||||
|
'native typeahead reaches the Scene owner and keeps focus',
|
||||||
|
sceneSelection.id === targetSceneId &&
|
||||||
|
sceneSelection.selected === targetSceneId &&
|
||||||
|
sceneSelection.changes.at(-1) === targetSceneId &&
|
||||||
|
sceneSelection.focus === 'scene-select',
|
||||||
|
sceneSelection,
|
||||||
|
);
|
||||||
|
check('keyboard focus remains visible', sceneSelection.outline === 'solid');
|
||||||
|
const localized = new URL(page.url());
|
||||||
|
localized.searchParams.set('lang', 'ru');
|
||||||
|
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||||
|
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.evaluate(() => {
|
||||||
|
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||||
|
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
await page.$eval(
|
||||||
|
'#draw-toggle',
|
||||||
|
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await page.click('#draw-toggle');
|
||||||
|
const translated = await page.evaluate(() => {
|
||||||
|
const nodes = [
|
||||||
|
'scene-select',
|
||||||
|
'cctv-camera-select',
|
||||||
|
'hud-layout-select',
|
||||||
|
'draw-color-select',
|
||||||
|
].map((id) => document.getElementById(id));
|
||||||
|
return (
|
||||||
|
document.documentElement.lang === 'ru' &&
|
||||||
|
nodes.every(
|
||||||
|
(node) =>
|
||||||
|
node.clientWidth > 0 &&
|
||||||
|
node.getBoundingClientRect().width <=
|
||||||
|
node.closest('.panel-collapsible').getBoundingClientRect().width,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
check('real Russian labels and fields fit at 390px', translated);
|
||||||
|
await page.screenshot({ path: 'qa-shots/form-fields/ru-390.png' });
|
||||||
|
check('no uncaught browser errors', errors.length === 0, errors);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
process.exitCode = failures ? 1 : 0;
|
||||||
@@ -306,7 +306,16 @@ try {
|
|||||||
await observeNetwork(page);
|
await observeNetwork(page);
|
||||||
page.on('pageerror', (error) => result.errors.push(error.message));
|
page.on('pageerror', (error) => result.errors.push(error.message));
|
||||||
page.on('console', (message) => {
|
page.on('console', (message) => {
|
||||||
if (message.type() === 'error' && result.consoleErrors.length < 30)
|
if (message.type() !== 'error') return;
|
||||||
|
// A keyless server does not offer optional Google tokens. Keep every other
|
||||||
|
// HTTP or JavaScript error visible, including failures of this route other
|
||||||
|
// than its expected capability-miss 404.
|
||||||
|
const optionalTokenMiss =
|
||||||
|
message.location().url === new URL('/api/google/tiles-token', url).href &&
|
||||||
|
/^Failed to load resource: the server responded with a status of 404\b/.test(
|
||||||
|
message.text(),
|
||||||
|
);
|
||||||
|
if (!optionalTokenMiss && result.consoleErrors.length < 30)
|
||||||
result.consoleErrors.push(message.text());
|
result.consoleErrors.push(message.text());
|
||||||
});
|
});
|
||||||
page.on('requestfailed', (request) => {
|
page.on('requestfailed', (request) => {
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ try {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await page.click('#scene-capture-btn');
|
await page.focus('#scene-capture-btn');
|
||||||
|
await page.keyboard.press('Space');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.querySelectorAll('.scene-shot-row').length === 1,
|
() => document.querySelectorAll('.scene-shot-row').length === 1,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Badge acceptance on dev/preview. Camera state is a disposable controller fixture. */
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
page.setDefaultNavigationTimeout(60000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
let checks = 0;
|
||||||
|
let failures = 0;
|
||||||
|
const check = (name, passed, detail) => {
|
||||||
|
checks++;
|
||||||
|
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||||
|
if (!passed) {
|
||||||
|
failures++;
|
||||||
|
console.log(JSON.stringify(detail));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const out = 'qa-shots/status-badges';
|
||||||
|
fs.mkdirSync(out, { recursive: true });
|
||||||
|
try {
|
||||||
|
for (const lang of ['en', 'ru']) {
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
await page.goto(
|
||||||
|
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4189'}/?welcome=0&lang=${lang}`,
|
||||||
|
{ waitUntil: 'domcontentloaded' },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { dataManager, styleManager: manager } = window.__godsEyeView;
|
||||||
|
await dataManager.setEnabled('wind', true);
|
||||||
|
manager.setPanelCollapsed('weather-panel', false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
manager.setPanelCollapsed('cctv-panel', false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
const owner = manager._cctvControls;
|
||||||
|
owner._cctvUnsubscribe?.();
|
||||||
|
owner._cctvUnsubscribe = null;
|
||||||
|
owner.actions = { ...owner.actions, isEnabled: () => true };
|
||||||
|
owner._renderCctvState({
|
||||||
|
enabled: true,
|
||||||
|
activeCameraId: 'badge-fixture',
|
||||||
|
cameras: [{ id: 'badge-fixture', city: 'QA', name: 'Badge fixture' }],
|
||||||
|
activeCamera: {
|
||||||
|
id: 'badge-fixture',
|
||||||
|
city: 'QA',
|
||||||
|
headingDeg: 30,
|
||||||
|
pitchDeg: -10,
|
||||||
|
fovDeg: 45,
|
||||||
|
rangeM: 120,
|
||||||
|
mountHeightM: 8,
|
||||||
|
sourceKind: 'snapshot',
|
||||||
|
sourceStatus: 'degraded',
|
||||||
|
calBadge: 'curated',
|
||||||
|
calDirty: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.__qaBadgeText =
|
||||||
|
document.getElementById('cctv-source-badge').textContent;
|
||||||
|
});
|
||||||
|
await page.waitForSelector('.weather-coverage.gev-status-badge');
|
||||||
|
const inventory = await page.evaluate(() => {
|
||||||
|
const source = document.getElementById('cctv-source-badge');
|
||||||
|
const quality = document.getElementById('cctv-quality-chip');
|
||||||
|
const coverage = [...document.querySelectorAll('.weather-coverage')];
|
||||||
|
return {
|
||||||
|
shared: [source, quality, ...coverage].every((node) =>
|
||||||
|
node.classList.contains('gev-status-badge'),
|
||||||
|
),
|
||||||
|
live:
|
||||||
|
source.getAttribute('role') === 'status' &&
|
||||||
|
source.getAttribute('aria-live') === 'polite',
|
||||||
|
silent: [quality, ...coverage].every(
|
||||||
|
(node) =>
|
||||||
|
!node.hasAttribute('aria-live') &&
|
||||||
|
!node.hasAttribute('role') &&
|
||||||
|
!node.hasAttribute('tabindex'),
|
||||||
|
),
|
||||||
|
coverage:
|
||||||
|
coverage.length > 0 &&
|
||||||
|
coverage.every(
|
||||||
|
(node) =>
|
||||||
|
node.textContent &&
|
||||||
|
!node.hidden &&
|
||||||
|
node.classList.contains('gev-status-badge--plain'),
|
||||||
|
),
|
||||||
|
source: source.textContent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
`${lang}: CCTV and populated Weather badges share the view`,
|
||||||
|
inventory.shared && inventory.coverage,
|
||||||
|
inventory,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${lang}: existing announcement and read-only semantics remain intact`,
|
||||||
|
inventory.live && inventory.silent,
|
||||||
|
inventory,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${lang}: source wording uses the active locale`,
|
||||||
|
lang === 'en'
|
||||||
|
? inventory.source === 'SNAPSHOT · DEGRADED'
|
||||||
|
: inventory.source !== 'SNAPSHOT · DEGRADED',
|
||||||
|
inventory,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
|
||||||
|
const result = await page.evaluate((state) => {
|
||||||
|
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||||
|
owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
|
||||||
|
owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
|
||||||
|
? 'true'
|
||||||
|
: '';
|
||||||
|
owner._cctvFrameWrap.classList.toggle(
|
||||||
|
'has-frame',
|
||||||
|
state === 'retained',
|
||||||
|
);
|
||||||
|
owner._syncCctvSourceBadge(
|
||||||
|
owner._cctvState.activeCamera,
|
||||||
|
state !== 'idle',
|
||||||
|
);
|
||||||
|
const badge = document.getElementById('cctv-source-badge');
|
||||||
|
return {
|
||||||
|
state: badge.dataset.frameState,
|
||||||
|
text: badge.textContent,
|
||||||
|
color: getComputedStyle(badge).color,
|
||||||
|
};
|
||||||
|
}, state);
|
||||||
|
const expected = {
|
||||||
|
loading: ['loading', 'rgb(255, 229, 155)'],
|
||||||
|
error: ['error', 'rgb(255, 176, 176)'],
|
||||||
|
retained: ['ready', 'rgb(157, 239, 255)'],
|
||||||
|
ready: ['ready', 'rgb(157, 239, 255)'],
|
||||||
|
idle: ['idle', 'rgb(157, 239, 255)'],
|
||||||
|
}[state];
|
||||||
|
check(
|
||||||
|
`${lang}: ${state} keeps its frame state and palette`,
|
||||||
|
result.state === expected[0] &&
|
||||||
|
result.color === expected[1] &&
|
||||||
|
(!['ready', 'retained'].includes(state) ||
|
||||||
|
result.text === inventory.source),
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [calBadge, dirty, color] of [
|
||||||
|
['raw-prior', false, 'rgb(143, 231, 255)'],
|
||||||
|
['curated', false, 'rgb(255, 230, 162)'],
|
||||||
|
['calibrated', false, 'rgb(173, 255, 191)'],
|
||||||
|
['curated', true, 'rgb(255, 208, 138)'],
|
||||||
|
]) {
|
||||||
|
const result = await page.evaluate(
|
||||||
|
({ calBadge, dirty }) => {
|
||||||
|
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||||
|
document.getElementById('cctv-next-btn').focus();
|
||||||
|
owner._renderCctvState({
|
||||||
|
...owner._cctvState,
|
||||||
|
activeCamera: {
|
||||||
|
...owner._cctvState.activeCamera,
|
||||||
|
calBadge,
|
||||||
|
calDirty: dirty,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const badge = document.getElementById('cctv-quality-chip');
|
||||||
|
return {
|
||||||
|
state: badge.dataset.calBadge,
|
||||||
|
color: getComputedStyle(badge).color,
|
||||||
|
focus: document.activeElement.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ calBadge, dirty },
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${lang}: ${dirty ? 'edited' : calBadge} keeps provenance color and control focus`,
|
||||||
|
result.state === (dirty ? 'edited' : calBadge) &&
|
||||||
|
result.color === color &&
|
||||||
|
result.focus === 'cctv-next-btn',
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const theme of ['tactical', 'cyber']) {
|
||||||
|
await page.evaluate(
|
||||||
|
(theme) => window.__godsEyeView.styleManager._setHudVariant(theme),
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
for (const width of [1440, 620, 390]) {
|
||||||
|
await page.setViewport({ width, height: 900 });
|
||||||
|
for (const panelId of ['cctv-panel', 'weather-panel']) {
|
||||||
|
await page.evaluate(async (panelId) => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
for (const other of ['cctv-panel', 'weather-panel']) {
|
||||||
|
if (other !== panelId)
|
||||||
|
manager.setPanelCollapsed(other, true, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
manager.setPanelCollapsed(panelId, false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||||
|
);
|
||||||
|
}, panelId);
|
||||||
|
const layout = await page.evaluate((panelId) => {
|
||||||
|
const selector =
|
||||||
|
panelId === 'cctv-panel'
|
||||||
|
? '#cctv-source-badge, #cctv-quality-chip'
|
||||||
|
: '.weather-coverage';
|
||||||
|
return [...document.querySelectorAll(selector)].map((node) => {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
const panel = node.closest(`#${panelId}`).getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
id: node.id || 'weather-coverage',
|
||||||
|
text: node.textContent,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
scroll: node.scrollWidth,
|
||||||
|
client: node.clientWidth,
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
panelLeft: panel.left,
|
||||||
|
panelRight: panel.right,
|
||||||
|
fits:
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0 &&
|
||||||
|
rect.left >= panel.left - 1 &&
|
||||||
|
rect.right <= panel.right + 1 &&
|
||||||
|
node.scrollWidth <= node.clientWidth + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, panelId);
|
||||||
|
check(
|
||||||
|
`${lang}/${theme}: ${panelId} badges fit at ${width}px`,
|
||||||
|
layout.length >= (panelId === 'cctv-panel' ? 2 : 1) &&
|
||||||
|
layout.every((node) => node.fits),
|
||||||
|
layout,
|
||||||
|
);
|
||||||
|
if (width === 390)
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${out}/${lang}-${theme}-${panelId}-${width}.png`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const disposed = await page.evaluate(() => {
|
||||||
|
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||||
|
const badge = document.getElementById('cctv-quality-chip');
|
||||||
|
const text = badge.textContent;
|
||||||
|
owner.destroy();
|
||||||
|
owner._renderCctvState(null);
|
||||||
|
return badge.textContent === text;
|
||||||
|
});
|
||||||
|
check(`${lang}: disposal prevents late state painting`, disposed);
|
||||||
|
}
|
||||||
|
check('no uncaught browser errors', errors.length === 0, errors);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
console.log(`${checks} checks; ${failures} failures`);
|
||||||
|
process.exitCode = failures ? 1 : 0;
|
||||||
@@ -94,6 +94,7 @@
|
|||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||||
|
|
||||||
@@ -2695,16 +2696,17 @@ async function main() {
|
|||||||
// reappear wearing its stale/creation-north nose for up to 1 s).
|
// reappear wearing its stale/creation-north nose for up to 1 s).
|
||||||
// Staged deterministically: park the camera nadir over the synthetic
|
// Staged deterministically: park the camera nadir over the synthetic
|
||||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||||
// trigger each path with the camera idle. The idle-drift catch-up
|
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
||||||
// (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a
|
// independently of rendered FPS. Observe the first completed settle and
|
||||||
// ~300 ms SwiftShader frame window, so a regression shows as an
|
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
|
||||||
// intermittent (not guaranteed) failure — never a false failure.
|
// rotation refresh can repair an incorrect first reveal.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||||
const arrival = await evalPage(async () => {
|
const arrival = await evalPage(async () => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = window.__godsEyeView.viewer;
|
||||||
const dm = window.__godsEyeView.dataManager;
|
const dm = window.__godsEyeView.dataManager;
|
||||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||||
|
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||||
// and skips rotation updates entirely — the probes need live billboards
|
// and skips rotation updates entirely — the probes need live billboards
|
||||||
// (this is also the app's default state the field report came from).
|
// (this is also the app's default state the field report came from).
|
||||||
@@ -2766,39 +2768,29 @@ async function main() {
|
|||||||
const bb = findBB(id);
|
const bb = findBB(id);
|
||||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||||
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
||||||
// (a) settle pass: tamper, then raise moveEnd with the camera IDLE —
|
const observed = await observeArrivalRotation({
|
||||||
// the pose signature is unchanged, so only the moveEnd hook can fix
|
scene: v.scene,
|
||||||
// this before the 1 s catch-up.
|
startMoveEnd() {
|
||||||
|
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||||
bb.rotation = r0 + 1.5;
|
bb.rotation = r0 + 1.5;
|
||||||
v.camera.moveEnd.raiseEvent();
|
v.camera.moveEnd.raiseEvent();
|
||||||
await nextFrames(2);
|
},
|
||||||
const afterMoveEnd = bb.rotation;
|
startReveal() {
|
||||||
// A real-GPU fleet tick can land near the edge of the frame budget and
|
// Hide immediately after the confirmed settle frame. The next
|
||||||
// advance the contact before the probe reads it. Compare with the
|
// eligible fleet tick must reveal AND re-aim in that same tick.
|
||||||
// production projection at the CURRENT position, not the now-stale r0.
|
|
||||||
const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null);
|
|
||||||
// (b) reveal pass: tamper + hide — the next fleet tick must flip it
|
|
||||||
// visible AND correct the nose in that same tick (camera still idle,
|
|
||||||
// no moveEnd raised). Diagnostic fields (round 5): record WHICH frame
|
|
||||||
// the flip landed on (flipFrame; -1 = never within the extended
|
|
||||||
// window) and whether a 3D model owns this id — distinguishes "tick
|
|
||||||
// slow under load" from "occluder says hidden" from "model handoff".
|
|
||||||
bb.rotation = r0 + 1.5;
|
bb.rotation = r0 + 1.5;
|
||||||
bb.show = false;
|
bb.show = false;
|
||||||
let flipFrame = -1;
|
},
|
||||||
for (let f = 1; f <= 6; f++) {
|
read() {
|
||||||
await nextFrames(1);
|
|
||||||
if (bb.show && flipFrame === -1) flipFrame = f;
|
|
||||||
if (f >= 2 && flipFrame !== -1) break;
|
|
||||||
}
|
|
||||||
const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null);
|
|
||||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
|
||||||
return {
|
return {
|
||||||
r0,
|
shown: bb.show,
|
||||||
dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
|
rotationError: angDiff(bb.rotation,
|
||||||
dReveal: angDiff(bb.rotation, expectedReveal),
|
screenProjectedRotation(v.scene, bb.position, course, null)),
|
||||||
shown: bb.show, flipFrame, hasModel,
|
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||||
|
return { r0, ...observed, hasModel };
|
||||||
};
|
};
|
||||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||||
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
|
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
|
||||||
@@ -2807,22 +2799,13 @@ async function main() {
|
|||||||
military: await probe('bbb101', militaryCourse),
|
military: await probe('bbb101', militaryCourse),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
|
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
|
||||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`);
|
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
|
||||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||||
// Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe
|
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||||
// shows the military flip consistently lands on frame 2 — the exact edge
|
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
||||||
// of the old budget — so any load hiccup flaked it (flights lands frame
|
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||||
// 1). The regression this protects against is a reveal that waits for
|
arrivalRotationPassed(arrival.military), fmtArr(arrival.military));
|
||||||
// the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still
|
|
||||||
// catches that with a wide margin, with zero-headroom flakes gone.
|
|
||||||
const arrOk = (a) => !!a && !a.error && a.shown
|
|
||||||
&& (a.flipFrame === -1 ? false : a.flipFrame <= 4)
|
|
||||||
&& a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10;
|
|
||||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
|
||||||
arrOk(arrival.flights), fmtArr(arrival.flights));
|
|
||||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
|
||||||
arrOk(arrival.military), fmtArr(arrival.military));
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// DISPLAY FLOOR (2026-08-19): a grounded contact must never render below
|
// DISPLAY FLOOR (2026-08-19): a grounded contact must never render below
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||||
import { readRequestBodyCapped } from './common/request.js';
|
import { readRequestBodyCapped } from './common/request.js';
|
||||||
|
import { admitSameSite } from './common/same-site.js';
|
||||||
import {
|
import {
|
||||||
OVERPASS_MAX_BODY_BYTES,
|
OVERPASS_MAX_BODY_BYTES,
|
||||||
OVERPASS_MAX_CONCURRENT,
|
OVERPASS_MAX_CONCURRENT,
|
||||||
@@ -27,6 +28,14 @@ const _overpassInFlight = new Map();
|
|||||||
|
|
||||||
let _overpassConcurrent = 0;
|
let _overpassConcurrent = 0;
|
||||||
|
|
||||||
|
// Bound body-reading and validation work, including malformed requests and
|
||||||
|
// cache hits. Keep the tighter upstream budget below independent of this gate.
|
||||||
|
const _overpassRequestLimiter = makeRateLimiter({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 300,
|
||||||
|
globalMax: 900,
|
||||||
|
});
|
||||||
|
|
||||||
const _overpassRateLimiter = makeRateLimiter({
|
const _overpassRateLimiter = makeRateLimiter({
|
||||||
windowMs: 60_000,
|
windowMs: 60_000,
|
||||||
max: 90,
|
max: 90,
|
||||||
@@ -94,6 +103,17 @@ function overpassProxy({ routing = {} } = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (admitSameSite(req, res)) return;
|
||||||
|
if (!_overpassRequestLimiter(clientKey(req))) {
|
||||||
|
res.writeHead(429, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Retry-After': '60',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Collect POST body with a hard byte cap (Overpass QL queries are small)
|
// Collect POST body with a hard byte cap (Overpass QL queries are small)
|
||||||
let body;
|
let body;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,6 +18,29 @@ function isOverpassBoundaryQuery(cacheKey) {
|
|||||||
return /is_in\s*\(|\bpivot\b/i.test(String(cacheKey || ''));
|
return /is_in\s*\(|\bpivot\b/i.test(String(cacheKey || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Input has already had quoted literals and comments removed. Consume each
|
||||||
|
// character once: a regex that searches for a closing bracket from every `[` is
|
||||||
|
// quadratic on an unterminated filter. Nested or unmatched brackets are invalid.
|
||||||
|
function stripTagFilters(text) {
|
||||||
|
const parts = [];
|
||||||
|
let start = 0;
|
||||||
|
let inFilter = false;
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
if (text[i] === '[') {
|
||||||
|
if (inFilter) return null;
|
||||||
|
parts.push(text.slice(start, i), ' ');
|
||||||
|
inFilter = true;
|
||||||
|
} else if (text[i] === ']') {
|
||||||
|
if (!inFilter) return null;
|
||||||
|
inFilter = false;
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inFilter) return null;
|
||||||
|
parts.push(text.slice(start));
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate + clamp an Overpass form body. Defends the generic proxy against
|
* Validate + clamp an Overpass form body. Defends the generic proxy against
|
||||||
* planet-scale abuse: requires exactly one `data` query in which EVERY element
|
* planet-scale abuse: requires exactly one `data` query in which EVERY element
|
||||||
@@ -50,6 +73,10 @@ function sanitizeOverpassBody(rawBody) {
|
|||||||
// Blank quoted literals + strip comments in one lexer pass so a fake bound or a
|
// Blank quoted literals + strip comments in one lexer pass so a fake bound or a
|
||||||
// `//` inside a string can't hide an unbounded selector (or satisfy a bound).
|
// `//` inside a string can't hide an unbounded selector (or satisfy a bound).
|
||||||
const stripped = stripOverpassNoise(data);
|
const stripped = stripOverpassNoise(data);
|
||||||
|
const withoutTags = stripTagFilters(stripped);
|
||||||
|
if (withoutTags === null) {
|
||||||
|
return { ok: false, error: 'Malformed Overpass tag filter' };
|
||||||
|
}
|
||||||
|
|
||||||
// Reject oversized radii in EVERY around form — point `around:r,lat,lon` AND the
|
// Reject oversized radii in EVERY around form — point `around:r,lat,lon` AND the
|
||||||
// input-set form `around.set:r` — and parse the full numeric token so scientific
|
// input-set form `around.set:r` — and parse the full numeric token so scientific
|
||||||
@@ -93,20 +120,19 @@ function sanitizeOverpassBody(rawBody) {
|
|||||||
// statement. So `way[...]->.a` (global assigned to a set) is rejected, while the
|
// statement. So `way[...]->.a` (global assigned to a set) is rejected, while the
|
||||||
// app's `is_in(...)->.a; area.a[...]` and `area(id)->.x; rel(pivot.x)` validate.
|
// app's `is_in(...)->.a; area.a[...]` and `area(id)->.x; rel(pivot.x)` validate.
|
||||||
const boundedSets = new Set();
|
const boundedSets = new Set();
|
||||||
for (let stmt of stripped.split(';')) {
|
for (let stmt of withoutTags.split(';')) {
|
||||||
stmt = stmt.trim();
|
stmt = stmt.trim();
|
||||||
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
|
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
|
||||||
|
|
||||||
// Strip output-set assignments (NOT input bounds), then strip bracket tag
|
// Strip output-set assignments (NOT input bounds). Bracket tag filters were
|
||||||
// filters so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) can never be
|
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
|
||||||
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
||||||
// refs, never inside [...], so the probe loses nothing real.
|
// refs, never inside [...], so the probe loses nothing real.
|
||||||
const outSets = [];
|
const outSets = [];
|
||||||
const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
||||||
outSets.push(name);
|
outSets.push(name);
|
||||||
return ' ';
|
return ' ';
|
||||||
});
|
});
|
||||||
const probe = body.replace(/\[[^\]]*\]/g, ' ');
|
|
||||||
|
|
||||||
// Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter
|
// Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter
|
||||||
// between the selector and the area filter (way["highway"](area.a)) can't hide
|
// between the selector and the area filter (way["highway"](area.a)) can't hide
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { overpassProxy } from '../overpass.js';
|
||||||
|
import { sanitizeOverpassBody } from './query.js';
|
||||||
|
|
||||||
|
const form = (query) => `data=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
|
||||||
|
for (const query of [
|
||||||
|
'node[',
|
||||||
|
'node]',
|
||||||
|
'node[[name]](around:20,1,1);out;',
|
||||||
|
'node[name=around:20];out;',
|
||||||
|
'node[is_in];out;',
|
||||||
|
'node[name="(around:20,1,1)"];out;',
|
||||||
|
'node(around:20,1,1);way[name];out;',
|
||||||
|
'way[highway](area.a);out;',
|
||||||
|
'node[->.a](around:20,1,1);way.a;out;',
|
||||||
|
]) {
|
||||||
|
assert.equal(sanitizeOverpassBody(form(query)).ok, false, query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bounded queries preserve quoted brackets, comments, multiple tags and timeout clamping', () => {
|
||||||
|
for (const query of [
|
||||||
|
'[out:json][timeout:999];node[name="[Town]; ]"](around:20,1,1);out;',
|
||||||
|
'/* [ */way[highway][name](30.1,-97.9,30.5,-97.5);out geom;',
|
||||||
|
'is_in(30.2,-97.7)->.a;area.a[boundary="administrative"];out tags;',
|
||||||
|
'area(3600039152)->.x;rel(pivot.x);out geom;',
|
||||||
|
]) {
|
||||||
|
const result = sanitizeOverpassBody(form(query));
|
||||||
|
assert.equal(result.ok, true, query);
|
||||||
|
assert.equal(
|
||||||
|
new URLSearchParams(result.body).get('data'),
|
||||||
|
query.replace('[timeout:999]', '[timeout:30]'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
|
||||||
|
// A child deadline can stop synchronous regex backtracking; a test timeout in
|
||||||
|
// the same event loop cannot. The old parser takes several seconds for this.
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--input-type=module',
|
||||||
|
'-e',
|
||||||
|
`import { sanitizeOverpassBody } from ${JSON.stringify(new URL('./query.js', import.meta.url).href)};
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
if (sanitizeOverpassBody('data=node' + '['.repeat(24000)).ok) process.exit(1);
|
||||||
|
}`,
|
||||||
|
],
|
||||||
|
{ timeout: 2000, encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.ifError(result.error);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handler(hook = 'configureServer') {
|
||||||
|
let route;
|
||||||
|
overpassProxy()[hook]({
|
||||||
|
middlewares: {
|
||||||
|
use(path, fn) {
|
||||||
|
if (path === '/api/overpass') route = fn;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(
|
||||||
|
route,
|
||||||
|
{
|
||||||
|
headers = {},
|
||||||
|
peer = '127.0.0.1',
|
||||||
|
body = 'data=node[',
|
||||||
|
forbidRead = false,
|
||||||
|
method = 'POST',
|
||||||
|
url = '/',
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
let reads = 0;
|
||||||
|
const req = new Readable({
|
||||||
|
read() {
|
||||||
|
reads++;
|
||||||
|
assert.equal(forbidRead, false, 'refused request must not read its body');
|
||||||
|
this.push(Buffer.from(body));
|
||||||
|
this.push(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.assign(req, {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers: { host: 'localhost:4173', ...headers },
|
||||||
|
socket: { remoteAddress: peer },
|
||||||
|
});
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {},
|
||||||
|
setHeader(key, value) {
|
||||||
|
this.headers[key.toLowerCase()] = value;
|
||||||
|
},
|
||||||
|
writeHead(status, values) {
|
||||||
|
this.statusCode = status;
|
||||||
|
for (const [key, value] of Object.entries(values))
|
||||||
|
this.setHeader(key, value);
|
||||||
|
},
|
||||||
|
end(value) {
|
||||||
|
this.body = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await route(req, res);
|
||||||
|
return { ...res, reads };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||||
|
test(`${hook}: cross-site requests are rejected before consuming a body or fetching`, async (t) => {
|
||||||
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
assert.fail('unexpected upstream fetch'),
|
||||||
|
);
|
||||||
|
const route = handler(hook);
|
||||||
|
for (const headers of [
|
||||||
|
{ origin: 'https://foreign.example', 'content-type': 'text/plain' },
|
||||||
|
{ 'sec-fetch-site': 'cross-site' },
|
||||||
|
{ 'sec-fetch-site': 'same-site' },
|
||||||
|
{ origin: 'null' },
|
||||||
|
{ 'x-forwarded-for': '127.0.0.1' },
|
||||||
|
]) {
|
||||||
|
const response = await call(route, { headers, forbidRead: true });
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
assert.equal(response.reads, 0);
|
||||||
|
assert.equal(response.headers['cache-control'], 'no-store');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => {
|
||||||
|
const route = handler();
|
||||||
|
for (const headers of [
|
||||||
|
{},
|
||||||
|
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
|
||||||
|
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
|
||||||
|
]) {
|
||||||
|
const response = await call(route, { headers });
|
||||||
|
assert.equal(response.statusCode, 400);
|
||||||
|
assert.equal(response.reads, 1);
|
||||||
|
assert.match(response.body, /Malformed Overpass tag filter/);
|
||||||
|
}
|
||||||
|
const response = await call(route, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/status',
|
||||||
|
forbidRead: true,
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(typeof JSON.parse(response.body).configured, 'boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
|
||||||
|
let now = Date.now() + 120000;
|
||||||
|
t.mock.method(Date, 'now', () => now);
|
||||||
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
assert.fail('unexpected upstream fetch'),
|
||||||
|
);
|
||||||
|
const route = handler();
|
||||||
|
const peer = 'admission-limit-test';
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
assert.equal((await call(route, { peer })).statusCode, 400);
|
||||||
|
}
|
||||||
|
const denied = await call(route, { peer, forbidRead: true });
|
||||||
|
assert.equal(denied.statusCode, 429);
|
||||||
|
assert.equal(denied.reads, 0);
|
||||||
|
assert.equal(denied.headers['retry-after'], '60');
|
||||||
|
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
|
||||||
|
now += 60001;
|
||||||
|
assert.equal((await call(route, { peer })).statusCode, 400);
|
||||||
|
});
|
||||||
@@ -69,7 +69,7 @@ test('focusable controls do not animate the global outline', () => {
|
|||||||
'#top-center-actions button {',
|
'#top-center-actions button {',
|
||||||
'.data-toggle-chip {',
|
'.data-toggle-chip {',
|
||||||
'.scene-btn {',
|
'.scene-btn {',
|
||||||
'.scene-shot-btn {',
|
'.gev-action-button--compact {',
|
||||||
]) {
|
]) {
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
ruleBody(selector),
|
ruleBody(selector),
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ export function createGoogleTokenSource({
|
|||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return parseToken(await response.json(), now());
|
return parseToken(await response.json(), now());
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -50,6 +50,23 @@ test('requests refused for the same token renew it once', async () => {
|
|||||||
assert.equal(calls.length, 2);
|
assert.equal(calls.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a refused token response releases its unread body before returning', async () => {
|
||||||
|
for (const status of [404, 429, 500]) {
|
||||||
|
let cancelled = false;
|
||||||
|
const response = new Response(
|
||||||
|
new ReadableStream({
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
|
const tokens = createGoogleTokenSource({ fetchImpl: async () => response });
|
||||||
|
assert.equal(await tokens.token(), null);
|
||||||
|
assert.equal(cancelled, true, `HTTP ${status} body released`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a server that does not offer tokens yields none', async () => {
|
test('a server that does not offer tokens yields none', async () => {
|
||||||
for (const answer of [
|
for (const answer of [
|
||||||
new Response('Not found', { status: 404 }),
|
new Response('Not found', { status: 404 }),
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */
|
||||||
|
export const ARRIVAL_OBSERVATION_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe the first completed moveEnd frame and the first subsequent reveal.
|
||||||
|
* The deadline starts when moveEnd is forced, before its rotation baseline, so
|
||||||
|
* waiting for either frame cannot let the one-second idle refresh mask a bug.
|
||||||
|
* `read` snapshots visibility and rotation error against the current position.
|
||||||
|
*/
|
||||||
|
export function observeArrivalRotation({
|
||||||
|
scene,
|
||||||
|
startMoveEnd,
|
||||||
|
startReveal,
|
||||||
|
read,
|
||||||
|
now = () => performance.now(),
|
||||||
|
setTimer = setTimeout,
|
||||||
|
clearTimer = clearTimeout,
|
||||||
|
}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const startedAt = now();
|
||||||
|
let moveEnd = null;
|
||||||
|
let moveEndMs = null;
|
||||||
|
let revealFrames = 0;
|
||||||
|
let done = false;
|
||||||
|
let remove;
|
||||||
|
let timer;
|
||||||
|
const cleanup = () => {
|
||||||
|
done = true;
|
||||||
|
remove?.();
|
||||||
|
clearTimer(timer);
|
||||||
|
};
|
||||||
|
const finish = (result) => {
|
||||||
|
if (done) return;
|
||||||
|
cleanup();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const expired = () => ({
|
||||||
|
error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`,
|
||||||
|
elapsedMs: now() - startedAt,
|
||||||
|
moveEndMs,
|
||||||
|
flipFrame: -1,
|
||||||
|
});
|
||||||
|
remove = scene.postRender.addEventListener(() => {
|
||||||
|
if (done) return;
|
||||||
|
try {
|
||||||
|
const elapsedMs = now() - startedAt;
|
||||||
|
if (elapsedMs >= ARRIVAL_OBSERVATION_MS) {
|
||||||
|
finish(expired());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sample = read();
|
||||||
|
if (moveEnd === null) {
|
||||||
|
moveEnd = sample;
|
||||||
|
moveEndMs = elapsedMs;
|
||||||
|
startReveal();
|
||||||
|
} else {
|
||||||
|
revealFrames++;
|
||||||
|
if (sample.shown) {
|
||||||
|
// Keep the FIRST visible result, including a wrong angle. Never
|
||||||
|
// wait for a subsequent tick to repair a failed reveal.
|
||||||
|
finish({
|
||||||
|
dMoveEnd: moveEnd.rotationError,
|
||||||
|
dReveal: sample.rotationError,
|
||||||
|
shown: sample.shown,
|
||||||
|
flipFrame: revealFrames,
|
||||||
|
moveEndMs,
|
||||||
|
revealMs: elapsedMs - moveEndMs,
|
||||||
|
elapsedMs,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene.requestRender();
|
||||||
|
} catch (error) {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
|
||||||
|
try {
|
||||||
|
startMoveEnd();
|
||||||
|
scene.requestRender();
|
||||||
|
} catch (error) {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test the captured frames without retrying a bad first observation. */
|
||||||
|
export function arrivalRotationPassed(observation) {
|
||||||
|
return (
|
||||||
|
!!observation &&
|
||||||
|
!observation.error &&
|
||||||
|
observation.shown === true &&
|
||||||
|
observation.elapsedMs < ARRIVAL_OBSERVATION_MS &&
|
||||||
|
(observation.dMoveEnd * 180) / Math.PI < 10 &&
|
||||||
|
(observation.dReveal * 180) / Math.PI < 10
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
ARRIVAL_OBSERVATION_MS,
|
||||||
|
arrivalRotationPassed,
|
||||||
|
observeArrivalRotation,
|
||||||
|
} from './trackArrivalObservation.mjs';
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
let time = 0;
|
||||||
|
let state = { shown: true, rotationError: 0 };
|
||||||
|
const listeners = new Set();
|
||||||
|
const timers = new Map();
|
||||||
|
let renders = 0;
|
||||||
|
let moveEnds = 0;
|
||||||
|
const observation = observeArrivalRotation({
|
||||||
|
scene: {
|
||||||
|
postRender: {
|
||||||
|
addEventListener(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requestRender() {
|
||||||
|
renders++;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startMoveEnd() {
|
||||||
|
moveEnds++;
|
||||||
|
state.rotationError = 1.5;
|
||||||
|
},
|
||||||
|
startReveal() {
|
||||||
|
state = { shown: false, rotationError: 1.5 };
|
||||||
|
},
|
||||||
|
read: () => ({ ...state }),
|
||||||
|
now: () => time,
|
||||||
|
setTimer(callback, delay) {
|
||||||
|
const handle = {};
|
||||||
|
timers.set(handle, { callback, at: time + delay });
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
clearTimer(handle) {
|
||||||
|
timers.delete(handle);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
observation,
|
||||||
|
frame(at, nextState) {
|
||||||
|
time = at;
|
||||||
|
state = { ...state, ...nextState };
|
||||||
|
for (const listener of [...listeners]) listener();
|
||||||
|
},
|
||||||
|
expire(at) {
|
||||||
|
time = at;
|
||||||
|
for (const timer of [...timers.values()])
|
||||||
|
if (timer.at <= at) timer.callback();
|
||||||
|
},
|
||||||
|
get resources() {
|
||||||
|
return {
|
||||||
|
listeners: listeners.size,
|
||||||
|
timers: timers.size,
|
||||||
|
renders,
|
||||||
|
moveEnds,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('80 ms fleet scheduling passes after more than four fast rendered frames', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.frame(8, { shown: true, rotationError: 0 });
|
||||||
|
// The forced fleet tick was at 8 ms. A 125 FPS renderer can complete nine
|
||||||
|
// more frames before the next 80 ms fleet interval becomes eligible.
|
||||||
|
for (let at = 16; at < 88; at += 8) f.frame(at);
|
||||||
|
f.frame(88, { shown: true, rotationError: 0 });
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.equal(result.flipFrame, 10);
|
||||||
|
assert.equal(result.moveEndMs, 8);
|
||||||
|
assert.equal(result.revealMs, 80);
|
||||||
|
assert.equal(result.elapsedMs, 88);
|
||||||
|
assert.equal(arrivalRotationPassed(result), true);
|
||||||
|
assert.equal(f.resources.listeners, 0);
|
||||||
|
assert.equal(f.resources.timers, 0);
|
||||||
|
assert.equal(f.resources.moveEnds, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a wrong first visible angle fails even if the next frame corrects it', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.frame(8, { shown: true, rotationError: 0 });
|
||||||
|
f.frame(88, { shown: true, rotationError: 1.5 });
|
||||||
|
f.frame(96, { shown: true, rotationError: 0 });
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.equal(result.dReveal, 1.5);
|
||||||
|
assert.equal(result.flipFrame, 1);
|
||||||
|
assert.equal(result.elapsedMs, 88);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a wrong first moveEnd angle is retained rather than repaired by reveal', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.frame(8, { shown: true, rotationError: 1.5 });
|
||||||
|
f.frame(88, { shown: true, rotationError: 0 });
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.equal(result.dMoveEnd, 1.5);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no reveal fails at a deadline before the one-second idle catch-up', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
assert.equal(ARRIVAL_OBSERVATION_MS, 500);
|
||||||
|
f.frame(8, { shown: true, rotationError: 0 });
|
||||||
|
f.frame(480);
|
||||||
|
f.expire(500);
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.match(result.error, /reveal.*500 ms/);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
assert.equal(f.resources.listeners, 0);
|
||||||
|
assert.equal(f.resources.timers, 0);
|
||||||
|
f.frame(1000, { shown: true, rotationError: 0 });
|
||||||
|
assert.equal(result.flipFrame, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a late visible frame fails even if the timeout callback is delayed', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.frame(8, { shown: true, rotationError: 0 });
|
||||||
|
f.frame(501, { shown: true, rotationError: 0 });
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.match(result.error, /reveal.*500 ms/);
|
||||||
|
assert.equal(result.elapsedMs, 501);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the deadline includes moveEnd setup rather than restarting when hidden', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.frame(400, { shown: true, rotationError: 0 });
|
||||||
|
f.frame(600, { shown: true, rotationError: 0 });
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.equal(result.moveEndMs, 400);
|
||||||
|
assert.equal(result.elapsedMs, 600);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing moveEnd frames fail without waiting for a render callback', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.expire(500);
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.match(result.error, /moveEnd.*500 ms/);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
assert.equal(f.resources.listeners, 0);
|
||||||
|
assert.equal(f.resources.timers, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Create an ordinary native action button. Static templates use the same CSS
|
||||||
|
* classes. Labels are already translated; feature owners retain callbacks,
|
||||||
|
* disabled-state updates and listener cleanup. Construction binds no listeners.
|
||||||
|
* @param {object} options
|
||||||
|
* @param {Document} [options.documentRef] Document that owns the button.
|
||||||
|
* @param {string} options.label Visible text, never interpreted as markup.
|
||||||
|
* @param {string} [options.className] Additional feature/layout hooks.
|
||||||
|
* @param {'default'|'danger'} [options.variant]
|
||||||
|
* @param {'default'|'compact'} [options.size]
|
||||||
|
* @param {boolean} [options.disabled]
|
||||||
|
* @param {string} [options.ariaLabel] Accessible name when visible text needs context.
|
||||||
|
* @param {string} [options.title]
|
||||||
|
* @returns {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
export function createActionButton({
|
||||||
|
documentRef = globalThis.document,
|
||||||
|
label,
|
||||||
|
className = '',
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel,
|
||||||
|
title,
|
||||||
|
}) {
|
||||||
|
const button = documentRef.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = [
|
||||||
|
'gev-action-button',
|
||||||
|
variant === 'danger' && 'gev-action-button--danger',
|
||||||
|
size === 'compact' && 'gev-action-button--compact',
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
button.textContent = label;
|
||||||
|
button.disabled = disabled;
|
||||||
|
if (ariaLabel !== undefined) button.setAttribute('aria-label', ariaLabel);
|
||||||
|
if (title !== undefined) button.title = title;
|
||||||
|
return button;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { CctvControls } from './cctvControls.js';
|
||||||
|
import { railFixture } from './railTestFixture.mjs';
|
||||||
|
import { setLocale, t } from '../i18n/index.js';
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const { document } = railFixture();
|
||||||
|
const controls = new CctvControls({
|
||||||
|
elements: {
|
||||||
|
_cctvQualityChip: document.createElement('span'),
|
||||||
|
_cctvSourceBadge: document.createElement('div'),
|
||||||
|
},
|
||||||
|
cctv: {},
|
||||||
|
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
||||||
|
});
|
||||||
|
t.after(() => controls.destroy());
|
||||||
|
return { controls, document };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('source and frame states retain their distinct meanings, including retained pixels', (testContext) => {
|
||||||
|
const { controls, document } = fixture(testContext);
|
||||||
|
const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
|
||||||
|
controls._cctvFrame = document.createElement('img');
|
||||||
|
controls._cctvFrameWrap = document.createElement('div');
|
||||||
|
controls._cctvFrame.removeAttribute = () => {};
|
||||||
|
for (const node of [controls._cctvFrame, controls._cctvFrameWrap]) {
|
||||||
|
node.classList.remove = (...names) =>
|
||||||
|
names.forEach((name) => node.classList.toggle(name, false));
|
||||||
|
}
|
||||||
|
const expect = (label, state, active = camera, enabled = true) => {
|
||||||
|
controls._syncCctvSourceBadge(active, enabled);
|
||||||
|
assert.equal(controls._cctvSourceBadge.textContent, label);
|
||||||
|
assert.equal(controls._cctvSourceBadge.dataset.frameState, state);
|
||||||
|
};
|
||||||
|
expect('SOURCE · UNKNOWN', 'idle', camera, false);
|
||||||
|
expect('SOURCE · UNKNOWN', 'idle', null);
|
||||||
|
controls._cctvFrame.dataset.loading = 'true';
|
||||||
|
expect('FRAME · LOADING', 'loading');
|
||||||
|
controls._cctvFrame.dataset.loading = '';
|
||||||
|
controls._cctvFrame.dataset.error = 'true';
|
||||||
|
expect('FRAME · UNAVAILABLE', 'error');
|
||||||
|
// A refresh failure keeps the settled pixels and the provider's source status.
|
||||||
|
controls._cctvFrameWrap.classList.toggle('has-frame', true);
|
||||||
|
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||||
|
controls._cctvFrame.dataset.loading = 'true';
|
||||||
|
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||||
|
controls._cctvFrame.dataset.loading = '';
|
||||||
|
controls._cctvFrame.dataset.error = '';
|
||||||
|
for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) {
|
||||||
|
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', {
|
||||||
|
...camera,
|
||||||
|
sourceStatus: status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', (testContext) => {
|
||||||
|
const { controls } = fixture(testContext);
|
||||||
|
const cases = [
|
||||||
|
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||||
|
['curated', false, 'CAL · CURATED'],
|
||||||
|
['curated', true, 'CAL · EDITED (UNSAVED)'],
|
||||||
|
['calibrated', false, 'CAL · CALIBRATED'],
|
||||||
|
['calibrated', true, 'CAL · EDITED (UNSAVED)'],
|
||||||
|
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||||
|
[null, false, 'CAL · --'],
|
||||||
|
];
|
||||||
|
for (const [calBadge, calDirty, label] of cases) {
|
||||||
|
const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty });
|
||||||
|
controls._renderCctvState({
|
||||||
|
enabled: true,
|
||||||
|
activeCameraId: 'a',
|
||||||
|
activeCamera,
|
||||||
|
});
|
||||||
|
assert.equal(controls._cctvQualityChip.textContent, label);
|
||||||
|
assert.equal(
|
||||||
|
controls._cctvQualityChip.dataset.calBadge,
|
||||||
|
calDirty ? 'edited' : calBadge || '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
controls._renderCctvState(null);
|
||||||
|
assert.equal(controls._cctvQualityChip.textContent, 'CAL · --');
|
||||||
|
assert.equal(controls._cctvQualityChip.dataset.calBadge, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('localization changes badge copy while preserving machine state ids', (testContext) => {
|
||||||
|
const { controls } = fixture(testContext);
|
||||||
|
testContext.after(() => setLocale('en'));
|
||||||
|
setLocale('ru');
|
||||||
|
controls._syncCctvSourceBadge(
|
||||||
|
{ sourceKind: 'snapshot', sourceStatus: 'degraded' },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
controls._cctvSourceBadge.textContent,
|
||||||
|
`${t('SNAPSHOT')} · ${t('DEGRADED')}`,
|
||||||
|
);
|
||||||
|
assert.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
|
||||||
|
assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
|
||||||
|
controls._renderCctvState({
|
||||||
|
enabled: true,
|
||||||
|
activeCameraId: 'a',
|
||||||
|
activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
controls._cctvQualityChip.textContent,
|
||||||
|
t('CAL · EDITED (UNSAVED)'),
|
||||||
|
);
|
||||||
|
assert.equal(controls._cctvQualityChip.dataset.calBadge, 'edited');
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||||
|
import { createNumberInput } from './formField.js';
|
||||||
|
|
||||||
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
||||||
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||||
@@ -117,11 +118,12 @@ export function _beginCctvCalValueEdit(chip) {
|
|||||||
const basePose = { ...activeCamera.basePose };
|
const basePose = { ...activeCamera.basePose };
|
||||||
const editListeners = new AbortController();
|
const editListeners = new AbortController();
|
||||||
const startValue = field.get(activeCamera);
|
const startValue = field.get(activeCamera);
|
||||||
const input = document.createElement('input');
|
const input = createNumberInput({
|
||||||
input.type = 'number';
|
className: 'gev-field--inline cctv-cal-input',
|
||||||
input.step = field.decimals > 0 ? '0.1' : '1';
|
step: field.decimals > 0 ? '0.1' : '1',
|
||||||
input.value = Number(startValue).toFixed(field.decimals);
|
value: Number(startValue).toFixed(field.decimals),
|
||||||
input.className = 'cctv-cal-input';
|
ariaLabel: t(field.label),
|
||||||
|
});
|
||||||
chip.textContent = `${t(field.label)} `;
|
chip.textContent = `${t(field.label)} `;
|
||||||
chip.appendChild(input);
|
chip.appendChild(input);
|
||||||
input.focus();
|
input.focus();
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ function calibrationFixture(t) {
|
|||||||
Object.assign(input, {
|
Object.assign(input, {
|
||||||
focus() {},
|
focus() {},
|
||||||
select() {},
|
select() {},
|
||||||
|
setAttribute(name, value) {
|
||||||
|
this[name] = value;
|
||||||
|
},
|
||||||
remove() {
|
remove() {
|
||||||
this.parent.input = null;
|
this.parent.input = null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { updateStatusBadge } from './statusBadge.js';
|
||||||
import { msg, t } from '../i18n/index.js';
|
import { msg, t } from '../i18n/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,19 +165,21 @@ export function _settleCctvFrame(token, src, ok) {
|
|||||||
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||||
if (!this._cctvSourceBadge) return;
|
if (!this._cctvSourceBadge) return;
|
||||||
if (!enabled || !activeCamera) {
|
if (!enabled || !activeCamera) {
|
||||||
this._cctvSourceBadge.textContent = t('SOURCE · UNKNOWN');
|
updateStatusBadge(this._cctvSourceBadge, { label: t('SOURCE · UNKNOWN') });
|
||||||
this._cctvSourceBadge.dataset.frameState = 'idle';
|
this._cctvSourceBadge.dataset.frameState = 'idle';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasDisplayedFrame =
|
const hasDisplayedFrame =
|
||||||
this._cctvFrameWrap?.classList.contains('has-frame');
|
this._cctvFrameWrap?.classList.contains('has-frame');
|
||||||
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
|
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
|
||||||
this._cctvSourceBadge.textContent = t('FRAME · LOADING');
|
updateStatusBadge(this._cctvSourceBadge, { label: t('FRAME · LOADING') });
|
||||||
this._cctvSourceBadge.dataset.frameState = 'loading';
|
this._cctvSourceBadge.dataset.frameState = 'loading';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
|
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
|
||||||
this._cctvSourceBadge.textContent = t('FRAME · UNAVAILABLE');
|
updateStatusBadge(this._cctvSourceBadge, {
|
||||||
|
label: t('FRAME · UNAVAILABLE'),
|
||||||
|
});
|
||||||
this._cctvSourceBadge.dataset.frameState = 'error';
|
this._cctvSourceBadge.dataset.frameState = 'error';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -184,6 +187,6 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
|
|||||||
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
|
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
|
||||||
);
|
);
|
||||||
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
|
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
|
||||||
this._cctvSourceBadge.textContent = `${kind} · ${status}`;
|
updateStatusBadge(this._cctvSourceBadge, { label: `${kind} · ${status}` });
|
||||||
this._cctvSourceBadge.dataset.frameState = 'ready';
|
this._cctvSourceBadge.dataset.frameState = 'ready';
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { updateStatusBadge } from './statusBadge.js';
|
||||||
import { t, tn } from '../i18n/index.js';
|
import { t, tn } from '../i18n/index.js';
|
||||||
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
|
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
|
||||||
|
|
||||||
@@ -137,10 +138,12 @@ export function _renderCctvState(state) {
|
|||||||
// promotes to CALIBRATED, RESET CAL clears.
|
// promotes to CALIBRATED, RESET CAL clears.
|
||||||
const badge = activeCamera?.calBadge || null;
|
const badge = activeCamera?.calBadge || null;
|
||||||
const dirty = !!activeCamera?.calDirty;
|
const dirty = !!activeCamera?.calDirty;
|
||||||
this._cctvQualityChip.textContent = dirty
|
updateStatusBadge(this._cctvQualityChip, {
|
||||||
|
label: dirty
|
||||||
? t('CAL · EDITED (UNSAVED)')
|
? t('CAL · EDITED (UNSAVED)')
|
||||||
: t('CAL · {badge}', {
|
: t('CAL · {badge}', {
|
||||||
badge: this._calBadgeLabel(badge),
|
badge: this._calBadgeLabel(badge),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
|
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Build a native numeric field without listeners, parsing or commit policy.
|
||||||
|
* Owners retain drafts, validation, input/change timing and disposal. Static
|
||||||
|
* selects and number inputs share the gev-field surface in their templates;
|
||||||
|
* options remain owned by each feature (no replacement popup or keyboard code).
|
||||||
|
* @param {object} options
|
||||||
|
* @param {Document} [options.documentRef]
|
||||||
|
* @param {string} [options.className] Additional density/layout hooks.
|
||||||
|
* @param {number|string} [options.min]
|
||||||
|
* @param {number|string} [options.max]
|
||||||
|
* @param {number|string} [options.step]
|
||||||
|
* @param {number|string} [options.value]
|
||||||
|
* @param {boolean} [options.disabled]
|
||||||
|
* @param {string} [options.ariaLabel] Already translated accessible name.
|
||||||
|
* @returns {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
export function createNumberInput({
|
||||||
|
documentRef = globalThis.document,
|
||||||
|
className = '',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
value,
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel,
|
||||||
|
} = {}) {
|
||||||
|
const input = documentRef.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.className = `gev-field${className ? ` ${className}` : ''}`;
|
||||||
|
for (const [key, setting] of Object.entries({ min, max, step, value })) {
|
||||||
|
if (setting !== undefined) input[key] = setting;
|
||||||
|
}
|
||||||
|
input.disabled = disabled;
|
||||||
|
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
+11
-7
@@ -1,4 +1,5 @@
|
|||||||
import { createRailCardBlocks } from './railCardBlocks.js';
|
import { createRailCardBlocks } from './railCardBlocks.js';
|
||||||
|
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
|
||||||
|
|
||||||
const set = (node, key, value) => {
|
const set = (node, key, value) => {
|
||||||
if (node[key] !== value) node[key] = value;
|
if (node[key] !== value) node[key] = value;
|
||||||
@@ -53,11 +54,12 @@ export function createRailCards({
|
|||||||
'rail-card-title rail-card-nowrap',
|
'rail-card-title rail-card-nowrap',
|
||||||
heading,
|
heading,
|
||||||
);
|
);
|
||||||
const badge = make(
|
const badge = createStatusBadge({
|
||||||
'span',
|
documentRef: document,
|
||||||
`rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
|
appearance: 'plain',
|
||||||
labels,
|
className: `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
|
||||||
);
|
});
|
||||||
|
labels.appendChild(badge);
|
||||||
const disclosure = make('span', 'rail-card-disclosure', header);
|
const disclosure = make('span', 'rail-card-disclosure', header);
|
||||||
disclosure.setAttribute('aria-hidden', 'true');
|
disclosure.setAttribute('aria-hidden', 'true');
|
||||||
const compact = make('span', 'rail-card-compact', header);
|
const compact = make('span', 'rail-card-compact', header);
|
||||||
@@ -90,8 +92,10 @@ export function createRailCards({
|
|||||||
row.title.setAttribute('title', card.title);
|
row.title.setAttribute('title', card.title);
|
||||||
set(row.icon, 'textContent', card.icon || '');
|
set(row.icon, 'textContent', card.icon || '');
|
||||||
set(row.icon, 'hidden', !card.icon);
|
set(row.icon, 'hidden', !card.icon);
|
||||||
set(row.badge, 'textContent', card.badge || '');
|
updateStatusBadge(row.badge, {
|
||||||
set(row.badge, 'hidden', !card.badge);
|
label: card.badge || '',
|
||||||
|
hidden: !card.badge,
|
||||||
|
});
|
||||||
const open = card.open !== false;
|
const open = card.open !== false;
|
||||||
set(row.element.dataset, 'open', String(open));
|
set(row.element.dataset, 'open', String(open));
|
||||||
row.element.classList.toggle('is-open', open);
|
row.element.classList.toggle('is-open', open);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Scene panel elements and text-only project presentation. */
|
/** Scene panel elements and text-only project presentation. */
|
||||||
import { formatNumber, msg, t } from '../i18n/index.js';
|
import { formatNumber, msg, t } from '../i18n/index.js';
|
||||||
|
import { createActionButton } from './actionButton.js';
|
||||||
|
|
||||||
// Labels for what a shot stores; anything else shows as stored. Style names
|
// Labels for what a shot stores; anything else shows as stored. Style names
|
||||||
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
|
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
|
||||||
@@ -121,13 +122,18 @@ export function renderSceneShots(
|
|||||||
});
|
});
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'scene-shot-actions';
|
actions.className = 'scene-shot-actions';
|
||||||
const loadButton = document.createElement('button');
|
const loadButton = createActionButton({
|
||||||
loadButton.className = 'scene-shot-btn';
|
label: t('LOAD'),
|
||||||
loadButton.textContent = t('LOAD');
|
className: 'scene-shot-btn',
|
||||||
|
size: 'compact',
|
||||||
|
});
|
||||||
listen(loadButton, 'click', () => load(scene.id, shot.id));
|
listen(loadButton, 'click', () => load(scene.id, shot.id));
|
||||||
const deleteButton = document.createElement('button');
|
const deleteButton = createActionButton({
|
||||||
deleteButton.className = 'scene-shot-btn scene-shot-danger';
|
label: t('DEL'),
|
||||||
deleteButton.textContent = t('DEL');
|
className: 'scene-shot-btn scene-shot-danger',
|
||||||
|
size: 'compact',
|
||||||
|
variant: 'danger',
|
||||||
|
});
|
||||||
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
|
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
|
||||||
actions.appendChild(loadButton);
|
actions.appendChild(loadButton);
|
||||||
actions.appendChild(deleteButton);
|
actions.appendChild(deleteButton);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { t, tn } from '../i18n/index.js';
|
import { t, tn } from '../i18n/index.js';
|
||||||
|
import { createActionButton } from './actionButton.js';
|
||||||
|
|
||||||
/** Small owned modal for scene authoring and import review; text never becomes markup. */
|
/** Small owned modal for scene authoring and import review; text never becomes markup. */
|
||||||
export function createSceneDialog(title, onClose) {
|
export function createSceneDialog(title, onClose) {
|
||||||
@@ -185,10 +186,7 @@ export function mountSceneSharing(
|
|||||||
];
|
];
|
||||||
const removers = [];
|
const removers = [];
|
||||||
for (const [text, fn] of entries) {
|
for (const [text, fn] of entries) {
|
||||||
const b = document.createElement('button');
|
const b = createActionButton({ label: text, className: 'scene-btn' });
|
||||||
b.type = 'button';
|
|
||||||
b.textContent = text;
|
|
||||||
b.className = 'scene-btn';
|
|
||||||
b.addEventListener('click', fn);
|
b.addEventListener('click', fn);
|
||||||
removers.push(() => b.removeEventListener('click', fn));
|
removers.push(() => b.removeEventListener('click', fn));
|
||||||
bar.append(b);
|
bar.append(b);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Ordinary read-only badge view. Labels are already translated. Feature owners
|
||||||
|
* retain status derivation, machine-state attributes and announcement policy.
|
||||||
|
* No listeners, timers, state inference or automatic live regions are installed.
|
||||||
|
* Vue can replace this DOM adapter while retaining the label/hidden contract
|
||||||
|
* and the gev-status-badge CSS surface.
|
||||||
|
* @param {object} options
|
||||||
|
* @param {Document} [options.documentRef]
|
||||||
|
* @param {string} [options.label]
|
||||||
|
* @param {'pill'|'box'|'plain'} [options.appearance]
|
||||||
|
* @param {string} [options.className] Feature/layout hooks.
|
||||||
|
* @param {boolean} [options.hidden]
|
||||||
|
* @returns {HTMLSpanElement}
|
||||||
|
*/
|
||||||
|
export function createStatusBadge({
|
||||||
|
documentRef = globalThis.document,
|
||||||
|
label = '',
|
||||||
|
appearance = 'pill',
|
||||||
|
className = '',
|
||||||
|
hidden = false,
|
||||||
|
} = {}) {
|
||||||
|
const badge = documentRef.createElement('span');
|
||||||
|
badge.className = [
|
||||||
|
'gev-status-badge',
|
||||||
|
appearance === 'box' && 'gev-status-badge--box',
|
||||||
|
appearance === 'plain' && 'gev-status-badge--plain',
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
updateStatusBadge(badge, { label, hidden });
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint controlled text/visibility without replacing nodes or redundant writes. */
|
||||||
|
export function updateStatusBadge(badge, { label, hidden = false }) {
|
||||||
|
if (badge.textContent !== label) badge.textContent = label;
|
||||||
|
if (badge.hidden !== hidden) badge.hidden = hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
|
||||||
|
import { createRailCards } from './railCards.js';
|
||||||
|
import { railFixture } from './railTestFixture.mjs';
|
||||||
|
|
||||||
|
test('badges paint literal translated text without installing announcement or interaction semantics', () => {
|
||||||
|
const { document } = railFixture();
|
||||||
|
for (const appearance of ['pill', 'box', 'plain']) {
|
||||||
|
const badge = createStatusBadge({
|
||||||
|
documentRef: document,
|
||||||
|
appearance,
|
||||||
|
className: 'feature-hook',
|
||||||
|
label: '<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
|
||||||
|
});
|
||||||
|
assert.equal(badge.tagName, 'SPAN');
|
||||||
|
assert.equal(
|
||||||
|
badge.textContent,
|
||||||
|
'<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
|
||||||
|
);
|
||||||
|
assert.equal(badge.children.length, 0);
|
||||||
|
assert.equal(badge.classList.contains('gev-status-badge'), true);
|
||||||
|
assert.equal(badge.classList.contains('feature-hook'), true);
|
||||||
|
assert.equal(badge.getAttribute('role'), null);
|
||||||
|
assert.equal(badge.getAttribute('aria-live'), null);
|
||||||
|
assert.equal(badge.getAttribute('tabindex'), null);
|
||||||
|
assert.equal(
|
||||||
|
badge.classList.contains(`gev-status-badge--${appearance}`),
|
||||||
|
appearance !== 'pill',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('painting preserves feature state, live regions and focus with zero identical writes', () => {
|
||||||
|
let writes = 0;
|
||||||
|
const { document } = railFixture(() => writes++);
|
||||||
|
const badge = createStatusBadge({ documentRef: document, hidden: true });
|
||||||
|
badge.setAttribute('role', 'status');
|
||||||
|
badge.setAttribute('aria-live', 'polite');
|
||||||
|
badge.dataset.frameState = 'ready';
|
||||||
|
const focusOwner = document.createElement('button');
|
||||||
|
focusOwner.focus();
|
||||||
|
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
|
||||||
|
assert.equal(badge.hidden, false);
|
||||||
|
writes = 0;
|
||||||
|
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
|
||||||
|
assert.equal(writes, 0);
|
||||||
|
assert.equal(badge.getAttribute('role'), 'status');
|
||||||
|
assert.equal(badge.getAttribute('aria-live'), 'polite');
|
||||||
|
assert.equal(badge.dataset.frameState, 'ready');
|
||||||
|
assert.equal(document.activeElement, focusOwner);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coverage badges keep keyed identity, hide empty coverage and never claim feed health', () => {
|
||||||
|
const f = railFixture();
|
||||||
|
const view = createRailCards({ ...f, badgeClassName: 'weather-coverage' });
|
||||||
|
const card = { id: 'radar', title: 'Rain radar', badge: 'CONUS', blocks: [] };
|
||||||
|
view.update([card]);
|
||||||
|
const badge = f.find((node) => node.classList.contains('gev-status-badge'));
|
||||||
|
const header = f.container.children[0].children[0];
|
||||||
|
header.focus();
|
||||||
|
assert.equal(badge.classList.contains('gev-status-badge--plain'), true);
|
||||||
|
assert.equal(badge.classList.contains('weather-coverage'), true);
|
||||||
|
view.update([{ ...card, badge: '' }]);
|
||||||
|
assert.equal(badge.hidden, true);
|
||||||
|
view.update([{ ...card, badge: 'Global · 1° grid' }]);
|
||||||
|
assert.equal(badge.hidden, false);
|
||||||
|
assert.equal(badge.textContent, 'Global · 1° grid');
|
||||||
|
assert.equal(
|
||||||
|
f.find((node) => node.classList.contains('gev-status-badge')),
|
||||||
|
badge,
|
||||||
|
);
|
||||||
|
assert.deepEqual({ ...badge.dataset }, {});
|
||||||
|
assert.equal(f.document.activeElement, header);
|
||||||
|
view.destroy();
|
||||||
|
view.update([card]);
|
||||||
|
assert.equal(f.container.children.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/* Ordinary actions share one surface; feature classes own placement and width.
|
||||||
|
* Legacy scene-btn hooks keep untouched toggles/Street Level controls stable. */
|
||||||
|
.gev-action-button,
|
||||||
|
.scene-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
color var(--transition-fast),
|
||||||
|
opacity var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gev-action-button:hover:not(:disabled),
|
||||||
|
.scene-btn:hover:not(:disabled) {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(0, 212, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gev-action-button:disabled,
|
||||||
|
.scene-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gev-action-button--danger:hover:not(:disabled),
|
||||||
|
.scene-btn-danger:hover:not(:disabled) {
|
||||||
|
color: #ff7272;
|
||||||
|
border-color: #ff7272;
|
||||||
|
background: rgba(255, 88, 88, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shot-row actions retain their smaller footprint and quieter hover treatment. */
|
||||||
|
.gev-action-button--compact {
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 8px;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gev-action-button--compact:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--glass-border-hover);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gev-action-button--compact.gev-action-button--danger:hover:not(:disabled) {
|
||||||
|
color: #ff7e7e;
|
||||||
|
border-color: #ff7e7e;
|
||||||
|
}
|
||||||
+22
-72
@@ -109,6 +109,10 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cctv-cal-input {
|
||||||
|
width: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
.cctv-cal-value {
|
.cctv-cal-value {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
@@ -129,26 +133,6 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
|||||||
background: rgba(0, 212, 255, 0.08);
|
background: rgba(0, 212, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cctv-cal-input {
|
|
||||||
width: 58px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: 0.7px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: rgba(0, 212, 255, 0.1);
|
|
||||||
border: 1px solid rgba(0, 212, 255, 0.5);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 3px;
|
|
||||||
outline: none;
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cctv-cal-input::-webkit-outer-spin-button,
|
|
||||||
.cctv-cal-input::-webkit-inner-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cctv-calibration-actions {
|
.cctv-calibration-actions {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
@@ -162,26 +146,9 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
|||||||
#cctv-camera-select {
|
#cctv-camera-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 7px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 12px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#cctv-camera-select:focus-visible {
|
|
||||||
outline: 2px solid var(--text-primary);
|
|
||||||
outline-offset: -3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#cctv-camera-select:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#cctv-enable-btn.active,
|
#cctv-enable-btn.active,
|
||||||
@@ -194,41 +161,32 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#cctv-quality-chip {
|
#cctv-quality-chip {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 90px;
|
min-width: 90px;
|
||||||
padding: 6px 8px;
|
--status-badge-border: rgba(0, 212, 255, 0.2);
|
||||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
--status-badge-color: #8fe7ff;
|
||||||
border-radius: 8px;
|
--status-badge-background: rgba(0, 212, 255, 0.08);
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: #8fe7ff;
|
|
||||||
background: rgba(0, 212, 255, 0.08);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no
|
/* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no
|
||||||
in-world tint). Three visually distinct states: calibrated / curated /
|
in-world tint). Three visually distinct states: calibrated / curated /
|
||||||
raw-prior. */
|
raw-prior. */
|
||||||
#cctv-quality-chip[data-cal-badge='calibrated'] {
|
#cctv-quality-chip[data-cal-badge='calibrated'] {
|
||||||
color: #adffbf;
|
--status-badge-color: #adffbf;
|
||||||
border-color: rgba(141, 255, 135, 0.42);
|
--status-badge-border: rgba(141, 255, 135, 0.42);
|
||||||
background: rgba(141, 255, 135, 0.12);
|
--status-badge-background: rgba(141, 255, 135, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
#cctv-quality-chip[data-cal-badge='curated'] {
|
#cctv-quality-chip[data-cal-badge='curated'] {
|
||||||
color: #ffe6a2;
|
--status-badge-color: #ffe6a2;
|
||||||
border-color: rgba(255, 217, 122, 0.35);
|
--status-badge-border: rgba(255, 217, 122, 0.35);
|
||||||
background: rgba(255, 217, 122, 0.1);
|
--status-badge-background: rgba(255, 217, 122, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Unsaved live edits (save-gated persistence, viewshed design §3e). */
|
/* Unsaved live edits (save-gated persistence, viewshed design §3e). */
|
||||||
#cctv-quality-chip[data-cal-badge='edited'] {
|
#cctv-quality-chip[data-cal-badge='edited'] {
|
||||||
color: #ffd08a;
|
--status-badge-color: #ffd08a;
|
||||||
border-color: rgba(255, 176, 82, 0.5);
|
--status-badge-border: rgba(255, 176, 82, 0.5);
|
||||||
background: rgba(255, 176, 82, 0.14);
|
--status-badge-background: rgba(255, 176, 82, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cctv-frame-wrap {
|
.cctv-frame-wrap {
|
||||||
@@ -346,26 +304,18 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
|||||||
|
|
||||||
#cctv-source-badge {
|
#cctv-source-badge {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
border: 1px solid rgba(0, 212, 255, 0.32);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(0, 212, 255, 0.09);
|
|
||||||
color: #9defff;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 8px;
|
|
||||||
letter-spacing: 1.3px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#cctv-source-badge[data-frame-state='loading'] {
|
#cctv-source-badge[data-frame-state='loading'] {
|
||||||
color: #ffe59b;
|
--status-badge-color: #ffe59b;
|
||||||
border-color: rgba(255, 205, 92, 0.45);
|
--status-badge-border: rgba(255, 205, 92, 0.45);
|
||||||
background: rgba(255, 205, 92, 0.1);
|
--status-badge-background: rgba(255, 205, 92, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#cctv-source-badge[data-frame-state='error'] {
|
#cctv-source-badge[data-frame-state='error'] {
|
||||||
color: #ffb0b0;
|
--status-badge-color: #ffb0b0;
|
||||||
border-color: rgba(255, 102, 102, 0.5);
|
--status-badge-border: rgba(255, 102, 102, 0.5);
|
||||||
background: rgba(255, 72, 72, 0.11);
|
--status-badge-background: rgba(255, 72, 72, 0.11);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cctv-summary-label {
|
.cctv-summary-label {
|
||||||
|
|||||||
@@ -727,16 +727,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
|||||||
.pp-select {
|
.pp-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */
|
/* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */
|
||||||
|
|||||||
+11
-1
@@ -171,6 +171,7 @@
|
|||||||
.dock-tray-toggle,
|
.dock-tray-toggle,
|
||||||
.dock-popover,
|
.dock-popover,
|
||||||
.scene-btn,
|
.scene-btn,
|
||||||
|
.gev-action-button:not(.gev-action-button--compact),
|
||||||
.context-mode-button,
|
.context-mode-button,
|
||||||
.map-stack-chip,
|
.map-stack-chip,
|
||||||
.key-setup-chip,
|
.key-setup-chip,
|
||||||
@@ -543,12 +544,21 @@
|
|||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-ui-theme='cyber'] :where(.pp-select, select, input, textarea) {
|
:root[data-ui-theme='cyber']
|
||||||
|
:where(.pp-select, select, input, textarea):where(:not(.gev-field)) {
|
||||||
border-color: rgba(206, 222, 226, 0.24);
|
border-color: rgba(206, 222, 226, 0.24);
|
||||||
background-color: rgba(26, 35, 39, 0.96);
|
background-color: rgba(26, 35, 39, 0.96);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shared fields consume theme palettes through their own surface rule.
|
||||||
|
Feature palettes (Radio) retain precedence without restyling native fields. */
|
||||||
|
:root[data-ui-theme='cyber'] .gev-field {
|
||||||
|
--gev-field-border-color: rgba(206, 222, 226, 0.24);
|
||||||
|
--gev-field-background: rgba(26, 35, 39, 0.96);
|
||||||
|
--gev-field-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-ui-theme='cyber']
|
:root[data-ui-theme='cyber']
|
||||||
:where(
|
:where(
|
||||||
.panel-title,
|
.panel-title,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* Native selects and numeric inputs. This file owns the field surface/states;
|
||||||
|
features and themes supply palette variables and retain their own layout. */
|
||||||
|
.gev-field {
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--gev-field-background, rgba(255, 255, 255, 0.06));
|
||||||
|
border: 1px solid var(--gev-field-border-color, rgba(255, 255, 255, 0.14));
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--gev-field-color, var(--text-primary));
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gev-field:focus-visible {
|
||||||
|
outline: 2px solid var(--text-primary);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gev-field:disabled {
|
||||||
|
opacity: var(--gev-field-disabled-opacity, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gev-field--compact {
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact inline editors share density and hide native number spinners.
|
||||||
|
Their containing feature sets width to fit the surrounding readout. */
|
||||||
|
.gev-field--inline {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.7px;
|
||||||
|
--gev-field-background: rgba(0, 212, 255, 0.1);
|
||||||
|
--gev-field-border-color: rgba(0, 212, 255, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gev-field--inline::-webkit-outer-spin-button,
|
||||||
|
.gev-field--inline::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
+10
-6
@@ -49,13 +49,15 @@
|
|||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
#radio-filter {
|
#radio-filter {
|
||||||
min-width: 0;
|
/* Radio owns its compact density and arrow space; shared fields paint the
|
||||||
|
palette and disabled state, including this intentional theme exception. */
|
||||||
padding: 7px 24px 7px 8px;
|
padding: 7px 24px 7px 8px;
|
||||||
border: 1px solid rgba(65, 220, 231, 0.28);
|
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
color: #c5faff;
|
|
||||||
background: rgba(5, 24, 31, 0.88);
|
|
||||||
font: 9px/1.2 var(--font-mono);
|
font: 9px/1.2 var(--font-mono);
|
||||||
|
--gev-field-border-color: rgba(65, 220, 231, 0.28);
|
||||||
|
--gev-field-color: #c5faff;
|
||||||
|
--gev-field-background: rgba(5, 24, 31, 0.88);
|
||||||
|
--gev-field-disabled-opacity: 0.42;
|
||||||
}
|
}
|
||||||
.radio-station-card {
|
.radio-station-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -273,11 +275,13 @@ html:not([lang='en']) .radio-transport button {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.radio-transport button:disabled,
|
.radio-transport button:disabled {
|
||||||
#radio-filter:disabled {
|
|
||||||
opacity: 0.42;
|
opacity: 0.42;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
#radio-filter:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
#radio-play-btn.active {
|
#radio-play-btn.active {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: rgba(101, 247, 255, 0.8);
|
border-color: rgba(101, 247, 255, 0.8);
|
||||||
|
|||||||
@@ -153,14 +153,6 @@
|
|||||||
#scene-select {
|
#scene-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 7px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 12px;
|
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A translated scene name needs the width: the select takes its own row,
|
/* A translated scene name needs the width: the select takes its own row,
|
||||||
@@ -184,21 +176,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scene-btn {
|
.scene-btn {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 7px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: 1.2px;
|
|
||||||
padding: 6px 9px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color var(--transition-fast),
|
|
||||||
border-color var(--transition-fast),
|
|
||||||
color var(--transition-fast),
|
|
||||||
opacity var(--transition-fast);
|
|
||||||
white-space: nowrap;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 72px;
|
min-width: 72px;
|
||||||
}
|
}
|
||||||
@@ -295,49 +272,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-shot-btn {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 8px;
|
|
||||||
letter-spacing: 0.8px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color var(--transition-fast),
|
|
||||||
border-color var(--transition-fast),
|
|
||||||
color var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-shot-btn:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--glass-border-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-shot-btn.scene-shot-danger:hover {
|
|
||||||
color: #ff7e7e;
|
|
||||||
border-color: #ff7e7e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-btn:hover:not(:disabled) {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: rgba(0, 212, 255, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-btn:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-btn-danger:hover:not(:disabled) {
|
|
||||||
color: #ff7272;
|
|
||||||
border-color: #ff7272;
|
|
||||||
background: rgba(255, 88, 88, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-progress {
|
.scene-progress {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/* Read-only badges. Feature CSS supplies meaning through explicit colors;
|
||||||
|
appearance variants change density/shape without interpreting status codes. */
|
||||||
|
.gev-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--status-badge-border, rgba(0, 212, 255, 0.32));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--status-badge-background, rgba(0, 212, 255, 0.09));
|
||||||
|
color: var(--status-badge-color, #9defff);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 8px;
|
||||||
|
letter-spacing: 1.3px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gev-status-badge--box {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gev-status-badge--plain {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--status-badge-color, var(--text-dim));
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Explicitly beat the component display rule, including in older browsers. */
|
||||||
|
.gev-status-badge[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -133,7 +133,6 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.rail-card-badge,
|
|
||||||
.rail-card-line,
|
.rail-card-line,
|
||||||
.rail-timeline-readout {
|
.rail-timeline-readout {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -304,9 +303,6 @@ html:not([lang='en']) .rail-card-zero::after {
|
|||||||
outline: 1px solid var(--accent);
|
outline: 1px solid var(--accent);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
.rail-card-badge {
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.rail-card-compact {
|
.rail-card-compact {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
<div class="radio-directory-row">
|
<div class="radio-directory-row">
|
||||||
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
|
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
|
||||||
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
|
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
|
||||||
<select id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
|
<select class="gev-field" id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
|
||||||
<option value="all" data-i18n>All</option>
|
<option value="all" data-i18n>All</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sdr-gain-row">
|
<div class="sdr-gain-row">
|
||||||
<label for="sdr-gain-select" data-i18n>GAIN</label>
|
<label for="sdr-gain-select" data-i18n>GAIN</label>
|
||||||
<select id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
|
<select class="gev-field" id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
|
||||||
<option value="auto" data-i18n>AUTO</option>
|
<option value="auto" data-i18n>AUTO</option>
|
||||||
<option value="0.0" translate="no">0.0 dB</option>
|
<option value="0.0" translate="no">0.0 dB</option>
|
||||||
<option value="0.9" translate="no">0.9 dB</option>
|
<option value="0.9" translate="no">0.9 dB</option>
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sdr-frequency-row">
|
<div class="sdr-frequency-row">
|
||||||
<label for="sdr-frequency-input" data-i18n>FM MHZ</label>
|
<label for="sdr-frequency-input" data-i18n>FM MHZ</label>
|
||||||
<input id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
|
<input class="gev-field" id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
|
||||||
<button id="sdr-tune-btn" type="button" data-i18n>TUNE</button>
|
<button id="sdr-tune-btn" type="button" data-i18n>TUNE</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
|
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="pp-slider-row" id="hud-layout-row">
|
<div class="pp-slider-row" id="hud-layout-row">
|
||||||
<span class="pp-slider-mini-label" data-i18n>Layout</span>
|
<span class="pp-slider-mini-label" data-i18n>Layout</span>
|
||||||
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout" data-i18n-aria-label>
|
<select id="hud-layout-select" class="pp-select gev-field gev-field--compact" aria-label="HUD layout" data-i18n-aria-label>
|
||||||
<option value="tactical" data-i18n>Tactical</option>
|
<option value="tactical" data-i18n>Tactical</option>
|
||||||
<option value="operator" data-i18n>Operator</option>
|
<option value="operator" data-i18n>Operator</option>
|
||||||
<option value="minimal" data-i18n>Minimal</option>
|
<option value="minimal" data-i18n>Minimal</option>
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="pp-slider-row" id="draw-label-row">
|
<div class="pp-slider-row" id="draw-label-row">
|
||||||
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
|
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
|
||||||
<select class="pp-select draw-color-select" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
|
<select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
|
||||||
<option value="primary" data-i18n>Primary</option>
|
<option value="primary" data-i18n>Primary</option>
|
||||||
<option value="amber" data-i18n>Amber</option>
|
<option value="amber" data-i18n>Amber</option>
|
||||||
<option value="cyan" data-i18n>Cyan</option>
|
<option value="cyan" data-i18n>Cyan</option>
|
||||||
|
|||||||
@@ -27,25 +27,25 @@
|
|||||||
<img id="cctv-frame" alt="CCTV feed frame" />
|
<img id="cctv-frame" alt="CCTV feed frame" />
|
||||||
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
|
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div id="cctv-source-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
|
<div id="cctv-source-badge" class="gev-status-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
|
||||||
<div id="cctv-meta" data-i18n>Enable CCTV to load camera intersections</div>
|
<div id="cctv-meta" data-i18n>Enable CCTV to load camera intersections</div>
|
||||||
<div class="cctv-controls">
|
<div class="cctv-controls">
|
||||||
<button id="cctv-enable-btn" class="scene-btn" data-i18n>CCTV OFF</button>
|
<button id="cctv-enable-btn" class="scene-btn" data-i18n>CCTV OFF</button>
|
||||||
<button id="cctv-nearest-btn" class="scene-btn" data-i18n>NEAREST</button>
|
<button id="cctv-nearest-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEAREST</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cctv-controls">
|
<div class="cctv-controls">
|
||||||
<button id="cctv-prev-btn" class="scene-btn" data-i18n>PREV</button>
|
<button id="cctv-prev-btn" type="button" class="gev-action-button scene-btn" data-i18n>PREV</button>
|
||||||
<select id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
|
<select class="gev-field" id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
|
||||||
<button id="cctv-next-btn" class="scene-btn" data-i18n>NEXT</button>
|
<button id="cctv-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cctv-controls">
|
<div class="cctv-controls">
|
||||||
<button id="cctv-focus-btn" class="scene-btn" data-i18n>FOCUS</button>
|
<button id="cctv-focus-btn" type="button" class="gev-action-button scene-btn" data-i18n>FOCUS</button>
|
||||||
<button id="cctv-coverage-btn" class="scene-btn" data-i18n>COVERAGE OFF</button>
|
<button id="cctv-coverage-btn" class="scene-btn" data-i18n>COVERAGE OFF</button>
|
||||||
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n>AUTO HOP OFF</button>
|
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n>AUTO HOP OFF</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cctv-controls">
|
<div class="cctv-controls">
|
||||||
<button id="cctv-projection-btn" class="scene-btn" data-i18n>PROJECTION ON</button>
|
<button id="cctv-projection-btn" class="scene-btn" data-i18n>PROJECTION ON</button>
|
||||||
<span id="cctv-quality-chip" data-i18n-runtime>CAL · --</span>
|
<span id="cctv-quality-chip" class="gev-status-badge gev-status-badge--box" data-i18n-runtime>CAL · --</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cctv-calibration-block">
|
<div class="cctv-calibration-block">
|
||||||
<div class="cctv-calibration-header">
|
<div class="cctv-calibration-header">
|
||||||
@@ -62,8 +62,8 @@
|
|||||||
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
|
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cctv-controls cctv-calibration-actions">
|
<div class="cctv-controls cctv-calibration-actions">
|
||||||
<button id="cctv-calib-save-btn" class="scene-btn" data-i18n>SAVE CAL</button>
|
<button id="cctv-calib-save-btn" type="button" class="gev-action-button scene-btn" data-i18n>SAVE CAL</button>
|
||||||
<button id="cctv-calib-reset-btn" class="scene-btn" data-i18n>RESET CAL</button>
|
<button id="cctv-calib-reset-btn" type="button" class="gev-action-button scene-btn" data-i18n>RESET CAL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
|
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
|
||||||
@@ -152,24 +152,24 @@
|
|||||||
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title>+</button>
|
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title>+</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="scene-controls">
|
<div class="scene-controls">
|
||||||
<select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
|
<select class="gev-field" id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
|
||||||
<button id="scene-new-btn" class="scene-btn" data-i18n>NEW</button>
|
<button id="scene-new-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEW</button>
|
||||||
<button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n>DEL</button>
|
<button id="scene-delete-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>DEL</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="scene-controls">
|
<div class="scene-controls">
|
||||||
<button id="scene-capture-btn" class="scene-btn" data-i18n>CAPTURE SHOT</button>
|
<button id="scene-capture-btn" type="button" class="gev-action-button scene-btn" data-i18n>CAPTURE SHOT</button>
|
||||||
<button id="scene-update-shot-btn" class="scene-btn" data-i18n>UPDATE SHOT</button>
|
<button id="scene-update-shot-btn" type="button" class="gev-action-button scene-btn" data-i18n>UPDATE SHOT</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="scene-shot-list" class="scene-shot-list"></div>
|
<div id="scene-shot-list" class="scene-shot-list"></div>
|
||||||
<div class="scene-controls">
|
<div class="scene-controls">
|
||||||
<button id="scene-start-btn" class="scene-btn" data-i18n>START</button>
|
<button id="scene-start-btn" type="button" class="gev-action-button scene-btn" data-i18n>START</button>
|
||||||
<button id="scene-stop-btn" class="scene-btn scene-btn-danger" data-i18n>STOP</button>
|
<button id="scene-stop-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>STOP</button>
|
||||||
<button id="scene-next-btn" class="scene-btn" data-i18n>NEXT</button>
|
<button id="scene-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="scene-controls">
|
<div class="scene-controls">
|
||||||
<button id="scene-export-btn" class="scene-btn" data-i18n>EXPORT PRESETS</button>
|
<button id="scene-export-btn" type="button" class="gev-action-button scene-btn" data-i18n>EXPORT PRESETS</button>
|
||||||
<button id="scene-import-btn" class="scene-btn" data-i18n>IMPORT</button>
|
<button id="scene-import-btn" type="button" class="gev-action-button scene-btn" data-i18n>IMPORT</button>
|
||||||
<button id="scene-download-btn" class="scene-btn" data-i18n>RUN LOG</button>
|
<button id="scene-download-btn" type="button" class="gev-action-button scene-btn" data-i18n>RUN LOG</button>
|
||||||
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
|
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
|
||||||
</div>
|
</div>
|
||||||
<div class="scene-progress">
|
<div class="scene-progress">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/* Preserve component and responsive rule order in one stylesheet entry. */
|
/* Preserve component and responsive rule order in one stylesheet entry. */
|
||||||
@import './src/ui/styles/foundation.css';
|
@import './src/ui/styles/foundation.css';
|
||||||
|
@import './src/ui/styles/action-button.css';
|
||||||
|
@import './src/ui/styles/form-field.css';
|
||||||
|
@import './src/ui/styles/status-badge.css';
|
||||||
@import './src/ui/styles/slider.css';
|
@import './src/ui/styles/slider.css';
|
||||||
@import './src/ui/styles/controls.css';
|
@import './src/ui/styles/controls.css';
|
||||||
@import './src/ui/styles/location.css';
|
@import './src/ui/styles/location.css';
|
||||||
|
|||||||
Reference in new issue
Block a user