diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index eb393a2da..6c2b6ee2a 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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 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 `ui/display` owns Display button, selector and slider subscriptions. It receives diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b3930264c..a7c00f287 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 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 use the controlled DOM adapter in `ui/segmentedControl.js` diff --git a/scripts/format-scope.json b/scripts/format-scope.json index a941b1d8f..0832ceeeb 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -281,6 +281,7 @@ "src/ui/scenePresentation.js", "src/ui/sceneControls.test.mjs", "scripts/qa-scene-controls.mjs", + "scripts/qa-action-buttons.mjs", "src/ui.js", "src/standalone/ui.js", "src/ui/applicationShell.js", @@ -305,6 +306,7 @@ "src/ui/styles/layers.css", "src/ui/styles/controls.css", "src/ui/styles/slider.css", + "src/ui/styles/action-button.css", "src/ui/styles/cctv.css", "src/ui/styles/foundation.css", "src/ui/styles/first-run.css", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3a9950f39..c15a35eae 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -614,6 +614,7 @@ "src/ui/scenes.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", + "src/ui/actionButton.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", @@ -737,6 +738,7 @@ "src/ui/rightPanelRail.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", + "src/ui/actionButton.js", "src/ui/scenes.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", @@ -849,6 +851,7 @@ "src/ui/scenes.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", + "src/ui/actionButton.js", "src/contextModePolicy.js", "src/director/clock.js", "src/director/timeline.js", @@ -2214,6 +2217,7 @@ "src/ui/rightPanelRail.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", + "src/ui/actionButton.js", "src/ui/scenes.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", diff --git a/scripts/qa-action-buttons.mjs b/scripts/qa-action-buttons.mjs new file mode 100644 index 000000000..ef11e8ff2 --- /dev/null +++ b/scripts/qa-action-buttons.mjs @@ -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; diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index 9dae69cdc..c7068f26c 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -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( () => document.querySelectorAll('.scene-shot-row').length === 1, ); diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs index 5244cffdc..fc59481aa 100644 --- a/src/keyboardFocusStyles.test.mjs +++ b/src/keyboardFocusStyles.test.mjs @@ -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), diff --git a/src/ui/actionButton.js b/src/ui/actionButton.js new file mode 100644 index 000000000..f1b4b85f9 --- /dev/null +++ b/src/ui/actionButton.js @@ -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; +} diff --git a/src/ui/scenePresentation.js b/src/ui/scenePresentation.js index 258ee5059..52e99796e 100644 --- a/src/ui/scenePresentation.js +++ b/src/ui/scenePresentation.js @@ -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); diff --git a/src/ui/sceneSharing.js b/src/ui/sceneSharing.js index 245561b8d..8c9843eba 100644 --- a/src/ui/sceneSharing.js +++ b/src/ui/sceneSharing.js @@ -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); diff --git a/src/ui/styles/action-button.css b/src/ui/styles/action-button.css new file mode 100644 index 000000000..4da40cdb1 --- /dev/null +++ b/src/ui/styles/action-button.css @@ -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; +} diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index fc93abcb6..cad6e69af 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -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, diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 287b8b842..8c8d663cd 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -184,21 +184,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 +280,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%; diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 78ecfd777..283891a98 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -31,15 +31,15 @@