mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor(ui): share read-only status badges
This commit is contained in:
1 parent
8ed1bbbd0f
commit
f70bc0cabb
16 files changed
+640
-62
No files matched your search
@@ -748,3 +748,14 @@ 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.
|
||||
@@ -1233,6 +1233,27 @@ 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
|
||||
|
||||
@@ -1073,5 +1073,9 @@
|
||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||
"src/terrainHeightsBounds.test.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.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"
|
||||
]
|
||||
@@ -500,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": []
|
||||
},
|
||||
@@ -550,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": []
|
||||
},
|
||||
@@ -777,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"]
|
||||
},
|
||||
@@ -2515,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",
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
|
||||
function fixture(t) {
|
||||
const { document } = railFixture();
|
||||
const controls = new CctvControls({
|
||||
elements: {
|
||||
_cctvQualityChip: document.createElement('span'),
|
||||
_cctvSourceBadge: document.createElement('div'),
|
||||
},
|
||||
cctv: {},
|
||||
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
return { controls, document };
|
||||
}
|
||||
|
||||
test('source and frame states retain their distinct meanings, including retained pixels', (testContext) => {
|
||||
const { controls, document } = fixture(testContext);
|
||||
const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
|
||||
controls._cctvFrame = document.createElement('img');
|
||||
controls._cctvFrameWrap = document.createElement('div');
|
||||
controls._cctvFrame.removeAttribute = () => {};
|
||||
for (const node of [controls._cctvFrame, controls._cctvFrameWrap]) {
|
||||
node.classList.remove = (...names) =>
|
||||
names.forEach((name) => node.classList.toggle(name, false));
|
||||
}
|
||||
const expect = (label, state, active = camera, enabled = true) => {
|
||||
controls._syncCctvSourceBadge(active, enabled);
|
||||
assert.equal(controls._cctvSourceBadge.textContent, label);
|
||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, state);
|
||||
};
|
||||
expect('SOURCE · UNKNOWN', 'idle', camera, false);
|
||||
expect('SOURCE · UNKNOWN', 'idle', null);
|
||||
controls._cctvFrame.dataset.loading = 'true';
|
||||
expect('FRAME · LOADING', 'loading');
|
||||
controls._cctvFrame.dataset.loading = '';
|
||||
controls._cctvFrame.dataset.error = 'true';
|
||||
expect('FRAME · UNAVAILABLE', 'error');
|
||||
// A refresh failure keeps the settled pixels and the provider's source status.
|
||||
controls._cctvFrameWrap.classList.toggle('has-frame', true);
|
||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||
controls._cctvFrame.dataset.loading = 'true';
|
||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||
controls._cctvFrame.dataset.loading = '';
|
||||
controls._cctvFrame.dataset.error = '';
|
||||
for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) {
|
||||
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', {
|
||||
...camera,
|
||||
sourceStatus: status,
|
||||
});
|
||||
}
|
||||
expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' });
|
||||
});
|
||||
|
||||
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', (testContext) => {
|
||||
const { controls } = fixture(testContext);
|
||||
const cases = [
|
||||
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||
['curated', false, 'CAL · CURATED'],
|
||||
['curated', true, 'CAL · EDITED (UNSAVED)'],
|
||||
['calibrated', false, 'CAL · CALIBRATED'],
|
||||
['calibrated', true, 'CAL · EDITED (UNSAVED)'],
|
||||
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||
[null, false, 'CAL · --'],
|
||||
];
|
||||
for (const [calBadge, calDirty, label] of cases) {
|
||||
const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty });
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
activeCamera,
|
||||
});
|
||||
assert.equal(controls._cctvQualityChip.textContent, label);
|
||||
assert.equal(
|
||||
controls._cctvQualityChip.dataset.calBadge,
|
||||
calDirty ? 'edited' : calBadge || '',
|
||||
);
|
||||
}
|
||||
controls._renderCctvState(null);
|
||||
assert.equal(controls._cctvQualityChip.textContent, 'CAL · --');
|
||||
assert.equal(controls._cctvQualityChip.dataset.calBadge, '');
|
||||
});
|
||||
|
||||
test('localization changes badge copy while preserving machine state ids', (testContext) => {
|
||||
const { controls } = fixture(testContext);
|
||||
testContext.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
controls._syncCctvSourceBadge(
|
||||
{ sourceKind: 'snapshot', sourceStatus: 'degraded' },
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
controls._cctvSourceBadge.textContent,
|
||||
`${t('SNAPSHOT')} · ${t('DEGRADED')}`,
|
||||
);
|
||||
assert.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
|
||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
|
||||
});
|
||||
assert.equal(
|
||||
controls._cctvQualityChip.textContent,
|
||||
t('CAL · EDITED (UNSAVED)'),
|
||||
);
|
||||
assert.equal(controls._cctvQualityChip.dataset.calBadge, 'edited');
|
||||
});
|
||||
@@ -1,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';
|
||||
}
|
||||
@@ -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 || '';
|
||||
}
|
||||
|
||||
|
||||
+11
-7
@@ -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);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Ordinary read-only badge view. Labels are already translated. Feature owners
|
||||
* retain status derivation, machine-state attributes and announcement policy.
|
||||
* No listeners, timers, state inference or automatic live regions are installed.
|
||||
* Vue can replace this DOM adapter while retaining the label/hidden contract
|
||||
* and the gev-status-badge CSS surface.
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef]
|
||||
* @param {string} [options.label]
|
||||
* @param {'pill'|'box'|'plain'} [options.appearance]
|
||||
* @param {string} [options.className] Feature/layout hooks.
|
||||
* @param {boolean} [options.hidden]
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
export function createStatusBadge({
|
||||
documentRef = globalThis.document,
|
||||
label = '',
|
||||
appearance = 'pill',
|
||||
className = '',
|
||||
hidden = false,
|
||||
} = {}) {
|
||||
const badge = documentRef.createElement('span');
|
||||
badge.className = [
|
||||
'gev-status-badge',
|
||||
appearance === 'box' && 'gev-status-badge--box',
|
||||
appearance === 'plain' && 'gev-status-badge--plain',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
updateStatusBadge(badge, { label, hidden });
|
||||
return badge;
|
||||
}
|
||||
|
||||
/** Paint controlled text/visibility without replacing nodes or redundant writes. */
|
||||
export function updateStatusBadge(badge, { label, hidden = false }) {
|
||||
if (badge.textContent !== label) badge.textContent = label;
|
||||
if (badge.hidden !== hidden) badge.hidden = hidden;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
|
||||
test('badges paint literal translated text without installing announcement or interaction semantics', () => {
|
||||
const { document } = railFixture();
|
||||
for (const appearance of ['pill', 'box', 'plain']) {
|
||||
const badge = createStatusBadge({
|
||||
documentRef: document,
|
||||
appearance,
|
||||
className: 'feature-hook',
|
||||
label: '<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
|
||||
});
|
||||
assert.equal(badge.tagName, 'SPAN');
|
||||
assert.equal(
|
||||
badge.textContent,
|
||||
'<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
|
||||
);
|
||||
assert.equal(badge.children.length, 0);
|
||||
assert.equal(badge.classList.contains('gev-status-badge'), true);
|
||||
assert.equal(badge.classList.contains('feature-hook'), true);
|
||||
assert.equal(badge.getAttribute('role'), null);
|
||||
assert.equal(badge.getAttribute('aria-live'), null);
|
||||
assert.equal(badge.getAttribute('tabindex'), null);
|
||||
assert.equal(
|
||||
badge.classList.contains(`gev-status-badge--${appearance}`),
|
||||
appearance !== 'pill',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('painting preserves feature state, live regions and focus with zero identical writes', () => {
|
||||
let writes = 0;
|
||||
const { document } = railFixture(() => writes++);
|
||||
const badge = createStatusBadge({ documentRef: document, hidden: true });
|
||||
badge.setAttribute('role', 'status');
|
||||
badge.setAttribute('aria-live', 'polite');
|
||||
badge.dataset.frameState = 'ready';
|
||||
const focusOwner = document.createElement('button');
|
||||
focusOwner.focus();
|
||||
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
|
||||
assert.equal(badge.hidden, false);
|
||||
writes = 0;
|
||||
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(badge.getAttribute('role'), 'status');
|
||||
assert.equal(badge.getAttribute('aria-live'), 'polite');
|
||||
assert.equal(badge.dataset.frameState, 'ready');
|
||||
assert.equal(document.activeElement, focusOwner);
|
||||
});
|
||||
|
||||
test('coverage badges keep keyed identity, hide empty coverage and never claim feed health', () => {
|
||||
const f = railFixture();
|
||||
const view = createRailCards({ ...f, badgeClassName: 'weather-coverage' });
|
||||
const card = { id: 'radar', title: 'Rain radar', badge: 'CONUS', blocks: [] };
|
||||
view.update([card]);
|
||||
const badge = f.find((node) => node.classList.contains('gev-status-badge'));
|
||||
const header = f.container.children[0].children[0];
|
||||
header.focus();
|
||||
assert.equal(badge.classList.contains('gev-status-badge--plain'), true);
|
||||
assert.equal(badge.classList.contains('weather-coverage'), true);
|
||||
view.update([{ ...card, badge: '' }]);
|
||||
assert.equal(badge.hidden, true);
|
||||
view.update([{ ...card, badge: 'Global · 1° grid' }]);
|
||||
assert.equal(badge.hidden, false);
|
||||
assert.equal(badge.textContent, 'Global · 1° grid');
|
||||
assert.equal(
|
||||
f.find((node) => node.classList.contains('gev-status-badge')),
|
||||
badge,
|
||||
);
|
||||
assert.deepEqual({ ...badge.dataset }, {});
|
||||
assert.equal(f.document.activeElement, header);
|
||||
view.destroy();
|
||||
view.update([card]);
|
||||
assert.equal(f.container.children.length, 0);
|
||||
});
|
||||
+18
-35
@@ -157,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 {
|
||||
@@ -309,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 {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Read-only badges. Feature CSS supplies meaning through explicit colors;
|
||||
appearance variants change density/shape without interpreting status codes. */
|
||||
.gev-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--status-badge-border, rgba(0, 212, 255, 0.32));
|
||||
border-radius: 999px;
|
||||
background: var(--status-badge-background, rgba(0, 212, 255, 0.09));
|
||||
color: var(--status-badge-color, #9defff);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gev-status-badge--box {
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gev-status-badge--plain {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
color: var(--status-badge-color, var(--text-dim));
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Explicitly beat the component display rule, including in older browsers. */
|
||||
.gev-status-badge[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -133,7 +133,6 @@
|
||||
margin-left: auto;
|
||||
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;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<img id="cctv-frame" alt="CCTV feed frame" />
|
||||
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
|
||||
</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 class="cctv-controls">
|
||||
<button id="cctv-enable-btn" class="scene-btn" data-i18n>CCTV OFF</button>
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<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 class="cctv-calibration-block">
|
||||
<div class="cctv-calibration-header">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@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/controls.css';
|
||||
@import './src/ui/styles/location.css';
|
||||
|
||||
Reference in new issue
Block a user