diff --git a/changelog.json b/changelog.json
index 156c8b5..3269ca1 100644
--- a/changelog.json
+++ b/changelog.json
@@ -13,7 +13,8 @@
"The split-pile-up chaser only appears in CW: the marker comes from a skimmer decoding a report, so on SSB or FT8 there is nothing for it to chase.",
"Cluster: a single click now only fills the callsign, and a DOUBLE click works the spot — QSY, mode and the rest. Running down the list used to drag the radio along with every line looked at.",
"Cluster: how many spots the list keeps is now a setting (Preferences → Cluster, beside the spot lifetime). It was fixed at a thousand, and on a busy evening that fills in a couple of minutes — so the cap, not the lifetime, decided when a spot disappeared and a 15-minute lifetime never expired anything.",
- "Elecraft console: the ATU tune sends the right switch. SWT19 is the ATU row in the K3 reference — TAP starts a tuning cycle, and a HOLD (the new ATU button) puts the tuner in line or bypasses it. It was written as SWT20, which is not an ATU switch at all. Power now takes the K3 range of 0-110 W."
+ "Elecraft console: the ATU tune sends the right switch. SWT19 is the ATU row in the K3 reference — TAP starts a tuning cycle, and a HOLD (the new ATU button) puts the tuner in line or bypasses it. It was written as SWT20, which is not an ATU switch at all. Power now takes the K3 range of 0-110 W.",
+ "Elecraft console: the mouse wheel moves the sliders, as it already did on the Flex and Yaesu consoles. Power steps 5 W a notch, the rest one unit."
],
"fr": [
"Quand TQSL refuse un envoi, le journal contient désormais l'enregistrement ADIF exact qui lui a été remis et l'emplacement de station demandé pour la signature. « No QSOs processed » recouvre plusieurs causes sans rapport et le fichier temporaire est supprimé dès que TQSL rend la main : la seule pièce à conviction utile était justement invisible.",
@@ -26,7 +27,8 @@
"Le chasseur de pile-up en split n'apparaît qu'en CW : le marqueur vient d'un skimmer qui décode un report, donc en SSB ou en FT8 il n'a rien à chasser.",
"Cluster : un clic simple ne remplit plus que l'indicatif, et le DOUBLE clic travaille le spot — QSY, mode et le reste. Parcourir la liste entraînait la radio à chaque ligne regardée.",
"Cluster : le nombre de spots conservés devient un réglage (Préférences → Cluster, à côté de la durée de vie). Il était fixé à mille, et un soir chargé remplit ça en quelques minutes — c'était donc le plafond, et non la durée de vie, qui décidait de la disparition d'un spot, et une durée de 15 minutes n'expirait jamais rien.",
- "Console Elecraft : l'accord ATU envoie la bonne touche. SWT19 est la ligne ATU du manuel K3 — l'appui bref lance un cycle d'accord, et le maintien (nouveau bouton ATU) met la boîte en ligne ou la contourne. C'était écrit SWT20, qui n'est pas une touche d'ATU. La puissance suit la plage 0-110 W du K3."
+ "Console Elecraft : l'accord ATU envoie la bonne touche. SWT19 est la ligne ATU du manuel K3 — l'appui bref lance un cycle d'accord, et le maintien (nouveau bouton ATU) met la boîte en ligne ou la contourne. C'était écrit SWT20, qui n'est pas une touche d'ATU. La puissance suit la plage 0-110 W du K3.",
+ "Console Elecraft : la molette agit sur les curseurs, comme elle le faisait déjà sur les consoles Flex et Yaesu. La puissance avance de 5 W par cran, le reste d'une unité."
]
},
{
diff --git a/frontend/src/components/ElecraftPanel.tsx b/frontend/src/components/ElecraftPanel.tsx
index 46cb650..3b4f0b5 100644
--- a/frontend/src/components/ElecraftPanel.tsx
+++ b/frontend/src/components/ElecraftPanel.tsx
@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
import { MeterBar } from '@/components/MeterBar';
+import { WheelRange } from '@/components/WheelRange';
type KenwoodState = {
available: boolean; model?: string; elecraft: boolean; mode?: string;
@@ -212,53 +213,47 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
{/* CW keyer speed — the radio's own keyer, the one the K3 sends with. */}
diff --git a/frontend/src/components/WheelRange.tsx b/frontend/src/components/WheelRange.tsx
new file mode 100644
index 0000000..45f8e05
--- /dev/null
+++ b/frontend/src/components/WheelRange.tsx
@@ -0,0 +1,62 @@
+import { useEffect, useRef } from 'react';
+import { cn } from '@/lib/utils';
+
+// A range slider the mouse wheel can move.
+//
+// Dragging a 4-pixel-tall slider to change the power by five watts is a fussy
+// gesture; rolling the wheel over it is not, and it is what an operator reaches
+// for after using the Flex and Yaesu consoles, which have had it for a while.
+//
+// React's own onWheel is registered PASSIVE, so preventDefault() inside it is
+// ignored and the panel scrolls under the pointer while the value changes. The
+// listener therefore has to be attached natively with { passive: false }, and
+// the live values read through refs — the listener is installed once and would
+// otherwise capture the first render's value for ever.
+//
+// The Flex and Yaesu panels each grew their own copy of this before it was worth
+// sharing. They are left alone deliberately: they work, they are in daily use,
+// and their styling differs in small ways that a merge would have to guess at.
+export function WheelRange({ value, onChange, min = 0, max = 100, step = 1, disabled, accent = 'var(--primary)', className }: {
+ value: number; onChange: (v: number) => void;
+ min?: number; max?: number; step?: number; disabled?: boolean; accent?: string; className?: string;
+}) {
+ const v = Math.max(min, Math.min(max, value));
+ const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
+ const ref = useRef(null);
+ const valRef = useRef(v); valRef.current = v;
+ const cbRef = useRef(onChange); cbRef.current = onChange;
+ const disRef = useRef(disabled); disRef.current = disabled;
+ const stepRef = useRef(step); stepRef.current = step;
+ const minRef = useRef(min); minRef.current = min;
+ const maxRef = useRef(max); maxRef.current = max;
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const onWheel = (e: WheelEvent) => {
+ if (disRef.current) return;
+ e.preventDefault();
+ const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
+ const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + d));
+ if (nv !== valRef.current) cbRef.current(nv);
+ };
+ el.addEventListener('wheel', onWheel, { passive: false });
+ return () => el.removeEventListener('wheel', onWheel);
+ }, []);
+
+ return (
+ onChange(parseInt(e.target.value, 10))}
+ className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
+ '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
+ '[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer',
+ className)}
+ style={{
+ background: `linear-gradient(to right, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%)`,
+ borderColor: accent,
+ }}
+ />
+ );
+}