Merge main and preserve shared control surfaces

# Conflicts:
#	src/ui/templates/layer-panels.html
#	style.css
This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-21 17:25:25 +02:00
commit 3f903f0b18
36 files changed
+1134 -176

No files matched your search

+1 -1
View File
@@ -945,7 +945,7 @@
"Image by {creator}": "Imagen de {creator}",
"zoom in to see KartaView coverage": "acércate para ver la cobertura de KartaView",
"no KartaView photos in view": "no hay fotos de KartaView a la vista",
"{names}: by upload date": "{names}: por fecha de subida",
"(by upload date)": "(por fecha de subida)",
"{n} streets in view · click a line for its nearest photo": {
"one": "{n} calle a la vista · haz clic en una línea para su foto más cercana",
"many": "{n} calles a la vista · haz clic en una línea para su foto más cercana",
+1 -1
View File
@@ -954,7 +954,7 @@
"Image by {creator}": "Автор снимка: {creator}",
"zoom in to see KartaView coverage": "приблизьтесь, чтобы увидеть покрытие KartaView",
"no KartaView photos in view": "фотографий KartaView в зоне видимости нет",
"{names}: by upload date": "{names}: по дате загрузки",
"(by upload date)": "(по дате загрузки)",
"{n} streets in view · click a line for its nearest photo": {
"one": "{n} улица в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
"few": "{n} улицы в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
+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),
@@ -141,7 +141,7 @@ export const FILTERED_OUT_MESSAGE = msg(
'None of KartaView’s photos here pass the imagery filter',
);
/** Under the SINCE slider while KartaView is lit. */
export const SINCE_NOTE = 'KartaView: by upload date';
export const SINCE_NOTE = '(by upload date)';
/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */
function utcDay(ms) {
+1 -1
View File
@@ -23,7 +23,7 @@ export function filtersOf(provider) {
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit.
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' notes "(by upload date)" beside the SINCE value while the provider is lit.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
+5 -7
View File
@@ -73,16 +73,14 @@ export function filterControls(providers) {
}
/**
* The note under SINCE when a lit provider dates its imagery by upload,
* e.g. "KartaView: by upload date", or null.
* The note beside the SINCE value when a lit provider dates its imagery by
* upload, "(by upload date)", or null. One source is lit at a time, so the
* note needs no provider name.
* @param {Array<ProviderSnapshot>} providers
*/
export function sinceNote(providers) {
const names = providers
.filter((p) => p.on && p.sinceBasis === 'upload')
.map((p) => p.name);
return names.length
? t('{names}: by upload date', { names: names.join(', ') })
return providers.some((p) => p.on && p.sinceBasis === 'upload')
? t('(by upload date)')
: null;
}
+2 -2
View File
@@ -67,7 +67,7 @@ test('the filter controls show only for a lit provider that honours them', () =>
);
});
test('a lit provider that dates by upload puts a note under SINCE', () => {
test('a lit provider that dates by upload notes it beside the SINCE value', () => {
const kartaView = provider({
id: 'kartaview',
name: 'KartaView',
@@ -75,7 +75,7 @@ test('a lit provider that dates by upload puts a note under SINCE', () => {
});
const note = (providers) => composeUIState({ ...base, providers }).sinceNote;
assert.equal(note([provider()]), null, 'Mapillary: capture date');
assert.equal(note([kartaView]), 'KartaView: by upload date');
assert.equal(note([kartaView]), '(by upload date)');
assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit');
});
+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;
}
+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);
+2 -2
View File
@@ -981,9 +981,9 @@ test('a note under SINCE says what date it compares, only while one is given', (
const { layer } = stubPanel(dom, state);
const note = () => dom.root.querySelector('#sl-since-note');
assert.equal(note().hidden, true, 'none for Mapillary');
layer.publish({ ...state, sinceNote: 'KartaView: by upload date' });
layer.publish({ ...state, sinceNote: '(by upload date)' });
assert.equal(note().hidden, false);
assert.equal(note().textContent, 'KartaView: by upload date');
assert.equal(note().textContent, '(by upload date)');
layer.publish(state);
assert.equal(note().hidden, true);
// The panel's own markup has it, read out with the slider.
+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;
}
+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,
-58
View File
@@ -176,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;
}
@@ -287,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%;
+25 -3
View File
@@ -236,10 +236,33 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
gap: 4px 10px;
}
.sl-since-note {
.sl-range {
width: 100%;
min-width: 0;
margin: 0;
accent-color: var(--sl-green);
cursor: pointer;
}
.sl-range:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 3px;
}
/* The value and its note share a line: ANY DATE (by upload date). */
.sl-since-value {
grid-column: 2;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 6px;
min-width: 0;
}
.sl-since-note {
font-family: var(--font-mono);
font-size: var(--sl-text-small);
font-size: 0.5rem;
letter-spacing: 0.08em;
color: var(--text-secondary);
}
@@ -248,7 +271,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
}
.sl-since-label {
grid-column: 2;
font-family: var(--font-mono);
font-size: var(--sl-text);
letter-spacing: 0.08em;
+21 -19
View File
@@ -31,15 +31,15 @@
<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>
<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" class="scene-btn" data-i18n>NEXT</button>
<button id="cctv-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
</div>
<div 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>
@@ -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>
@@ -126,9 +126,11 @@
</div>
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
<input id="sl-since" class="gev-quantitative-slider sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
<small id="sl-since-note" class="sl-since-note" hidden></small>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
<span class="sl-since-value">
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
<small id="sl-since-note" class="sl-since-note" hidden></small>
</span>
</div>
</fieldset>
<!-- The hint says how to open imagery; the button does it without a pointer. -->
@@ -151,23 +153,23 @@
</div>
<div class="scene-controls">
<select class="gev-field" id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
<button id="scene-new-btn" class="scene-btn" data-i18n>NEW</button>
<button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n>DEL</button>
<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">