mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor(ui): share Scene and CCTV action buttons
This commit is contained in:
1 parent
5a1598fd9e
commit
538f2f1bc0
15 files changed
+505
-86
No files matched your search
@@ -374,6 +374,15 @@ renderer, persistence or services. The facade retains panel visibility, share
|
|||||||
restore claims and render scheduling. Both controls are destroyed before the
|
restore claims and render scheduling. Both controls are destroyed before the
|
||||||
facade's asynchronous teardown can yield.
|
facade's asynchronous teardown can yield.
|
||||||
|
|
||||||
|
## Ordinary action buttons
|
||||||
|
|
||||||
|
`src/ui/actionButton.js` is a native, listener-free view helper used by Scene
|
||||||
|
presentation and sharing-toolbar owners. It receives translated text and explicit
|
||||||
|
presentation/accessibility properties. Feature controllers own action dispatch,
|
||||||
|
state, persistence and cleanup. Static Scenes/CCTV templates use the same CSS
|
||||||
|
surface and preserve IDs and layout hooks. The helper imports no application,
|
||||||
|
renderer, storage or services; it belongs to the consuming package closures.
|
||||||
|
|
||||||
## Display controls
|
## Display controls
|
||||||
|
|
||||||
`ui/display` owns Display button, selector and slider subscriptions. It receives
|
`ui/display` owns Display button, selector and slider subscriptions. It receives
|
||||||
|
|||||||
@@ -1233,6 +1233,26 @@ Feature owners retain bounds, steps, formatted values, input/change timing and
|
|||||||
listener cleanup. The radio tuning dial, imagery comparison divider and event
|
listener cleanup. The radio tuning dial, imagery comparison divider and event
|
||||||
comparison/progression controls keep their specialized presentation and behavior.
|
comparison/progression controls keep their specialized presentation and behavior.
|
||||||
|
|
||||||
|
## Ordinary action buttons
|
||||||
|
|
||||||
|
Scene panel actions, shot LOAD/DEL actions, the EDIT DETAILS / SHARE SCENE
|
||||||
|
bar and one-shot CCTV actions use `ui/actionButton.js` and the shared
|
||||||
|
`gev-action-button` surface in `ui/styles/action-button.css`. Static templates
|
||||||
|
apply the same classes as generated buttons. The native builder accepts translated
|
||||||
|
text, default/danger variants, default/compact sizes and disabled/accessibility
|
||||||
|
properties; it installs no listeners and owns no application state. Feature
|
||||||
|
controllers retain callbacks, enable/disable rules, confirmation prompts,
|
||||||
|
persistence and teardown. The smaller shot actions retain their existing sizing
|
||||||
|
and hover treatment. Feature CSS still owns panel layout and translated wrapping.
|
||||||
|
|
||||||
|
CCTV enable, coverage, auto-hop, projection and adjustment toggles, editable
|
||||||
|
calibration-value chips and the scene authoring dialogs retain their specialized
|
||||||
|
controls. Existing `scene-btn` hooks remain compatible with Street Level and
|
||||||
|
legacy toggles. The shared CSS, explicit input/action boundary and behavior tests
|
||||||
|
can carry to a Vue view while Vue replaces native DOM construction.
|
||||||
|
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs`;
|
||||||
|
`qa-scene-controls.mjs` also covers real Scene authoring/playback with native Space capture.
|
||||||
|
|
||||||
## Segmented selectors
|
## Segmented selectors
|
||||||
|
|
||||||
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
|
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
|
||||||
|
|||||||
@@ -281,6 +281,7 @@
|
|||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
"src/ui/sceneControls.test.mjs",
|
"src/ui/sceneControls.test.mjs",
|
||||||
"scripts/qa-scene-controls.mjs",
|
"scripts/qa-scene-controls.mjs",
|
||||||
|
"scripts/qa-action-buttons.mjs",
|
||||||
"src/ui.js",
|
"src/ui.js",
|
||||||
"src/standalone/ui.js",
|
"src/standalone/ui.js",
|
||||||
"src/ui/applicationShell.js",
|
"src/ui/applicationShell.js",
|
||||||
@@ -305,6 +306,7 @@
|
|||||||
"src/ui/styles/layers.css",
|
"src/ui/styles/layers.css",
|
||||||
"src/ui/styles/controls.css",
|
"src/ui/styles/controls.css",
|
||||||
"src/ui/styles/slider.css",
|
"src/ui/styles/slider.css",
|
||||||
|
"src/ui/styles/action-button.css",
|
||||||
"src/ui/styles/cctv.css",
|
"src/ui/styles/cctv.css",
|
||||||
"src/ui/styles/foundation.css",
|
"src/ui/styles/foundation.css",
|
||||||
"src/ui/styles/first-run.css",
|
"src/ui/styles/first-run.css",
|
||||||
|
|||||||
@@ -614,6 +614,7 @@
|
|||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
|
"src/ui/actionButton.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
@@ -737,6 +738,7 @@
|
|||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
|
"src/ui/actionButton.js",
|
||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
"src/ui/shareRestoration.js",
|
"src/ui/shareRestoration.js",
|
||||||
"src/ui/shellElements.js",
|
"src/ui/shellElements.js",
|
||||||
@@ -849,6 +851,7 @@
|
|||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
|
"src/ui/actionButton.js",
|
||||||
"src/contextModePolicy.js",
|
"src/contextModePolicy.js",
|
||||||
"src/director/clock.js",
|
"src/director/clock.js",
|
||||||
"src/director/timeline.js",
|
"src/director/timeline.js",
|
||||||
@@ -2214,6 +2217,7 @@
|
|||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
|
"src/ui/actionButton.js",
|
||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
"src/ui/shareRestoration.js",
|
"src/ui/shareRestoration.js",
|
||||||
"src/ui/shellElements.js",
|
"src/ui/shellElements.js",
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Scenes/CCTV action-button acceptance against dev or production preview:
|
||||||
|
* QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs
|
||||||
|
* Uses the installed CCTV controller with fixture camera/action ports to prove
|
||||||
|
* native dispatch/disabled/teardown without providers or world calibration writes.
|
||||||
|
* Real Scene authoring/playback remains covered by qa-scene-controls.mjs.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
let failures = 0;
|
||||||
|
const check = (name, passed, detail) => {
|
||||||
|
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||||
|
if (!passed) {
|
||||||
|
failures++;
|
||||||
|
console.log(JSON.stringify(detail));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const shots = 'qa-shots/action-buttons';
|
||||||
|
fs.mkdirSync(shots, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
await page.goto(
|
||||||
|
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4179'}/?welcome=0`,
|
||||||
|
{ waitUntil: 'domcontentloaded' },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
const inventory = await page.evaluate(() => {
|
||||||
|
const ids = [
|
||||||
|
'scene-new-btn',
|
||||||
|
'scene-delete-btn',
|
||||||
|
'scene-capture-btn',
|
||||||
|
'scene-update-shot-btn',
|
||||||
|
'scene-start-btn',
|
||||||
|
'scene-stop-btn',
|
||||||
|
'scene-next-btn',
|
||||||
|
'scene-export-btn',
|
||||||
|
'scene-import-btn',
|
||||||
|
'scene-download-btn',
|
||||||
|
'cctv-nearest-btn',
|
||||||
|
'cctv-prev-btn',
|
||||||
|
'cctv-next-btn',
|
||||||
|
'cctv-focus-btn',
|
||||||
|
'cctv-calib-save-btn',
|
||||||
|
'cctv-calib-reset-btn',
|
||||||
|
];
|
||||||
|
const ordinary = ids.map((id) => document.getElementById(id));
|
||||||
|
const generated = [
|
||||||
|
...document.querySelectorAll(
|
||||||
|
'.scene-shot-btn, [data-director-authoring] button',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const toggles = [
|
||||||
|
'cctv-enable-btn',
|
||||||
|
'cctv-coverage-btn',
|
||||||
|
'cctv-auto-hop-btn',
|
||||||
|
'cctv-projection-btn',
|
||||||
|
'cctv-adjust-btn',
|
||||||
|
].map((id) => document.getElementById(id));
|
||||||
|
return {
|
||||||
|
shared: [...ordinary, ...generated].every(
|
||||||
|
(node) =>
|
||||||
|
node?.type === 'button' &&
|
||||||
|
node.classList.contains('gev-action-button'),
|
||||||
|
),
|
||||||
|
generated: generated.length,
|
||||||
|
layout: ordinary.every((node) => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
return style.minWidth === '72px' && style.flexGrow === '1';
|
||||||
|
}),
|
||||||
|
togglesSeparate: toggles.every(
|
||||||
|
(node) => node && !node.classList.contains('gev-action-button'),
|
||||||
|
),
|
||||||
|
compactDanger: !!document.querySelector(
|
||||||
|
'.scene-shot-danger.gev-action-button--compact.gev-action-button--danger',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'static and generated actions share the native component',
|
||||||
|
inventory.shared && inventory.generated >= 4 && inventory.compactDanger,
|
||||||
|
inventory,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'persistent CCTV toggles retain their existing controls',
|
||||||
|
inventory.togglesSeparate,
|
||||||
|
);
|
||||||
|
check('ordinary actions retain their panel sizing', inventory.layout);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
const owner = manager._cctvControls;
|
||||||
|
owner._cctvUnsubscribe?.();
|
||||||
|
owner._cctvUnsubscribe = null;
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
const record = (...args) => window.__qaButtonCalls.push(args);
|
||||||
|
owner.actions = {
|
||||||
|
...owner.actions,
|
||||||
|
isEnabled: () => true,
|
||||||
|
toggleEnabled: async () => {
|
||||||
|
record('enable');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
runExplicitFocus: (select, focus) => focus(select()),
|
||||||
|
setParams: (params, options) => record('params', params, options),
|
||||||
|
showToast: () => {},
|
||||||
|
};
|
||||||
|
owner.cctv = {
|
||||||
|
focusNearest: () => {
|
||||||
|
record('nearest');
|
||||||
|
return 'a';
|
||||||
|
},
|
||||||
|
cycleCamera: (direction) => {
|
||||||
|
record('cycle', direction);
|
||||||
|
return 'b';
|
||||||
|
},
|
||||||
|
focusCamera: (id, seconds) => record('focus', id, seconds),
|
||||||
|
};
|
||||||
|
window.__qaCameraState = {
|
||||||
|
enabled: true,
|
||||||
|
activeCameraId: 'a',
|
||||||
|
cameras: [
|
||||||
|
{ id: 'a', city: 'Fixture', name: 'A' },
|
||||||
|
{ id: 'b', city: 'Fixture', name: 'B' },
|
||||||
|
],
|
||||||
|
activeCamera: { id: 'a', name: 'A', basePose: {} },
|
||||||
|
};
|
||||||
|
owner._renderCctvState(window.__qaCameraState);
|
||||||
|
manager.setPanelCollapsed('cctv-panel', false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.focus('#cctv-next-btn');
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||||
|
check(
|
||||||
|
'native Space dispatches Next and its focus once',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
JSON.stringify(window.__qaButtonCalls) ===
|
||||||
|
JSON.stringify([['enable'], ['cycle', 1], ['focus', 'b', 1.4]]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'native keyboard activation keeps a visible focus ring',
|
||||||
|
await page.$eval(
|
||||||
|
'#cctv-next-btn',
|
||||||
|
(node) =>
|
||||||
|
document.activeElement === node &&
|
||||||
|
getComputedStyle(node).outlineStyle === 'solid',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
});
|
||||||
|
await page.focus('#cctv-prev-btn');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||||
|
check(
|
||||||
|
'native Enter dispatches Previous once',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
JSON.stringify(window.__qaButtonCalls) ===
|
||||||
|
JSON.stringify([['enable'], ['cycle', -1], ['focus', 'b', 1.4]]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [id, expected] of [
|
||||||
|
['cctv-nearest-btn', [['enable'], ['nearest'], ['focus', 'a', 1.8]]],
|
||||||
|
[
|
||||||
|
'cctv-focus-btn',
|
||||||
|
[
|
||||||
|
['enable'],
|
||||||
|
['focus', 'a', 1.9],
|
||||||
|
['params', { selectedCameraId: 'a' }, { origin: 'user' }],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
});
|
||||||
|
await page.click(`#${id}`);
|
||||||
|
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||||
|
const calls = await page.evaluate(() => window.__qaButtonCalls);
|
||||||
|
check(
|
||||||
|
`${id} retains its existing action and focus timing`,
|
||||||
|
JSON.stringify(calls) === JSON.stringify(expected),
|
||||||
|
calls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
});
|
||||||
|
await page.click('#cctv-calib-save-btn');
|
||||||
|
await page.click('#cctv-calib-reset-btn');
|
||||||
|
const calibration = await page.evaluate(() => window.__qaButtonCalls);
|
||||||
|
check(
|
||||||
|
'Save and Reset retain camera identity and user-origin dispatch',
|
||||||
|
JSON.stringify(calibration) ===
|
||||||
|
JSON.stringify([
|
||||||
|
[
|
||||||
|
'params',
|
||||||
|
{ selectedCameraId: 'a', calibration: { cameraId: 'a', save: true } },
|
||||||
|
{ origin: 'user' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'params',
|
||||||
|
{
|
||||||
|
selectedCameraId: 'a',
|
||||||
|
calibration: { cameraId: 'a', reset: true },
|
||||||
|
},
|
||||||
|
{ origin: 'user' },
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
calibration,
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
window.__godsEyeView.styleManager._cctvControls._renderCctvState({
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.click('#cctv-next-btn');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const node = document.getElementById('cctv-next-btn');
|
||||||
|
return node.disabled && Number(getComputedStyle(node).opacity) === 0.45;
|
||||||
|
},
|
||||||
|
{ timeout: 3000 },
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'disabled actions stay inert and keep disabled presentation',
|
||||||
|
await page.$eval(
|
||||||
|
'#cctv-next-btn',
|
||||||
|
(node) =>
|
||||||
|
node.disabled &&
|
||||||
|
getComputedStyle(node).opacity === '0.45' &&
|
||||||
|
window.__qaButtonCalls.length === 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const width of [1440, 620, 390]) {
|
||||||
|
await page.setViewport({ width, height: 900 });
|
||||||
|
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||||
|
await page.evaluate((panel) => {
|
||||||
|
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
}, panel);
|
||||||
|
const fit = await page.$eval(`#${panel}`, (root) => {
|
||||||
|
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||||
|
const rect = root.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
count: buttons.length,
|
||||||
|
fits:
|
||||||
|
buttons.length > 0 &&
|
||||||
|
buttons.every((button) => {
|
||||||
|
const box = button.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
box.width > 0 &&
|
||||||
|
box.left >= rect.left &&
|
||||||
|
box.right <= rect.right + 1 &&
|
||||||
|
box.left >= 0 &&
|
||||||
|
box.right <= innerWidth + 1 &&
|
||||||
|
button.scrollWidth <= button.clientWidth
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(`${panel} actions fit at ${width}px`, fit.fits, fit);
|
||||||
|
if (width !== 620)
|
||||||
|
await page.screenshot({ path: `${shots}/${panel}-${width}.png` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||||
|
owner.destroy();
|
||||||
|
document.getElementById('cctv-next-btn').disabled = false;
|
||||||
|
window.__qaButtonCalls = [];
|
||||||
|
});
|
||||||
|
await page.click('#cctv-next-btn');
|
||||||
|
check(
|
||||||
|
'destroyed controllers revoke action listeners',
|
||||||
|
await page.evaluate(() => window.__qaButtonCalls.length === 0),
|
||||||
|
);
|
||||||
|
const localized = new URL(page.url());
|
||||||
|
localized.searchParams.set('lang', 'ru');
|
||||||
|
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||||
|
await page.evaluate((panel) => {
|
||||||
|
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
}, panel);
|
||||||
|
const translated = await page.$eval(`#${panel}`, (root) => {
|
||||||
|
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||||
|
return (
|
||||||
|
document.documentElement.lang === 'ru' &&
|
||||||
|
buttons.length > 0 &&
|
||||||
|
buttons.every((node) => node.scrollWidth <= node.clientWidth)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
check(`Russian ${panel} action labels fit at 390px`, translated);
|
||||||
|
await page.screenshot({ path: `${shots}/${panel}-ru-390.png` });
|
||||||
|
}
|
||||||
|
check('no uncaught browser errors', errors.length === 0, errors);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
console.log(`RESULT: ${failures} failures`);
|
||||||
|
process.exitCode = failures ? 1 : 0;
|
||||||
@@ -66,7 +66,8 @@ try {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await page.click('#scene-capture-btn');
|
await page.focus('#scene-capture-btn');
|
||||||
|
await page.keyboard.press('Space');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.querySelectorAll('.scene-shot-row').length === 1,
|
() => document.querySelectorAll('.scene-shot-row').length === 1,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ test('focusable controls do not animate the global outline', () => {
|
|||||||
'#top-center-actions button {',
|
'#top-center-actions button {',
|
||||||
'.data-toggle-chip {',
|
'.data-toggle-chip {',
|
||||||
'.scene-btn {',
|
'.scene-btn {',
|
||||||
'.scene-shot-btn {',
|
'.gev-action-button--compact {',
|
||||||
]) {
|
]) {
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
ruleBody(selector),
|
ruleBody(selector),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Scene panel elements and text-only project presentation. */
|
/** Scene panel elements and text-only project presentation. */
|
||||||
import { formatNumber, msg, t } from '../i18n/index.js';
|
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
|
// Labels for what a shot stores; anything else shows as stored. Style names
|
||||||
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
|
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
|
||||||
@@ -121,13 +122,18 @@ export function renderSceneShots(
|
|||||||
});
|
});
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'scene-shot-actions';
|
actions.className = 'scene-shot-actions';
|
||||||
const loadButton = document.createElement('button');
|
const loadButton = createActionButton({
|
||||||
loadButton.className = 'scene-shot-btn';
|
label: t('LOAD'),
|
||||||
loadButton.textContent = t('LOAD');
|
className: 'scene-shot-btn',
|
||||||
|
size: 'compact',
|
||||||
|
});
|
||||||
listen(loadButton, 'click', () => load(scene.id, shot.id));
|
listen(loadButton, 'click', () => load(scene.id, shot.id));
|
||||||
const deleteButton = document.createElement('button');
|
const deleteButton = createActionButton({
|
||||||
deleteButton.className = 'scene-shot-btn scene-shot-danger';
|
label: t('DEL'),
|
||||||
deleteButton.textContent = t('DEL');
|
className: 'scene-shot-btn scene-shot-danger',
|
||||||
|
size: 'compact',
|
||||||
|
variant: 'danger',
|
||||||
|
});
|
||||||
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
|
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
|
||||||
actions.appendChild(loadButton);
|
actions.appendChild(loadButton);
|
||||||
actions.appendChild(deleteButton);
|
actions.appendChild(deleteButton);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { t, tn } from '../i18n/index.js';
|
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. */
|
/** Small owned modal for scene authoring and import review; text never becomes markup. */
|
||||||
export function createSceneDialog(title, onClose) {
|
export function createSceneDialog(title, onClose) {
|
||||||
@@ -185,10 +186,7 @@ export function mountSceneSharing(
|
|||||||
];
|
];
|
||||||
const removers = [];
|
const removers = [];
|
||||||
for (const [text, fn] of entries) {
|
for (const [text, fn] of entries) {
|
||||||
const b = document.createElement('button');
|
const b = createActionButton({ label: text, className: 'scene-btn' });
|
||||||
b.type = 'button';
|
|
||||||
b.textContent = text;
|
|
||||||
b.className = 'scene-btn';
|
|
||||||
b.addEventListener('click', fn);
|
b.addEventListener('click', fn);
|
||||||
removers.push(() => b.removeEventListener('click', fn));
|
removers.push(() => b.removeEventListener('click', fn));
|
||||||
bar.append(b);
|
bar.append(b);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -171,6 +171,7 @@
|
|||||||
.dock-tray-toggle,
|
.dock-tray-toggle,
|
||||||
.dock-popover,
|
.dock-popover,
|
||||||
.scene-btn,
|
.scene-btn,
|
||||||
|
.gev-action-button:not(.gev-action-button--compact),
|
||||||
.context-mode-button,
|
.context-mode-button,
|
||||||
.map-stack-chip,
|
.map-stack-chip,
|
||||||
.key-setup-chip,
|
.key-setup-chip,
|
||||||
|
|||||||
@@ -184,21 +184,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
flex: 1;
|
||||||
min-width: 72px;
|
min-width: 72px;
|
||||||
}
|
}
|
||||||
@@ -295,49 +280,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
|||||||
gap: 6px;
|
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 {
|
.scene-progress {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -31,15 +31,15 @@
|
|||||||
<div id="cctv-meta" data-i18n>Enable CCTV to load camera intersections</div>
|
<div id="cctv-meta" data-i18n>Enable CCTV to load camera intersections</div>
|
||||||
<div class="cctv-controls">
|
<div class="cctv-controls">
|
||||||
<button id="cctv-enable-btn" class="scene-btn" data-i18n>CCTV OFF</button>
|
<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>
|
||||||
<div class="cctv-controls">
|
<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 id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
|
<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-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cctv-controls">
|
<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-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>
|
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n>AUTO HOP OFF</button>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
<div class="cctv-controls cctv-calibration-actions">
|
<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-save-btn" type="button" class="gev-action-button 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-reset-btn" type="button" class="gev-action-button scene-btn" data-i18n>RESET CAL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
|
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
|
||||||
@@ -151,23 +151,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="scene-controls">
|
<div class="scene-controls">
|
||||||
<select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
|
<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-new-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEW</button>
|
||||||
<button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n>DEL</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>
|
||||||
<div class="scene-controls">
|
<div class="scene-controls">
|
||||||
<button id="scene-capture-btn" class="scene-btn" data-i18n>CAPTURE 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" class="scene-btn" data-i18n>UPDATE SHOT</button>
|
<button id="scene-update-shot-btn" type="button" class="gev-action-button scene-btn" data-i18n>UPDATE SHOT</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="scene-shot-list" class="scene-shot-list"></div>
|
<div id="scene-shot-list" class="scene-shot-list"></div>
|
||||||
<div class="scene-controls">
|
<div class="scene-controls">
|
||||||
<button id="scene-start-btn" class="scene-btn" data-i18n>START</button>
|
<button id="scene-start-btn" type="button" class="gev-action-button scene-btn" data-i18n>START</button>
|
||||||
<button id="scene-stop-btn" class="scene-btn scene-btn-danger" data-i18n>STOP</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" class="scene-btn" data-i18n>NEXT</button>
|
<button id="scene-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="scene-controls">
|
<div class="scene-controls">
|
||||||
<button id="scene-export-btn" class="scene-btn" data-i18n>EXPORT PRESETS</button>
|
<button id="scene-export-btn" type="button" class="gev-action-button scene-btn" data-i18n>EXPORT PRESETS</button>
|
||||||
<button id="scene-import-btn" class="scene-btn" data-i18n>IMPORT</button>
|
<button id="scene-import-btn" type="button" class="gev-action-button scene-btn" data-i18n>IMPORT</button>
|
||||||
<button id="scene-download-btn" class="scene-btn" data-i18n>RUN LOG</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 />
|
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
|
||||||
</div>
|
</div>
|
||||||
<div class="scene-progress">
|
<div class="scene-progress">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* Preserve component and responsive rule order in one stylesheet entry. */
|
/* Preserve component and responsive rule order in one stylesheet entry. */
|
||||||
@import './src/ui/styles/foundation.css';
|
@import './src/ui/styles/foundation.css';
|
||||||
|
@import './src/ui/styles/action-button.css';
|
||||||
@import './src/ui/styles/slider.css';
|
@import './src/ui/styles/slider.css';
|
||||||
@import './src/ui/styles/controls.css';
|
@import './src/ui/styles/controls.css';
|
||||||
@import './src/ui/styles/location.css';
|
@import './src/ui/styles/location.css';
|
||||||
|
|||||||
Reference in new issue
Block a user