Spectrum Toolbar Ergonomics — Design Plan¶
Date: 2026-04-18
Scope: Desktop skin (RadioLayout.svelte center column). Mobile spectrum is #812 — out of scope.
Owner files:
- frontend/src/components/spectrum/SpectrumToolbar.svelte
- frontend/src/components/spectrum/spectrum-toolbar-logic.ts
- frontend/src/components-v2/layout/VfoHeader.svelte
1. Problem statement¶
The current desktop spectrum toolbar packs ~16 controls into a single 28 px dense row at 10 px monospace. There is no visual grouping beyond thin 1 px separators at 16 px height, no color differentiation between "display setting" vs "radio state" vs "action", and cryptic 3-char labels (CTR, FIX, S-C, S-F, SPD, FST) that are unreadable to new operators and still slow for pros. The VFO header's bridge column (132 px, center) has vertical headroom and — on wide dual layouts — the MAIN/SUB VfoPanels themselves have horizontal real estate that is currently unused above the frequency display.
Design goal: reduce toolbar cognitive load from "wall of beige tokens" to four immediately-recognisable groups, while surfacing purely informational state (current receiver, DUAL on/off, current SPAN value) to the VFO header where it belongs.
2. Inventory — every control on the spectrum toolbar¶
| # | Control | Type | Purpose | Frequency | Affects |
|---|---|---|---|---|---|
| 1 | STEP ◀ / value / ▶ |
stepper + value | Tuning step cycle (shared with VFO encoder) | tuning (constant) | radio state (indirect — mouse-wheel on VFO) |
| 2 | AVG |
toggle | Enable/disable spectrum averaging (client-side) | view (set-and-forget) | display only |
| 3 | PEAK |
toggle | Enable/disable peak-hold overlay (client-side) | view (set-and-forget) | display only |
| 4 | BRT − / value / + |
stepper | Waterfall intensity ±30 | view (rarely) | display only (client) |
| 5 | REF − / value / + |
stepper | Scope reference level ±30 dB (radio setting) | tuning (sometimes) | radio scope data |
| 6 | CTR |
radio-segment | Scope mode 0 — CENTER (±span around VFO) | ops | radio scope mode |
| 7 | FIX |
radio-segment | Scope mode 1 — FIXED (fixed lower/upper edges) | ops | radio scope mode |
| 8 | S-C |
radio-segment | Scope mode 2 — SCROLL-CENTER | rare | radio scope mode |
| 9 | S-F |
radio-segment | Scope mode 3 — SCROLL-FIXED | rare | radio scope mode |
| 10 | EDGE 1..4 |
radio-segment (conditional) | Edge preset for FIX / S-F only | ops (when in FIX) | radio scope edges |
| 11 | SPAN ◀ / value / ▶ |
stepper (conditional) | ±2.5k..±500k, CTR/S-C only | tuning (often) | radio scope data |
| 12 | SPD ◀ / value / ▶ |
stepper | Sweep speed FST / MID / SLO | ops (rare) | radio scope data |
| 13 | HOLD |
toggle | Freeze waterfall | rare | radio scope data |
| 14 | DUAL |
toggle | Dual-scope on/off (MAIN + SUB simultaneously) | set-and-forget | radio scope state |
| 15 | MAIN / SUB |
momentary | Switch which receiver drives scope | ops | radio scope state |
| 16 | ⚙ |
button-popover | Scope settings popover (ATT, VBW, expand) | rare | radio scope data |
| 17 | Classic / Thermal / Gray |
dropdown | Waterfall palette | set-and-forget | display only |
| 18 | BANDS |
toggle | Band-plan overlay | set-and-forget | display only |
| 19 | ▾ (layers) |
button-popover | Layer/region picker + EiBi launcher | set-and-forget | display only |
| 20 | ⛶ / ✕ |
momentary | Toggle fullscreen spectrum | ops | layout |
Decoding the cryptic labels (Icom-standard terms from IC-7610 manual, not inventable):
- CTR = Center mode. VFO stays centred, span expands symmetrically.
- FIX = Fixed mode. Fixed lower/upper edges, one of four EDGE presets.
- S-C = Scroll-Center. Centred, but scope scrolls when tuning outside the window rather than re-centering each tick.
- S-F = Scroll-Fixed. Like FIX, but when tuning past the upper edge the window shifts forward (auto-advancing fixed window).
- SPD / FST / MID / SLO = sweep speed. FST/MID/SLO already expanded. SPD → SPEED is a trivial clarity win.
- BRT = Brightness (waterfall palette gain).
- REF = Reference level, in dB, for the scope amplitude axis.
These Icom abbreviations are muscle-memory for existing operators, so we keep them as primary labels but expand via tooltip, and rename SPD → SPEED (same width at this size — 5 chars fits in 34 px).
3. Categories¶
Six logical groups, ordered by frequency of use (left-to-right on the toolbar):
| Group | Controls | Tint |
|---|---|---|
| A. Tuning | STEP |
none (leftmost, highest-priority, needs visual weight) |
| B. Scope mode | CTR FIX S-C S-F, EDGE (conditional) |
rgba(0,212,255,0.03) cyan wash — "radio state" |
| C. Scope data | SPAN, SPEED, HOLD, REF |
rgba(0,212,255,0.03) cyan wash — "radio state" (same as B) |
| D. Display | AVG, PEAK, BRT, palette dropdown, BANDS+▾ |
rgba(255,255,255,0.02) neutral wash — "client-only" |
| E. Settings | ⚙ |
none |
| F. Actions | fullscreen | none (rightmost, pushed by spacer) |
Why this split: the most important ergonomic signal is does this change the radio or only my view? Cyan wash for scope-state (B+C) matches the existing accent for .active buttons and tells the operator "this affects the rig". Neutral wash for display (D) says "harmless, experiment freely". Tuning (A) gets no wash because it already has the auto-step amber badge providing visual weight, and placing it leftmost with breathing room reinforces its always-active status.
Why merge B and C visually rather than separating them: the distinction between "pick a mode" and "tune the mode's parameters" is real, but on a 28 px row two cyan blocks separated by a 1 px divider communicates "this cluster is scope state" more cleanly than three alternating tints.
4. Proposed toolbar layout¶
┌───────────────────────────────────────────────── SPECTRUM TOOLBAR 28px ───────────────────────────────────────────────────┐
│ [◀ STEP 1kHz ▶]║ CTR FIX S-C S-F │ EDGE 1 2 3 4 │ [◀ SPAN ±25k ▶] [◀ SPEED MID ▶] HOLD │ REF − 0 + ║ AVG PEAK │ BRT − 0 + │ Classic▾ │ BANDS ▾ ║ ⚙ ════════════════spacer═══════ ⛶ │
│ ~110px ║ ↑ group B: SCOPE MODE ↑ ║ ↑ group C: SCOPE DATA ↑ ║ ↑ group D: DISPLAY ↑ ║ │
│ Tuning cyan wash rgba(0,212,255,.03) neutral wash rgba(255,255,255,.02) │
│ │
│ ║ = 2px group boundary (separator becomes 2px + 6px margin) │
│ │ = 1px in-group sub-separator (4px margin — keeps EDGE visually adjacent to scope-mode without merging) │
└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Pixel budget at 1440 px viewport (center column ≈ 960 px with sidebars):
| Segment | Width |
|---|---|
| STEP triplet | 110 |
| group separator | 8 |
| scope-mode quad (CTR FIX S-C S-F) | 128 |
| sub-sep + EDGE 1..4 (conditional, 92 px when shown) | 96 |
| group separator | 8 |
| SPAN triplet (conditional) | 110 |
| SPEED triplet | 110 |
| HOLD | 42 |
| sub-sep | 6 |
| REF triplet | 88 |
| group separator | 8 |
| AVG PEAK | 76 |
| sub-sep | 6 |
| BRT triplet | 88 |
| sub-sep | 6 |
| palette dropdown | 78 |
| sub-sep | 6 |
| BANDS+▾ | 68 |
| group separator | 8 |
| ⚙ | 22 |
| flex spacer | rest |
| ⛶ | 22 |
| TOTAL (non-conditional) | ~910 px |
At 1280 px center column (~800 px), ~110 px overflows — handled in §7.
5. Move-out plan¶
5a. Move to VFO header¶
| Control | Why | New home |
|---|---|---|
DUAL toggle |
Pure scope visibility state; belongs alongside SPLIT / DUAL-WATCH which already live in VFO bridge | VfoHeader.svelte bridge stack (below VfoOps, above SPLIT row) |
MAIN/SUB scope receiver selector |
Informational + switches which VFO drives the scope; redundant with the visual ACTIVE/STANDBY pill already on each VfoPanel | VfoHeader.svelte bridge stack — as a tiny two-pill indicator "SCOPE: ▣ MAIN ▢ SUB" clickable |
| Current SPAN label (read-only mirror) | Operators reading the VFO header already look there for bandwidth context | VfoPanel badge row, right side of main VFO header only (not both) |
5b. Move to scope settings ⚙ popover¶
- Palette dropdown (
Classic / Thermal / Gray) — set-and-forget, no business being in the hot row. BANDSlayer dropdown region selector (US / R1 / R2 / R3) — already inside the popover conceptually; keep the BANDS toggle on the toolbar but move region chooser into ⚙.HOLD— stays on toolbar (it's operational, though rare).
5c. Keyboard shortcuts only (power users, no UI)¶
[/]— decrement/increment SPAN.-/+— decrement/increment REF level (+chosen over=to avoid colliding with the existing VFO-equalize shortcut in the runtime keyboard profile).Shift+H— toggle HOLD.Shift+F— toggle fast scroll (FST).Shift+D— toggle DUAL scope. These are registered throughKeyboardHandler.svelte; document inshortcut-hints.ts.
5d. Stays on toolbar¶
Everything in §3 groups A, B, C (minus DUAL + MAIN/SUB), D, E, F.
6. VFO header impact¶
VfoHeader.svelte currently has three regions: main, bridge (132 px center column), sub. The bridge stack is vertical: VfoOps → gap → SPLIT row → SPEAK. We have ~60 px of vertical slack in the bridge between VfoOps and the SPLIT row at current --vfo-bridge-width: 132px.
Add a new scope-status stack item between VfoOps and the SPLIT row (only when hasCapability('scope')):
┌─ bridge 132px ─┐
│ VfoOps │ ← unchanged
│ │
│ ┌─ SCOPE ─┐ │ ← NEW block
│ │[▣M][ S] │ │ two micro-pills, 42×18 each, click = switchReceiver
│ │ DUAL │ │ smaller second row: DUAL toggle (48×16) with cyan accent when on
│ │±25k MID │ │ read-only SPAN + SPEED digest, 7px monospace
│ └─────────┘ │
│ │
│ SPLIT ... │ ← unchanged
│ SPEAK │ ← unchanged
└────────────────┘
Visual spec for the new block:
- Border-top: 1px solid var(--v2-vfo-header-border) — matches SPLIT row.
- Title row: 7 px uppercase "SCOPE", letter-spacing: 0.1em, color var(--v2-text-muted) — matches SPLIT title.
- Pills: 18 px tall, cyan-bordered when active (reuses .toolbar-btn.active tokens).
- In single-receiver mode (no hasDualReceiver()): only the DUAL row is hidden; the SPAN/SPEED digest still shows. MAIN/SUB pills are hidden (nothing to switch).
- Width: fits existing 132 px bridge. No change to --vfo-bridge-width.
Responsive consideration: the existing @media (max-width: 1024px) collapses the bridge onto its own row and flips .vfo-ops to horizontal. The new block inherits this — in collapsed mode it lives inline with VfoOps on the horizontal row. That's acceptable because on narrow screens there's less space pressure on the spectrum toolbar anyway.
Does not break dual-receiver: no grid-area changes. Nothing is added to MAIN/SUB VfoPanels. The SPAN-label badge proposed in §5a is deferred to a later issue — flagged in §10 as open question.
7. Visual spec¶
| Property | Value |
|---|---|
| Toolbar height | 32 px (↑ from 28) — one extra vertical pixel pays for better separator visibility and 11 px font |
| Base font | 11 px Roboto Mono (↑ from 10) — numeric readability tested; 10 px is below comfortable small-cap threshold |
| Group separator | 2px wide, var(--panel-border), full 20 px height, margin: 0 6px |
| In-group sub-separator | 1px wide, var(--panel-border) alpha 0.5, 14 px height, margin: 0 4px |
| Group B+C wash | rgba(0, 212, 255, 0.03) — applied to group container background |
| Group D wash | rgba(255, 255, 255, 0.02) |
| Active button (unchanged) | cyan #00d4ff text + rgba(0,212,255,0.1) bg + rgba(0,212,255,0.3) border |
| Label vs value weight | labels 500, values 600 — replaces current uniform 400 for scanning |
| Min touch target | 22×22 (unchanged — desktop only) |
| Button gap within group | 3 px (↑ from 2 — breathes at 11 px font) |
8. Responsive behavior¶
At ≤1280 px center column (container-query: @container spectrum (max-width: 800px) on toolbar container):
Overflow priority (last to collapse first — i.e. stays visible longest): 1. STEP (never collapses) 2. Scope-mode quad (never collapses — it IS the scope) 3. SPAN (never collapses — it's the primary scope tuning knob) 4. fullscreen (stays — one-click escape) 5. ⚙ (stays — accesses overflow) 6. AVG / PEAK (collapse into ⚙ popover first) 7. BRT (collapse into ⚙) 8. REF (collapse into ⚙) 9. SPEED, HOLD (collapse into ⚙) 10. EDGE (never visible outside FIX/S-F, so not a budget issue) 11. Palette dropdown (collapse into ⚙) 12. BANDS+▾ (collapse into ⚙ — dropdown itself is already a popover, becomes a submenu)
Strategy: rather than a More… button, use the existing ⚙ popover as the overflow target. Add a overflowed: boolean[] CSS-driven state; the popover shows duplicates of whatever the container-query CSS has hidden. This keeps logic simple: no JS measurement.
At ≤1024 px (tablet), the whole spectrum panel already rearranges — out of scope here, covered by #812.
9. Interaction notes¶
Tooltips (title= on hover, shown after 500 ms by browser):
| Label | Tooltip text |
|---|---|
| STEP | "Tuning step — click cycles up, right-click cycles down. A = auto-step follows band" |
| AVG | "Spectrum averaging (client-side display only)" |
| PEAK | "Peak hold overlay (client-side display only)" |
| BRT | "Waterfall brightness (palette gain, display only)" |
| REF | "Scope reference level in dB — radio setting" |
| CTR | "Scope mode: CENTER — VFO centered in window" |
| FIX | "Scope mode: FIXED edges — choose EDGE preset 1–4" |
| S-C | "Scope mode: SCROLL-CENTER — window scrolls instead of recentering" |
| S-F | "Scope mode: SCROLL-FIXED — fixed window auto-advances" |
| EDGE 1..4 | "Edge preset N — configure in scope settings ⚙" |
| SPAN | "Scope span ±freq (CTR and S-C modes only)" |
| SPEED | "Scope sweep speed: FST fastest / MID / SLO slowest" |
| HOLD | "Freeze scope waterfall — release to resume" |
| ⚙ | "Scope settings (ATT, VBW, expand, overflowed controls)" |
| ⛶ | "Toggle fullscreen spectrum (F)" |
Do not expand the on-button labels themselves. CTR/FIX/S-C/S-F are reproducing the Icom front-panel labels and operators with any prior IC-7xxx experience expect them. Changing SPD → SPEED is the only label edit (4 chars saved elsewhere buys it).
Cursor affordance: stepper buttons ◀ ▶ get cursor: ew-resize on hover to signal "scrubable" (future: add <wheel> on stepper-container to cycle).
10. Atomic implementation issues¶
Each ≤3 files, ≤200 LOC, TDD (pure logic in *-logic.ts, test first).
Issue #A — Toolbar grouping, separators, visual spec¶
- Files:
SpectrumToolbar.svelte,spectrum-toolbar-logic.ts(label constants),__tests__/spectrum-toolbar-logic.test.ts - Scope: wrap groups in
.toolbar-group-b/.toolbar-group-c/.toolbar-group-dcontainers with the wash backgrounds; 2 px vs 1 px separator classes; height bump 28 → 32; font 10 → 11;SPD → SPEEDlabel; weight 500/600 split. - No new behavior. Snapshot test only.
- LOC: ~80.
Issue #B — Tooltips + keyboard shortcuts¶
- Files:
SpectrumToolbar.svelte,KeyboardHandler.svelte,shortcut-hints.ts - Scope: add
title=to every button per §9 table; register[,],-,=,H,D,Fin KeyboardHandler; update shortcut-hints registry. - Tests:
keyboard-map.tspure-fn tests for new bindings. - LOC: ~120.
Issue #C — Move DUAL + MAIN/SUB + SPAN/SPEED digest to VfoHeader bridge¶
- Files:
VfoHeader.svelte,SpectrumToolbar.svelte(remove DUAL + MAIN/SUB block),__tests__/vfo-header.test.ts - Scope: add
ScopeStatussubcomponent (inline or new file — inline keeps file count at 2); wirescopeControlsfromradio.currentinto VfoHeader via new prop; remove moved blocks from toolbar; adjust responsive CSS for ≤1024 px bridge-horizontal mode. - Gotcha: VfoHeader currently doesn't know about
scopeControls. Pass as prop fromRadioLayout.svelte(NOT imported directly — preservescomponents-v2/layout/purity per CLAUDE.md frontend layering rule). - LOC: ~180.
Issue #D (stretch — optional) — Overflow via ⚙ popover at narrow widths¶
- Files:
SpectrumToolbar.svelte,ScopeSettingsPopover.svelte - Scope: container-query CSS hides overflow-eligible groups below 800 px; popover renders the same controls conditionally.
- LOC: ~150.
- Defer if #A–#C land cleanly and 1440 px+ feedback is positive; narrow widths are a smaller segment of usage.
Recommended order: A → B → C → (D). Each ships independently; A is pure visual and reviewable fastest, C is highest-value but depends on A's group container markup.
11. Open questions¶
- SPAN-label mirror on VfoPanel (§5a last row). Does adding a "±25k" pill to the main VFO's badge row clutter the frequency display, or is it useful passive readout? Recommend A/B with two radio operators before committing. Out of scope for the three atomic issues above.
- Palette dropdown — keep on toolbar or move to ⚙? Operators who switch palettes do so maybe once per session. Moving it saves 78 px but some users visually reach for it. Default: keep on toolbar for now; revisit if #A user-tests reveal nobody uses it.
- EDGE preset editing. Currently EDGE 1..4 is select-only; configuration of each edge's low/high lives in ⚙ popover (presumed — to verify). If not, that's a separate issue.
- DUAL scope semantics when MAIN/SUB is in VFO bridge. In current toolbar,
MAIN/SUBbutton is a scope-source selector. After move, is there a risk of confusion with the active-receiver ACTIVE/STANDBY already shown on each VfoPanel? Consider labelling the bridge pills explicitlySCOPE SRCto disambiguate. - Container-query support. We rely on
@container(baseline in modern Chromium/Firefox/Safari). Confirm the spectrum panel hascontainer-type: inline-sizeset or add it. If project targets pre-2023 browsers, fall back to JS measurement — unlikely given Svelte 5 baseline. - Mobile skin. #812 owns mobile. This plan's VFO-bridge-move (Issue #C) MUST be verified against
MobileRadioLayout.svelteso the bridge doesn't explode in the mobile layout — add that as a regression-check line-item when #C lands.
12. Non-goals¶
- No changes to waterfall renderer or canvas logic.
- No new commands in
commands/or CI-V protocol work. - No new abstractions beyond the
ScopeStatusbridge subcomponent. - No behavioural changes to what each button does — every control maps 1:1 to current handlers.
- Color scheme tokens stay as-is; only adding two new alpha washes.