From 5019bd9e8a1b94141a156c76ccf99ff55bfa4adb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 11:27:52 -0800 Subject: [PATCH] feat(kit): render the sliders with UiSlider UiSlider takes v-model, min, max, step and disabled over Core's native range input, emits numbers, and with `hold` never rewrites a focused handle and takes the model back on blur. It is a functional render function, so the portable style parameter rows use it too. The Display, Radio, RTL-SDR, Cockpit, mission replay and style parameter sliders render with it; server-rendered markup keeps their default values. --- CHANGELOG.md | 4 +- docs/VUE-MIGRATION.md | 11 ++ scripts/format-scope.json | 2 + scripts/package-boundaries.json | 19 +++- src/radioMarkup.test.mjs | 2 +- src/ui/cyberTheme.test.mjs | 2 +- src/ui/displayPanelActions.test.mjs | 2 +- src/ui/kit/UiSlider.js | 63 +++++++++++ src/ui/kit/UiSlider.test.mjs | 107 ++++++++++++++++++ src/ui/missionPanels.test.mjs | 4 +- src/ui/radioPanelActions.test.mjs | 2 +- src/ui/sdrCardActions.test.mjs | 2 +- src/ui/styleParameters.js | 32 +++--- .../views/panels/CockpitUtilityControls.vue | 4 +- src/ui/views/panels/DisplayPanel.vue | 24 ++-- src/ui/views/panels/MissionDetail.vue | 4 +- src/ui/views/panels/RadioPanel.vue | 4 +- src/ui/views/panels/SdrCard.vue | 3 +- 18 files changed, 241 insertions(+), 50 deletions(-) create mode 100644 src/ui/kit/UiSlider.js create mode 100644 src/ui/kit/UiSlider.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index be12030d5..4f2fa3051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,9 @@ the tuner slider's events call `RadioControls`, and the local RTL-SDR card's controls `LocalSdrControls`, through the radio store's new `connect`/`act`; the card labels its gain options itself. `act` returns - what the answering action returned. `syncScopeControls` (`./ui/display`) + what the answering action returned. The Display, Radio, RTL-SDR, Cockpit, + mission replay and style parameter sliders are the kit's `UiSlider`, a + native range input that emits numbers. `syncScopeControls` (`./ui/display`) and `shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the Display view and the rail now own what they did. `./ui/input` imports Vue (samehkhamis). diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index daa17ff84..bb4d4739a 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -56,6 +56,17 @@ Home and End), since each of Core's groups keeps its own keyboard policy, and once and asks for a change only when it lands on another option. Every segmented group in Core is a `UiSegmented`; the old `segmentedControl.js` adapter is gone. +`UiSlider` keeps the native range input where ui-vue draws a +`role="slider"` track, so Core's keyboard, form behaviour and styling stay; +it takes ui-vue's `v-model`, `min`, `max`, `step` and `disabled`, emits a +number, and adds `hold` (never rewrite a focused handle, and take the model +back on blur). It is a functional render-function component, so the +portable `./ui/input` rows use it without a component compiler. The +Display, Radio, RTL-SDR, Cockpit, mission replay and style parameter +sliders are `UiSlider`s; Street Level's date slider, Bhote Koshi's split +and progress ranges and the rail card settings' ranges keep their own +inputs for now. Merging the kits means settling native input against the +drawn track. Controls share one skin, so every panel reads the same. Every scrolling surface carries the kit's `.ui-scroll` (ui-vue's name), which draws the thin diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f427a64a3..654513258 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1128,6 +1128,8 @@ "src/ui/kit/UiRail.test.mjs", "src/ui/kit/UiSegmented.vue", "src/ui/kit/UiSegmented.test.mjs", + "src/ui/kit/UiSlider.js", + "src/ui/kit/UiSlider.test.mjs", "src/ui/displayPanelActions.test.mjs", "src/testSupport/vueDom.mjs", "build/vueLoader.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 87a960a4e..8d5c468db 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -133,7 +133,8 @@ "src/ui/stores/panelChrome.js", "src/ui/rail/PanelCollapseButton.vue", "src/ui/rail/panelState.js", - "src/sdr/gain.js" + "src/sdr/gain.js", + "src/ui/kit/UiSlider.js" ], "external": ["vue", "pinia"] }, @@ -568,7 +569,9 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/ui/rail/fieldValue.js", + "src/ui/kit/UiSlider.js" ], "external": ["vue"] }, @@ -1061,7 +1064,9 @@ "src/ui/stores/awarenessDirection.js", "src/ui/stores/hud.js", "src/ui/stores/panelChrome.js", - "src/ui/rail/panelState.js" + "src/ui/rail/panelState.js", + "src/ui/rail/fieldValue.js", + "src/ui/kit/UiSlider.js" ], "external": ["cesium", "vue", "pinia"] }, @@ -3069,7 +3074,9 @@ "src/ui/stores/firstRun.js", "src/firstRunCopy.js", "src/ui/stores/panelChrome.js", - "src/ui/rail/panelState.js" + "src/ui/rail/panelState.js", + "src/ui/rail/fieldValue.js", + "src/ui/kit/UiSlider.js" ], "external": [ "hls.js", @@ -3843,7 +3850,9 @@ "src/i18n/locales/zh-Hant.json", "src/scopeMaskPolicy.js", "src/ui/displayControls.js", - "src/visualStylePolicy.js" + "src/visualStylePolicy.js", + "src/ui/rail/fieldValue.js", + "src/ui/kit/UiSlider.js" ], "external": ["cesium", "vue", "pinia"] }, diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index c2acba8c7..f9ec7751f 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -270,7 +270,7 @@ test('Radio volume and mission speed share the Sharpen slider visual language', } assert.match( missionDetail, - /id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0\.25" max="4" step="0\.25" value="1"/, + /]*:model-value="m \? m\.replay\.speed : 1"/, ); assert.match(missionDetail, /class="gev-slider-value"[^>]*data-mission-replay-speed-output/); assert.match(css, /\.gev-quantitative-slider\s*\{[\s\S]*?min-width: 0;[\s\S]*?height: 18px;/); diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 8ac35747c..db90ecfb6 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -658,7 +658,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () assert.match(display, /id="cyber-sonar-toggle"/); assert.match( display, - /id="cyber-sonar-opacity"[^>]*value="84"[^>]*aria-label="Cyber contact and label opacity"/, + /id="cyber-sonar-opacity"[^>]*:model-value="sonar\?\.opacity \?\? 84" aria-label="Cyber contact and label opacity"/, ); assert.match(display, /id="cyber-sonar-sector"/); assert.deepEqual(setHud.parameters.properties.layout.enum, HUD_LAYOUTS); diff --git a/src/ui/displayPanelActions.test.mjs b/src/ui/displayPanelActions.test.mjs index 214a840b3..339a643f4 100644 --- a/src/ui/displayPanelActions.test.mjs +++ b/src/ui/displayPanelActions.test.mjs @@ -87,7 +87,7 @@ test('buttons, sliders, the layout select and the Scope checkbox call their acti ['setSonarOpacity', 60], ['setSonarSector', 30], // Density passes the slider's own value, as it always has. - ['setDensity', '75'], + ['setDensity', 75], ['setFade', 12], ['setOutsideOpacity', 3], ['setScopeFeather', 150], diff --git a/src/ui/kit/UiSlider.js b/src/ui/kit/UiSlider.js new file mode 100644 index 000000000..dbed86315 --- /dev/null +++ b/src/ui/kit/UiSlider.js @@ -0,0 +1,63 @@ +import { h, withDirectives } from 'vue'; +import { vFieldValue } from '../rail/fieldValue.js'; +import { vRangeValue } from '../rail/rangeValue.js'; + +// Server-rendered markup carries the model as the input's value. +const ssrValue = ({ value }) => (value == null ? {} : { value }); +const LIVE = { ...vRangeValue, getSSRProps: ssrValue }; +const HOLD = { ...vFieldValue, getSSRProps: ssrValue }; + +/** Put the model back on a held input that lost focus. */ +function takeModel(input, props) { + const next = props.modelValue; + if (next != null && input.value !== String(next)) input.value = String(next); +} + +/** + * Core's slider, with @halfpixel/ui-vue UiSlider's v-model, min, max, step + * and disabled so the two kits can merge. Core keeps the native range input + * (its keyboard, form and styling) where ui-vue draws a `role="slider"` + * track. The input carries Core's `gev-quantitative-slider` class ahead of + * the caller's, the caller's attributes come first, as in the markup this + * replaced, and the accessible name comes as an `aria-label` attribute so + * the attribute pass translates it. The value emits as a number. + * + * The input takes the model's value only when it differs (`v-range-value`); + * with `hold`, never while it is focused (`v-field-value`), so a render + * cannot move a handle a person is dragging, and it takes the model back + * when it loses focus: a model left unchanged renders nothing. Owners take + * every value their slider can reach, some only after a delay, so an input + * is never put back on its own. It is a functional component written as a + * render function, so portable entries need no component compiler. Not + * mirrored yet: `label`, `format`, `markers` and `endLabels`. + */ +export default function UiSlider(props, { attrs, emit }) { + const { class: className, ...rest } = attrs; + return withDirectives( + h('input', { + ...rest, + class: ['gev-quantitative-slider', className], + type: 'range', + min: props.min, + max: props.max, + step: props.step, + disabled: props.disabled, + onInput: (event) => emit('update:modelValue', Number(event.target.value)), + onBlur: props.hold + ? (event) => takeModel(event.target, props) + : undefined, + }), + [[props.hold ? HOLD : LIVE, props.modelValue]], + ); +} + +UiSlider.props = { + modelValue: { type: [Number, String], default: undefined }, + min: { type: [Number, String], required: true }, + max: { type: [Number, String], required: true }, + step: { type: [Number, String], default: undefined }, + disabled: { type: Boolean, default: false }, + hold: { type: Boolean, default: false }, +}; +UiSlider.emits = ['update:modelValue']; +UiSlider.inheritAttrs = false; diff --git a/src/ui/kit/UiSlider.test.mjs b/src/ui/kit/UiSlider.test.mjs new file mode 100644 index 000000000..329a015a1 --- /dev/null +++ b/src/ui/kit/UiSlider.test.mjs @@ -0,0 +1,107 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { installVueDom } from '../../testSupport/vueDom.mjs'; + +let mount; +let UiSlider; +let uninstall; +before(async () => { + uninstall = installVueDom(); + ({ mount } = await import('@vue/test-utils')); + UiSlider = (await import('./UiSlider.js')).default; +}); +after(() => uninstall()); + +function mountSlider(props = {}, attrs = {}) { + return mount(UiSlider, { + attachTo: document.body, + props: { min: 0, max: 100, modelValue: 40, ...props }, + attrs, + }); +} + +test('a native range input with Core’s class beside the caller’s', () => { + const wrapper = mountSlider( + { step: 5 }, + { id: 'vol', class: 'pp-slider', 'aria-label': 'Volume' }, + ); + const input = wrapper.get('input'); + assert.equal(input.attributes('type'), 'range'); + assert.equal(input.attributes('class'), 'gev-quantitative-slider pp-slider'); + assert.equal(input.attributes('id'), 'vol'); + assert.equal(input.attributes('aria-label'), 'Volume'); + assert.equal(input.attributes('step'), '5'); + assert.equal(input.attributes('value'), undefined); + assert.equal(input.element.value, '40'); + wrapper.unmount(); +}); + +test('no step attribute unless one is given, and disabled reaches the input', () => { + const wrapper = mountSlider({ disabled: true }); + const input = wrapper.get('input'); + assert.equal(input.attributes('step'), undefined); + assert.equal(input.element.disabled, true); + wrapper.unmount(); +}); + +test('a drag emits the value as a number', async () => { + const wrapper = mountSlider({ min: 0.25, max: 4, step: 0.25, modelValue: 1 }); + const input = wrapper.get('input'); + input.element.value = '2.5'; + await input.trigger('input'); + assert.deepEqual(wrapper.emitted('update:modelValue'), [[2.5]]); + wrapper.unmount(); +}); + +test('the model moves the handle, even while focused, unless it holds', async () => { + const live = mountSlider(); + live.element.focus(); + await live.setProps({ modelValue: 70 }); + assert.equal(live.element.value, '70'); + live.unmount(); + + const held = mountSlider({ hold: true }); + held.element.focus(); + held.element.value = '55'; + await held.setProps({ modelValue: 70 }); + assert.equal(held.element.value, '55'); + held.element.blur(); + await held.setProps({ modelValue: 80 }); + assert.equal(held.element.value, '80'); + held.unmount(); +}); + +test('a held slider with no model keeps its value', async () => { + const wrapper = mountSlider({ hold: true, modelValue: 30 }); + await wrapper.setProps({ modelValue: null }); + assert.equal(wrapper.element.value, '30'); + wrapper.unmount(); +}); + +test('a held handle takes the model back when it loses focus', async () => { + const wrapper = mountSlider({ hold: true, modelValue: 60 }); + const input = wrapper.get('input'); + input.element.focus(); + input.element.value = '35'; + await input.trigger('input'); + await wrapper.setProps({ modelValue: 60 }); + assert.equal(input.element.value, '35'); + input.element.blur(); + await input.trigger('blur'); + assert.equal(input.element.value, '60'); + wrapper.unmount(); +}); + +test('an input stays until the owner answers, however late', async () => { + for (const hold of [false, true]) { + const wrapper = mountSlider({ hold, modelValue: 40 }); + const input = wrapper.get('input'); + input.element.value = '70'; + await input.trigger('input'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(input.element.value, '70', `hold: ${hold}`); + await wrapper.setProps({ modelValue: 70 }); + assert.equal(input.element.value, '70', `hold: ${hold}`); + wrapper.unmount(); + } +}); diff --git a/src/ui/missionPanels.test.mjs b/src/ui/missionPanels.test.mjs index ed1bcb594..73432fd11 100644 --- a/src/ui/missionPanels.test.mjs +++ b/src/ui/missionPanels.test.mjs @@ -111,7 +111,7 @@ test('before the layer presents, the roster loads and no mission shows', (t) => ); assert.equal($('#space-mission-panel').hidden, true); assert.equal($('[data-mission-replay-speed-output]').textContent, '1×'); - assert.equal($('[data-mission-replay-speed]').getAttribute('value'), '1'); + assert.equal($('[data-mission-replay-speed]').value, '1'); }); test('the roster lists missions and drives selection and preview', async (t) => { @@ -316,7 +316,7 @@ test('the replay controls follow the layer and call its commands', async (t) => ['replay'], ['toggle'], ['cancel'], - ['speed', '3'], + ['speed', 3], ['focus'], ['previous'], ['next'], diff --git a/src/ui/radioPanelActions.test.mjs b/src/ui/radioPanelActions.test.mjs index 73b30c246..f859b76b7 100644 --- a/src/ui/radioPanelActions.test.mjs +++ b/src/ui/radioPanelActions.test.mjs @@ -64,7 +64,7 @@ test('the panel’s Enable, transport and volume call RadioControls', (t) => { ['toggleRadioPlayback'], ['cycleRadio', 1], ['stopRadio'], - ['setRadioVolume', '35'], + ['setRadioVolume', 35], ]); }); diff --git a/src/ui/sdrCardActions.test.mjs b/src/ui/sdrCardActions.test.mjs index b0af93895..37ad45fd2 100644 --- a/src/ui/sdrCardActions.test.mjs +++ b/src/ui/sdrCardActions.test.mjs @@ -68,7 +68,7 @@ test('the card’s buttons, gain, tuning and volume call LocalSdrControls', (t) ['setSdrGain', '20.7'], ['tuneSdr', '101.1'], ['tuneSdr', '95.3'], - ['setSdrVolume', '40'], + ['setSdrVolume', 40], ]); }); diff --git a/src/ui/styleParameters.js b/src/ui/styleParameters.js index bf00ee0ba..df6efc84b 100644 --- a/src/ui/styleParameters.js +++ b/src/ui/styleParameters.js @@ -1,6 +1,6 @@ -import { h, render, withDirectives } from 'vue'; +import { h, render } from 'vue'; import { formatNumber, t } from '../i18n/index.js'; -import { vRangeValue } from './rail/rangeValue.js'; +import UiSlider from './kit/UiSlider.js'; /** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */ const formatParam = (value, digits) => @@ -11,8 +11,8 @@ const formatParam = (value, digits) => }); /** - * The generated rows: a label, the shared native slider and its value in the - * UI language. A render function, so the portable input package needs no + * The generated rows: a label, the kit's slider and its value in the UI + * language. A render function, so the portable input package needs no * single-file-component compiler. */ const StyleParameterRows = { @@ -24,18 +24,15 @@ const StyleParameterRows = { return this.rows.map((row) => h('div', { key: row.name, class: 'param-slider-row' }, [ h('span', { class: 'param-label' }, row.label), - withDirectives( - h('input', { - type: 'range', - class: 'gev-quantitative-slider param-slider', - min: row.min, - max: row.max, - step: row.step, - 'aria-label': row.label, - onInput: (event) => this.onInput(row.name, event.target.value), - }), - [[vRangeValue, row.value]], - ), + h(UiSlider, { + class: 'param-slider', + min: row.min, + max: row.max, + step: row.step, + 'aria-label': row.label, + modelValue: row.value, + 'onUpdate:modelValue': (value) => this.onInput(row.name, value), + }), h('span', { class: 'param-value' }, row.text), ]), ); @@ -82,9 +79,8 @@ export function createStyleParameters({ container }) { value: readValue(name), text: '', })); - onInput = (name, raw) => { + onInput = (name, value) => { if (destroyed || current !== generation) return; - const value = parseFloat(raw); writeValue(name, value); rows = rows.map((row) => row.name === name diff --git a/src/ui/views/panels/CockpitUtilityControls.vue b/src/ui/views/panels/CockpitUtilityControls.vue index d74985c70..d98281dee 100644 --- a/src/ui/views/panels/CockpitUtilityControls.vue +++ b/src/ui/views/panels/CockpitUtilityControls.vue @@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia'; import { computed } from 'vue'; import { t } from '../../../i18n/index.js'; import { vClassToggle } from '../../rail/classToggle.js'; -import { vFieldValue } from '../../rail/fieldValue.js'; +import UiSlider from '../../kit/UiSlider.js'; import { useRadioStore } from '../../stores/radio.js'; // The Cockpit's Display and Radio launchers and their popovers. RadioControls @@ -60,7 +60,7 @@ const radioLabel = computed(() => diff --git a/src/ui/views/panels/DisplayPanel.vue b/src/ui/views/panels/DisplayPanel.vue index bdc5644e7..17cf19ee2 100644 --- a/src/ui/views/panels/DisplayPanel.vue +++ b/src/ui/views/panels/DisplayPanel.vue @@ -6,6 +6,7 @@ import { vPanelState } from '../../rail/panelState.js'; import PanelCollapseButton from '../../rail/PanelCollapseButton.vue'; import { vRangeValue } from '../../rail/rangeValue.js'; import UiSegmented from '../../kit/UiSegmented.vue'; +import UiSlider from '../../kit/UiSlider.js'; import GeoImport from './GeoImport.vue'; import { useDisplayStore } from '../../stores/display.js'; @@ -17,7 +18,6 @@ const display = useDisplayStore(); const { view, models3d, draw } = storeToRefs(display); // A control's change goes to its owner (DisplayBindings or the draw tool). const act = (name, ...args) => display.act(name, ...args); -const integer = (event) => parseInt(event.target.value, 10); const pct = (value) => `${value}%`; const hud = computed(() => view.value?.hud ?? null); const sonar = computed(() => view.value?.sonar ?? null); @@ -80,27 +80,27 @@ const allocationOptions = [
- + {{ sonar?.rings ?? 8 }}
- + {{ pct(sonar?.range ?? 100) }}
- + {{ pct(sonar?.intensity ?? 70) }}
- + {{ pct(sonar?.opacity ?? 84) }}
- + {{ `${sonar?.sector ?? 24}°` }}
@@ -111,7 +111,7 @@ const allocationOptions = [
- + {{ pct(detection?.densityPct ?? 50) }}
@@ -120,12 +120,12 @@ const allocationOptions = [
- + {{ pct(detection?.fadePct ?? 7) }}
- + {{ pct(detection?.outsideOpacityPct ?? 1) }}
@@ -160,7 +160,7 @@ const allocationOptions = [
Feather - + {{ pct(scopeFeather) }}
@@ -203,7 +203,7 @@ const allocationOptions = [ Bloom
- + {{ pct(view?.bloom.intensity ?? 0) }}
@@ -213,7 +213,7 @@ const allocationOptions = [ Sharpen
- + {{ pct(view?.sharpen.intensity ?? 49) }}
diff --git a/src/ui/views/panels/MissionDetail.vue b/src/ui/views/panels/MissionDetail.vue index 6e902e9a9..3e3d98998 100644 --- a/src/ui/views/panels/MissionDetail.vue +++ b/src/ui/views/panels/MissionDetail.vue @@ -2,7 +2,7 @@ import { storeToRefs } from 'pinia'; import { nextTick, ref, watch } from 'vue'; import { t } from '../../../i18n/index.js'; -import { vFieldValue } from '../../rail/fieldValue.js'; +import UiSlider from '../../kit/UiSlider.js'; import { useContextStore } from '../../stores/context.js'; // The selected space mission inside Context. The launches layer presents it @@ -62,7 +62,7 @@ watch(
{{ m ? m.replay.speedText : '1×' }}
- +
diff --git a/src/ui/views/panels/RadioPanel.vue b/src/ui/views/panels/RadioPanel.vue index 8d82365a5..168198644 100644 --- a/src/ui/views/panels/RadioPanel.vue +++ b/src/ui/views/panels/RadioPanel.vue @@ -4,7 +4,7 @@ import { computed } from 'vue'; import { t } from '../../../i18n/index.js'; import RailPanel from '../../rail/RailPanel.vue'; import { vClassToggle } from '../../rail/classToggle.js'; -import { vFieldValue } from '../../rail/fieldValue.js'; +import UiSlider from '../../kit/UiSlider.js'; import { vRangeValue } from '../../rail/rangeValue.js'; import { useRadioStore } from '../../stores/radio.js'; import RadioTuner from './RadioTuner.vue'; @@ -58,7 +58,7 @@ const radioWords = {
{{ radio ? radio.playback.text : t('Radio off') }}
diff --git a/src/ui/views/panels/SdrCard.vue b/src/ui/views/panels/SdrCard.vue index b58bbf12b..7e72beec5 100644 --- a/src/ui/views/panels/SdrCard.vue +++ b/src/ui/views/panels/SdrCard.vue @@ -6,6 +6,7 @@ import { R820T_GAIN_STEPS_DB } from '../../../sdr/gain.js'; import { localSdrGainOptionLabel } from '../../localSdrPresentation.js'; import { vClassToggle } from '../../rail/classToggle.js'; import { vFieldValue } from '../../rail/fieldValue.js'; +import UiSlider from '../../kit/UiSlider.js'; import { useRadioStore } from '../../stores/radio.js'; // The local RTL-SDR card in the Radio panel. LocalSdrControls presents it and @@ -60,7 +61,7 @@ const tune = () => act('tuneSdr', frequency.value.value);
{{ sdr ? sdr.statusText : t('Connect an RTL-SDR to begin.') }}