diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md
index 6b1c88ea8..8ca26d9f2 100644
--- a/docs/CODE-BOUNDARIES.md
+++ b/docs/CODE-BOUNDARIES.md
@@ -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.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index c75aa1d3a..7f0dd1476 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -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
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 34219f856..135954a84 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -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"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 39ccf99b5..acdeff01e 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -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",
diff --git a/scripts/qa-status-badges.mjs b/scripts/qa-status-badges.mjs
new file mode 100644
index 000000000..961fdba7f
--- /dev/null
+++ b/scripts/qa-status-badges.mjs
@@ -0,0 +1,276 @@
+#!/usr/bin/env node
+/** Badge acceptance on dev/preview. Camera state is a disposable controller fixture. */
+import fs from 'node:fs';
+import puppeteer from 'puppeteer';
+
+const browser = await puppeteer.launch({
+ headless: true,
+ args: ['--use-gl=angle', '--use-angle=swiftshader'],
+});
+const page = await browser.newPage();
+page.setDefaultNavigationTimeout(60000);
+const errors = [];
+page.on('pageerror', (error) => errors.push(error.message));
+let checks = 0;
+let failures = 0;
+const check = (name, passed, detail) => {
+ checks++;
+ console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
+ if (!passed) {
+ failures++;
+ console.log(JSON.stringify(detail));
+ }
+};
+const out = 'qa-shots/status-badges';
+fs.mkdirSync(out, { recursive: true });
+try {
+ for (const lang of ['en', 'ru']) {
+ await page.setViewport({ width: 1440, height: 900 });
+ await page.goto(
+ `${process.env.QA_BASE_URL || 'http://127.0.0.1:4189'}/?welcome=0&lang=${lang}`,
+ { waitUntil: 'domcontentloaded' },
+ );
+ await page.waitForFunction(
+ () =>
+ window.__godsEyeView?.styleManager?._cctvControls &&
+ document.getElementById('loading-screen')?.classList.contains('hidden'),
+ { timeout: 60000 },
+ );
+ await page.evaluate(async () => {
+ const { dataManager, styleManager: manager } = window.__godsEyeView;
+ await dataManager.setEnabled('wind', true);
+ manager.setPanelCollapsed('weather-panel', false, {
+ persist: false,
+ syncShare: false,
+ });
+ manager.setPanelCollapsed('cctv-panel', false, {
+ persist: false,
+ syncShare: false,
+ });
+ const owner = manager._cctvControls;
+ owner._cctvUnsubscribe?.();
+ owner._cctvUnsubscribe = null;
+ owner.actions = { ...owner.actions, isEnabled: () => true };
+ owner._renderCctvState({
+ enabled: true,
+ activeCameraId: 'badge-fixture',
+ cameras: [{ id: 'badge-fixture', city: 'QA', name: 'Badge fixture' }],
+ activeCamera: {
+ id: 'badge-fixture',
+ city: 'QA',
+ headingDeg: 30,
+ pitchDeg: -10,
+ fovDeg: 45,
+ rangeM: 120,
+ mountHeightM: 8,
+ sourceKind: 'snapshot',
+ sourceStatus: 'degraded',
+ calBadge: 'curated',
+ calDirty: false,
+ },
+ });
+ window.__qaBadgeText =
+ document.getElementById('cctv-source-badge').textContent;
+ });
+ await page.waitForSelector('.weather-coverage.gev-status-badge');
+ const inventory = await page.evaluate(() => {
+ const source = document.getElementById('cctv-source-badge');
+ const quality = document.getElementById('cctv-quality-chip');
+ const coverage = [...document.querySelectorAll('.weather-coverage')];
+ return {
+ shared: [source, quality, ...coverage].every((node) =>
+ node.classList.contains('gev-status-badge'),
+ ),
+ live:
+ source.getAttribute('role') === 'status' &&
+ source.getAttribute('aria-live') === 'polite',
+ silent: [quality, ...coverage].every(
+ (node) =>
+ !node.hasAttribute('aria-live') &&
+ !node.hasAttribute('role') &&
+ !node.hasAttribute('tabindex'),
+ ),
+ coverage:
+ coverage.length > 0 &&
+ coverage.every(
+ (node) =>
+ node.textContent &&
+ !node.hidden &&
+ node.classList.contains('gev-status-badge--plain'),
+ ),
+ source: source.textContent,
+ };
+ });
+ check(
+ `${lang}: CCTV and populated Weather badges share the view`,
+ inventory.shared && inventory.coverage,
+ inventory,
+ );
+ check(
+ `${lang}: existing announcement and read-only semantics remain intact`,
+ inventory.live && inventory.silent,
+ inventory,
+ );
+ check(
+ `${lang}: source wording uses the active locale`,
+ lang === 'en'
+ ? inventory.source === 'SNAPSHOT · DEGRADED'
+ : inventory.source !== 'SNAPSHOT · DEGRADED',
+ inventory,
+ );
+
+ for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
+ const result = await page.evaluate((state) => {
+ const owner = window.__godsEyeView.styleManager._cctvControls;
+ owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
+ owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
+ ? 'true'
+ : '';
+ owner._cctvFrameWrap.classList.toggle(
+ 'has-frame',
+ state === 'retained',
+ );
+ owner._syncCctvSourceBadge(
+ owner._cctvState.activeCamera,
+ state !== 'idle',
+ );
+ const badge = document.getElementById('cctv-source-badge');
+ return {
+ state: badge.dataset.frameState,
+ text: badge.textContent,
+ color: getComputedStyle(badge).color,
+ };
+ }, state);
+ const expected = {
+ loading: ['loading', 'rgb(255, 229, 155)'],
+ error: ['error', 'rgb(255, 176, 176)'],
+ retained: ['ready', 'rgb(157, 239, 255)'],
+ ready: ['ready', 'rgb(157, 239, 255)'],
+ idle: ['idle', 'rgb(157, 239, 255)'],
+ }[state];
+ check(
+ `${lang}: ${state} keeps its frame state and palette`,
+ result.state === expected[0] &&
+ result.color === expected[1] &&
+ (!['ready', 'retained'].includes(state) ||
+ result.text === inventory.source),
+ result,
+ );
+ }
+ for (const [calBadge, dirty, color] of [
+ ['raw-prior', false, 'rgb(143, 231, 255)'],
+ ['curated', false, 'rgb(255, 230, 162)'],
+ ['calibrated', false, 'rgb(173, 255, 191)'],
+ ['curated', true, 'rgb(255, 208, 138)'],
+ ]) {
+ const result = await page.evaluate(
+ ({ calBadge, dirty }) => {
+ const owner = window.__godsEyeView.styleManager._cctvControls;
+ document.getElementById('cctv-next-btn').focus();
+ owner._renderCctvState({
+ ...owner._cctvState,
+ activeCamera: {
+ ...owner._cctvState.activeCamera,
+ calBadge,
+ calDirty: dirty,
+ },
+ });
+ const badge = document.getElementById('cctv-quality-chip');
+ return {
+ state: badge.dataset.calBadge,
+ color: getComputedStyle(badge).color,
+ focus: document.activeElement.id,
+ };
+ },
+ { calBadge, dirty },
+ );
+ check(
+ `${lang}: ${dirty ? 'edited' : calBadge} keeps provenance color and control focus`,
+ result.state === (dirty ? 'edited' : calBadge) &&
+ result.color === color &&
+ result.focus === 'cctv-next-btn',
+ result,
+ );
+ }
+ for (const theme of ['tactical', 'cyber']) {
+ await page.evaluate(
+ (theme) => window.__godsEyeView.styleManager._setHudVariant(theme),
+ theme,
+ );
+ for (const width of [1440, 620, 390]) {
+ await page.setViewport({ width, height: 900 });
+ for (const panelId of ['cctv-panel', 'weather-panel']) {
+ await page.evaluate(async (panelId) => {
+ const manager = window.__godsEyeView.styleManager;
+ for (const other of ['cctv-panel', 'weather-panel']) {
+ if (other !== panelId)
+ manager.setPanelCollapsed(other, true, {
+ persist: false,
+ syncShare: false,
+ });
+ }
+ manager.setPanelCollapsed(panelId, false, {
+ persist: false,
+ syncShare: false,
+ });
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
+ );
+ }, panelId);
+ const layout = await page.evaluate((panelId) => {
+ const selector =
+ panelId === 'cctv-panel'
+ ? '#cctv-source-badge, #cctv-quality-chip'
+ : '.weather-coverage';
+ return [...document.querySelectorAll(selector)].map((node) => {
+ const rect = node.getBoundingClientRect();
+ const panel = node.closest(`#${panelId}`).getBoundingClientRect();
+ return {
+ id: node.id || 'weather-coverage',
+ text: node.textContent,
+ width: rect.width,
+ height: rect.height,
+ scroll: node.scrollWidth,
+ client: node.clientWidth,
+ left: rect.left,
+ right: rect.right,
+ panelLeft: panel.left,
+ panelRight: panel.right,
+ fits:
+ rect.width > 0 &&
+ rect.height > 0 &&
+ rect.left >= panel.left - 1 &&
+ rect.right <= panel.right + 1 &&
+ node.scrollWidth <= node.clientWidth + 1,
+ };
+ });
+ }, panelId);
+ check(
+ `${lang}/${theme}: ${panelId} badges fit at ${width}px`,
+ layout.length >= (panelId === 'cctv-panel' ? 2 : 1) &&
+ layout.every((node) => node.fits),
+ layout,
+ );
+ if (width === 390)
+ await page.screenshot({
+ path: `${out}/${lang}-${theme}-${panelId}-${width}.png`,
+ });
+ }
+ }
+ }
+ const disposed = await page.evaluate(() => {
+ const owner = window.__godsEyeView.styleManager._cctvControls;
+ const badge = document.getElementById('cctv-quality-chip');
+ const text = badge.textContent;
+ owner.destroy();
+ owner._renderCctvState(null);
+ return badge.textContent === text;
+ });
+ check(`${lang}: disposal prevents late state painting`, disposed);
+ }
+ check('no uncaught browser errors', errors.length === 0, errors);
+} finally {
+ await browser.close();
+}
+console.log(`${checks} checks; ${failures} failures`);
+process.exitCode = failures ? 1 : 0;
diff --git a/src/ui/cctvBadges.test.mjs b/src/ui/cctvBadges.test.mjs
new file mode 100644
index 000000000..1703d5d54
--- /dev/null
+++ b/src/ui/cctvBadges.test.mjs
@@ -0,0 +1,112 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { CctvControls } from './cctvControls.js';
+import { railFixture } from './railTestFixture.mjs';
+import { setLocale, t } from '../i18n/index.js';
+
+function fixture(t) {
+ const { document } = railFixture();
+ const controls = new CctvControls({
+ elements: {
+ _cctvQualityChip: document.createElement('span'),
+ _cctvSourceBadge: document.createElement('div'),
+ },
+ cctv: {},
+ actions: { isEnabled: () => true, setPanelCollapsed() {} },
+ });
+ t.after(() => controls.destroy());
+ return { controls, document };
+}
+
+test('source and frame states retain their distinct meanings, including retained pixels', (testContext) => {
+ const { controls, document } = fixture(testContext);
+ const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
+ controls._cctvFrame = document.createElement('img');
+ controls._cctvFrameWrap = document.createElement('div');
+ controls._cctvFrame.removeAttribute = () => {};
+ for (const node of [controls._cctvFrame, controls._cctvFrameWrap]) {
+ node.classList.remove = (...names) =>
+ names.forEach((name) => node.classList.toggle(name, false));
+ }
+ const expect = (label, state, active = camera, enabled = true) => {
+ controls._syncCctvSourceBadge(active, enabled);
+ assert.equal(controls._cctvSourceBadge.textContent, label);
+ assert.equal(controls._cctvSourceBadge.dataset.frameState, state);
+ };
+ expect('SOURCE · UNKNOWN', 'idle', camera, false);
+ expect('SOURCE · UNKNOWN', 'idle', null);
+ controls._cctvFrame.dataset.loading = 'true';
+ expect('FRAME · LOADING', 'loading');
+ controls._cctvFrame.dataset.loading = '';
+ controls._cctvFrame.dataset.error = 'true';
+ expect('FRAME · UNAVAILABLE', 'error');
+ // A refresh failure keeps the settled pixels and the provider's source status.
+ controls._cctvFrameWrap.classList.toggle('has-frame', true);
+ expect('SNAPSHOT · DEGRADED', 'ready');
+ controls._cctvFrame.dataset.loading = 'true';
+ expect('SNAPSHOT · DEGRADED', 'ready');
+ controls._cctvFrame.dataset.loading = '';
+ controls._cctvFrame.dataset.error = '';
+ for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) {
+ expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', {
+ ...camera,
+ sourceStatus: status,
+ });
+ }
+ expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' });
+});
+
+test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', (testContext) => {
+ const { controls } = fixture(testContext);
+ const cases = [
+ ['raw-prior', false, 'CAL · RAW PRIOR'],
+ ['curated', false, 'CAL · CURATED'],
+ ['curated', true, 'CAL · EDITED (UNSAVED)'],
+ ['calibrated', false, 'CAL · CALIBRATED'],
+ ['calibrated', true, 'CAL · EDITED (UNSAVED)'],
+ ['raw-prior', false, 'CAL · RAW PRIOR'],
+ [null, false, 'CAL · --'],
+ ];
+ for (const [calBadge, calDirty, label] of cases) {
+ const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty });
+ controls._renderCctvState({
+ enabled: true,
+ activeCameraId: 'a',
+ activeCamera,
+ });
+ assert.equal(controls._cctvQualityChip.textContent, label);
+ assert.equal(
+ controls._cctvQualityChip.dataset.calBadge,
+ calDirty ? 'edited' : calBadge || '',
+ );
+ }
+ controls._renderCctvState(null);
+ assert.equal(controls._cctvQualityChip.textContent, 'CAL · --');
+ assert.equal(controls._cctvQualityChip.dataset.calBadge, '');
+});
+
+test('localization changes badge copy while preserving machine state ids', (testContext) => {
+ const { controls } = fixture(testContext);
+ testContext.after(() => setLocale('en'));
+ setLocale('ru');
+ controls._syncCctvSourceBadge(
+ { sourceKind: 'snapshot', sourceStatus: 'degraded' },
+ true,
+ );
+ assert.equal(
+ controls._cctvSourceBadge.textContent,
+ `${t('SNAPSHOT')} · ${t('DEGRADED')}`,
+ );
+ assert.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
+ assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
+ controls._renderCctvState({
+ enabled: true,
+ activeCameraId: 'a',
+ activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
+ });
+ assert.equal(
+ controls._cctvQualityChip.textContent,
+ t('CAL · EDITED (UNSAVED)'),
+ );
+ assert.equal(controls._cctvQualityChip.dataset.calBadge, 'edited');
+});
diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js
index 7976d46c8..ee324e0ad 100644
--- a/src/ui/cctvFrames.js
+++ b/src/ui/cctvFrames.js
@@ -1,3 +1,4 @@
+import { updateStatusBadge } from './statusBadge.js';
import { msg, t } from '../i18n/index.js';
/**
@@ -164,19 +165,21 @@ export function _settleCctvFrame(token, src, ok) {
export function _syncCctvSourceBadge(activeCamera, enabled) {
if (!this._cctvSourceBadge) return;
if (!enabled || !activeCamera) {
- this._cctvSourceBadge.textContent = t('SOURCE · UNKNOWN');
+ updateStatusBadge(this._cctvSourceBadge, { label: t('SOURCE · UNKNOWN') });
this._cctvSourceBadge.dataset.frameState = 'idle';
return;
}
const hasDisplayedFrame =
this._cctvFrameWrap?.classList.contains('has-frame');
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
- this._cctvSourceBadge.textContent = t('FRAME · LOADING');
+ updateStatusBadge(this._cctvSourceBadge, { label: t('FRAME · LOADING') });
this._cctvSourceBadge.dataset.frameState = 'loading';
return;
}
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
- this._cctvSourceBadge.textContent = t('FRAME · UNAVAILABLE');
+ updateStatusBadge(this._cctvSourceBadge, {
+ label: t('FRAME · UNAVAILABLE'),
+ });
this._cctvSourceBadge.dataset.frameState = 'error';
return;
}
@@ -184,6 +187,6 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
);
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
- this._cctvSourceBadge.textContent = `${kind} · ${status}`;
+ updateStatusBadge(this._cctvSourceBadge, { label: `${kind} · ${status}` });
this._cctvSourceBadge.dataset.frameState = 'ready';
}
diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js
index 3e738e109..070c47f64 100644
--- a/src/ui/cctvPresentation.js
+++ b/src/ui/cctvPresentation.js
@@ -1,3 +1,4 @@
+import { updateStatusBadge } from './statusBadge.js';
import { t, tn } from '../i18n/index.js';
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
@@ -137,11 +138,13 @@ export function _renderCctvState(state) {
// promotes to CALIBRATED, RESET CAL clears.
const badge = activeCamera?.calBadge || null;
const dirty = !!activeCamera?.calDirty;
- this._cctvQualityChip.textContent = dirty
- ? t('CAL · EDITED (UNSAVED)')
- : t('CAL · {badge}', {
- badge: this._calBadgeLabel(badge),
- });
+ updateStatusBadge(this._cctvQualityChip, {
+ label: dirty
+ ? t('CAL · EDITED (UNSAVED)')
+ : t('CAL · {badge}', {
+ badge: this._calBadgeLabel(badge),
+ }),
+ });
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
}
diff --git a/src/ui/railCards.js b/src/ui/railCards.js
index 17f04436f..7317693be 100644
--- a/src/ui/railCards.js
+++ b/src/ui/railCards.js
@@ -1,4 +1,5 @@
import { createRailCardBlocks } from './railCardBlocks.js';
+import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
@@ -53,11 +54,12 @@ export function createRailCards({
'rail-card-title rail-card-nowrap',
heading,
);
- const badge = make(
- 'span',
- `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
- labels,
- );
+ const badge = createStatusBadge({
+ documentRef: document,
+ appearance: 'plain',
+ className: `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
+ });
+ labels.appendChild(badge);
const disclosure = make('span', 'rail-card-disclosure', header);
disclosure.setAttribute('aria-hidden', 'true');
const compact = make('span', 'rail-card-compact', header);
@@ -90,8 +92,10 @@ export function createRailCards({
row.title.setAttribute('title', card.title);
set(row.icon, 'textContent', card.icon || '');
set(row.icon, 'hidden', !card.icon);
- set(row.badge, 'textContent', card.badge || '');
- set(row.badge, 'hidden', !card.badge);
+ updateStatusBadge(row.badge, {
+ label: card.badge || '',
+ hidden: !card.badge,
+ });
const open = card.open !== false;
set(row.element.dataset, 'open', String(open));
row.element.classList.toggle('is-open', open);
diff --git a/src/ui/statusBadge.js b/src/ui/statusBadge.js
new file mode 100644
index 000000000..ec7f3019a
--- /dev/null
+++ b/src/ui/statusBadge.js
@@ -0,0 +1,39 @@
+/**
+ * Ordinary read-only badge view. Labels are already translated. Feature owners
+ * retain status derivation, machine-state attributes and announcement policy.
+ * No listeners, timers, state inference or automatic live regions are installed.
+ * Vue can replace this DOM adapter while retaining the label/hidden contract
+ * and the gev-status-badge CSS surface.
+ * @param {object} options
+ * @param {Document} [options.documentRef]
+ * @param {string} [options.label]
+ * @param {'pill'|'box'|'plain'} [options.appearance]
+ * @param {string} [options.className] Feature/layout hooks.
+ * @param {boolean} [options.hidden]
+ * @returns {HTMLSpanElement}
+ */
+export function createStatusBadge({
+ documentRef = globalThis.document,
+ label = '',
+ appearance = 'pill',
+ className = '',
+ hidden = false,
+} = {}) {
+ const badge = documentRef.createElement('span');
+ badge.className = [
+ 'gev-status-badge',
+ appearance === 'box' && 'gev-status-badge--box',
+ appearance === 'plain' && 'gev-status-badge--plain',
+ className,
+ ]
+ .filter(Boolean)
+ .join(' ');
+ updateStatusBadge(badge, { label, hidden });
+ return badge;
+}
+
+/** Paint controlled text/visibility without replacing nodes or redundant writes. */
+export function updateStatusBadge(badge, { label, hidden = false }) {
+ if (badge.textContent !== label) badge.textContent = label;
+ if (badge.hidden !== hidden) badge.hidden = hidden;
+}
diff --git a/src/ui/statusBadge.test.mjs b/src/ui/statusBadge.test.mjs
new file mode 100644
index 000000000..2e576af69
--- /dev/null
+++ b/src/ui/statusBadge.test.mjs
@@ -0,0 +1,78 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
+import { createRailCards } from './railCards.js';
+import { railFixture } from './railTestFixture.mjs';
+
+test('badges paint literal translated text without installing announcement or interaction semantics', () => {
+ const { document } = railFixture();
+ for (const appearance of ['pill', 'box', 'plain']) {
+ const badge = createStatusBadge({
+ documentRef: document,
+ appearance,
+ className: 'feature-hook',
+ label: ' · НЕИЗВЕСТНО',
+ });
+ assert.equal(badge.tagName, 'SPAN');
+ assert.equal(
+ badge.textContent,
+ '
· НЕИЗВЕСТНО',
+ );
+ assert.equal(badge.children.length, 0);
+ assert.equal(badge.classList.contains('gev-status-badge'), true);
+ assert.equal(badge.classList.contains('feature-hook'), true);
+ assert.equal(badge.getAttribute('role'), null);
+ assert.equal(badge.getAttribute('aria-live'), null);
+ assert.equal(badge.getAttribute('tabindex'), null);
+ assert.equal(
+ badge.classList.contains(`gev-status-badge--${appearance}`),
+ appearance !== 'pill',
+ );
+ }
+});
+
+test('painting preserves feature state, live regions and focus with zero identical writes', () => {
+ let writes = 0;
+ const { document } = railFixture(() => writes++);
+ const badge = createStatusBadge({ documentRef: document, hidden: true });
+ badge.setAttribute('role', 'status');
+ badge.setAttribute('aria-live', 'polite');
+ badge.dataset.frameState = 'ready';
+ const focusOwner = document.createElement('button');
+ focusOwner.focus();
+ updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
+ assert.equal(badge.hidden, false);
+ writes = 0;
+ updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
+ assert.equal(writes, 0);
+ assert.equal(badge.getAttribute('role'), 'status');
+ assert.equal(badge.getAttribute('aria-live'), 'polite');
+ assert.equal(badge.dataset.frameState, 'ready');
+ assert.equal(document.activeElement, focusOwner);
+});
+
+test('coverage badges keep keyed identity, hide empty coverage and never claim feed health', () => {
+ const f = railFixture();
+ const view = createRailCards({ ...f, badgeClassName: 'weather-coverage' });
+ const card = { id: 'radar', title: 'Rain radar', badge: 'CONUS', blocks: [] };
+ view.update([card]);
+ const badge = f.find((node) => node.classList.contains('gev-status-badge'));
+ const header = f.container.children[0].children[0];
+ header.focus();
+ assert.equal(badge.classList.contains('gev-status-badge--plain'), true);
+ assert.equal(badge.classList.contains('weather-coverage'), true);
+ view.update([{ ...card, badge: '' }]);
+ assert.equal(badge.hidden, true);
+ view.update([{ ...card, badge: 'Global · 1° grid' }]);
+ assert.equal(badge.hidden, false);
+ assert.equal(badge.textContent, 'Global · 1° grid');
+ assert.equal(
+ f.find((node) => node.classList.contains('gev-status-badge')),
+ badge,
+ );
+ assert.deepEqual({ ...badge.dataset }, {});
+ assert.equal(f.document.activeElement, header);
+ view.destroy();
+ view.update([card]);
+ assert.equal(f.container.children.length, 0);
+});
diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css
index 67f18212b..12c369a9e 100644
--- a/src/ui/styles/cctv.css
+++ b/src/ui/styles/cctv.css
@@ -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 {
diff --git a/src/ui/styles/status-badge.css b/src/ui/styles/status-badge.css
new file mode 100644
index 000000000..87459b013
--- /dev/null
+++ b/src/ui/styles/status-badge.css
@@ -0,0 +1,43 @@
+/* Read-only badges. Feature CSS supplies meaning through explicit colors;
+ appearance variants change density/shape without interpreting status codes. */
+.gev-status-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+ padding: 4px 8px;
+ border: 1px solid var(--status-badge-border, rgba(0, 212, 255, 0.32));
+ border-radius: 999px;
+ background: var(--status-badge-background, rgba(0, 212, 255, 0.09));
+ color: var(--status-badge-color, #9defff);
+ font-family: var(--font-mono);
+ font-size: 8px;
+ letter-spacing: 1.3px;
+ overflow-wrap: anywhere;
+}
+
+.gev-status-badge--box {
+ padding: 6px 8px;
+ border-radius: 8px;
+ font-size: 9px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+}
+
+.gev-status-badge--plain {
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: none;
+ color: var(--status-badge-color, var(--text-dim));
+ font-size: 9px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+}
+
+/* Explicitly beat the component display rule, including in older browsers. */
+.gev-status-badge[hidden] {
+ display: none;
+}
diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css
index a47fa87b1..f7cd999d3 100644
--- a/src/ui/styles/weather.css
+++ b/src/ui/styles/weather.css
@@ -133,7 +133,6 @@
margin-left: auto;
text-align: right;
}
-.rail-card-badge,
.rail-card-line,
.rail-timeline-readout {
font-family: var(--font-mono);
@@ -304,9 +303,6 @@ html:not([lang='en']) .rail-card-zero::after {
outline: 1px solid var(--accent);
outline-offset: 3px;
}
-.rail-card-badge {
- text-transform: uppercase;
-}
.rail-card-compact {
grid-column: 1 / -1;
min-width: 0;
diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html
index a6dae6650..9d8d15a97 100644
--- a/src/ui/templates/layer-panels.html
+++ b/src/ui/templates/layer-panels.html
@@ -27,7 +27,7 @@
-