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 @@ CCTV feed frame -
SOURCE · UNKNOWN
+
SOURCE · UNKNOWN
Enable CCTV to load camera intersections
@@ -45,7 +45,7 @@
- CAL · -- + CAL · --
diff --git a/style.css b/style.css index e59db8df3..7f041cabe 100644 --- a/style.css +++ b/style.css @@ -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';