From c9be877f9c24d5ca61c7ccd5e2102da477ce6162 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 20:39:47 -0700 Subject: [PATCH] style(ui): format shortcut and parameter components --- scripts/format-scope.json | 7 +- scripts/qa-visual-input.mjs | 161 +++++++++++++++++++++++++-------- src/ui/applicationShortcuts.js | 26 ++++-- src/ui/styleParameters.js | 8 +- src/ui/visualInput.test.mjs | 132 ++++++++++++++++++++++----- 5 files changed, 262 insertions(+), 72 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 62a34944c..a77bbdb9b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -129,5 +129,10 @@ "src/ui/panelMeasurement.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.test.mjs", - "src/panelStackLayout.js" + "src/panelStackLayout.js", + "src/ui/applicationShortcuts.js", + "src/ui/styleParameters.js", + "src/ui/visualInput.js", + "src/ui/visualInput.test.mjs", + "scripts/qa-visual-input.mjs" ] diff --git a/scripts/qa-visual-input.mjs b/scripts/qa-visual-input.mjs index 441325f8c..dbbfe8e01 100644 --- a/scripts/qa-visual-input.mjs +++ b/scripts/qa-visual-input.mjs @@ -5,7 +5,12 @@ import puppeteer from 'puppeteer'; const browser = await puppeteer.launch({ headless: true, - args: ['--no-sandbox', ...(process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu'] : ['--use-gl=angle', '--use-angle=swiftshader'])], + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], }); const page = await browser.newPage(); const errors = []; @@ -20,64 +25,129 @@ try { const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); url.searchParams.set('welcome', '0'); await page.goto(url.href, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => window.__godsEyeView?.styleManager?._applicationShortcuts, { timeout: 60_000 }); - await page.waitForFunction(() => { - const cover = document.getElementById('loading-screen'); - return cover?.classList.contains('hidden') && Number(getComputedStyle(cover).opacity) === 0; - }, { timeout: 60_000 }); + await page.waitForFunction( + () => window.__godsEyeView?.styleManager?._applicationShortcuts, + { timeout: 60_000 }, + ); + await page.waitForFunction( + () => { + const cover = document.getElementById('loading-screen'); + return ( + cover?.classList.contains('hidden') && + Number(getComputedStyle(cover).opacity) === 0 + ); + }, + { timeout: 60_000 }, + ); await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; manager.setStyle('normal'); document.activeElement?.blur(); }); await page.keyboard.press('2'); - check('native number shortcut selects CRT', await page.evaluate(() => window.__godsEyeView.styleManager.activeStyle === 'retro')); + check( + 'native number shortcut selects CRT', + await page.evaluate( + () => window.__godsEyeView.styleManager.activeStyle === 'retro', + ), + ); const initial = await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; - manager.setPanelCollapsed('pp-toggles', false, { persist: false, syncShare: false }); + manager.setPanelCollapsed('pp-toggles', false, { + persist: false, + syncShare: false, + }); manager._sliderPanel.classList.remove('collapsed'); const slider = manager._sliderContainer.querySelector('input'); window.__qaOldSlider = slider; const name = 'pixelation'; - window.__qaParameter = { name, before: manager.stages.retro.uniforms[name] }; + window.__qaParameter = { + name, + before: manager.stages.retro.uniforms[name], + }; slider.focus(); - return { before: Number(slider.value), step: Number(slider.step), label: slider.getAttribute('aria-label') }; + return { + before: Number(slider.value), + step: Number(slider.step), + label: slider.getAttribute('aria-label'), + }; }); - check('generated range has an accessible label and a numeric step', initial.label === 'Pixelation' && initial.step > 0); + check( + 'generated range has an accessible label and a numeric step', + initial.label === 'Pixelation' && initial.step > 0, + ); await page.keyboard.press('ArrowRight'); const changed = await page.evaluate(() => { const slider = window.__qaOldSlider; const manager = window.__godsEyeView.styleManager; - return { value: Number(slider.value), readout: slider.nextElementSibling.textContent, uniform: manager.stages.retro.uniforms[window.__qaParameter.name] }; + return { + value: Number(slider.value), + readout: slider.nextElementSibling.textContent, + uniform: manager.stages.retro.uniforms[window.__qaParameter.name], + }; }); - check('native range input changes the shader and formatted readout', changed.value > initial.before && changed.uniform === changed.value && Number(changed.readout) === changed.value); + check( + 'native range input changes the shader and formatted readout', + changed.value > initial.before && + changed.uniform === changed.value && + Number(changed.readout) === changed.value, + ); await page.keyboard.press('3'); - check('focused range retains native input instead of changing style', await page.evaluate(() => window.__godsEyeView.styleManager.activeStyle === 'retro')); - check('replacing a style revokes its detached slider', await page.evaluate(() => { - const manager = window.__godsEyeView.styleManager; - const before = manager.stages.retro.uniforms[window.__qaParameter.name]; - manager.setStyle('thermal'); - window.__qaOldSlider.value = '0'; - window.__qaOldSlider.dispatchEvent(new Event('input', { bubbles: true })); - return manager.stages.retro.uniforms[window.__qaParameter.name] === before; - })); - check('normal style clears rows; reopening restores current shader values', await page.evaluate(() => { - const manager = window.__godsEyeView.styleManager; - manager.setStyle('normal'); - const cleared = manager._sliderContainer.children.length === 0; - manager.setStyle('retro'); - return cleared && Number(manager._sliderContainer.querySelector('input').value) === manager.stages.retro.uniforms[window.__qaParameter.name]; - })); + check( + 'focused range retains native input instead of changing style', + await page.evaluate( + () => window.__godsEyeView.styleManager.activeStyle === 'retro', + ), + ); + check( + 'replacing a style revokes its detached slider', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const before = manager.stages.retro.uniforms[window.__qaParameter.name]; + manager.setStyle('thermal'); + window.__qaOldSlider.value = '0'; + window.__qaOldSlider.dispatchEvent(new Event('input', { bubbles: true })); + return ( + manager.stages.retro.uniforms[window.__qaParameter.name] === before + ); + }), + ); + check( + 'normal style clears rows; reopening restores current shader values', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setStyle('normal'); + const cleared = manager._sliderContainer.children.length === 0; + manager.setStyle('retro'); + return ( + cleared && + Number(manager._sliderContainer.querySelector('input').value) === + manager.stages.retro.uniforms[window.__qaParameter.name] + ); + }), + ); const hudBefore = await page.evaluate(() => { document.getElementById('hud-layout-select').focus(); return window.__godsEyeView.styleManager.hud.visible; }); await page.keyboard.press('h'); - check('HUD select keeps its native typing and selection', await page.evaluate((visible) => document.activeElement?.id === 'hud-layout-select' && window.__godsEyeView.styleManager.hud.visible === visible, hudBefore)); + check( + 'HUD select keeps its native typing and selection', + await page.evaluate( + (visible) => + document.activeElement?.id === 'hud-layout-select' && + window.__godsEyeView.styleManager.hud.visible === visible, + hudBefore, + ), + ); fs.mkdirSync('qa-shots/visual-input', { recursive: true }); await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' }); await page.setViewport({ width: 620, height: 900 }); - await page.waitForFunction(() => document.getElementById('left-panel-stack')?.dataset.layoutMode === 'mobile'); + await page.waitForFunction( + () => + document.getElementById('left-panel-stack')?.dataset.layoutMode === + 'mobile', + ); await page.screenshot({ path: 'qa-shots/visual-input/narrow.png' }); await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; @@ -87,14 +157,27 @@ try { document.activeElement?.blur(); }); await page.keyboard.press('3'); - check('destroyed application shortcuts cannot change the selected style', await page.evaluate(() => window.__godsEyeView.styleManager.activeStyle === 'retro')); - check('destroyed parameter controls cannot write through a retained element', await page.evaluate(() => { - const manager = window.__godsEyeView.styleManager; - const before = manager.stages.retro.uniforms[window.__qaParameter.name]; - window.__qaCurrentSlider.value = '0'; - window.__qaCurrentSlider.dispatchEvent(new Event('input', { bubbles: true })); - return manager._sliderContainer.children.length === 0 && manager.stages.retro.uniforms[window.__qaParameter.name] === before; - })); + check( + 'destroyed application shortcuts cannot change the selected style', + await page.evaluate( + () => window.__godsEyeView.styleManager.activeStyle === 'retro', + ), + ); + check( + 'destroyed parameter controls cannot write through a retained element', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const before = manager.stages.retro.uniforms[window.__qaParameter.name]; + window.__qaCurrentSlider.value = '0'; + window.__qaCurrentSlider.dispatchEvent( + new Event('input', { bubbles: true }), + ); + return ( + manager._sliderContainer.children.length === 0 && + manager.stages.retro.uniforms[window.__qaParameter.name] === before + ); + }), + ); check('runtime has no uncaught browser errors', errors.length === 0); } finally { await browser.close(); diff --git a/src/ui/applicationShortcuts.js b/src/ui/applicationShortcuts.js index 6cc81caab..010b6b620 100644 --- a/src/ui/applicationShortcuts.js +++ b/src/ui/applicationShortcuts.js @@ -1,6 +1,11 @@ const STYLE_KEYS = Object.freeze({ - '1': 'normal', '2': 'retro', '3': 'surveillance', - '4': 'thermal', '5': 'anime', '6': 'noir', '7': 'snow', + 1: 'normal', + 2: 'retro', + 3: 'surveillance', + 4: 'thermal', + 5: 'anime', + 6: 'noir', + 7: 'snow', }); /** @@ -13,10 +18,15 @@ const STYLE_KEYS = Object.freeze({ * @param {object} options.actions Existing application operations. * @returns {{destroy: Function}} Synchronous, idempotent listener cleanup. */ -export function bindApplicationShortcuts({ documentRef, searchInput, actions }) { +export function bindApplicationShortcuts({ + documentRef, + searchInput, + actions, +}) { const onKeyDown = (event) => { - const isFormControl = event.target?.matches?.('select, input, textarea') - || event.target === searchInput; + const isFormControl = + event.target?.matches?.('select, input, textarea') || + event.target === searchInput; if (isFormControl && event.key !== 'Escape') return; if (STYLE_KEYS[event.key]) actions.setStyle(STYLE_KEYS[event.key]); @@ -30,5 +40,9 @@ export function bindApplicationShortcuts({ documentRef, searchInput, actions }) if (key === 'c') actions.toggleCctv(); }; documentRef.addEventListener('keydown', onKeyDown); - return { destroy() { documentRef.removeEventListener('keydown', onKeyDown); } }; + return { + destroy() { + documentRef.removeEventListener('keydown', onKeyDown); + }, + }; } diff --git a/src/ui/styleParameters.js b/src/ui/styleParameters.js index 4ffdc0b22..6afa3e36b 100644 --- a/src/ui/styleParameters.js +++ b/src/ui/styleParameters.js @@ -7,12 +7,16 @@ * @param {Document} [options.documentRef] Document used to construct rows. * @returns {{render: Function, clear: Function, destroy: Function}} */ -export function createStyleParameters({ container, documentRef = container.ownerDocument }) { +export function createStyleParameters({ + container, + documentRef = container.ownerDocument, +}) { let listeners = []; let destroyed = false; const clear = () => { if (destroyed) return; - for (const [slider, listener] of listeners) slider.removeEventListener('input', listener); + for (const [slider, listener] of listeners) + slider.removeEventListener('input', listener); listeners = []; container.replaceChildren(); }; diff --git a/src/ui/visualInput.test.mjs b/src/ui/visualInput.test.mjs index 2c5bf829a..586c3086e 100644 --- a/src/ui/visualInput.test.mjs +++ b/src/ui/visualInput.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { bindApplicationShortcuts, createStyleParameters } from './visualInput.js'; +import { + bindApplicationShortcuts, + createStyleParameters, +} from './visualInput.js'; class Element { constructor(tagName = 'DIV', ownerDocument) { @@ -11,46 +14,98 @@ class Element { this.attributes = new Map(); } addEventListener(type, listener, capture) { - assert.notEqual(capture, true, 'these controls must not preempt capture-phase surfaces'); + assert.notEqual( + capture, + true, + 'these controls must not preempt capture-phase surfaces', + ); if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(listener); } - removeEventListener(type, listener) { this.listeners.get(type)?.delete(listener); } - emit(type, event = {}) { for (const listener of [...(this.listeners.get(type) || [])]) listener(event); } - setAttribute(name, value) { this.attributes.set(name, value); } - append(...children) { this.children.push(...children); } - appendChild(child) { this.children.push(child); } - replaceChildren(...children) { this.children = children; } - matches() { return ['INPUT', 'SELECT', 'TEXTAREA'].includes(this.tagName); } + removeEventListener(type, listener) { + this.listeners.get(type)?.delete(listener); + } + emit(type, event = {}) { + for (const listener of [...(this.listeners.get(type) || [])]) + listener(event); + } + setAttribute(name, value) { + this.attributes.set(name, value); + } + append(...children) { + this.children.push(...children); + } + appendChild(child) { + this.children.push(child); + } + replaceChildren(...children) { + this.children = children; + } + matches() { + return ['INPUT', 'SELECT', 'TEXTAREA'].includes(this.tagName); + } } function shortcuts() { const documentRef = new Element(); const searchInput = new Element(); const calls = []; - const names = ['setStyle', 'dismissSearch', 'toggleHud', 'toggleOrbit', 'toggleCleanView', 'toggleLayers', 'cycleDetection', 'toggleCctv']; - const actions = Object.fromEntries(names.map((name) => [name, (...args) => calls.push([name, ...args])])); - const controller = bindApplicationShortcuts({ documentRef, searchInput, actions }); - const press = (key, target = new Element(), rest = {}) => documentRef.emit('keydown', { key, target, ...rest }); + const names = [ + 'setStyle', + 'dismissSearch', + 'toggleHud', + 'toggleOrbit', + 'toggleCleanView', + 'toggleLayers', + 'cycleDetection', + 'toggleCctv', + ]; + const actions = Object.fromEntries( + names.map((name) => [name, (...args) => calls.push([name, ...args])]), + ); + const controller = bindApplicationShortcuts({ + documentRef, + searchInput, + actions, + }); + const press = (key, target = new Element(), rest = {}) => + documentRef.emit('keydown', { key, target, ...rest }); return { documentRef, searchInput, calls, controller, press }; } test('number keys retain the seven style mappings', () => { const f = shortcuts(); - for (const key of ['1', '2', '3', '4', '5', '6', '7', '8', 'Space']) f.press(key); - assert.deepEqual(f.calls, ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'].map((style) => ['setStyle', style])); + for (const key of ['1', '2', '3', '4', '5', '6', '7', '8', 'Space']) + f.press(key); + assert.deepEqual( + f.calls, + ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'].map( + (style) => ['setStyle', style], + ), + ); }); test('letter shortcuts retain uppercase handling and existing actions', () => { const f = shortcuts(); for (const key of ['H', 'o', 'V', 'f', 'D', 'c']) f.press(key); - assert.deepEqual(f.calls, ['toggleHud', 'toggleOrbit', 'toggleCleanView', 'toggleLayers', 'cycleDetection', 'toggleCctv'].map((name) => [name])); + assert.deepEqual( + f.calls, + [ + 'toggleHud', + 'toggleOrbit', + 'toggleCleanView', + 'toggleLayers', + 'cycleDetection', + 'toggleCctv', + ].map((name) => [name]), + ); }); for (const tag of ['INPUT', 'SELECT', 'TEXTAREA']) { test(`${tag} retains native editing, while Escape reaches search dismissal`, () => { const f = shortcuts(); - for (const key of ['1', 'h', 'o', 'v', 'f', 'd', 'c']) f.press(key, new Element(tag)); + for (const key of ['1', 'h', 'o', 'v', 'f', 'd', 'c']) + f.press(key, new Element(tag)); assert.deepEqual(f.calls, []); f.press('Escape', new Element(tag)); assert.deepEqual(f.calls, [['dismissSearch']]); @@ -77,20 +132,39 @@ test('destroy synchronously removes shortcuts and a replacement binds once', () f.press('h'); assert.deepEqual(f.calls, []); assert.equal(f.documentRef.listeners.get('keydown').size, 0); - const replacement = bindApplicationShortcuts({ documentRef: f.documentRef, searchInput: f.searchInput, actions: { toggleHud: () => f.calls.push('new') } }); + const replacement = bindApplicationShortcuts({ + documentRef: f.documentRef, + searchInput: f.searchInput, + actions: { toggleHud: () => f.calls.push('new') }, + }); f.press('h'); assert.deepEqual(f.calls, ['new']); replacement.destroy(); }); function parameters() { - const documentRef = { createElement: (tag) => new Element(tag.toUpperCase(), documentRef) }; + const documentRef = { + createElement: (tag) => new Element(tag.toUpperCase(), documentRef), + }; const container = new Element('DIV', documentRef); const controller = createStyleParameters({ container }); const values = { amount: 0.25, scale: 2 }; - const uniforms = { amount: { label: '', min: 0, max: 1 }, scale: { label: 'Scale', min: 0, max: 10 } }; + const uniforms = { + amount: { label: '', min: 0, max: 1 }, + scale: { label: 'Scale', min: 0, max: 10 }, + }; const order = []; - const options = { uniforms, readValue: (name) => values[name], writeValue: (name, value) => { order.push(['write', name, value]); values[name] = value; }, onChange: () => { order.push(['change', container.children[0]?.children[2].textContent]); } }; + const options = { + uniforms, + readValue: (name) => values[name], + writeValue: (name, value) => { + order.push(['write', name, value]); + values[name] = value; + }, + onChange: () => { + order.push(['change', container.children[0]?.children[2].textContent]); + }, + }; controller.render(options); return { container, controller, values, options, order }; } @@ -109,7 +183,11 @@ test('parameter rows preserve labels, bounds, precision and initial values', () assert.equal(value.textContent, '0.25'); assert.equal(f.container.children[1].children[1].step, '0.1'); assert.equal(f.container.children[1].children[2].textContent, '2.0'); - assert.deepEqual(f.order, [], 'rendering must not write shader values or publish changes'); + assert.deepEqual( + f.order, + [], + 'rendering must not write shader values or publish changes', + ); }); test('input writes numeric values, updates the readout, then requests a change', () => { @@ -118,13 +196,19 @@ test('input writes numeric values, updates the readout, then requests a change', slider.value = '0.75'; slider.emit('input'); assert.equal(f.values.amount, 0.75); - assert.deepEqual(f.order, [['write', 'amount', 0.75], ['change', '0.75']]); + assert.deepEqual(f.order, [ + ['write', 'amount', 0.75], + ['change', '0.75'], + ]); }); test('rebuilding the parameter panel revokes detached slider listeners', () => { const f = parameters(); const oldSlider = f.container.children[0].children[1]; - f.controller.render({ ...f.options, uniforms: { scale: f.options.uniforms.scale } }); + f.controller.render({ + ...f.options, + uniforms: { scale: f.options.uniforms.scale }, + }); oldSlider.value = '0.8'; oldSlider.emit('input'); assert.equal(f.values.amount, 0.25);