From f1c617ad275cae6a0337fff77637491a933c6e84 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 20 Oct 2026 14:48:42 +0200 Subject: [PATCH 1/2] refactor(ui): share ordinary slider components --- docs/CURRENT-STATE.md | 9 ++ scripts/format-scope.json | 1 + scripts/package-boundaries.json | 5 + scripts/qa-visual-input.mjs | 32 ++++++ src/hudA11y.test.mjs | 2 +- src/ui/railCardBlocks.js | 12 +- src/ui/railTimeline.js | 18 +-- src/ui/recentImagery.js | 12 +- src/ui/slider.js | 32 ++++++ src/ui/styleParameters.js | 18 +-- src/ui/styles/controls.css | 152 ------------------------- src/ui/styles/recent-imagery.css | 10 -- src/ui/styles/slider.css | 70 ++++++++++++ src/ui/styles/street-level.css | 13 --- src/ui/styles/weather.css | 13 --- src/ui/templates/display-controls.html | 22 ++-- src/ui/templates/layer-panels.html | 2 +- src/ui/visualInput.test.mjs | 2 +- style.css | 1 + 19 files changed, 197 insertions(+), 229 deletions(-) create mode 100644 src/ui/slider.js create mode 100644 src/ui/styles/slider.css diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 42f338ce5..0b9b3de0d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1187,6 +1187,15 @@ The application supplies actions and retains effect settings, restore claims and rendering. Native keyboard editing, model modes and current defaults are preserved. Destroying the UI removes these listeners before asynchronous teardown. +Ordinary sliders share the native range component in `ui/slider.js` and the +`gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader +parameters, volume, replay speed, weather settings/history, imagery opacity and +Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state. +Generated controls use `createSlider`; static templates apply the same class. +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. + ## Application shortcuts and shader parameter controls `ui/input` supplies the bubbling application shortcut listener and generated diff --git a/scripts/format-scope.json b/scripts/format-scope.json index fa5ebc3b2..3a4bba831 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -299,6 +299,7 @@ "src/ui/styles/status.css", "src/ui/styles/layers.css", "src/ui/styles/controls.css", + "src/ui/styles/slider.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 5e3f3eb69..0c50be306 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -421,6 +421,7 @@ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", "src/ui/styleParameters.js", + "src/ui/slider.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", @@ -479,6 +480,7 @@ "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", + "src/ui/slider.js", "src/ui/chipGroup.js", "src/ui/rowList.js", "src/ui/railTimeline.js", @@ -738,6 +740,7 @@ "src/ui/shellElements.js", "src/ui/shellFeedback.js", "src/ui/styleParameters.js", + "src/ui/slider.js", "src/ui/surfaceKeyboard.js", "src/ui/uiLifetime.js", "src/ui/visualEffects.js", @@ -2213,6 +2216,7 @@ "src/ui/shellElements.js", "src/ui/shellFeedback.js", "src/ui/styleParameters.js", + "src/ui/slider.js", "src/ui/surfaceKeyboard.js", "src/ui/uiLifetime.js", "src/ui/visualEffects.js", @@ -3206,6 +3210,7 @@ "src/ui/applicationShortcuts.js", "src/ui/effects.js", "src/ui/styleParameters.js", + "src/ui/slider.js", "src/ui/uiLifetime.js", "src/ui/visualEffects.js", "src/ui/visualInput.js", diff --git a/scripts/qa-visual-input.mjs b/scripts/qa-visual-input.mjs index 3117e1414..5e3991806 100644 --- a/scripts/qa-visual-input.mjs +++ b/scripts/qa-visual-input.mjs @@ -183,6 +183,38 @@ try { return manager.bloomEnabled !== before; }), ); + const sliders = await page.evaluate(() => { + const ordinary = [ + ...document.querySelectorAll('input[type="range"]'), + ].filter( + (input) => !input.matches('#radio-tuner-slider, .bhote-event-range'), + ); + return { + count: ordinary.length, + shared: ordinary.every((input) => + input.classList.contains('gev-quantitative-slider'), + ), + surface: ordinary.every((input) => { + const style = getComputedStyle(input); + return ( + style.height === '18px' && + style.appearance === 'none' && + style.padding === '0px' && + style.borderWidth === '0px' && + (!input.disabled || + (style.opacity === '0.42' && style.cursor === 'not-allowed')) + ); + }), + tunerSeparate: !document + .getElementById('radio-tuner-slider') + .classList.contains('gev-quantitative-slider'), + }; + }); + check( + 'ordinary sliders share one native surface', + sliders.count > 10 && sliders.shared && sliders.surface, + ); + check('radio tuning keeps its specialized control', sliders.tunerSeparate); 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 }); diff --git a/src/hudA11y.test.mjs b/src/hudA11y.test.mjs index 0c0fd6fe2..a854b5964 100644 --- a/src/hudA11y.test.mjs +++ b/src/hudA11y.test.mjs @@ -29,5 +29,5 @@ test('the first-run checkbox keeps its native visible label', () => { test('generated style sliders use the visible parameter label as their name', () => { assert.match(parameters, /label\.textContent\s*=\s*t\(metadata\.label\);/); - assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*t\(metadata\.label\)\)/); + assert.match(parameters, /ariaLabel:\s*t\(metadata\.label\)/); }); diff --git a/src/ui/railCardBlocks.js b/src/ui/railCardBlocks.js index e1d37356c..b63d0a6a0 100644 --- a/src/ui/railCardBlocks.js +++ b/src/ui/railCardBlocks.js @@ -2,6 +2,7 @@ import { t } from '../i18n/index.js'; import { syncChipGroup } from './chipGroup.js'; import { syncRowList } from './rowList.js'; import { formatScaleLabel, unitLabel } from './unitLabels.js'; +import { createSlider } from './slider.js'; const set = (node, key, value) => { if (node[key] !== value) node[key] = value; @@ -90,12 +91,11 @@ export function createRailCardBlocks({ container, cardId, onParams }) { 'rail-card-setting-range-label', block.rangeRow, ); - block.rangeInput = make( - 'input', - 'rail-card-setting-range', - block.rangeRow, - ); - block.rangeInput.type = 'range'; + block.rangeInput = createSlider({ + documentRef: document, + className: 'rail-card-setting-range', + }); + block.rangeRow.appendChild(block.rangeInput); block.rangeValue = make( 'span', 'rail-card-setting-range-value', diff --git a/src/ui/railTimeline.js b/src/ui/railTimeline.js index b96ded34e..181f9a73f 100644 --- a/src/ui/railTimeline.js +++ b/src/ui/railTimeline.js @@ -1,4 +1,5 @@ import { formatUtcDayMonth, t } from '../i18n/index.js'; +import { createSlider } from './slider.js'; const set = (node, key, value) => { if (node[key] !== value) node[key] = value; }; @@ -43,14 +44,15 @@ export function createRailTimeline({ return node; }; const previous = makeButton('‹', t('Earlier observation'), row); - const slider = document.createElement('input'); - slider.type = 'range'; - slider.className = `gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`; - slider.min = '0'; - slider.max = '0'; - slider.step = '1'; - slider.value = '0'; - slider.setAttribute('aria-label', t('Observed history')); + const slider = createSlider({ + documentRef: document, + className: sliderClassName, + min: '0', + max: '0', + step: '1', + value: '0', + ariaLabel: t('Observed history'), + }); row.appendChild(slider); const next = makeButton('›', t('Later observation'), row); const controls = document.createElement('div'); diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index 857271c8b..3c305a3da 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -12,6 +12,7 @@ import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js'; import { createRailCardBlocks } from './railCardBlocks.js'; import { createRailCards } from './railCards.js'; import { createImagerySplit } from './imagerySplit.js'; +import { createSlider } from './slider.js'; import { PRODUCTS, hhmm, @@ -318,16 +319,17 @@ export function createRecentImageryPanel({ const opacityLabel = el('label', 'ri-opacity-label', controls); el('span', '', opacityLabel, t('OPACITY')); const opacityValue = el('span', 'ri-opacity-value', opacityLabel, '100%'); - const opacity = el('input', 'ri-opacity', controls); - opacity.id = 'ri-opacity'; - Object.assign(opacity, { - type: 'range', + const opacity = createSlider({ + documentRef: document, + className: 'ri-opacity', min: '0', max: '100', step: '1', value: '100', + ariaLabel: t('Imagery opacity'), }); - opacity.setAttribute('aria-label', t('Imagery opacity')); + opacity.id = 'ri-opacity'; + controls.appendChild(opacity); opacityLabel.htmlFor = 'ri-opacity'; // Its slot is always there; it is only usable while a swipe is live. const swap = el('button', 'data-toggle-chip ri-swap', controls, t('SWAP')); diff --git a/src/ui/slider.js b/src/ui/slider.js new file mode 100644 index 000000000..1bb179b75 --- /dev/null +++ b/src/ui/slider.js @@ -0,0 +1,32 @@ +/** + * Build the shared native slider. Feature owners retain their input/change + * listeners, formatting and teardown; this component installs no handlers. + * Static sliders use the same gev-quantitative-slider class in their template. + * @param {object} options + * @param {Document} [options.documentRef] Document that owns the input. + * @param {string} [options.className] Additional feature/layout hooks. + * @param {number|string} [options.min] + * @param {number|string} [options.max] + * @param {number|string} [options.step] + * @param {number|string} [options.value] + * @param {string} [options.ariaLabel] Already translated accessible name. + * @returns {HTMLInputElement} + */ +export function createSlider({ + documentRef = globalThis.document, + className = '', + min, + max, + step, + value, + ariaLabel, +} = {}) { + const input = documentRef.createElement('input'); + input.type = 'range'; + input.className = `gev-quantitative-slider${className ? ` ${className}` : ''}`; + for (const [key, setting] of Object.entries({ min, max, step, value })) { + if (setting !== undefined) input[key] = setting; + } + if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel); + return input; +} diff --git a/src/ui/styleParameters.js b/src/ui/styleParameters.js index c88872d69..2ce1c32a1 100644 --- a/src/ui/styleParameters.js +++ b/src/ui/styleParameters.js @@ -1,4 +1,5 @@ import { formatNumber, t } from '../i18n/index.js'; +import { createSlider } from './slider.js'; /** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */ const formatParam = (value, digits) => @@ -41,14 +42,15 @@ export function createStyleParameters({ const label = documentRef.createElement('span'); label.className = 'param-label'; label.textContent = t(metadata.label); - const slider = documentRef.createElement('input'); - slider.type = 'range'; - slider.className = 'param-slider'; - slider.setAttribute('aria-label', t(metadata.label)); - slider.min = metadata.min; - slider.max = metadata.max; - slider.step = metadata.max <= 1 ? '0.01' : '0.1'; - slider.value = readValue(name); + const slider = createSlider({ + documentRef, + className: 'param-slider', + ariaLabel: t(metadata.label), + min: metadata.min, + max: metadata.max, + step: metadata.max <= 1 ? '0.01' : '0.1', + value: readValue(name), + }); const digits = metadata.max <= 1 ? 2 : 1; const valueDisplay = documentRef.createElement('span'); valueDisplay.className = 'param-value'; diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index f8a7906fe..36d3fb4f6 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -532,110 +532,7 @@ html:not([lang='en']) .panel-collapse-btn { .pp-slider { flex: 1; - -webkit-appearance: none; - appearance: none; - height: 3px; - background: rgba(255, 255, 255, 0.08); - border-radius: 2px; - outline: none; - cursor: pointer; - min-width: 60px; -} - -.pp-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; -} - -.pp-slider::-moz-range-thumb { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--accent); - border: none; - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; -} - -.pp-slider-value { - font-family: var(--font-mono); - font-size: 9px; - letter-spacing: 0.5px; - color: var(--accent); - min-width: 28px; - text-align: right; - flex-shrink: 0; -} - -/* Quantitative companion sliders share Display / Sharpen's visible rail, - * circular thumb, glow, and value treatment while retaining a taller hit area. */ -.gev-quantitative-slider { - width: 100%; min-width: 0; - height: 18px; - margin: 0; - -webkit-appearance: none; - appearance: none; - background: transparent; - outline: none; - cursor: pointer; -} -.gev-quantitative-slider::-webkit-slider-runnable-track { - height: 3px; - border-radius: 2px; - background: rgba(255, 255, 255, 0.08); -} -.gev-quantitative-slider::-webkit-slider-thumb { - width: 10px; - height: 10px; - margin-top: -3.5px; - border: 0; - border-radius: 50%; - -webkit-appearance: none; - appearance: none; - background: var(--accent); - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; -} -.gev-quantitative-slider::-moz-range-track, -.gev-quantitative-slider::-moz-range-progress { - height: 3px; - border: 0; - border-radius: 2px; - background: rgba(255, 255, 255, 0.08); -} -.gev-quantitative-slider::-moz-range-thumb { - width: 10px; - height: 10px; - border: 0; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; -} -.gev-quantitative-slider:focus-visible { - border-radius: 4px; - outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent); - outline-offset: 2px; -} -.gev-quantitative-slider:disabled { - opacity: 0.42; - cursor: not-allowed; -} -.gev-slider-value { - min-width: 28px; - color: var(--accent); - font-family: var(--font-mono); - font-size: 9px; - letter-spacing: 0.5px; - text-align: right; - flex-shrink: 0; } .pp-slider-mini-label { @@ -1028,12 +925,6 @@ body.gev-geo-import-drag #cesiumContainer { box-shadow: inset 0 0 0 4px #061014; } -.pp-slider:focus-visible, -.param-slider:focus-visible { - outline: 2px solid var(--text-primary); - outline-offset: 4px; -} - #clean-view-toggle { width: var(--pp-expanded-width); } @@ -1240,47 +1131,4 @@ html:not([lang='en']) .param-slider-row { .param-slider { flex: 1; min-width: 0; - -webkit-appearance: none; - appearance: none; - height: 3px; - background: rgba(255, 255, 255, 0.08); - border-radius: 2px; - outline: none; - cursor: pointer; -} - -.param-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; - transition: box-shadow var(--transition-fast); -} - -.param-slider::-webkit-slider-thumb:hover { - box-shadow: 0 0 12px var(--accent-glow); -} - -.param-slider::-moz-range-thumb { - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--accent); - border: none; - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; -} - -.param-value { - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.5px; - color: var(--accent); - min-width: 32px; - text-align: right; - flex-shrink: 0; } diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css index e04955e2f..72bb407c7 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -423,16 +423,6 @@ html:not([lang='en']) .recent-imagery-readout .ri-opacity-label { } .recent-imagery-readout .ri-opacity-value { font-variant-numeric: tabular-nums; - color: var(--text-secondary); -} -.recent-imagery-readout .ri-opacity { - width: 100%; - min-width: 0; - margin: 0; - accent-color: var(--accent); -} -.recent-imagery-readout .ri-opacity:disabled { - opacity: 0.35; } .recent-imagery-readout .ri-swap { width: 40px; diff --git a/src/ui/styles/slider.css b/src/ui/styles/slider.css new file mode 100644 index 000000000..4a5ee2ba2 --- /dev/null +++ b/src/ui/styles/slider.css @@ -0,0 +1,70 @@ +/* Shared ordinary slider surface; feature classes only own surrounding layout. + * Native range inputs retain keyboard, pointer, bounds and step behavior. */ +.gev-quantitative-slider { + width: 100%; + min-width: 0; + height: 18px; + margin: 0; + padding: 0; + border: 0; + -webkit-appearance: none; + appearance: none; + background: transparent; + outline: none; + cursor: pointer; +} +.gev-quantitative-slider::-webkit-slider-runnable-track { + height: 3px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); +} +.gev-quantitative-slider::-webkit-slider-thumb { + width: 10px; + height: 10px; + margin-top: -3.5px; + border: 0; + border-radius: 50%; + -webkit-appearance: none; + appearance: none; + background: var(--accent); + box-shadow: 0 0 6px var(--accent-glow); + cursor: pointer; +} +.gev-quantitative-slider::-moz-range-track, +.gev-quantitative-slider::-moz-range-progress { + height: 3px; + border: 0; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); +} +.gev-quantitative-slider::-moz-range-thumb { + width: 10px; + height: 10px; + border: 0; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 6px var(--accent-glow); + cursor: pointer; +} +.gev-quantitative-slider:focus-visible { + border-radius: 4px; + outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent); + outline-offset: 2px; +} +.gev-quantitative-slider:disabled { + opacity: 0.42; + cursor: not-allowed; +} +.pp-slider-value, +.param-value, +.ri-opacity-value, +.rail-card-setting-range-value, +.gev-slider-value { + min-width: 28px; + color: var(--accent); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.5px; + text-align: right; + flex-shrink: 0; +} diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index c218765d9..c0623203d 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -278,19 +278,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn { gap: 4px 10px; } -.sl-range { - width: 100%; - min-width: 0; - margin: 0; - accent-color: var(--sl-green); - cursor: pointer; -} - -.sl-range:focus-visible { - outline: 1px solid var(--accent); - outline-offset: 3px; -} - .sl-since-note { grid-column: 2; font-family: var(--font-mono); diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css index c8b2d544c..a47fa87b1 100644 --- a/src/ui/styles/weather.css +++ b/src/ui/styles/weather.css @@ -362,22 +362,9 @@ html:not([lang='en']) .rail-card-setting { font: 8px var(--font-mono); white-space: nowrap; } -.rail-card-setting-range { - width: 100%; - min-width: 0; - accent-color: var(--accent); - cursor: pointer; -} -.rail-card-setting-range:focus-visible { - outline: 2px solid var(--text-primary); - outline-offset: 3px; -} .rail-card-setting-range-value { flex: 0 0 4ch; white-space: nowrap; - color: var(--text-primary); - font: 9px var(--font-mono); - text-align: right; } .rail-card-result { display: grid; diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index 220dc373b..aabba14ff 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -27,27 +27,27 @@
- + 8
- + 100%
- + 70%
- + 84%
- + 24°
@@ -58,7 +58,7 @@
- + 50%
@@ -70,12 +70,12 @@
- + 7%
- + 1%
@@ -117,7 +117,7 @@ mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather softens the black mask edge; label fading is the separate Fade slider. --> - + 11% @@ -172,7 +172,7 @@ Bloom
- + 0%
@@ -182,7 +182,7 @@ Sharpen
- + 49%
diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index b170b01e3..9869ea40c 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -126,7 +126,7 @@
- + ANY DATE
diff --git a/src/ui/visualInput.test.mjs b/src/ui/visualInput.test.mjs index 586c3086e..c69c935ba 100644 --- a/src/ui/visualInput.test.mjs +++ b/src/ui/visualInput.test.mjs @@ -176,7 +176,7 @@ test('parameter rows preserve labels, bounds, precision and initial values', () assert.equal(label.textContent, ''); assert.equal(slider.attributes.get('aria-label'), ''); assert.equal(slider.type, 'range'); - assert.equal(slider.className, 'param-slider'); + assert.equal(slider.className, 'gev-quantitative-slider param-slider'); assert.equal(slider.min, 0); assert.equal(slider.max, 1); assert.equal(slider.step, '0.01'); diff --git a/style.css b/style.css index 44b7e07bc..2cebd0e27 100644 --- a/style.css +++ b/style.css @@ -1,5 +1,6 @@ /* Preserve component and responsive rule order in one stylesheet entry. */ @import './src/ui/styles/foundation.css'; +@import './src/ui/styles/slider.css'; @import './src/ui/styles/controls.css'; @import './src/ui/styles/location.css'; @import './src/ui/styles/status.css'; From 64fd9ba1c23161e8535542b5d677eb18c85f4850 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 20 Oct 2026 14:49:06 +0200 Subject: [PATCH 2/2] docs: add shared slider changelog fragment --- changelog.d/1287-shared-sliders.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1287-shared-sliders.md diff --git a/changelog.d/1287-shared-sliders.md b/changelog.d/1287-shared-sliders.md new file mode 100644 index 000000000..b5e5fb215 --- /dev/null +++ b/changelog.d/1287-shared-sliders.md @@ -0,0 +1,3 @@ +- Give ordinary sliders a shared native control and consistent track, thumb, + value, focus and disabled styling while preserving feature behavior and + specialized controls (mazy1998, #1287).