Merge branch 'main' into codex/fix-docker-trusted-peers

Resolve scripts/format-scope.json by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-21 17:39:47 +01:00
commit eaf14e2937
46 files changed
+2332 -279

No files matched your search

+1 -1
View File
@@ -69,7 +69,7 @@ test('focusable controls do not animate the global outline', () => {
'#top-center-actions button {',
'.data-toggle-chip {',
'.scene-btn {',
'.scene-shot-btn {',
'.gev-action-button--compact {',
]) {
assert.doesNotMatch(
ruleBody(selector),
+4 -1
View File
@@ -38,7 +38,10 @@ export function createGoogleTokenSource({
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!response.ok) return null;
if (!response.ok) {
await response.body?.cancel();
return null;
}
return parseToken(await response.json(), now());
} catch {
return null;
+17
View File
@@ -50,6 +50,23 @@ test('requests refused for the same token renew it once', async () => {
assert.equal(calls.length, 2);
});
test('a refused token response releases its unread body before returning', async () => {
for (const status of [404, 429, 500]) {
let cancelled = false;
const response = new Response(
new ReadableStream({
cancel() {
cancelled = true;
},
}),
{ status },
);
const tokens = createGoogleTokenSource({ fetchImpl: async () => response });
assert.equal(await tokens.token(), null);
assert.equal(cancelled, true, `HTTP ${status} body released`);
}
});
test('a server that does not offer tokens yields none', async () => {
for (const answer of [
new Response('Not found', { status: 404 }),
+100
View File
@@ -0,0 +1,100 @@
/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */
export const ARRIVAL_OBSERVATION_MS = 500;
/**
* Observe the first completed moveEnd frame and the first subsequent reveal.
* The deadline starts when moveEnd is forced, before its rotation baseline, so
* waiting for either frame cannot let the one-second idle refresh mask a bug.
* `read` snapshots visibility and rotation error against the current position.
*/
export function observeArrivalRotation({
scene,
startMoveEnd,
startReveal,
read,
now = () => performance.now(),
setTimer = setTimeout,
clearTimer = clearTimeout,
}) {
return new Promise((resolve, reject) => {
const startedAt = now();
let moveEnd = null;
let moveEndMs = null;
let revealFrames = 0;
let done = false;
let remove;
let timer;
const cleanup = () => {
done = true;
remove?.();
clearTimer(timer);
};
const finish = (result) => {
if (done) return;
cleanup();
resolve(result);
};
const expired = () => ({
error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`,
elapsedMs: now() - startedAt,
moveEndMs,
flipFrame: -1,
});
remove = scene.postRender.addEventListener(() => {
if (done) return;
try {
const elapsedMs = now() - startedAt;
if (elapsedMs >= ARRIVAL_OBSERVATION_MS) {
finish(expired());
return;
}
const sample = read();
if (moveEnd === null) {
moveEnd = sample;
moveEndMs = elapsedMs;
startReveal();
} else {
revealFrames++;
if (sample.shown) {
// Keep the FIRST visible result, including a wrong angle. Never
// wait for a subsequent tick to repair a failed reveal.
finish({
dMoveEnd: moveEnd.rotationError,
dReveal: sample.rotationError,
shown: sample.shown,
flipFrame: revealFrames,
moveEndMs,
revealMs: elapsedMs - moveEndMs,
elapsedMs,
});
return;
}
}
scene.requestRender();
} catch (error) {
cleanup();
reject(error);
}
});
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
try {
startMoveEnd();
scene.requestRender();
} catch (error) {
cleanup();
reject(error);
}
});
}
/** Test the captured frames without retrying a bad first observation. */
export function arrivalRotationPassed(observation) {
return (
!!observation &&
!observation.error &&
observation.shown === true &&
observation.elapsedMs < ARRIVAL_OBSERVATION_MS &&
(observation.dMoveEnd * 180) / Math.PI < 10 &&
(observation.dReveal * 180) / Math.PI < 10
);
}
@@ -0,0 +1,151 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ARRIVAL_OBSERVATION_MS,
arrivalRotationPassed,
observeArrivalRotation,
} from './trackArrivalObservation.mjs';
function fixture() {
let time = 0;
let state = { shown: true, rotationError: 0 };
const listeners = new Set();
const timers = new Map();
let renders = 0;
let moveEnds = 0;
const observation = observeArrivalRotation({
scene: {
postRender: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
requestRender() {
renders++;
},
},
startMoveEnd() {
moveEnds++;
state.rotationError = 1.5;
},
startReveal() {
state = { shown: false, rotationError: 1.5 };
},
read: () => ({ ...state }),
now: () => time,
setTimer(callback, delay) {
const handle = {};
timers.set(handle, { callback, at: time + delay });
return handle;
},
clearTimer(handle) {
timers.delete(handle);
},
});
return {
observation,
frame(at, nextState) {
time = at;
state = { ...state, ...nextState };
for (const listener of [...listeners]) listener();
},
expire(at) {
time = at;
for (const timer of [...timers.values()])
if (timer.at <= at) timer.callback();
},
get resources() {
return {
listeners: listeners.size,
timers: timers.size,
renders,
moveEnds,
};
},
};
}
test('80 ms fleet scheduling passes after more than four fast rendered frames', async () => {
const f = fixture();
f.frame(8, { shown: true, rotationError: 0 });
// The forced fleet tick was at 8 ms. A 125 FPS renderer can complete nine
// more frames before the next 80 ms fleet interval becomes eligible.
for (let at = 16; at < 88; at += 8) f.frame(at);
f.frame(88, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.equal(result.flipFrame, 10);
assert.equal(result.moveEndMs, 8);
assert.equal(result.revealMs, 80);
assert.equal(result.elapsedMs, 88);
assert.equal(arrivalRotationPassed(result), true);
assert.equal(f.resources.listeners, 0);
assert.equal(f.resources.timers, 0);
assert.equal(f.resources.moveEnds, 1);
});
test('a wrong first visible angle fails even if the next frame corrects it', async () => {
const f = fixture();
f.frame(8, { shown: true, rotationError: 0 });
f.frame(88, { shown: true, rotationError: 1.5 });
f.frame(96, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.equal(result.dReveal, 1.5);
assert.equal(result.flipFrame, 1);
assert.equal(result.elapsedMs, 88);
assert.equal(arrivalRotationPassed(result), false);
});
test('a wrong first moveEnd angle is retained rather than repaired by reveal', async () => {
const f = fixture();
f.frame(8, { shown: true, rotationError: 1.5 });
f.frame(88, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.equal(result.dMoveEnd, 1.5);
assert.equal(arrivalRotationPassed(result), false);
});
test('no reveal fails at a deadline before the one-second idle catch-up', async () => {
const f = fixture();
assert.equal(ARRIVAL_OBSERVATION_MS, 500);
f.frame(8, { shown: true, rotationError: 0 });
f.frame(480);
f.expire(500);
const result = await f.observation;
assert.match(result.error, /reveal.*500 ms/);
assert.equal(arrivalRotationPassed(result), false);
assert.equal(f.resources.listeners, 0);
assert.equal(f.resources.timers, 0);
f.frame(1000, { shown: true, rotationError: 0 });
assert.equal(result.flipFrame, -1);
});
test('a late visible frame fails even if the timeout callback is delayed', async () => {
const f = fixture();
f.frame(8, { shown: true, rotationError: 0 });
f.frame(501, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.match(result.error, /reveal.*500 ms/);
assert.equal(result.elapsedMs, 501);
assert.equal(arrivalRotationPassed(result), false);
});
test('the deadline includes moveEnd setup rather than restarting when hidden', async () => {
const f = fixture();
f.frame(400, { shown: true, rotationError: 0 });
f.frame(600, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.equal(result.moveEndMs, 400);
assert.equal(result.elapsedMs, 600);
assert.equal(arrivalRotationPassed(result), false);
});
test('missing moveEnd frames fail without waiting for a render callback', async () => {
const f = fixture();
f.expire(500);
const result = await f.observation;
assert.match(result.error, /moveEnd.*500 ms/);
assert.equal(arrivalRotationPassed(result), false);
assert.equal(f.resources.listeners, 0);
assert.equal(f.resources.timers, 0);
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Create an ordinary native action button. Static templates use the same CSS
* classes. Labels are already translated; feature owners retain callbacks,
* disabled-state updates and listener cleanup. Construction binds no listeners.
* @param {object} options
* @param {Document} [options.documentRef] Document that owns the button.
* @param {string} options.label Visible text, never interpreted as markup.
* @param {string} [options.className] Additional feature/layout hooks.
* @param {'default'|'danger'} [options.variant]
* @param {'default'|'compact'} [options.size]
* @param {boolean} [options.disabled]
* @param {string} [options.ariaLabel] Accessible name when visible text needs context.
* @param {string} [options.title]
* @returns {HTMLButtonElement}
*/
export function createActionButton({
documentRef = globalThis.document,
label,
className = '',
variant = 'default',
size = 'default',
disabled = false,
ariaLabel,
title,
}) {
const button = documentRef.createElement('button');
button.type = 'button';
button.className = [
'gev-action-button',
variant === 'danger' && 'gev-action-button--danger',
size === 'compact' && 'gev-action-button--compact',
className,
]
.filter(Boolean)
.join(' ');
button.textContent = label;
button.disabled = disabled;
if (ariaLabel !== undefined) button.setAttribute('aria-label', ariaLabel);
if (title !== undefined) button.title = title;
return button;
}
+112
View File
@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CctvControls } from './cctvControls.js';
import { railFixture } from './railTestFixture.mjs';
import { setLocale, t } from '../i18n/index.js';
function fixture(t) {
const { document } = railFixture();
const controls = new CctvControls({
elements: {
_cctvQualityChip: document.createElement('span'),
_cctvSourceBadge: document.createElement('div'),
},
cctv: {},
actions: { isEnabled: () => true, setPanelCollapsed() {} },
});
t.after(() => controls.destroy());
return { controls, document };
}
test('source and frame states retain their distinct meanings, including retained pixels', (testContext) => {
const { controls, document } = fixture(testContext);
const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
controls._cctvFrame = document.createElement('img');
controls._cctvFrameWrap = document.createElement('div');
controls._cctvFrame.removeAttribute = () => {};
for (const node of [controls._cctvFrame, controls._cctvFrameWrap]) {
node.classList.remove = (...names) =>
names.forEach((name) => node.classList.toggle(name, false));
}
const expect = (label, state, active = camera, enabled = true) => {
controls._syncCctvSourceBadge(active, enabled);
assert.equal(controls._cctvSourceBadge.textContent, label);
assert.equal(controls._cctvSourceBadge.dataset.frameState, state);
};
expect('SOURCE · UNKNOWN', 'idle', camera, false);
expect('SOURCE · UNKNOWN', 'idle', null);
controls._cctvFrame.dataset.loading = 'true';
expect('FRAME · LOADING', 'loading');
controls._cctvFrame.dataset.loading = '';
controls._cctvFrame.dataset.error = 'true';
expect('FRAME · UNAVAILABLE', 'error');
// A refresh failure keeps the settled pixels and the provider's source status.
controls._cctvFrameWrap.classList.toggle('has-frame', true);
expect('SNAPSHOT · DEGRADED', 'ready');
controls._cctvFrame.dataset.loading = 'true';
expect('SNAPSHOT · DEGRADED', 'ready');
controls._cctvFrame.dataset.loading = '';
controls._cctvFrame.dataset.error = '';
for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) {
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', {
...camera,
sourceStatus: status,
});
}
expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' });
});
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', (testContext) => {
const { controls } = fixture(testContext);
const cases = [
['raw-prior', false, 'CAL · RAW PRIOR'],
['curated', false, 'CAL · CURATED'],
['curated', true, 'CAL · EDITED (UNSAVED)'],
['calibrated', false, 'CAL · CALIBRATED'],
['calibrated', true, 'CAL · EDITED (UNSAVED)'],
['raw-prior', false, 'CAL · RAW PRIOR'],
[null, false, 'CAL · --'],
];
for (const [calBadge, calDirty, label] of cases) {
const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty });
controls._renderCctvState({
enabled: true,
activeCameraId: 'a',
activeCamera,
});
assert.equal(controls._cctvQualityChip.textContent, label);
assert.equal(
controls._cctvQualityChip.dataset.calBadge,
calDirty ? 'edited' : calBadge || '',
);
}
controls._renderCctvState(null);
assert.equal(controls._cctvQualityChip.textContent, 'CAL · --');
assert.equal(controls._cctvQualityChip.dataset.calBadge, '');
});
test('localization changes badge copy while preserving machine state ids', (testContext) => {
const { controls } = fixture(testContext);
testContext.after(() => setLocale('en'));
setLocale('ru');
controls._syncCctvSourceBadge(
{ sourceKind: 'snapshot', sourceStatus: 'degraded' },
true,
);
assert.equal(
controls._cctvSourceBadge.textContent,
`${t('SNAPSHOT')} · ${t('DEGRADED')}`,
);
assert.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
controls._renderCctvState({
enabled: true,
activeCameraId: 'a',
activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
});
assert.equal(
controls._cctvQualityChip.textContent,
t('CAL · EDITED (UNSAVED)'),
);
assert.equal(controls._cctvQualityChip.dataset.calBadge, 'edited');
});
+7 -5
View File
@@ -1,4 +1,5 @@
import { formatNumber, t, msg } from '../i18n/index.js';
import { createNumberInput } from './formField.js';
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
@@ -117,11 +118,12 @@ export function _beginCctvCalValueEdit(chip) {
const basePose = { ...activeCamera.basePose };
const editListeners = new AbortController();
const startValue = field.get(activeCamera);
const input = document.createElement('input');
input.type = 'number';
input.step = field.decimals > 0 ? '0.1' : '1';
input.value = Number(startValue).toFixed(field.decimals);
input.className = 'cctv-cal-input';
const input = createNumberInput({
className: 'gev-field--inline cctv-cal-input',
step: field.decimals > 0 ? '0.1' : '1',
value: Number(startValue).toFixed(field.decimals),
ariaLabel: t(field.label),
});
chip.textContent = `${t(field.label)} `;
chip.appendChild(input);
input.focus();
+3
View File
@@ -105,6 +105,9 @@ function calibrationFixture(t) {
Object.assign(input, {
focus() {},
select() {},
setAttribute(name, value) {
this[name] = value;
},
remove() {
this.parent.input = null;
},
+7 -4
View File
@@ -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';
}
+8 -5
View File
@@ -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 || '';
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Build a native numeric field without listeners, parsing or commit policy.
* Owners retain drafts, validation, input/change timing and disposal. Static
* selects and number inputs share the gev-field surface in their templates;
* options remain owned by each feature (no replacement popup or keyboard code).
* @param {object} options
* @param {Document} [options.documentRef]
* @param {string} [options.className] Additional density/layout hooks.
* @param {number|string} [options.min]
* @param {number|string} [options.max]
* @param {number|string} [options.step]
* @param {number|string} [options.value]
* @param {boolean} [options.disabled]
* @param {string} [options.ariaLabel] Already translated accessible name.
* @returns {HTMLInputElement}
*/
export function createNumberInput({
documentRef = globalThis.document,
className = '',
min,
max,
step,
value,
disabled = false,
ariaLabel,
} = {}) {
const input = documentRef.createElement('input');
input.type = 'number';
input.className = `gev-field${className ? ` ${className}` : ''}`;
for (const [key, setting] of Object.entries({ min, max, step, value })) {
if (setting !== undefined) input[key] = setting;
}
input.disabled = disabled;
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
return input;
}
+11 -7
View File
@@ -1,4 +1,5 @@
import { createRailCardBlocks } from './railCardBlocks.js';
import { 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);
+12 -6
View File
@@ -1,5 +1,6 @@
/** Scene panel elements and text-only project presentation. */
import { formatNumber, msg, t } from '../i18n/index.js';
import { createActionButton } from './actionButton.js';
// Labels for what a shot stores; anything else shows as stored. Style names
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
@@ -121,13 +122,18 @@ export function renderSceneShots(
});
const actions = document.createElement('div');
actions.className = 'scene-shot-actions';
const loadButton = document.createElement('button');
loadButton.className = 'scene-shot-btn';
loadButton.textContent = t('LOAD');
const loadButton = createActionButton({
label: t('LOAD'),
className: 'scene-shot-btn',
size: 'compact',
});
listen(loadButton, 'click', () => load(scene.id, shot.id));
const deleteButton = document.createElement('button');
deleteButton.className = 'scene-shot-btn scene-shot-danger';
deleteButton.textContent = t('DEL');
const deleteButton = createActionButton({
label: t('DEL'),
className: 'scene-shot-btn scene-shot-danger',
size: 'compact',
variant: 'danger',
});
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
actions.appendChild(loadButton);
actions.appendChild(deleteButton);
+2 -4
View File
@@ -1,4 +1,5 @@
import { t, tn } from '../i18n/index.js';
import { createActionButton } from './actionButton.js';
/** Small owned modal for scene authoring and import review; text never becomes markup. */
export function createSceneDialog(title, onClose) {
@@ -185,10 +186,7 @@ export function mountSceneSharing(
];
const removers = [];
for (const [text, fn] of entries) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = text;
b.className = 'scene-btn';
const b = createActionButton({ label: text, className: 'scene-btn' });
b.addEventListener('click', fn);
removers.push(() => b.removeEventListener('click', fn));
bar.append(b);
+39
View File
@@ -0,0 +1,39 @@
/**
* Ordinary read-only badge view. Labels are already translated. Feature owners
* retain status derivation, machine-state attributes and announcement policy.
* No listeners, timers, state inference or automatic live regions are installed.
* Vue can replace this DOM adapter while retaining the label/hidden contract
* and the gev-status-badge CSS surface.
* @param {object} options
* @param {Document} [options.documentRef]
* @param {string} [options.label]
* @param {'pill'|'box'|'plain'} [options.appearance]
* @param {string} [options.className] Feature/layout hooks.
* @param {boolean} [options.hidden]
* @returns {HTMLSpanElement}
*/
export function createStatusBadge({
documentRef = globalThis.document,
label = '',
appearance = 'pill',
className = '',
hidden = false,
} = {}) {
const badge = documentRef.createElement('span');
badge.className = [
'gev-status-badge',
appearance === 'box' && 'gev-status-badge--box',
appearance === 'plain' && 'gev-status-badge--plain',
className,
]
.filter(Boolean)
.join(' ');
updateStatusBadge(badge, { label, hidden });
return badge;
}
/** Paint controlled text/visibility without replacing nodes or redundant writes. */
export function updateStatusBadge(badge, { label, hidden = false }) {
if (badge.textContent !== label) badge.textContent = label;
if (badge.hidden !== hidden) badge.hidden = hidden;
}
+78
View File
@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
import { createRailCards } from './railCards.js';
import { railFixture } from './railTestFixture.mjs';
test('badges paint literal translated text without installing announcement or interaction semantics', () => {
const { document } = railFixture();
for (const appearance of ['pill', 'box', 'plain']) {
const badge = createStatusBadge({
documentRef: document,
appearance,
className: 'feature-hook',
label: '<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
});
assert.equal(badge.tagName, 'SPAN');
assert.equal(
badge.textContent,
'<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
);
assert.equal(badge.children.length, 0);
assert.equal(badge.classList.contains('gev-status-badge'), true);
assert.equal(badge.classList.contains('feature-hook'), true);
assert.equal(badge.getAttribute('role'), null);
assert.equal(badge.getAttribute('aria-live'), null);
assert.equal(badge.getAttribute('tabindex'), null);
assert.equal(
badge.classList.contains(`gev-status-badge--${appearance}`),
appearance !== 'pill',
);
}
});
test('painting preserves feature state, live regions and focus with zero identical writes', () => {
let writes = 0;
const { document } = railFixture(() => writes++);
const badge = createStatusBadge({ documentRef: document, hidden: true });
badge.setAttribute('role', 'status');
badge.setAttribute('aria-live', 'polite');
badge.dataset.frameState = 'ready';
const focusOwner = document.createElement('button');
focusOwner.focus();
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
assert.equal(badge.hidden, false);
writes = 0;
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
assert.equal(writes, 0);
assert.equal(badge.getAttribute('role'), 'status');
assert.equal(badge.getAttribute('aria-live'), 'polite');
assert.equal(badge.dataset.frameState, 'ready');
assert.equal(document.activeElement, focusOwner);
});
test('coverage badges keep keyed identity, hide empty coverage and never claim feed health', () => {
const f = railFixture();
const view = createRailCards({ ...f, badgeClassName: 'weather-coverage' });
const card = { id: 'radar', title: 'Rain radar', badge: 'CONUS', blocks: [] };
view.update([card]);
const badge = f.find((node) => node.classList.contains('gev-status-badge'));
const header = f.container.children[0].children[0];
header.focus();
assert.equal(badge.classList.contains('gev-status-badge--plain'), true);
assert.equal(badge.classList.contains('weather-coverage'), true);
view.update([{ ...card, badge: '' }]);
assert.equal(badge.hidden, true);
view.update([{ ...card, badge: 'Global · 1° grid' }]);
assert.equal(badge.hidden, false);
assert.equal(badge.textContent, 'Global · 1° grid');
assert.equal(
f.find((node) => node.classList.contains('gev-status-badge')),
badge,
);
assert.deepEqual({ ...badge.dataset }, {});
assert.equal(f.document.activeElement, header);
view.destroy();
view.update([card]);
assert.equal(f.container.children.length, 0);
});
+59
View File
@@ -0,0 +1,59 @@
/* Ordinary actions share one surface; feature classes own placement and width.
* Legacy scene-btn hooks keep untouched toggles/Street Level controls stable. */
.gev-action-button,
.scene-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1.2px;
padding: 6px 9px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast);
white-space: nowrap;
}
.gev-action-button:hover:not(:disabled),
.scene-btn:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
background: rgba(0, 212, 255, 0.14);
}
.gev-action-button:disabled,
.scene-btn:disabled {
opacity: 0.45;
cursor: default;
}
.gev-action-button--danger:hover:not(:disabled),
.scene-btn-danger:hover:not(:disabled) {
color: #ff7272;
border-color: #ff7272;
background: rgba(255, 88, 88, 0.15);
}
/* Shot-row actions retain their smaller footprint and quieter hover treatment. */
.gev-action-button--compact {
border-radius: 6px;
font-size: 8px;
letter-spacing: 0.8px;
padding: 4px 6px;
}
.gev-action-button--compact:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.05);
}
.gev-action-button--compact.gev-action-button--danger:hover:not(:disabled) {
color: #ff7e7e;
border-color: #ff7e7e;
}
+18 -72
View File
@@ -129,26 +129,6 @@ html:not([lang='en']) .cctv-controls .scene-btn {
background: rgba(0, 212, 255, 0.08);
}
.cctv-cal-input {
width: 58px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.7px;
color: var(--text-primary);
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.5);
border-radius: 4px;
padding: 1px 3px;
outline: none;
-moz-appearance: textfield;
}
.cctv-cal-input::-webkit-outer-spin-button,
.cctv-cal-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.cctv-calibration-actions {
margin-top: 8px;
}
@@ -162,26 +142,9 @@ html:not([lang='en']) .cctv-controls .scene-btn {
#cctv-camera-select {
flex: 1;
min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-primary);
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
outline: none;
}
#cctv-camera-select:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
#cctv-camera-select:disabled {
opacity: 0.45;
}
#cctv-enable-btn.active,
@@ -194,41 +157,32 @@ html:not([lang='en']) .cctv-controls .scene-btn {
}
#cctv-quality-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 90px;
padding: 6px 8px;
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 8px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1px;
color: #8fe7ff;
background: rgba(0, 212, 255, 0.08);
text-transform: uppercase;
--status-badge-border: rgba(0, 212, 255, 0.2);
--status-badge-color: #8fe7ff;
--status-badge-background: rgba(0, 212, 255, 0.08);
}
/* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no
in-world tint). Three visually distinct states: calibrated / curated /
raw-prior. */
#cctv-quality-chip[data-cal-badge='calibrated'] {
color: #adffbf;
border-color: rgba(141, 255, 135, 0.42);
background: rgba(141, 255, 135, 0.12);
--status-badge-color: #adffbf;
--status-badge-border: rgba(141, 255, 135, 0.42);
--status-badge-background: rgba(141, 255, 135, 0.12);
}
#cctv-quality-chip[data-cal-badge='curated'] {
color: #ffe6a2;
border-color: rgba(255, 217, 122, 0.35);
background: rgba(255, 217, 122, 0.1);
--status-badge-color: #ffe6a2;
--status-badge-border: rgba(255, 217, 122, 0.35);
--status-badge-background: rgba(255, 217, 122, 0.1);
}
/* Unsaved live edits (save-gated persistence, viewshed design §3e). */
#cctv-quality-chip[data-cal-badge='edited'] {
color: #ffd08a;
border-color: rgba(255, 176, 82, 0.5);
background: rgba(255, 176, 82, 0.14);
--status-badge-color: #ffd08a;
--status-badge-border: rgba(255, 176, 82, 0.5);
--status-badge-background: rgba(255, 176, 82, 0.14);
}
.cctv-frame-wrap {
@@ -346,26 +300,18 @@ html:not([lang='en']) .cctv-controls .scene-btn {
#cctv-source-badge {
align-self: flex-start;
border: 1px solid rgba(0, 212, 255, 0.32);
border-radius: 999px;
background: rgba(0, 212, 255, 0.09);
color: #9defff;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.3px;
padding: 4px 8px;
}
#cctv-source-badge[data-frame-state='loading'] {
color: #ffe59b;
border-color: rgba(255, 205, 92, 0.45);
background: rgba(255, 205, 92, 0.1);
--status-badge-color: #ffe59b;
--status-badge-border: rgba(255, 205, 92, 0.45);
--status-badge-background: rgba(255, 205, 92, 0.1);
}
#cctv-source-badge[data-frame-state='error'] {
color: #ffb0b0;
border-color: rgba(255, 102, 102, 0.5);
background: rgba(255, 72, 72, 0.11);
--status-badge-color: #ffb0b0;
--status-badge-border: rgba(255, 102, 102, 0.5);
--status-badge-background: rgba(255, 72, 72, 0.11);
}
.cctv-summary-label {
-10
View File
@@ -727,16 +727,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
.pp-select {
flex: 1;
min-width: 0;
height: 24px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
padding: 2px 6px;
outline: none;
}
/* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */
+1
View File
@@ -171,6 +171,7 @@
.dock-tray-toggle,
.dock-popover,
.scene-btn,
.gev-action-button:not(.gev-action-button--compact),
.context-mode-button,
.map-stack-chip,
.key-setup-chip,
+51
View File
@@ -0,0 +1,51 @@
/* Native selects and numeric inputs. Features own options, constraints and
commit timing; density variants do not introduce custom keyboard behavior. */
.gev-field {
min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-primary);
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
outline: none;
}
.gev-field:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
.gev-field:disabled {
opacity: 0.45;
}
.gev-field--compact {
height: 24px;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
padding: 2px 6px;
}
/* Effective-pose editors must fit inside CCTV value chips. Retain their
existing spinner-free surface; calibration conversion stays with the owner. */
.gev-field--inline {
width: 58px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.7px;
background: rgba(0, 212, 255, 0.1);
border-color: rgba(0, 212, 255, 0.5);
border-radius: 4px;
padding: 1px 3px;
-moz-appearance: textfield;
}
.gev-field--inline::-webkit-outer-spin-button,
.gev-field--inline::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
-66
View File
@@ -153,14 +153,6 @@
#scene-select {
flex: 1;
min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-primary);
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
outline: none;
}
/* A translated scene name needs the width: the select takes its own row,
@@ -184,21 +176,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
}
.scene-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1.2px;
padding: 6px 9px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast);
white-space: nowrap;
flex: 1;
min-width: 72px;
}
@@ -295,49 +272,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
gap: 6px;
}
.scene-shot-btn {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.8px;
padding: 4px 6px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.scene-shot-btn:hover {
color: var(--text-primary);
border-color: var(--glass-border-hover);
}
.scene-shot-btn.scene-shot-danger:hover {
color: #ff7e7e;
border-color: #ff7e7e;
}
.scene-btn:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
background: rgba(0, 212, 255, 0.14);
}
.scene-btn:disabled {
opacity: 0.45;
cursor: default;
}
.scene-btn-danger:hover:not(:disabled) {
color: #ff7272;
border-color: #ff7272;
background: rgba(255, 88, 88, 0.15);
}
.scene-progress {
margin-top: 8px;
width: 100%;
+43
View File
@@ -0,0 +1,43 @@
/* Read-only badges. Feature CSS supplies meaning through explicit colors;
appearance variants change density/shape without interpreting status codes. */
.gev-status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
padding: 4px 8px;
border: 1px solid var(--status-badge-border, rgba(0, 212, 255, 0.32));
border-radius: 999px;
background: var(--status-badge-background, rgba(0, 212, 255, 0.09));
color: var(--status-badge-color, #9defff);
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.3px;
overflow-wrap: anywhere;
}
.gev-status-badge--box {
padding: 6px 8px;
border-radius: 8px;
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
}
.gev-status-badge--plain {
padding: 0;
border: 0;
border-radius: 0;
background: none;
color: var(--status-badge-color, var(--text-dim));
font-size: 9px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
/* Explicitly beat the component display rule, including in older browsers. */
.gev-status-badge[hidden] {
display: none;
}
-4
View File
@@ -133,7 +133,6 @@
margin-left: auto;
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;
+3 -3
View File
@@ -121,7 +121,7 @@
<div class="radio-directory-row">
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
<select id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
<select class="gev-field" id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
<option value="all" data-i18n>All</option>
</select>
</div>
@@ -180,7 +180,7 @@
</div>
<div class="sdr-gain-row">
<label for="sdr-gain-select" data-i18n>GAIN</label>
<select id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
<select class="gev-field" id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
<option value="auto" data-i18n>AUTO</option>
<option value="0.0" translate="no">0.0 dB</option>
<option value="0.9" translate="no">0.9 dB</option>
@@ -221,7 +221,7 @@
</div>
<div class="sdr-frequency-row">
<label for="sdr-frequency-input" data-i18n>FM MHZ</label>
<input id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
<input class="gev-field" id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
<button id="sdr-tune-btn" type="button" data-i18n>TUNE</button>
</div>
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
+2 -2
View File
@@ -13,7 +13,7 @@
</button>
<div class="pp-slider-row" id="hud-layout-row">
<span class="pp-slider-mini-label" data-i18n>Layout</span>
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout" data-i18n-aria-label>
<select id="hud-layout-select" class="pp-select gev-field gev-field--compact" aria-label="HUD layout" data-i18n-aria-label>
<option value="tactical" data-i18n>Tactical</option>
<option value="operator" data-i18n>Operator</option>
<option value="minimal" data-i18n>Minimal</option>
@@ -136,7 +136,7 @@
</div>
<div class="pp-slider-row" id="draw-label-row">
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
<select class="pp-select draw-color-select" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
<select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
<option value="primary" data-i18n>Primary</option>
<option value="amber" data-i18n>Amber</option>
<option value="cyan" data-i18n>Cyan</option>
+20 -20
View File
@@ -27,25 +27,25 @@
<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>
<button id="cctv-nearest-btn" class="scene-btn" data-i18n>NEAREST</button>
<button id="cctv-nearest-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEAREST</button>
</div>
<div class="cctv-controls">
<button id="cctv-prev-btn" class="scene-btn" data-i18n>PREV</button>
<select id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
<button id="cctv-next-btn" class="scene-btn" data-i18n>NEXT</button>
<button id="cctv-prev-btn" type="button" class="gev-action-button scene-btn" data-i18n>PREV</button>
<select class="gev-field" id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
<button id="cctv-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
</div>
<div class="cctv-controls">
<button id="cctv-focus-btn" class="scene-btn" data-i18n>FOCUS</button>
<button id="cctv-focus-btn" type="button" class="gev-action-button scene-btn" data-i18n>FOCUS</button>
<button id="cctv-coverage-btn" class="scene-btn" data-i18n>COVERAGE OFF</button>
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n>AUTO HOP OFF</button>
</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">
@@ -62,8 +62,8 @@
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
</div>
<div class="cctv-controls cctv-calibration-actions">
<button id="cctv-calib-save-btn" class="scene-btn" data-i18n>SAVE CAL</button>
<button id="cctv-calib-reset-btn" class="scene-btn" data-i18n>RESET CAL</button>
<button id="cctv-calib-save-btn" type="button" class="gev-action-button scene-btn" data-i18n>SAVE CAL</button>
<button id="cctv-calib-reset-btn" type="button" class="gev-action-button scene-btn" data-i18n>RESET CAL</button>
</div>
</div>
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
@@ -152,24 +152,24 @@
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title>+</button>
</div>
<div class="scene-controls">
<select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
<button id="scene-new-btn" class="scene-btn" data-i18n>NEW</button>
<button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n>DEL</button>
<select class="gev-field" id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
<button id="scene-new-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEW</button>
<button id="scene-delete-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>DEL</button>
</div>
<div class="scene-controls">
<button id="scene-capture-btn" class="scene-btn" data-i18n>CAPTURE SHOT</button>
<button id="scene-update-shot-btn" class="scene-btn" data-i18n>UPDATE SHOT</button>
<button id="scene-capture-btn" type="button" class="gev-action-button scene-btn" data-i18n>CAPTURE SHOT</button>
<button id="scene-update-shot-btn" type="button" class="gev-action-button scene-btn" data-i18n>UPDATE SHOT</button>
</div>
<div id="scene-shot-list" class="scene-shot-list"></div>
<div class="scene-controls">
<button id="scene-start-btn" class="scene-btn" data-i18n>START</button>
<button id="scene-stop-btn" class="scene-btn scene-btn-danger" data-i18n>STOP</button>
<button id="scene-next-btn" class="scene-btn" data-i18n>NEXT</button>
<button id="scene-start-btn" type="button" class="gev-action-button scene-btn" data-i18n>START</button>
<button id="scene-stop-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>STOP</button>
<button id="scene-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
</div>
<div class="scene-controls">
<button id="scene-export-btn" class="scene-btn" data-i18n>EXPORT PRESETS</button>
<button id="scene-import-btn" class="scene-btn" data-i18n>IMPORT</button>
<button id="scene-download-btn" class="scene-btn" data-i18n>RUN LOG</button>
<button id="scene-export-btn" type="button" class="gev-action-button scene-btn" data-i18n>EXPORT PRESETS</button>
<button id="scene-import-btn" type="button" class="gev-action-button scene-btn" data-i18n>IMPORT</button>
<button id="scene-download-btn" type="button" class="gev-action-button scene-btn" data-i18n>RUN LOG</button>
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
</div>
<div class="scene-progress">