Merge remote-tracking branch 'upstream/main' into codex/overpass-admission-review-fixes

# Conflicts:
#	scripts/format-scope.json
This commit is contained in:
Said Turksever committed 2026-10-21 17:34:23 +01:00
commit 09ea8f33b9
36 files changed
+1757 -214

No files matched your search

@@ -0,0 +1,3 @@
- Give ordinary Scene and CCTV actions shared native buttons with consistent
default, destructive and compact styling while preserving their existing
behavior and specialized controls (mazy1998, #1302).
@@ -0,0 +1,3 @@
- Share native dropdown and numeric-field styling across Scene, CCTV, Display
and Radio controls, with a shared CCTV number-input builder that preserves
existing precision, validation and commit behavior (mazy1998, #1305).
+4
View File
@@ -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).
+30
View File
@@ -374,6 +374,15 @@ renderer, persistence or services. The facade retains panel visibility, share
restore claims and render scheduling. Both controls are destroyed before the restore claims and render scheduling. Both controls are destroyed before the
facade's asynchronous teardown can yield. facade's asynchronous teardown can yield.
## Ordinary action buttons
`src/ui/actionButton.js` is a native, listener-free view helper used by Scene
presentation and sharing-toolbar owners. It receives translated text and explicit
presentation/accessibility properties. Feature controllers own action dispatch,
state, persistence and cleanup. Static Scenes/CCTV templates use the same CSS
surface and preserve IDs and layout hooks. The helper imports no application,
renderer, storage or services; it belongs to the consuming package closures.
## Display controls ## Display controls
`ui/display` owns Display button, selector and slider subscriptions. It receives `ui/display` owns Display button, selector and slider subscriptions. It receives
@@ -729,3 +738,24 @@ sources supply `{records, droppedCount, status, saturated}`; viewport filtering,
exact-bound retry and rendering stay in the layer. Legacy cache saturation is exact-bound retry and rendering stay in the layer. Legacy cache saturation is
decoded in the source. Source and geometry modules remain independent of Cesium, decoded in the source. Source and geometry modules remain independent of Cesium,
DOM and platform middleware. DOM and platform middleware.
### Native form fields
`src/ui/formField.js` constructs native numeric inputs without listeners or
application state. Static selects and number inputs use the same `gev-field`
surface, with explicit compact and inline densities. Feature owners retain
option identity/order, bounds, parsing, draft values, input/change timing and
teardown. CCTV effective-pose conversion and tuner/device operations remain
feature responsibilities. A Vue replacement can reuse this contract and CSS;
it replaces DOM construction, rather than adding a second state owner.
### 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.
+58
View File
@@ -1233,6 +1233,47 @@ Feature owners retain bounds, steps, formatted values, input/change timing and
listener cleanup. The radio tuning dial, imagery comparison divider and event listener cleanup. The radio tuning dial, imagery comparison divider and event
comparison/progression controls keep their specialized presentation and behavior. comparison/progression controls keep their specialized presentation and behavior.
## Ordinary status badges
CCTV source/frame labels, calibration provenance and Weather coverage labels use
`ui/statusBadge.js` and the `gev-status-badge` CSS surface. The listener-free
adapter accepts already-translated `label` and `hidden` values; pill, box and plain
appearances describe shape/density only. Static CCTV markup uses the same classes
as generated rail-card badges. Repeated identical updates do not rewrite text or
replace nodes. Empty coverage remains hidden.
Feature owners retain the status rules, machine-state attributes, translations
and announcement policy. CCTV frame acquisition (`idle/loading/error/ready`)
remains separate from provider status: retained pixels do not imply a healthy
feed. Calibration still distinguishes manually saved, catalog-curated and raw
prior poses; unsaved edits override the label until save/reset. Coverage labels
remain geographic scope, not health claims. Existing colors and the source
badge's polite live region are preserved; coverage and calibration do not gain
live regions. Interactive layer toggles, progress indicators and world overlays
retain their specialized ownership. Vue can reuse the view contract and CSS while
replacing DOM construction. Browser acceptance:
`QA_BASE_URL=http://127.0.0.1:4189 node scripts/qa-status-badges.mjs`.
## Ordinary action buttons
Scene panel actions, shot LOAD/DEL actions, the EDIT DETAILS / SHARE SCENE
bar and one-shot CCTV actions use `ui/actionButton.js` and the shared
`gev-action-button` surface in `ui/styles/action-button.css`. Static templates
apply the same classes as generated buttons. The native builder accepts translated
text, default/danger variants, default/compact sizes and disabled/accessibility
properties; it installs no listeners and owns no application state. Feature
controllers retain callbacks, enable/disable rules, confirmation prompts,
persistence and teardown. The smaller shot actions retain their existing sizing
and hover treatment. Feature CSS still owns panel layout and translated wrapping.
CCTV enable, coverage, auto-hop, projection and adjustment toggles, editable
calibration-value chips and the scene authoring dialogs retain their specialized
controls. Existing `scene-btn` hooks remain compatible with Street Level and
legacy toggles. The shared CSS, explicit input/action boundary and behavior tests
can carry to a Vue view while Vue replaces native DOM construction.
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs`;
`qa-scene-controls.mjs` also covers real Scene authoring/playback with native Space capture.
## Segmented selectors ## Segmented selectors
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js` Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
@@ -5952,3 +5993,20 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye
Military fragments stitch by source identity. Different mapped parcels merge only Military fragments stitch by source identity. Different mapped parcels merge only
when their footprints touch or contain one another, never across a seam tolerance gap. when their footprints touch or contain one another, never across a seam tolerance gap.
### Shared native dropdowns and numeric fields
- Scene and CCTV selects share the default `gev-field` surface. HUD layout and
draw colour retain compact density. Radio filter, tuner gain and FM frequency
use the same base while retaining their feature-specific skins and layout.
- The CCTV inline number editor uses `createNumberInput` with its existing
precision and inline density. Enter/blur commit, Escape cancellation, frozen
camera base, camera-switch guards and disposal remain with CCTV controls.
- Options, values, native keyboard/typeahead, disabled attributes and browser
validity remain native. Frequency bounds, tuner commit timing and calibration
parsing are unchanged. Specialized tuner graphics and calibration chips
retain their own controls. Provider settings and scene-authoring dialogs are
outside this change.
- `scripts/qa-form-fields.mjs` exercises native selections, number validity,
calibration commit/cancel and stale-camera/disposal guards against an installed
controller with fixture ports, plus default/Cyber and narrow translated layouts.
+9
View File
@@ -2,6 +2,8 @@
".prettierrc.json", ".prettierrc.json",
"package.json", "package.json",
"scripts/format-scope.json", "scripts/format-scope.json",
"src/ui/styles/form-field.css",
"scripts/qa-form-fields.mjs",
"scripts/package-boundaries.json", "scripts/package-boundaries.json",
"scripts/format.mjs", "scripts/format.mjs",
"scripts/next-layer-state-token.mjs", "scripts/next-layer-state-token.mjs",
@@ -281,6 +283,7 @@
"src/ui/scenePresentation.js", "src/ui/scenePresentation.js",
"src/ui/sceneControls.test.mjs", "src/ui/sceneControls.test.mjs",
"scripts/qa-scene-controls.mjs", "scripts/qa-scene-controls.mjs",
"scripts/qa-action-buttons.mjs",
"src/ui.js", "src/ui.js",
"src/standalone/ui.js", "src/standalone/ui.js",
"src/ui/applicationShell.js", "src/ui/applicationShell.js",
@@ -305,6 +308,7 @@
"src/ui/styles/layers.css", "src/ui/styles/layers.css",
"src/ui/styles/controls.css", "src/ui/styles/controls.css",
"src/ui/styles/slider.css", "src/ui/styles/slider.css",
"src/ui/styles/action-button.css",
"src/ui/styles/cctv.css", "src/ui/styles/cctv.css",
"src/ui/styles/foundation.css", "src/ui/styles/foundation.css",
"src/ui/styles/first-run.css", "src/ui/styles/first-run.css",
@@ -1069,6 +1073,11 @@
"src/data/gbfsProxyRateLimit.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs",
"src/terrainHeightsBounds.test.mjs", "src/terrainHeightsBounds.test.mjs",
"server/providers/overpass/requestAdmission.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",
"server/providers/common/rateLimitKeys.test.mjs", "server/providers/common/rateLimitKeys.test.mjs",
"src/tooling/trackArrivalObservation.test.mjs" "src/tooling/trackArrivalObservation.test.mjs"
] ]
+15 -4
View File
@@ -500,7 +500,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
"src/ui/statusText.js" "src/ui/statusText.js",
"src/ui/statusBadge.js"
], ],
"external": [] "external": []
}, },
@@ -541,6 +542,7 @@
"src/ui/cctv.js", "src/ui/cctv.js",
"src/ui/cctvBindings.js", "src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js", "src/ui/cctvCalibration.js",
"src/ui/formField.js",
"src/ui/cctvControls.js", "src/ui/cctvControls.js",
"src/ui/cctvFrames.js", "src/ui/cctvFrames.js",
"src/ui/cctvVideo.js", "src/ui/cctvVideo.js",
@@ -549,7 +551,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js",
"src/ui/statusBadge.js"
], ],
"external": [] "external": []
}, },
@@ -616,6 +619,7 @@
"src/ui/scenes.js", "src/ui/scenes.js",
"src/ui/sceneControls.js", "src/ui/sceneControls.js",
"src/ui/scenePresentation.js", "src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
@@ -677,6 +681,7 @@
"src/ui/cctv.js", "src/ui/cctv.js",
"src/ui/cctvBindings.js", "src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js", "src/ui/cctvCalibration.js",
"src/ui/formField.js",
"src/ui/cctvControls.js", "src/ui/cctvControls.js",
"src/ui/cctvFrames.js", "src/ui/cctvFrames.js",
"src/ui/cctvVideo.js", "src/ui/cctvVideo.js",
@@ -739,6 +744,7 @@
"src/ui/rightPanelRail.js", "src/ui/rightPanelRail.js",
"src/ui/sceneControls.js", "src/ui/sceneControls.js",
"src/ui/scenePresentation.js", "src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/ui/scenes.js", "src/ui/scenes.js",
"src/ui/shareRestoration.js", "src/ui/shareRestoration.js",
"src/ui/shellElements.js", "src/ui/shellElements.js",
@@ -773,7 +779,8 @@
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
"src/ui/layerNames.js", "src/ui/layerNames.js",
"src/ui/statusText.js" "src/ui/statusText.js",
"src/ui/statusBadge.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -851,6 +858,7 @@
"src/ui/scenes.js", "src/ui/scenes.js",
"src/ui/sceneControls.js", "src/ui/sceneControls.js",
"src/ui/scenePresentation.js", "src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/contextModePolicy.js", "src/contextModePolicy.js",
"src/director/clock.js", "src/director/clock.js",
"src/director/timeline.js", "src/director/timeline.js",
@@ -2153,6 +2161,7 @@
"src/ui/cctv.js", "src/ui/cctv.js",
"src/ui/cctvBindings.js", "src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js", "src/ui/cctvCalibration.js",
"src/ui/formField.js",
"src/ui/cctvControls.js", "src/ui/cctvControls.js",
"src/ui/cctvFrames.js", "src/ui/cctvFrames.js",
"src/ui/cctvVideo.js", "src/ui/cctvVideo.js",
@@ -2216,6 +2225,7 @@
"src/ui/rightPanelRail.js", "src/ui/rightPanelRail.js",
"src/ui/sceneControls.js", "src/ui/sceneControls.js",
"src/ui/scenePresentation.js", "src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/ui/scenes.js", "src/ui/scenes.js",
"src/ui/shareRestoration.js", "src/ui/shareRestoration.js",
"src/ui/shellElements.js", "src/ui/shellElements.js",
@@ -2508,7 +2518,8 @@
"src/voice/providerCapabilities.js", "src/voice/providerCapabilities.js",
"src/voice/geminiSession.js", "src/voice/geminiSession.js",
"src/voice/geminiAudio.js", "src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js" "src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
+335
View File
@@ -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;
+465
View File
@@ -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;
+2 -1
View File
@@ -66,7 +66,8 @@ try {
); );
}), }),
); );
await page.click('#scene-capture-btn'); await page.focus('#scene-capture-btn');
await page.keyboard.press('Space');
await page.waitForFunction( await page.waitForFunction(
() => document.querySelectorAll('.scene-shot-row').length === 1, () => document.querySelectorAll('.scene-shot-row').length === 1,
); );
+276
View File
@@ -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;
+1 -1
View File
@@ -69,7 +69,7 @@ test('focusable controls do not animate the global outline', () => {
'#top-center-actions button {', '#top-center-actions button {',
'.data-toggle-chip {', '.data-toggle-chip {',
'.scene-btn {', '.scene-btn {',
'.scene-shot-btn {', '.gev-action-button--compact {',
]) { ]) {
assert.doesNotMatch( assert.doesNotMatch(
ruleBody(selector), ruleBody(selector),
+41
View File
@@ -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;
}
+112
View File
@@ -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');
});
+7 -5
View File
@@ -1,4 +1,5 @@
import { formatNumber, t, msg } from '../i18n/index.js'; import { formatNumber, t, msg } from '../i18n/index.js';
import { createNumberInput } from './formField.js';
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ /** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
@@ -117,11 +118,12 @@ export function _beginCctvCalValueEdit(chip) {
const basePose = { ...activeCamera.basePose }; const basePose = { ...activeCamera.basePose };
const editListeners = new AbortController(); const editListeners = new AbortController();
const startValue = field.get(activeCamera); const startValue = field.get(activeCamera);
const input = document.createElement('input'); const input = createNumberInput({
input.type = 'number'; className: 'gev-field--inline cctv-cal-input',
input.step = field.decimals > 0 ? '0.1' : '1'; step: field.decimals > 0 ? '0.1' : '1',
input.value = Number(startValue).toFixed(field.decimals); value: Number(startValue).toFixed(field.decimals),
input.className = 'cctv-cal-input'; ariaLabel: t(field.label),
});
chip.textContent = `${t(field.label)} `; chip.textContent = `${t(field.label)} `;
chip.appendChild(input); chip.appendChild(input);
input.focus(); input.focus();
+3
View File
@@ -105,6 +105,9 @@ function calibrationFixture(t) {
Object.assign(input, { Object.assign(input, {
focus() {}, focus() {},
select() {}, select() {},
setAttribute(name, value) {
this[name] = value;
},
remove() { remove() {
this.parent.input = null; this.parent.input = null;
}, },
+7 -4
View File
@@ -1,3 +1,4 @@
import { updateStatusBadge } from './statusBadge.js';
import { msg, t } from '../i18n/index.js'; import { msg, t } from '../i18n/index.js';
/** /**
@@ -164,19 +165,21 @@ export function _settleCctvFrame(token, src, ok) {
export function _syncCctvSourceBadge(activeCamera, enabled) { export function _syncCctvSourceBadge(activeCamera, enabled) {
if (!this._cctvSourceBadge) return; if (!this._cctvSourceBadge) return;
if (!enabled || !activeCamera) { if (!enabled || !activeCamera) {
this._cctvSourceBadge.textContent = t('SOURCE · UNKNOWN'); updateStatusBadge(this._cctvSourceBadge, { label: t('SOURCE · UNKNOWN') });
this._cctvSourceBadge.dataset.frameState = 'idle'; this._cctvSourceBadge.dataset.frameState = 'idle';
return; return;
} }
const hasDisplayedFrame = const hasDisplayedFrame =
this._cctvFrameWrap?.classList.contains('has-frame'); this._cctvFrameWrap?.classList.contains('has-frame');
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
this._cctvSourceBadge.textContent = t('FRAME · LOADING'); updateStatusBadge(this._cctvSourceBadge, { label: t('FRAME · LOADING') });
this._cctvSourceBadge.dataset.frameState = 'loading'; this._cctvSourceBadge.dataset.frameState = 'loading';
return; return;
} }
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
this._cctvSourceBadge.textContent = t('FRAME · UNAVAILABLE'); updateStatusBadge(this._cctvSourceBadge, {
label: t('FRAME · UNAVAILABLE'),
});
this._cctvSourceBadge.dataset.frameState = 'error'; this._cctvSourceBadge.dataset.frameState = 'error';
return; return;
} }
@@ -184,6 +187,6 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
activeCamera.sourceKind || activeCamera.feedType || 'unknown', activeCamera.sourceKind || activeCamera.feedType || 'unknown',
); );
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown'); const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
this._cctvSourceBadge.textContent = `${kind} · ${status}`; updateStatusBadge(this._cctvSourceBadge, { label: `${kind} · ${status}` });
this._cctvSourceBadge.dataset.frameState = 'ready'; this._cctvSourceBadge.dataset.frameState = 'ready';
} }
+8 -5
View File
@@ -1,3 +1,4 @@
import { updateStatusBadge } from './statusBadge.js';
import { t, tn } from '../i18n/index.js'; import { t, tn } from '../i18n/index.js';
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js'; import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
@@ -137,11 +138,13 @@ export function _renderCctvState(state) {
// promotes to CALIBRATED, RESET CAL clears. // promotes to CALIBRATED, RESET CAL clears.
const badge = activeCamera?.calBadge || null; const badge = activeCamera?.calBadge || null;
const dirty = !!activeCamera?.calDirty; const dirty = !!activeCamera?.calDirty;
this._cctvQualityChip.textContent = dirty updateStatusBadge(this._cctvQualityChip, {
? t('CAL · EDITED (UNSAVED)') label: dirty
: t('CAL · {badge}', { ? t('CAL · EDITED (UNSAVED)')
badge: this._calBadgeLabel(badge), : t('CAL · {badge}', {
}); badge: this._calBadgeLabel(badge),
}),
});
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || ''; this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
} }
+36
View File
@@ -0,0 +1,36 @@
/**
* Build a native numeric field without listeners, parsing or commit policy.
* Owners retain drafts, validation, input/change timing and disposal. Static
* selects and number inputs share the gev-field surface in their templates;
* options remain owned by each feature (no replacement popup or keyboard code).
* @param {object} options
* @param {Document} [options.documentRef]
* @param {string} [options.className] Additional density/layout hooks.
* @param {number|string} [options.min]
* @param {number|string} [options.max]
* @param {number|string} [options.step]
* @param {number|string} [options.value]
* @param {boolean} [options.disabled]
* @param {string} [options.ariaLabel] Already translated accessible name.
* @returns {HTMLInputElement}
*/
export function createNumberInput({
documentRef = globalThis.document,
className = '',
min,
max,
step,
value,
disabled = false,
ariaLabel,
} = {}) {
const input = documentRef.createElement('input');
input.type = 'number';
input.className = `gev-field${className ? ` ${className}` : ''}`;
for (const [key, setting] of Object.entries({ min, max, step, value })) {
if (setting !== undefined) input[key] = setting;
}
input.disabled = disabled;
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
return input;
}
+11 -7
View File
@@ -1,4 +1,5 @@
import { createRailCardBlocks } from './railCardBlocks.js'; import { createRailCardBlocks } from './railCardBlocks.js';
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
const set = (node, key, value) => { const set = (node, key, value) => {
if (node[key] !== value) node[key] = value; if (node[key] !== value) node[key] = value;
@@ -53,11 +54,12 @@ export function createRailCards({
'rail-card-title rail-card-nowrap', 'rail-card-title rail-card-nowrap',
heading, heading,
); );
const badge = make( const badge = createStatusBadge({
'span', documentRef: document,
`rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`, appearance: 'plain',
labels, className: `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
); });
labels.appendChild(badge);
const disclosure = make('span', 'rail-card-disclosure', header); const disclosure = make('span', 'rail-card-disclosure', header);
disclosure.setAttribute('aria-hidden', 'true'); disclosure.setAttribute('aria-hidden', 'true');
const compact = make('span', 'rail-card-compact', header); const compact = make('span', 'rail-card-compact', header);
@@ -90,8 +92,10 @@ export function createRailCards({
row.title.setAttribute('title', card.title); row.title.setAttribute('title', card.title);
set(row.icon, 'textContent', card.icon || ''); set(row.icon, 'textContent', card.icon || '');
set(row.icon, 'hidden', !card.icon); set(row.icon, 'hidden', !card.icon);
set(row.badge, 'textContent', card.badge || ''); updateStatusBadge(row.badge, {
set(row.badge, 'hidden', !card.badge); label: card.badge || '',
hidden: !card.badge,
});
const open = card.open !== false; const open = card.open !== false;
set(row.element.dataset, 'open', String(open)); set(row.element.dataset, 'open', String(open));
row.element.classList.toggle('is-open', open); row.element.classList.toggle('is-open', open);
+12 -6
View File
@@ -1,5 +1,6 @@
/** Scene panel elements and text-only project presentation. */ /** Scene panel elements and text-only project presentation. */
import { formatNumber, msg, t } from '../i18n/index.js'; import { formatNumber, msg, t } from '../i18n/index.js';
import { createActionButton } from './actionButton.js';
// Labels for what a shot stores; anything else shows as stored. Style names // Labels for what a shot stores; anything else shows as stored. Style names
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot // match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
@@ -121,13 +122,18 @@ export function renderSceneShots(
}); });
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'scene-shot-actions'; actions.className = 'scene-shot-actions';
const loadButton = document.createElement('button'); const loadButton = createActionButton({
loadButton.className = 'scene-shot-btn'; label: t('LOAD'),
loadButton.textContent = t('LOAD'); className: 'scene-shot-btn',
size: 'compact',
});
listen(loadButton, 'click', () => load(scene.id, shot.id)); listen(loadButton, 'click', () => load(scene.id, shot.id));
const deleteButton = document.createElement('button'); const deleteButton = createActionButton({
deleteButton.className = 'scene-shot-btn scene-shot-danger'; label: t('DEL'),
deleteButton.textContent = t('DEL'); className: 'scene-shot-btn scene-shot-danger',
size: 'compact',
variant: 'danger',
});
listen(deleteButton, 'click', () => remove(scene.id, shot.id)); listen(deleteButton, 'click', () => remove(scene.id, shot.id));
actions.appendChild(loadButton); actions.appendChild(loadButton);
actions.appendChild(deleteButton); actions.appendChild(deleteButton);
+2 -4
View File
@@ -1,4 +1,5 @@
import { t, tn } from '../i18n/index.js'; import { t, tn } from '../i18n/index.js';
import { createActionButton } from './actionButton.js';
/** Small owned modal for scene authoring and import review; text never becomes markup. */ /** Small owned modal for scene authoring and import review; text never becomes markup. */
export function createSceneDialog(title, onClose) { export function createSceneDialog(title, onClose) {
@@ -185,10 +186,7 @@ export function mountSceneSharing(
]; ];
const removers = []; const removers = [];
for (const [text, fn] of entries) { for (const [text, fn] of entries) {
const b = document.createElement('button'); const b = createActionButton({ label: text, className: 'scene-btn' });
b.type = 'button';
b.textContent = text;
b.className = 'scene-btn';
b.addEventListener('click', fn); b.addEventListener('click', fn);
removers.push(() => b.removeEventListener('click', fn)); removers.push(() => b.removeEventListener('click', fn));
bar.append(b); bar.append(b);
+39
View File
@@ -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;
}
+78
View File
@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
import { createRailCards } from './railCards.js';
import { railFixture } from './railTestFixture.mjs';
test('badges paint literal translated text without installing announcement or interaction semantics', () => {
const { document } = railFixture();
for (const appearance of ['pill', 'box', 'plain']) {
const badge = createStatusBadge({
documentRef: document,
appearance,
className: 'feature-hook',
label: '<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
});
assert.equal(badge.tagName, 'SPAN');
assert.equal(
badge.textContent,
'<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
);
assert.equal(badge.children.length, 0);
assert.equal(badge.classList.contains('gev-status-badge'), true);
assert.equal(badge.classList.contains('feature-hook'), true);
assert.equal(badge.getAttribute('role'), null);
assert.equal(badge.getAttribute('aria-live'), null);
assert.equal(badge.getAttribute('tabindex'), null);
assert.equal(
badge.classList.contains(`gev-status-badge--${appearance}`),
appearance !== 'pill',
);
}
});
test('painting preserves feature state, live regions and focus with zero identical writes', () => {
let writes = 0;
const { document } = railFixture(() => writes++);
const badge = createStatusBadge({ documentRef: document, hidden: true });
badge.setAttribute('role', 'status');
badge.setAttribute('aria-live', 'polite');
badge.dataset.frameState = 'ready';
const focusOwner = document.createElement('button');
focusOwner.focus();
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
assert.equal(badge.hidden, false);
writes = 0;
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
assert.equal(writes, 0);
assert.equal(badge.getAttribute('role'), 'status');
assert.equal(badge.getAttribute('aria-live'), 'polite');
assert.equal(badge.dataset.frameState, 'ready');
assert.equal(document.activeElement, focusOwner);
});
test('coverage badges keep keyed identity, hide empty coverage and never claim feed health', () => {
const f = railFixture();
const view = createRailCards({ ...f, badgeClassName: 'weather-coverage' });
const card = { id: 'radar', title: 'Rain radar', badge: 'CONUS', blocks: [] };
view.update([card]);
const badge = f.find((node) => node.classList.contains('gev-status-badge'));
const header = f.container.children[0].children[0];
header.focus();
assert.equal(badge.classList.contains('gev-status-badge--plain'), true);
assert.equal(badge.classList.contains('weather-coverage'), true);
view.update([{ ...card, badge: '' }]);
assert.equal(badge.hidden, true);
view.update([{ ...card, badge: 'Global · 1° grid' }]);
assert.equal(badge.hidden, false);
assert.equal(badge.textContent, 'Global · 1° grid');
assert.equal(
f.find((node) => node.classList.contains('gev-status-badge')),
badge,
);
assert.deepEqual({ ...badge.dataset }, {});
assert.equal(f.document.activeElement, header);
view.destroy();
view.update([card]);
assert.equal(f.container.children.length, 0);
});
+59
View File
@@ -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;
}
+18 -72
View File
@@ -129,26 +129,6 @@ html:not([lang='en']) .cctv-controls .scene-btn {
background: rgba(0, 212, 255, 0.08); background: rgba(0, 212, 255, 0.08);
} }
.cctv-cal-input {
width: 58px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.7px;
color: var(--text-primary);
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.5);
border-radius: 4px;
padding: 1px 3px;
outline: none;
-moz-appearance: textfield;
}
.cctv-cal-input::-webkit-outer-spin-button,
.cctv-cal-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.cctv-calibration-actions { .cctv-calibration-actions {
margin-top: 8px; margin-top: 8px;
} }
@@ -162,26 +142,9 @@ html:not([lang='en']) .cctv-controls .scene-btn {
#cctv-camera-select { #cctv-camera-select {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-primary);
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
outline: none;
}
#cctv-camera-select:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
#cctv-camera-select:disabled {
opacity: 0.45;
} }
#cctv-enable-btn.active, #cctv-enable-btn.active,
@@ -194,41 +157,32 @@ html:not([lang='en']) .cctv-controls .scene-btn {
} }
#cctv-quality-chip { #cctv-quality-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 90px; min-width: 90px;
padding: 6px 8px; --status-badge-border: rgba(0, 212, 255, 0.2);
border: 1px solid rgba(0, 212, 255, 0.2); --status-badge-color: #8fe7ff;
border-radius: 8px; --status-badge-background: rgba(0, 212, 255, 0.08);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1px;
color: #8fe7ff;
background: rgba(0, 212, 255, 0.08);
text-transform: uppercase;
} }
/* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no /* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no
in-world tint). Three visually distinct states: calibrated / curated / in-world tint). Three visually distinct states: calibrated / curated /
raw-prior. */ raw-prior. */
#cctv-quality-chip[data-cal-badge='calibrated'] { #cctv-quality-chip[data-cal-badge='calibrated'] {
color: #adffbf; --status-badge-color: #adffbf;
border-color: rgba(141, 255, 135, 0.42); --status-badge-border: rgba(141, 255, 135, 0.42);
background: rgba(141, 255, 135, 0.12); --status-badge-background: rgba(141, 255, 135, 0.12);
} }
#cctv-quality-chip[data-cal-badge='curated'] { #cctv-quality-chip[data-cal-badge='curated'] {
color: #ffe6a2; --status-badge-color: #ffe6a2;
border-color: rgba(255, 217, 122, 0.35); --status-badge-border: rgba(255, 217, 122, 0.35);
background: rgba(255, 217, 122, 0.1); --status-badge-background: rgba(255, 217, 122, 0.1);
} }
/* Unsaved live edits (save-gated persistence, viewshed design §3e). */ /* Unsaved live edits (save-gated persistence, viewshed design §3e). */
#cctv-quality-chip[data-cal-badge='edited'] { #cctv-quality-chip[data-cal-badge='edited'] {
color: #ffd08a; --status-badge-color: #ffd08a;
border-color: rgba(255, 176, 82, 0.5); --status-badge-border: rgba(255, 176, 82, 0.5);
background: rgba(255, 176, 82, 0.14); --status-badge-background: rgba(255, 176, 82, 0.14);
} }
.cctv-frame-wrap { .cctv-frame-wrap {
@@ -346,26 +300,18 @@ html:not([lang='en']) .cctv-controls .scene-btn {
#cctv-source-badge { #cctv-source-badge {
align-self: flex-start; align-self: flex-start;
border: 1px solid rgba(0, 212, 255, 0.32);
border-radius: 999px;
background: rgba(0, 212, 255, 0.09);
color: #9defff;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.3px;
padding: 4px 8px;
} }
#cctv-source-badge[data-frame-state='loading'] { #cctv-source-badge[data-frame-state='loading'] {
color: #ffe59b; --status-badge-color: #ffe59b;
border-color: rgba(255, 205, 92, 0.45); --status-badge-border: rgba(255, 205, 92, 0.45);
background: rgba(255, 205, 92, 0.1); --status-badge-background: rgba(255, 205, 92, 0.1);
} }
#cctv-source-badge[data-frame-state='error'] { #cctv-source-badge[data-frame-state='error'] {
color: #ffb0b0; --status-badge-color: #ffb0b0;
border-color: rgba(255, 102, 102, 0.5); --status-badge-border: rgba(255, 102, 102, 0.5);
background: rgba(255, 72, 72, 0.11); --status-badge-background: rgba(255, 72, 72, 0.11);
} }
.cctv-summary-label { .cctv-summary-label {
-10
View File
@@ -727,16 +727,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
.pp-select { .pp-select {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 24px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
padding: 2px 6px;
outline: none;
} }
/* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */ /* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */
+1
View File
@@ -171,6 +171,7 @@
.dock-tray-toggle, .dock-tray-toggle,
.dock-popover, .dock-popover,
.scene-btn, .scene-btn,
.gev-action-button:not(.gev-action-button--compact),
.context-mode-button, .context-mode-button,
.map-stack-chip, .map-stack-chip,
.key-setup-chip, .key-setup-chip,
+51
View File
@@ -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;
}
-66
View File
@@ -153,14 +153,6 @@
#scene-select { #scene-select {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-primary);
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
outline: none;
} }
/* A translated scene name needs the width: the select takes its own row, /* A translated scene name needs the width: the select takes its own row,
@@ -184,21 +176,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
} }
.scene-btn { .scene-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1.2px;
padding: 6px 9px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast);
white-space: nowrap;
flex: 1; flex: 1;
min-width: 72px; min-width: 72px;
} }
@@ -295,49 +272,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
gap: 6px; gap: 6px;
} }
.scene-shot-btn {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.8px;
padding: 4px 6px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.scene-shot-btn:hover {
color: var(--text-primary);
border-color: var(--glass-border-hover);
}
.scene-shot-btn.scene-shot-danger:hover {
color: #ff7e7e;
border-color: #ff7e7e;
}
.scene-btn:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
background: rgba(0, 212, 255, 0.14);
}
.scene-btn:disabled {
opacity: 0.45;
cursor: default;
}
.scene-btn-danger:hover:not(:disabled) {
color: #ff7272;
border-color: #ff7272;
background: rgba(255, 88, 88, 0.15);
}
.scene-progress { .scene-progress {
margin-top: 8px; margin-top: 8px;
width: 100%; width: 100%;
+43
View File
@@ -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;
}
-4
View File
@@ -133,7 +133,6 @@
margin-left: auto; margin-left: auto;
text-align: right; text-align: right;
} }
.rail-card-badge,
.rail-card-line, .rail-card-line,
.rail-timeline-readout { .rail-timeline-readout {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -304,9 +303,6 @@ html:not([lang='en']) .rail-card-zero::after {
outline: 1px solid var(--accent); outline: 1px solid var(--accent);
outline-offset: 3px; outline-offset: 3px;
} }
.rail-card-badge {
text-transform: uppercase;
}
.rail-card-compact { .rail-card-compact {
grid-column: 1 / -1; grid-column: 1 / -1;
min-width: 0; min-width: 0;
+3 -3
View File
@@ -121,7 +121,7 @@
<div class="radio-directory-row"> <div class="radio-directory-row">
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button> <button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label> <label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
<select id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label> <select class="gev-field" id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
<option value="all" data-i18n>All</option> <option value="all" data-i18n>All</option>
</select> </select>
</div> </div>
@@ -180,7 +180,7 @@
</div> </div>
<div class="sdr-gain-row"> <div class="sdr-gain-row">
<label for="sdr-gain-select" data-i18n>GAIN</label> <label for="sdr-gain-select" data-i18n>GAIN</label>
<select id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label> <select class="gev-field" id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
<option value="auto" data-i18n>AUTO</option> <option value="auto" data-i18n>AUTO</option>
<option value="0.0" translate="no">0.0 dB</option> <option value="0.0" translate="no">0.0 dB</option>
<option value="0.9" translate="no">0.9 dB</option> <option value="0.9" translate="no">0.9 dB</option>
@@ -221,7 +221,7 @@
</div> </div>
<div class="sdr-frequency-row"> <div class="sdr-frequency-row">
<label for="sdr-frequency-input" data-i18n>FM MHZ</label> <label for="sdr-frequency-input" data-i18n>FM MHZ</label>
<input id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" /> <input class="gev-field" id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
<button id="sdr-tune-btn" type="button" data-i18n>TUNE</button> <button id="sdr-tune-btn" type="button" data-i18n>TUNE</button>
</div> </div>
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label> <div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
+2 -2
View File
@@ -13,7 +13,7 @@
</button> </button>
<div class="pp-slider-row" id="hud-layout-row"> <div class="pp-slider-row" id="hud-layout-row">
<span class="pp-slider-mini-label" data-i18n>Layout</span> <span class="pp-slider-mini-label" data-i18n>Layout</span>
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout" data-i18n-aria-label> <select id="hud-layout-select" class="pp-select gev-field gev-field--compact" aria-label="HUD layout" data-i18n-aria-label>
<option value="tactical" data-i18n>Tactical</option> <option value="tactical" data-i18n>Tactical</option>
<option value="operator" data-i18n>Operator</option> <option value="operator" data-i18n>Operator</option>
<option value="minimal" data-i18n>Minimal</option> <option value="minimal" data-i18n>Minimal</option>
@@ -136,7 +136,7 @@
</div> </div>
<div class="pp-slider-row" id="draw-label-row"> <div class="pp-slider-row" id="draw-label-row">
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false"> <input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
<select class="pp-select draw-color-select" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label> <select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
<option value="primary" data-i18n>Primary</option> <option value="primary" data-i18n>Primary</option>
<option value="amber" data-i18n>Amber</option> <option value="amber" data-i18n>Amber</option>
<option value="cyan" data-i18n>Cyan</option> <option value="cyan" data-i18n>Cyan</option>
+20 -20
View File
@@ -27,25 +27,25 @@
<img id="cctv-frame" alt="CCTV feed frame" /> <img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas> <canvas id="cctv-video" class="cctv-frame" hidden></canvas>
</div> </div>
<div id="cctv-source-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div> <div id="cctv-source-badge" class="gev-status-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
<div id="cctv-meta" data-i18n>Enable CCTV to load camera intersections</div> <div id="cctv-meta" data-i18n>Enable CCTV to load camera intersections</div>
<div class="cctv-controls"> <div class="cctv-controls">
<button id="cctv-enable-btn" class="scene-btn" data-i18n>CCTV OFF</button> <button id="cctv-enable-btn" class="scene-btn" data-i18n>CCTV OFF</button>
<button id="cctv-nearest-btn" class="scene-btn" data-i18n>NEAREST</button> <button id="cctv-nearest-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEAREST</button>
</div> </div>
<div class="cctv-controls"> <div class="cctv-controls">
<button id="cctv-prev-btn" class="scene-btn" data-i18n>PREV</button> <button id="cctv-prev-btn" type="button" class="gev-action-button scene-btn" data-i18n>PREV</button>
<select id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select> <select class="gev-field" id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
<button id="cctv-next-btn" class="scene-btn" data-i18n>NEXT</button> <button id="cctv-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
</div> </div>
<div class="cctv-controls"> <div class="cctv-controls">
<button id="cctv-focus-btn" class="scene-btn" data-i18n>FOCUS</button> <button id="cctv-focus-btn" type="button" class="gev-action-button scene-btn" data-i18n>FOCUS</button>
<button id="cctv-coverage-btn" class="scene-btn" data-i18n>COVERAGE OFF</button> <button id="cctv-coverage-btn" class="scene-btn" data-i18n>COVERAGE OFF</button>
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n>AUTO HOP OFF</button> <button id="cctv-auto-hop-btn" class="scene-btn" data-i18n>AUTO HOP OFF</button>
</div> </div>
<div class="cctv-controls"> <div class="cctv-controls">
<button id="cctv-projection-btn" class="scene-btn" data-i18n>PROJECTION ON</button> <button id="cctv-projection-btn" class="scene-btn" data-i18n>PROJECTION ON</button>
<span id="cctv-quality-chip" data-i18n-runtime>CAL · --</span> <span id="cctv-quality-chip" class="gev-status-badge gev-status-badge--box" data-i18n-runtime>CAL · --</span>
</div> </div>
<div class="cctv-calibration-block"> <div class="cctv-calibration-block">
<div class="cctv-calibration-header"> <div class="cctv-calibration-header">
@@ -62,8 +62,8 @@
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button> <button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
</div> </div>
<div class="cctv-controls cctv-calibration-actions"> <div class="cctv-controls cctv-calibration-actions">
<button id="cctv-calib-save-btn" class="scene-btn" data-i18n>SAVE CAL</button> <button id="cctv-calib-save-btn" type="button" class="gev-action-button scene-btn" data-i18n>SAVE CAL</button>
<button id="cctv-calib-reset-btn" class="scene-btn" data-i18n>RESET CAL</button> <button id="cctv-calib-reset-btn" type="button" class="gev-action-button scene-btn" data-i18n>RESET CAL</button>
</div> </div>
</div> </div>
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div> <div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
@@ -152,24 +152,24 @@
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title>+</button> <button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title>+</button>
</div> </div>
<div class="scene-controls"> <div class="scene-controls">
<select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select> <select class="gev-field" id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
<button id="scene-new-btn" class="scene-btn" data-i18n>NEW</button> <button id="scene-new-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEW</button>
<button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n>DEL</button> <button id="scene-delete-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>DEL</button>
</div> </div>
<div class="scene-controls"> <div class="scene-controls">
<button id="scene-capture-btn" class="scene-btn" data-i18n>CAPTURE SHOT</button> <button id="scene-capture-btn" type="button" class="gev-action-button scene-btn" data-i18n>CAPTURE SHOT</button>
<button id="scene-update-shot-btn" class="scene-btn" data-i18n>UPDATE SHOT</button> <button id="scene-update-shot-btn" type="button" class="gev-action-button scene-btn" data-i18n>UPDATE SHOT</button>
</div> </div>
<div id="scene-shot-list" class="scene-shot-list"></div> <div id="scene-shot-list" class="scene-shot-list"></div>
<div class="scene-controls"> <div class="scene-controls">
<button id="scene-start-btn" class="scene-btn" data-i18n>START</button> <button id="scene-start-btn" type="button" class="gev-action-button scene-btn" data-i18n>START</button>
<button id="scene-stop-btn" class="scene-btn scene-btn-danger" data-i18n>STOP</button> <button id="scene-stop-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>STOP</button>
<button id="scene-next-btn" class="scene-btn" data-i18n>NEXT</button> <button id="scene-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
</div> </div>
<div class="scene-controls"> <div class="scene-controls">
<button id="scene-export-btn" class="scene-btn" data-i18n>EXPORT PRESETS</button> <button id="scene-export-btn" type="button" class="gev-action-button scene-btn" data-i18n>EXPORT PRESETS</button>
<button id="scene-import-btn" class="scene-btn" data-i18n>IMPORT</button> <button id="scene-import-btn" type="button" class="gev-action-button scene-btn" data-i18n>IMPORT</button>
<button id="scene-download-btn" class="scene-btn" data-i18n>RUN LOG</button> <button id="scene-download-btn" type="button" class="gev-action-button scene-btn" data-i18n>RUN LOG</button>
<input type="file" id="scene-import-file" accept="application/json,.json" hidden /> <input type="file" id="scene-import-file" accept="application/json,.json" hidden />
</div> </div>
<div class="scene-progress"> <div class="scene-progress">
+3
View File
@@ -1,5 +1,8 @@
/* Preserve component and responsive rule order in one stylesheet entry. */ /* Preserve component and responsive rule order in one stylesheet entry. */
@import './src/ui/styles/foundation.css'; @import './src/ui/styles/foundation.css';
@import './src/ui/styles/action-button.css';
@import './src/ui/styles/form-field.css';
@import './src/ui/styles/status-badge.css';
@import './src/ui/styles/slider.css'; @import './src/ui/styles/slider.css';
@import './src/ui/styles/controls.css'; @import './src/ui/styles/controls.css';
@import './src/ui/styles/location.css'; @import './src/ui/styles/location.css';