diff --git a/changelog.d/1264-overpass-request-admission.md b/changelog.d/1264-overpass-request-admission.md
new file mode 100644
index 000000000..c5e763e30
--- /dev/null
+++ b/changelog.d/1264-overpass-request-admission.md
@@ -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).
diff --git a/changelog.d/1302-shared-action-buttons.md b/changelog.d/1302-shared-action-buttons.md
new file mode 100644
index 000000000..3c8318488
--- /dev/null
+++ b/changelog.d/1302-shared-action-buttons.md
@@ -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).
diff --git a/changelog.d/1305-shared-native-form-fields.md b/changelog.d/1305-shared-native-form-fields.md
new file mode 100644
index 000000000..129d81bcf
--- /dev/null
+++ b/changelog.d/1305-shared-native-form-fields.md
@@ -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).
diff --git a/changelog.d/1306-shared-status-badges.md b/changelog.d/1306-shared-status-badges.md
new file mode 100644
index 000000000..007a028d1
--- /dev/null
+++ b/changelog.d/1306-shared-status-badges.md
@@ -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).
diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md
index eb393a2da..8ca26d9f2 100644
--- a/docs/CODE-BOUNDARIES.md
+++ b/docs/CODE-BOUNDARIES.md
@@ -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
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
`ui/display` owns Display button, selector and slider subscriptions. It receives
@@ -729,3 +738,24 @@ sources supply `{records, droppedCount, status, saturated}`; viewport filtering,
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,
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.
+
+### 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.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 7b2f2a146..08cefbdb4 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1242,6 +1242,47 @@ Feature owners retain bounds, steps, formatted values, input/change timing and
listener cleanup. The radio tuning dial, imagery comparison divider and event
comparison/progression controls keep their specialized presentation and 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 use the controlled DOM adapter in `ui/segmentedControl.js`
@@ -2808,11 +2849,16 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> first later successful fetch. Smaller chunks reduce timeout risk; complete
> camera batches still wait for their sequential requests, and unresolved
> placement continues to use the existing prior until real heights arrive.
-> - **Overpass cache admission:** `/api/overpass` parses and sanitizes requests,
-> then checks fresh memory, identical in-flight work, and fresh disk entries
-> before invoking its local 90/min limiter. Cache and single-flight responses
-> therefore do not spend 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.
+> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
+> POSTs through the shared local-request gate, then limits requests to 300/min
+> per socket peer and 900/min globally before reading or validating bodies.
+> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
+> 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
> activates it and routes the camera flight through the panel FOCUS policy.
> Aircraft/satellite tracking releases outside cockpit; cockpit retains the
@@ -4628,6 +4674,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.
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
`out geom` (with `center` for focus), preserving relation member outlines.
Member geometry is printed only inside a box around the point (6 km for
@@ -5076,7 +5128,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.
- 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.
-- 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)
@@ -5947,3 +5999,20 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye
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.
+
+### 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.
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 7e7226ebb..fb291362d 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -2,6 +2,8 @@
".prettierrc.json",
"package.json",
"scripts/format-scope.json",
+ "src/ui/styles/form-field.css",
+ "scripts/qa-form-fields.mjs",
"scripts/package-boundaries.json",
"scripts/format.mjs",
"scripts/next-layer-state-token.mjs",
@@ -281,6 +283,7 @@
"src/ui/scenePresentation.js",
"src/ui/sceneControls.test.mjs",
"scripts/qa-scene-controls.mjs",
+ "scripts/qa-action-buttons.mjs",
"src/ui.js",
"src/standalone/ui.js",
"src/ui/applicationShell.js",
@@ -305,6 +308,7 @@
"src/ui/styles/layers.css",
"src/ui/styles/controls.css",
"src/ui/styles/slider.css",
+ "src/ui/styles/action-button.css",
"src/ui/styles/cctv.css",
"src/ui/styles/foundation.css",
"src/ui/styles/first-run.css",
@@ -1067,7 +1071,13 @@
"scripts/assemble-changelog.mjs",
"src/tooling/changelogFragments.test.mjs",
"src/data/gbfsProxyRateLimit.test.mjs",
- "src/data/routeSegment.test.mjs",
"src/terrainHeightsBounds.test.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",
+ "src/data/routeSegment.test.mjs",
"src/dockerTrustedPeers.test.mjs"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 3a9950f39..acdeff01e 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -252,6 +252,7 @@
"server/providers/common/http.js",
"server/providers/common/rate-limit.js",
"server/providers/common/request.js",
+ "server/providers/common/same-site.js",
"server/providers/overpass.js",
"server/providers/overpass/cache.js",
"server/providers/overpass/constants.js",
@@ -261,6 +262,7 @@
"server/providers/places/routes.js",
"src/data/placeProviderPayloads.js",
"src/data/routeSteps.js",
+ "src/localRequestGate.mjs",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/overpass.js",
@@ -498,7 +500,8 @@
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js",
"src/ui/keyGuidance.js",
- "src/ui/statusText.js"
+ "src/ui/statusText.js",
+ "src/ui/statusBadge.js"
],
"external": []
},
@@ -539,6 +542,7 @@
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js",
+ "src/ui/formField.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
@@ -547,7 +551,8 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
- "src/ui/unitLabels.js"
+ "src/ui/unitLabels.js",
+ "src/ui/statusBadge.js"
],
"external": []
},
@@ -614,6 +619,7 @@
"src/ui/scenes.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
+ "src/ui/actionButton.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
@@ -675,6 +681,7 @@
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js",
+ "src/ui/formField.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
@@ -737,6 +744,7 @@
"src/ui/rightPanelRail.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
+ "src/ui/actionButton.js",
"src/ui/scenes.js",
"src/ui/shareRestoration.js",
"src/ui/shellElements.js",
@@ -771,7 +779,8 @@
"src/ui/unitLabels.js",
"src/ui/keyGuidance.js",
"src/ui/layerNames.js",
- "src/ui/statusText.js"
+ "src/ui/statusText.js",
+ "src/ui/statusBadge.js"
],
"external": ["cesium"]
},
@@ -849,6 +858,7 @@
"src/ui/scenes.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
+ "src/ui/actionButton.js",
"src/contextModePolicy.js",
"src/director/clock.js",
"src/director/timeline.js",
@@ -2151,6 +2161,7 @@
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js",
+ "src/ui/formField.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
@@ -2214,6 +2225,7 @@
"src/ui/rightPanelRail.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
+ "src/ui/actionButton.js",
"src/ui/scenes.js",
"src/ui/shareRestoration.js",
"src/ui/shellElements.js",
@@ -2506,7 +2518,8 @@
"src/voice/providerCapabilities.js",
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
- "src/voice/geminiCaptureWorklet.js"
+ "src/voice/geminiCaptureWorklet.js",
+ "src/ui/statusBadge.js"
],
"external": [
"hls.js",
diff --git a/scripts/qa-action-buttons.mjs b/scripts/qa-action-buttons.mjs
new file mode 100644
index 000000000..ef11e8ff2
--- /dev/null
+++ b/scripts/qa-action-buttons.mjs
@@ -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;
diff --git a/scripts/qa-form-fields.mjs b/scripts/qa-form-fields.mjs
new file mode 100644
index 000000000..3bbee3816
--- /dev/null
+++ b/scripts/qa-form-fields.mjs
@@ -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;
diff --git a/scripts/qa-overpass-offload.mjs b/scripts/qa-overpass-offload.mjs
index e04a05b86..8f6594011 100644
--- a/scripts/qa-overpass-offload.mjs
+++ b/scripts/qa-overpass-offload.mjs
@@ -306,7 +306,16 @@ try {
await observeNetwork(page);
page.on('pageerror', (error) => result.errors.push(error.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());
});
page.on('requestfailed', (request) => {
diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs
index 9dae69cdc..c7068f26c 100644
--- a/scripts/qa-scene-controls.mjs
+++ b/scripts/qa-scene-controls.mjs
@@ -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(
() => document.querySelectorAll('.scene-shot-row').length === 1,
);
diff --git a/scripts/qa-status-badges.mjs b/scripts/qa-status-badges.mjs
new file mode 100644
index 000000000..961fdba7f
--- /dev/null
+++ b/scripts/qa-status-badges.mjs
@@ -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;
diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs
index 756daccfc..044fdf74f 100644
--- a/scripts/track-regression.mjs
+++ b/scripts/track-regression.mjs
@@ -94,6 +94,7 @@
import fs from 'node:fs';
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 { 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).
// Staged deterministically: park the camera nadir over the synthetic
// cluster, let rotations settle, TAMPER a billboard's rotation, then
- // trigger each path with the camera idle. The idle-drift catch-up
- // (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a
- // ~300 ms SwiftShader frame window, so a regression shows as an
- // intermittent (not guaranteed) failure — never a false failure.
+ // trigger each path with the camera idle. The fleet runs every 80 ms,
+ // independently of rendered FPS. Observe the first completed settle and
+ // reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
+ // rotation refresh can repair an incorrect first reveal.
// ============================================================
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
const arrival = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const dm = window.__godsEyeView.dataManager;
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
// and skips rotation updates entirely — the probes need live billboards
// (this is also the app's default state the field report came from).
@@ -2766,39 +2768,29 @@ async function main() {
const bb = findBB(id);
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
- // (a) settle pass: tamper, then raise moveEnd with the camera IDLE —
- // the pose signature is unchanged, so only the moveEnd hook can fix
- // this before the 1 s catch-up.
- bb.rotation = r0 + 1.5;
- v.camera.moveEnd.raiseEvent();
- await nextFrames(2);
- const afterMoveEnd = bb.rotation;
- // A real-GPU fleet tick can land near the edge of the frame budget and
- // advance the contact before the probe reads it. Compare with the
- // 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.show = false;
- let flipFrame = -1;
- for (let f = 1; f <= 6; f++) {
- 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 observed = await observeArrivalRotation({
+ scene: v.scene,
+ startMoveEnd() {
+ // Only moveEnd can repair the tampered angle at the idle pose.
+ bb.rotation = r0 + 1.5;
+ v.camera.moveEnd.raiseEvent();
+ },
+ startReveal() {
+ // Hide immediately after the confirmed settle frame. The next
+ // eligible fleet tick must reveal AND re-aim in that same tick.
+ bb.rotation = r0 + 1.5;
+ bb.show = false;
+ },
+ read() {
+ return {
+ shown: bb.show,
+ rotationError: angDiff(bb.rotation,
+ screenProjectedRotation(v.scene, bb.position, course, null)),
+ };
+ },
+ });
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
- return {
- r0,
- dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
- dReveal: angDiff(bb.rotation, expectedReveal),
- shown: bb.show, flipFrame, hasModel,
- };
+ return { r0, ...observed, hasModel };
};
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.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),
};
});
- const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
- : `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}`);
+ 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} 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
- // Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe
- // shows the military flip consistently lands on frame 2 — the exact edge
- // of the old budget — so any load hiccup flaked it (flights lands frame
- // 1). The regression this protects against is a reveal that waits for
- // 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));
+ record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
+ arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
+ record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
+ arrivalRotationPassed(arrival.military), fmtArr(arrival.military));
// ============================================================
// DISPLAY FLOOR (2026-08-19): a grounded contact must never render below
diff --git a/server/providers/overpass.js b/server/providers/overpass.js
index e6631e889..3b5a2e575 100644
--- a/server/providers/overpass.js
+++ b/server/providers/overpass.js
@@ -1,5 +1,6 @@
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { readRequestBodyCapped } from './common/request.js';
+import { admitSameSite } from './common/same-site.js';
import {
OVERPASS_MAX_BODY_BYTES,
OVERPASS_MAX_CONCURRENT,
@@ -27,6 +28,14 @@ const _overpassInFlight = new Map();
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({
windowMs: 60_000,
max: 90,
@@ -94,6 +103,17 @@ function overpassProxy({ routing = {} } = {}) {
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)
let body;
try {
diff --git a/server/providers/overpass/query.js b/server/providers/overpass/query.js
index cee444d3a..4091c9efd 100644
--- a/server/providers/overpass/query.js
+++ b/server/providers/overpass/query.js
@@ -18,6 +18,29 @@ function isOverpassBoundaryQuery(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
* 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
// `//` inside a string can't hide an unbounded selector (or satisfy a bound).
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
// 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
// app's `is_in(...)->.a; area.a[...]` and `area(id)->.x; rel(pivot.x)` validate.
const boundedSets = new Set();
- for (let stmt of stripped.split(';')) {
+ for (let stmt of withoutTags.split(';')) {
stmt = stmt.trim();
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
- // Strip output-set assignments (NOT input bounds), then strip bracket tag
- // filters so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) can never be
+ // Strip output-set assignments (NOT input bounds). Bracket tag filters were
+ // 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
// refs, never inside [...], so the probe loses nothing real.
const outSets = [];
- const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
+ const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
outSets.push(name);
return ' ';
});
- const probe = body.replace(/\[[^\]]*\]/g, ' ');
// 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
diff --git a/server/providers/overpass/requestAdmission.test.mjs b/server/providers/overpass/requestAdmission.test.mjs
new file mode 100644
index 000000000..ecd642320
--- /dev/null
+++ b/server/providers/overpass/requestAdmission.test.mjs
@@ -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);
+});
diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs
index 5244cffdc..fc59481aa 100644
--- a/src/keyboardFocusStyles.test.mjs
+++ b/src/keyboardFocusStyles.test.mjs
@@ -69,7 +69,7 @@ test('focusable controls do not animate the global outline', () => {
'#top-center-actions button {',
'.data-toggle-chip {',
'.scene-btn {',
- '.scene-shot-btn {',
+ '.gev-action-button--compact {',
]) {
assert.doesNotMatch(
ruleBody(selector),
diff --git a/src/maps/googleTokens.js b/src/maps/googleTokens.js
index 7896c8f5c..884c5b199 100644
--- a/src/maps/googleTokens.js
+++ b/src/maps/googleTokens.js
@@ -38,7 +38,10 @@ export function createGoogleTokenSource({
headers: { Accept: 'application/json' },
cache: 'no-store',
});
- if (!response.ok) return null;
+ if (!response.ok) {
+ await response.body?.cancel();
+ return null;
+ }
return parseToken(await response.json(), now());
} catch {
return null;
diff --git a/src/maps/googleTokens.test.mjs b/src/maps/googleTokens.test.mjs
index 87b7cf8dc..d8a1e84ed 100644
--- a/src/maps/googleTokens.test.mjs
+++ b/src/maps/googleTokens.test.mjs
@@ -50,6 +50,23 @@ test('requests refused for the same token renew it once', async () => {
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 () => {
for (const answer of [
new Response('Not found', { status: 404 }),
diff --git a/src/tooling/trackArrivalObservation.mjs b/src/tooling/trackArrivalObservation.mjs
new file mode 100644
index 000000000..370624891
--- /dev/null
+++ b/src/tooling/trackArrivalObservation.mjs
@@ -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
+ );
+}
diff --git a/src/tooling/trackArrivalObservation.test.mjs b/src/tooling/trackArrivalObservation.test.mjs
new file mode 100644
index 000000000..7c140bf37
--- /dev/null
+++ b/src/tooling/trackArrivalObservation.test.mjs
@@ -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);
+});
diff --git a/src/ui/actionButton.js b/src/ui/actionButton.js
new file mode 100644
index 000000000..f1b4b85f9
--- /dev/null
+++ b/src/ui/actionButton.js
@@ -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;
+}
diff --git a/src/ui/cctvBadges.test.mjs b/src/ui/cctvBadges.test.mjs
new file mode 100644
index 000000000..1703d5d54
--- /dev/null
+++ b/src/ui/cctvBadges.test.mjs
@@ -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');
+});
diff --git a/src/ui/cctvCalibration.js b/src/ui/cctvCalibration.js
index 8144edd8a..9e51540c5 100644
--- a/src/ui/cctvCalibration.js
+++ b/src/ui/cctvCalibration.js
@@ -1,4 +1,5 @@
import { formatNumber, t, msg } from '../i18n/index.js';
+import { createNumberInput } from './formField.js';
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
@@ -117,11 +118,12 @@ export function _beginCctvCalValueEdit(chip) {
const basePose = { ...activeCamera.basePose };
const editListeners = new AbortController();
const startValue = field.get(activeCamera);
- const input = document.createElement('input');
- input.type = 'number';
- input.step = field.decimals > 0 ? '0.1' : '1';
- input.value = Number(startValue).toFixed(field.decimals);
- input.className = 'cctv-cal-input';
+ const input = createNumberInput({
+ className: 'gev-field--inline cctv-cal-input',
+ step: field.decimals > 0 ? '0.1' : '1',
+ value: Number(startValue).toFixed(field.decimals),
+ ariaLabel: t(field.label),
+ });
chip.textContent = `${t(field.label)} `;
chip.appendChild(input);
input.focus();
diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs
index 5ab5a3280..6a47ff91f 100644
--- a/src/ui/cctvControls.test.mjs
+++ b/src/ui/cctvControls.test.mjs
@@ -105,6 +105,9 @@ function calibrationFixture(t) {
Object.assign(input, {
focus() {},
select() {},
+ setAttribute(name, value) {
+ this[name] = value;
+ },
remove() {
this.parent.input = null;
},
diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js
index 7976d46c8..ee324e0ad 100644
--- a/src/ui/cctvFrames.js
+++ b/src/ui/cctvFrames.js
@@ -1,3 +1,4 @@
+import { updateStatusBadge } from './statusBadge.js';
import { msg, t } from '../i18n/index.js';
/**
@@ -164,19 +165,21 @@ export function _settleCctvFrame(token, src, ok) {
export function _syncCctvSourceBadge(activeCamera, enabled) {
if (!this._cctvSourceBadge) return;
if (!enabled || !activeCamera) {
- this._cctvSourceBadge.textContent = t('SOURCE · UNKNOWN');
+ updateStatusBadge(this._cctvSourceBadge, { label: t('SOURCE · UNKNOWN') });
this._cctvSourceBadge.dataset.frameState = 'idle';
return;
}
const hasDisplayedFrame =
this._cctvFrameWrap?.classList.contains('has-frame');
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';
return;
}
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';
return;
}
@@ -184,6 +187,6 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
);
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
- this._cctvSourceBadge.textContent = `${kind} · ${status}`;
+ updateStatusBadge(this._cctvSourceBadge, { label: `${kind} · ${status}` });
this._cctvSourceBadge.dataset.frameState = 'ready';
}
diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js
index 3e738e109..070c47f64 100644
--- a/src/ui/cctvPresentation.js
+++ b/src/ui/cctvPresentation.js
@@ -1,3 +1,4 @@
+import { updateStatusBadge } from './statusBadge.js';
import { t, tn } from '../i18n/index.js';
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
@@ -137,11 +138,13 @@ export function _renderCctvState(state) {
// promotes to CALIBRATED, RESET CAL clears.
const badge = activeCamera?.calBadge || null;
const dirty = !!activeCamera?.calDirty;
- this._cctvQualityChip.textContent = dirty
- ? t('CAL · EDITED (UNSAVED)')
- : t('CAL · {badge}', {
- badge: this._calBadgeLabel(badge),
- });
+ updateStatusBadge(this._cctvQualityChip, {
+ label: dirty
+ ? t('CAL · EDITED (UNSAVED)')
+ : t('CAL · {badge}', {
+ badge: this._calBadgeLabel(badge),
+ }),
+ });
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
}
diff --git a/src/ui/formField.js b/src/ui/formField.js
new file mode 100644
index 000000000..8b2127b4d
--- /dev/null
+++ b/src/ui/formField.js
@@ -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;
+}
diff --git a/src/ui/railCards.js b/src/ui/railCards.js
index 17f04436f..7317693be 100644
--- a/src/ui/railCards.js
+++ b/src/ui/railCards.js
@@ -1,4 +1,5 @@
import { createRailCardBlocks } from './railCardBlocks.js';
+import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
@@ -53,11 +54,12 @@ export function createRailCards({
'rail-card-title rail-card-nowrap',
heading,
);
- const badge = make(
- 'span',
- `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
- labels,
- );
+ const badge = createStatusBadge({
+ documentRef: document,
+ appearance: 'plain',
+ className: `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
+ });
+ labels.appendChild(badge);
const disclosure = make('span', 'rail-card-disclosure', header);
disclosure.setAttribute('aria-hidden', 'true');
const compact = make('span', 'rail-card-compact', header);
@@ -90,8 +92,10 @@ export function createRailCards({
row.title.setAttribute('title', card.title);
set(row.icon, 'textContent', card.icon || '');
set(row.icon, 'hidden', !card.icon);
- set(row.badge, 'textContent', card.badge || '');
- set(row.badge, 'hidden', !card.badge);
+ updateStatusBadge(row.badge, {
+ label: card.badge || '',
+ hidden: !card.badge,
+ });
const open = card.open !== false;
set(row.element.dataset, 'open', String(open));
row.element.classList.toggle('is-open', open);
diff --git a/src/ui/scenePresentation.js b/src/ui/scenePresentation.js
index 258ee5059..52e99796e 100644
--- a/src/ui/scenePresentation.js
+++ b/src/ui/scenePresentation.js
@@ -1,5 +1,6 @@
/** Scene panel elements and text-only project presentation. */
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
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
@@ -121,13 +122,18 @@ export function renderSceneShots(
});
const actions = document.createElement('div');
actions.className = 'scene-shot-actions';
- const loadButton = document.createElement('button');
- loadButton.className = 'scene-shot-btn';
- loadButton.textContent = t('LOAD');
+ const loadButton = createActionButton({
+ label: t('LOAD'),
+ className: 'scene-shot-btn',
+ size: 'compact',
+ });
listen(loadButton, 'click', () => load(scene.id, shot.id));
- const deleteButton = document.createElement('button');
- deleteButton.className = 'scene-shot-btn scene-shot-danger';
- deleteButton.textContent = t('DEL');
+ const deleteButton = createActionButton({
+ label: t('DEL'),
+ className: 'scene-shot-btn scene-shot-danger',
+ size: 'compact',
+ variant: 'danger',
+ });
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
actions.appendChild(loadButton);
actions.appendChild(deleteButton);
diff --git a/src/ui/sceneSharing.js b/src/ui/sceneSharing.js
index 245561b8d..8c9843eba 100644
--- a/src/ui/sceneSharing.js
+++ b/src/ui/sceneSharing.js
@@ -1,4 +1,5 @@
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. */
export function createSceneDialog(title, onClose) {
@@ -185,10 +186,7 @@ export function mountSceneSharing(
];
const removers = [];
for (const [text, fn] of entries) {
- const b = document.createElement('button');
- b.type = 'button';
- b.textContent = text;
- b.className = 'scene-btn';
+ const b = createActionButton({ label: text, className: 'scene-btn' });
b.addEventListener('click', fn);
removers.push(() => b.removeEventListener('click', fn));
bar.append(b);
diff --git a/src/ui/statusBadge.js b/src/ui/statusBadge.js
new file mode 100644
index 000000000..ec7f3019a
--- /dev/null
+++ b/src/ui/statusBadge.js
@@ -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;
+}
diff --git a/src/ui/statusBadge.test.mjs b/src/ui/statusBadge.test.mjs
new file mode 100644
index 000000000..2e576af69
--- /dev/null
+++ b/src/ui/statusBadge.test.mjs
@@ -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: ' · НЕИЗВЕСТНО',
+ });
+ assert.equal(badge.tagName, 'SPAN');
+ assert.equal(
+ badge.textContent,
+ '
· НЕИЗВЕСТНО',
+ );
+ 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);
+});
diff --git a/src/ui/styles/action-button.css b/src/ui/styles/action-button.css
new file mode 100644
index 000000000..4da40cdb1
--- /dev/null
+++ b/src/ui/styles/action-button.css
@@ -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;
+}
diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css
index d6f895681..12c369a9e 100644
--- a/src/ui/styles/cctv.css
+++ b/src/ui/styles/cctv.css
@@ -129,26 +129,6 @@ html:not([lang='en']) .cctv-controls .scene-btn {
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 {
margin-top: 8px;
}
@@ -162,26 +142,9 @@ html:not([lang='en']) .cctv-controls .scene-btn {
#cctv-camera-select {
flex: 1;
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;
text-overflow: ellipsis;
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,
@@ -194,41 +157,32 @@ html:not([lang='en']) .cctv-controls .scene-btn {
}
#cctv-quality-chip {
- display: inline-flex;
- align-items: center;
- justify-content: center;
min-width: 90px;
- padding: 6px 8px;
- border: 1px solid rgba(0, 212, 255, 0.2);
- border-radius: 8px;
- font-family: var(--font-mono);
- font-size: 9px;
- letter-spacing: 1px;
- color: #8fe7ff;
- background: rgba(0, 212, 255, 0.08);
- text-transform: uppercase;
+ --status-badge-border: rgba(0, 212, 255, 0.2);
+ --status-badge-color: #8fe7ff;
+ --status-badge-background: rgba(0, 212, 255, 0.08);
}
/* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no
in-world tint). Three visually distinct states: calibrated / curated /
raw-prior. */
#cctv-quality-chip[data-cal-badge='calibrated'] {
- color: #adffbf;
- border-color: rgba(141, 255, 135, 0.42);
- background: rgba(141, 255, 135, 0.12);
+ --status-badge-color: #adffbf;
+ --status-badge-border: rgba(141, 255, 135, 0.42);
+ --status-badge-background: rgba(141, 255, 135, 0.12);
}
#cctv-quality-chip[data-cal-badge='curated'] {
- color: #ffe6a2;
- border-color: rgba(255, 217, 122, 0.35);
- background: rgba(255, 217, 122, 0.1);
+ --status-badge-color: #ffe6a2;
+ --status-badge-border: rgba(255, 217, 122, 0.35);
+ --status-badge-background: rgba(255, 217, 122, 0.1);
}
/* Unsaved live edits (save-gated persistence, viewshed design §3e). */
#cctv-quality-chip[data-cal-badge='edited'] {
- color: #ffd08a;
- border-color: rgba(255, 176, 82, 0.5);
- background: rgba(255, 176, 82, 0.14);
+ --status-badge-color: #ffd08a;
+ --status-badge-border: rgba(255, 176, 82, 0.5);
+ --status-badge-background: rgba(255, 176, 82, 0.14);
}
.cctv-frame-wrap {
@@ -346,26 +300,18 @@ html:not([lang='en']) .cctv-controls .scene-btn {
#cctv-source-badge {
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'] {
- color: #ffe59b;
- border-color: rgba(255, 205, 92, 0.45);
- background: rgba(255, 205, 92, 0.1);
+ --status-badge-color: #ffe59b;
+ --status-badge-border: rgba(255, 205, 92, 0.45);
+ --status-badge-background: rgba(255, 205, 92, 0.1);
}
#cctv-source-badge[data-frame-state='error'] {
- color: #ffb0b0;
- border-color: rgba(255, 102, 102, 0.5);
- background: rgba(255, 72, 72, 0.11);
+ --status-badge-color: #ffb0b0;
+ --status-badge-border: rgba(255, 102, 102, 0.5);
+ --status-badge-background: rgba(255, 72, 72, 0.11);
}
.cctv-summary-label {
diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css
index 7fdb51276..f11378bb9 100644
--- a/src/ui/styles/controls.css
+++ b/src/ui/styles/controls.css
@@ -727,16 +727,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
.pp-select {
flex: 1;
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 */
diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css
index fc93abcb6..cad6e69af 100644
--- a/src/ui/styles/cyber.css
+++ b/src/ui/styles/cyber.css
@@ -171,6 +171,7 @@
.dock-tray-toggle,
.dock-popover,
.scene-btn,
+ .gev-action-button:not(.gev-action-button--compact),
.context-mode-button,
.map-stack-chip,
.key-setup-chip,
diff --git a/src/ui/styles/form-field.css b/src/ui/styles/form-field.css
new file mode 100644
index 000000000..e0eec99b1
--- /dev/null
+++ b/src/ui/styles/form-field.css
@@ -0,0 +1,51 @@
+/* Native selects and numeric inputs. Features own options, constraints and
+ commit timing; density variants do not introduce custom keyboard behavior. */
+.gev-field {
+ 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;
+}
+
+.gev-field:focus-visible {
+ outline: 2px solid var(--text-primary);
+ outline-offset: -3px;
+}
+
+.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;
+}
+
+/* Effective-pose editors must fit inside CCTV value chips. Retain their
+ existing spinner-free surface; calibration conversion stays with the owner. */
+.gev-field--inline {
+ width: 58px;
+ font-family: var(--font-mono);
+ font-size: 9px;
+ letter-spacing: 0.7px;
+ background: rgba(0, 212, 255, 0.1);
+ 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;
+}
diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css
index 287b8b842..b048c5bd2 100644
--- a/src/ui/styles/scenes.css
+++ b/src/ui/styles/scenes.css
@@ -153,14 +153,6 @@
#scene-select {
flex: 1;
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,
@@ -184,21 +176,6 @@ html:not([lang='en']) #scene-panel .scene-controls .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;
min-width: 72px;
}
@@ -295,49 +272,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
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 {
margin-top: 8px;
width: 100%;
diff --git a/src/ui/styles/status-badge.css b/src/ui/styles/status-badge.css
new file mode 100644
index 000000000..87459b013
--- /dev/null
+++ b/src/ui/styles/status-badge.css
@@ -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;
+}
diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css
index a47fa87b1..f7cd999d3 100644
--- a/src/ui/styles/weather.css
+++ b/src/ui/styles/weather.css
@@ -133,7 +133,6 @@
margin-left: auto;
text-align: right;
}
-.rail-card-badge,
.rail-card-line,
.rail-timeline-readout {
font-family: var(--font-mono);
@@ -304,9 +303,6 @@ html:not([lang='en']) .rail-card-zero::after {
outline: 1px solid var(--accent);
outline-offset: 3px;
}
-.rail-card-badge {
- text-transform: uppercase;
-}
.rail-card-compact {
grid-column: 1 / -1;
min-width: 0;
diff --git a/src/ui/templates/context.html b/src/ui/templates/context.html
index 290726417..9e88d20f4 100644
--- a/src/ui/templates/context.html
+++ b/src/ui/templates/context.html
@@ -121,7 +121,7 @@