diff --git a/changelog.d/1305-shared-native-form-fields.md b/changelog.d/1305-shared-native-form-fields.md new file mode 100644 index 000000000..129d81bcf --- /dev/null +++ b/changelog.d/1305-shared-native-form-fields.md @@ -0,0 +1,3 @@ +- Share native dropdown and numeric-field styling across Scene, CCTV, Display + and Radio controls, with a shared CCTV number-input builder that preserves + existing precision, validation and commit behavior (mazy1998, #1305). diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 6c2b6ee2a..6b1c88ea8 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -738,3 +738,13 @@ sources supply `{records, droppedCount, status, saturated}`; viewport filtering, exact-bound retry and rendering stay in the layer. Legacy cache saturation is decoded in the source. Source and geometry modules remain independent of Cesium, DOM and platform middleware. + +### Native form fields + +`src/ui/formField.js` constructs native numeric inputs without listeners or +application state. Static selects and number inputs use the same `gev-field` +surface, with explicit compact and inline densities. Feature owners retain +option identity/order, bounds, parsing, draft values, input/change timing and +teardown. CCTV effective-pose conversion and tuner/device operations remain +feature responsibilities. A Vue replacement can reuse this contract and CSS; +it replaces DOM construction, rather than adding a second state owner. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8625f06de..c75aa1d3a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5969,3 +5969,20 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + +### Shared native dropdowns and numeric fields + +- Scene and CCTV selects share the default `gev-field` surface. HUD layout and + draw colour retain compact density. Radio filter, tuner gain and FM frequency + use the same base while retaining their feature-specific skins and layout. +- The CCTV inline number editor uses `createNumberInput` with its existing + precision and inline density. Enter/blur commit, Escape cancellation, frozen + camera base, camera-switch guards and disposal remain with CCTV controls. +- Options, values, native keyboard/typeahead, disabled attributes and browser + validity remain native. Frequency bounds, tuner commit timing and calibration + parsing are unchanged. Specialized tuner graphics and calibration chips + retain their own controls. Provider settings and scene-authoring dialogs are + outside this change. +- `scripts/qa-form-fields.mjs` exercises native selections, number validity, + calibration commit/cancel and stale-camera/disposal guards against an installed + controller with fixture ports, plus default/Cyber and narrow translated layouts. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f0531a3fa..34219f856 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -2,6 +2,8 @@ ".prettierrc.json", "package.json", "scripts/format-scope.json", + "src/ui/styles/form-field.css", + "scripts/qa-form-fields.mjs", "scripts/package-boundaries.json", "scripts/format.mjs", "scripts/next-layer-state-token.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 19f31f1c2..39ccf99b5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -541,6 +541,7 @@ "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", + "src/ui/formField.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", "src/ui/cctvVideo.js", @@ -678,6 +679,7 @@ "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", + "src/ui/formField.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", "src/ui/cctvVideo.js", @@ -2156,6 +2158,7 @@ "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", + "src/ui/formField.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", "src/ui/cctvVideo.js", diff --git a/scripts/qa-form-fields.mjs b/scripts/qa-form-fields.mjs new file mode 100644 index 000000000..3bbee3816 --- /dev/null +++ b/scripts/qa-form-fields.mjs @@ -0,0 +1,465 @@ +#!/usr/bin/env node +/** Native form-field regression gate. CCTV ports are fixtures, never live writes. */ +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(); +page.setDefaultNavigationTimeout(60000); +let failures = 0; +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +const check = (name, passed, detail) => { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) { + failures++; + console.log(JSON.stringify(detail)); + } +}; +fs.mkdirSync('qa-shots/form-fields', { recursive: true }); +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://127.0.0.1:4183'}/?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 selects = [ + 'scene-select', + 'cctv-camera-select', + 'hud-layout-select', + 'draw-color-select', + 'radio-filter', + 'sdr-gain-select', + ].map((id) => document.getElementById(id)); + return { + ids: selects.map((node) => node.id), + shared: + selects.length === 6 && + selects.every((node) => node.classList.contains('gev-field')), + native: selects.every( + (node) => + node instanceof HTMLSelectElement && !node.hasAttribute('role'), + ), + dark: selects.every( + (node) => getComputedStyle(node).colorScheme === 'dark', + ), + }; + }); + check( + 'all six panel dropdowns retain native select semantics and shared surface', + inventory.shared && inventory.native, + inventory, + ); + check('native option menus keep the dark scheme', inventory.dark); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setPanelCollapsed('pp-toggles', false, { + persist: false, + syncShare: false, + }); + manager.setPanelCollapsed('scene-panel', false, { + persist: false, + syncShare: false, + }); + manager.setPanelCollapsed('cctv-panel', false, { + persist: false, + syncShare: false, + }); + window.__qaFormChanges = []; + document + .getElementById('scene-select') + .addEventListener('change', (event) => + window.__qaFormChanges.push(event.target.value), + ); + }); + const ownerSetup = await page.evaluate(() => { + const owner = window.__godsEyeView.styleManager._cctvControls; + owner._cctvUnsubscribe?.(); + owner._cctvUnsubscribe = null; + window.__qaFormCalls = []; + owner.actions = { + ...owner.actions, + isEnabled: () => true, + toggleEnabled: async () => true, + setParams: (...args) => window.__qaFormCalls.push(args), + runExplicitFocus: (select, focus) => focus(select()), + showToast: () => {}, + }; + owner.cctv = { + setParams() {}, + focusCamera: (id) => window.__qaFormCalls.push(['focus', id]), + selectCamera: (id) => id, + }; + owner._cctvState = { + enabled: true, + activeCameraId: 'a', + cameras: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ], + activeCamera: { + id: 'a', + headingDeg: 30, + pitchDeg: -10, + fovDeg: 45, + rangeM: 120, + mountHeightM: 8, + calibration: {}, + basePose: { + headingDeg: 20, + pitchDeg: -10, + fovDeg: 45, + rangeM: 120, + mountHeightM: 8, + }, + }, + }; + owner._renderCctvState(owner._cctvState); + const input = document.getElementById('sdr-frequency-input'); + return { + min: input.min, + max: input.max, + step: input.step, + type: input.type, + shared: input.classList.contains('gev-field'), + }; + }); + check( + 'FM frequency retains native bounds, precision and shared surface', + ownerSetup.min === '87.5' && + ownerSetup.max === '108' && + ownerSetup.step === '0.1' && + ownerSetup.type === 'number' && + ownerSetup.shared, + ownerSetup, + ); + const validity = await page.$eval('#sdr-frequency-input', (input) => { + const original = input.value; + input.value = '87.4'; + const below = input.validity.rangeUnderflow; + input.value = '108.1'; + const above = input.validity.rangeOverflow; + input.value = '98.55'; + const step = input.validity.stepMismatch; + input.value = ''; + const empty = input.validity.valid; + input.value = original; + return below && above && step && empty; + }); + check( + 'native numeric validity preserves underflow, overflow, steps and empty drafts', + validity, + ); + await page.select('#cctv-camera-select', 'b'); + await page.waitForFunction(() => window.__qaFormCalls.length === 2); + const cameraSelection = await page.evaluate(() => window.__qaFormCalls); + check( + 'camera dropdown retains selection, focus and user-origin dispatch', + cameraSelection[0][0] === 'focus' && + cameraSelection[0][1] === 'b' && + cameraSelection[1][0].selectedCameraId === 'b' && + cameraSelection[1][1].origin === 'user', + cameraSelection, + ); + await page.evaluate(() => { + window.__qaFormCalls = []; + document.getElementById('cctv-camera-select').disabled = true; + }); + await page.focus('#scene-select'); + await page.focus('#cctv-camera-select'); + check( + 'disabled native selects cannot receive keyboard focus', + await page.evaluate( + () => document.activeElement.id !== 'cctv-camera-select', + ), + ); + await page.evaluate(() => { + document.getElementById('cctv-camera-select').disabled = false; + }); + const begin = async (field) => { + await page.evaluate((field) => { + const owner = window.__godsEyeView.styleManager._cctvControls; + owner._beginCctvCalValueEdit( + document.querySelector(`[data-cal-field="${field}"]`), + ); + }, field); + }; + for (const [field, step, value] of [ + ['heading', '0.1', '30.0'], + ['pitch', '0.1', '-10.0'], + ['fov', '1', '45'], + ['range', '1', '120'], + ['height', '1', '8'], + ['north', '0.1', '0.0'], + ['east', '0.1', '0.0'], + ]) { + await begin(field); + const result = await page.$eval('.cctv-cal-input', (input) => ({ + step: input.step, + value: input.value, + native: input.type === 'number', + shared: input.classList.contains('gev-field'), + label: input.getAttribute('aria-label'), + focus: document.activeElement === input, + })); + check( + `${field} editor retains precision, initial value and focus`, + result.step === step && + result.value === value && + result.native && + result.shared && + !!result.label && + result.focus, + result, + ); + await page.keyboard.press('Escape'); + } + check( + 'Escape cancels each field without a calibration write', + await page.evaluate(() => window.__qaFormCalls.length === 0), + ); + await begin('heading'); + await page.keyboard.press('Backspace'); + await page.type('.cctv-cal-input', '100'); + await page.evaluate(() => { + window.__godsEyeView.styleManager._cctvControls._cctvState.activeCamera.basePose.headingDeg = 60; + }); + await page.keyboard.press('Enter'); + const commit = await page.evaluate(() => window.__qaFormCalls); + check( + 'Enter commits once against the frozen camera base', + commit.length === 1 && + commit[0][0].calibration.patch.headingDeg === 80 && + commit[0][0].calibration.cameraId === 'a' && + commit[0][1].origin === 'user', + commit, + ); + await begin('north'); + await page.keyboard.press('Backspace'); + await page.type('.cctv-cal-input', '12.5'); + await page.$eval('.cctv-cal-input', (input) => input.blur()); + const blur = await page.evaluate(() => window.__qaFormCalls); + check( + 'blur commits a decimal offset exactly once', + blur.length === 2 && blur[1][0].calibration.patch.offsetNorthM === 12.5, + blur, + ); + await begin('east'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Enter'); + check( + 'an empty numeric draft does not write calibration', + await page.evaluate(() => window.__qaFormCalls.length === 2), + ); + await begin('heading'); + await page.type('.cctv-cal-input', '90'); + await page.evaluate(() => { + window.__godsEyeView.styleManager._cctvControls._cctvState.activeCameraId = + 'b'; + }); + await page.keyboard.press('Enter'); + check( + 'a stale camera editor cannot commit to the next camera', + await page.evaluate(() => window.__qaFormCalls.length === 2), + ); + + await page.evaluate(() => { + for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel']) + window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, { + persist: false, + syncShare: false, + }); + }); + if ( + await page.$eval( + '#draw-toggle', + (node) => node.getAttribute('aria-pressed') !== 'true', + ) + ) + await page.click('#draw-toggle'); + for (const lang of ['en']) { + await page.evaluate((lang) => { + document.documentElement.lang = lang; + }, lang); + for (const width of [1440, 620, 390]) { + await page.setViewport({ width, height: 900 }); + const layout = await page.evaluate(() => { + const ids = [ + 'scene-select', + 'cctv-camera-select', + 'hud-layout-select', + 'draw-color-select', + ]; + return ids.map((id) => { + const node = document.getElementById(id), + rect = node.getBoundingClientRect(), + panel = node.closest('.panel-collapsible')?.getBoundingClientRect(); + return { + id, + width: rect.width, + fits: rect.width > 0 && rect.width <= (panel?.width || innerWidth), + radius: getComputedStyle(node).borderRadius, + }; + }); + }); + check( + `${lang} fields fit their panels at ${width}px`, + layout.every((item) => item.fits), + layout, + ); + if (width === 390) + await page.screenshot({ + path: `qa-shots/form-fields/${lang}-${width}.png`, + }); + } + } + await page.evaluate(() => { + document.documentElement.lang = 'en'; + document.documentElement.dataset.uiTheme = 'cyber'; + }); + const cyber = await page.$eval( + '#scene-select', + (node) => getComputedStyle(node).backgroundColor, + ); + check( + 'Cyber keeps its native field theme', + cyber === 'rgba(26, 35, 39, 0.96)', + cyber, + ); + await begin('heading'); + await page.evaluate(() => { + const owner = window.__godsEyeView.styleManager._cctvControls; + window.__qaDetachedInput = document.querySelector('.cctv-cal-input'); + owner.destroy(); + window.__qaDetachedInput.dispatchEvent(new Event('blur')); + }); + check( + 'disposal removes the editor and prevents late commits', + await page.evaluate( + () => + !document.querySelector('.cctv-cal-input') && + window.__qaFormCalls.length === 2, + ), + ); + // Scene selection can restore application settings. Exercise it after the + // fixture-owned CCTV phase so those asynchronous effects cannot replace ports. + page.once('dialog', (dialog) => dialog.accept('Baseline selection fixture')); + await page.click('#scene-new-btn'); + await page.waitForFunction(() => + [...document.querySelectorAll('#scene-select option')].some( + (option) => option.textContent === 'Baseline selection fixture', + ), + ); + page.once('dialog', (dialog) => dialog.accept('QA selection fixture')); + await page.click('#scene-new-btn'); + await page.waitForFunction( + () => document.querySelectorAll('#scene-select option').length > 1, + ); + const targetSceneId = await page.$eval( + '#scene-select', + (node) => + [...node.options].find( + (option) => option.textContent === 'QA selection fixture', + ).value, + ); + const baselineSceneId = await page.$eval( + '#scene-select', + (node) => + [...node.options].find( + (option) => option.textContent === 'Baseline selection fixture', + ).value, + ); + await page.select('#scene-select', baselineSceneId); + await page.waitForFunction( + (id) => window.__godsEyeView.sceneDirector._getSelectedScene().id !== id, + {}, + targetSceneId, + ); + await page.evaluate(() => { + window.__qaFormChanges = []; + }); + await page.focus('#scene-select'); + // Typeahead is native even when headless Chrome does not drive OS popups. + await page.keyboard.press('q'); + const sceneSelection = await page.evaluate(() => ({ + id: document.getElementById('scene-select').value, + selected: window.__godsEyeView.sceneDirector._getSelectedScene().id, + changes: window.__qaFormChanges, + focus: document.activeElement.id, + outline: getComputedStyle(document.activeElement).outlineStyle, + })); + check( + 'native typeahead reaches the Scene owner and keeps focus', + sceneSelection.id === targetSceneId && + sceneSelection.selected === targetSceneId && + sceneSelection.changes.at(-1) === targetSceneId && + sceneSelection.focus === 'scene-select', + sceneSelection, + ); + check('keyboard focus remains visible', sceneSelection.outline === 'solid'); + 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 }, + ); + await page.evaluate(() => { + for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel']) + window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, { + persist: false, + syncShare: false, + }); + }); + await page.evaluate(() => { + for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel']) + window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, { + persist: false, + syncShare: false, + }); + }); + if ( + await page.$eval( + '#draw-toggle', + (node) => node.getAttribute('aria-pressed') !== 'true', + ) + ) + await page.click('#draw-toggle'); + const translated = await page.evaluate(() => { + const nodes = [ + 'scene-select', + 'cctv-camera-select', + 'hud-layout-select', + 'draw-color-select', + ].map((id) => document.getElementById(id)); + return ( + document.documentElement.lang === 'ru' && + nodes.every( + (node) => + node.clientWidth > 0 && + node.getBoundingClientRect().width <= + node.closest('.panel-collapsible').getBoundingClientRect().width, + ) + ); + }); + check('real Russian labels and fields fit at 390px', translated); + await page.screenshot({ path: 'qa-shots/form-fields/ru-390.png' }); + check('no uncaught browser errors', errors.length === 0, errors); +} finally { + await browser.close(); +} +process.exitCode = failures ? 1 : 0; diff --git a/src/ui/cctvCalibration.js b/src/ui/cctvCalibration.js index 8144edd8a..9e51540c5 100644 --- a/src/ui/cctvCalibration.js +++ b/src/ui/cctvCalibration.js @@ -1,4 +1,5 @@ import { formatNumber, t, msg } from '../i18n/index.js'; +import { createNumberInput } from './formField.js'; /** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; @@ -117,11 +118,12 @@ export function _beginCctvCalValueEdit(chip) { const basePose = { ...activeCamera.basePose }; const editListeners = new AbortController(); const startValue = field.get(activeCamera); - const input = document.createElement('input'); - input.type = 'number'; - input.step = field.decimals > 0 ? '0.1' : '1'; - input.value = Number(startValue).toFixed(field.decimals); - input.className = 'cctv-cal-input'; + const input = createNumberInput({ + className: 'gev-field--inline cctv-cal-input', + step: field.decimals > 0 ? '0.1' : '1', + value: Number(startValue).toFixed(field.decimals), + ariaLabel: t(field.label), + }); chip.textContent = `${t(field.label)} `; chip.appendChild(input); input.focus(); diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 5ab5a3280..6a47ff91f 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -105,6 +105,9 @@ function calibrationFixture(t) { Object.assign(input, { focus() {}, select() {}, + setAttribute(name, value) { + this[name] = value; + }, remove() { this.parent.input = null; }, diff --git a/src/ui/formField.js b/src/ui/formField.js new file mode 100644 index 000000000..8b2127b4d --- /dev/null +++ b/src/ui/formField.js @@ -0,0 +1,36 @@ +/** + * Build a native numeric field without listeners, parsing or commit policy. + * Owners retain drafts, validation, input/change timing and disposal. Static + * selects and number inputs share the gev-field surface in their templates; + * options remain owned by each feature (no replacement popup or keyboard code). + * @param {object} options + * @param {Document} [options.documentRef] + * @param {string} [options.className] Additional density/layout hooks. + * @param {number|string} [options.min] + * @param {number|string} [options.max] + * @param {number|string} [options.step] + * @param {number|string} [options.value] + * @param {boolean} [options.disabled] + * @param {string} [options.ariaLabel] Already translated accessible name. + * @returns {HTMLInputElement} + */ +export function createNumberInput({ + documentRef = globalThis.document, + className = '', + min, + max, + step, + value, + disabled = false, + ariaLabel, +} = {}) { + const input = documentRef.createElement('input'); + input.type = 'number'; + input.className = `gev-field${className ? ` ${className}` : ''}`; + for (const [key, setting] of Object.entries({ min, max, step, value })) { + if (setting !== undefined) input[key] = setting; + } + input.disabled = disabled; + if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel); + return input; +} diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css index d6f895681..67f18212b 100644 --- a/src/ui/styles/cctv.css +++ b/src/ui/styles/cctv.css @@ -129,26 +129,6 @@ html:not([lang='en']) .cctv-controls .scene-btn { background: rgba(0, 212, 255, 0.08); } -.cctv-cal-input { - width: 58px; - font-family: var(--font-mono); - font-size: 9px; - letter-spacing: 0.7px; - color: var(--text-primary); - background: rgba(0, 212, 255, 0.1); - border: 1px solid rgba(0, 212, 255, 0.5); - border-radius: 4px; - padding: 1px 3px; - outline: none; - -moz-appearance: textfield; -} - -.cctv-cal-input::-webkit-outer-spin-button, -.cctv-cal-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - .cctv-calibration-actions { margin-top: 8px; } @@ -162,26 +142,9 @@ html:not([lang='en']) .cctv-controls .scene-btn { #cctv-camera-select { flex: 1; min-width: 0; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 7px; - color: var(--text-primary); - padding: 6px 8px; - font-family: var(--font-sans); - font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - outline: none; -} - -#cctv-camera-select:focus-visible { - outline: 2px solid var(--text-primary); - outline-offset: -3px; -} - -#cctv-camera-select:disabled { - opacity: 0.45; } #cctv-enable-btn.active, diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 7fdb51276..f11378bb9 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -727,16 +727,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label { .pp-select { flex: 1; min-width: 0; - height: 24px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 6px; - color: var(--text-primary); - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.6px; - padding: 2px 6px; - outline: none; } /* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */ diff --git a/src/ui/styles/form-field.css b/src/ui/styles/form-field.css new file mode 100644 index 000000000..e0eec99b1 --- /dev/null +++ b/src/ui/styles/form-field.css @@ -0,0 +1,51 @@ +/* Native selects and numeric inputs. Features own options, constraints and + commit timing; density variants do not introduce custom keyboard behavior. */ +.gev-field { + min-width: 0; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 7px; + color: var(--text-primary); + padding: 6px 8px; + font-family: var(--font-sans); + font-size: 12px; + outline: none; +} + +.gev-field:focus-visible { + outline: 2px solid var(--text-primary); + outline-offset: -3px; +} + +.gev-field:disabled { + opacity: 0.45; +} + +.gev-field--compact { + height: 24px; + border-radius: 6px; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.6px; + padding: 2px 6px; +} + +/* Effective-pose editors must fit inside CCTV value chips. Retain their + existing spinner-free surface; calibration conversion stays with the owner. */ +.gev-field--inline { + width: 58px; + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.7px; + background: rgba(0, 212, 255, 0.1); + border-color: rgba(0, 212, 255, 0.5); + border-radius: 4px; + padding: 1px 3px; + -moz-appearance: textfield; +} + +.gev-field--inline::-webkit-outer-spin-button, +.gev-field--inline::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 8c8d663cd..b048c5bd2 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -153,14 +153,6 @@ #scene-select { flex: 1; min-width: 0; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 7px; - color: var(--text-primary); - padding: 6px 8px; - font-family: var(--font-sans); - font-size: 12px; - outline: none; } /* A translated scene name needs the width: the select takes its own row, diff --git a/src/ui/templates/context.html b/src/ui/templates/context.html index 290726417..9e88d20f4 100644 --- a/src/ui/templates/context.html +++ b/src/ui/templates/context.html @@ -121,7 +121,7 @@