[feat](trx-frontend-http): spectrum view UI/UX improvements

Add 8 enhancements to the spectrum display:

1. Noise floor reference line — dashed horizontal line at estimated
   noise floor (15th-percentile heuristic)
2. Peak frequency labels — top 5 strongest peaks labeled with
   frequency text on the spectrum canvas
3. Crosshair lines — vertical + horizontal guide lines follow
   cursor on hover for precise frequency/dB reading
4. Zoom indicator + minimap — shows current zoom level (e.g. "4.0x")
   and a minimap showing the visible window within the full span
5. dB range control — new Range input alongside Floor, with Auto
   button updating both; allows direct control of vertical span
6. Keyboard shortcuts — Arrow Left/Right to pan, +/- to zoom,
   0 to reset zoom; documented in hint bar
7. Full waterfall panel — WebGL waterfall canvas below the spectrum
   plot, synchronized with zoom/pan, with scroll/click/drag support
8. Signal overlay extended — overlay height now includes waterfall
   canvas for consistent BW/bookmark/freq marker coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-26 20:44:08 +01:00
parent caa7603489
commit 36325a2eef
3 changed files with 399 additions and 1 deletions
@@ -1142,6 +1142,10 @@ function signalOverlayHeight() {
getComputedStyle(spectrumPanelEl).display !== "none";
if (spectrumVisible) {
height += spectrumCanvasEl.clientHeight || 0;
const wfCanvas = document.getElementById("spectrum-waterfall-canvas");
if (wfCanvas && wfCanvas.clientHeight > 0) {
height += wfCanvas.clientHeight;
}
}
return Math.floor(height);
}
@@ -2660,6 +2664,7 @@ function updateSpectrumAutoHeight() {
if (lastSpectrumData) {
scheduleSpectrumDraw();
scheduleOverviewDraw();
scheduleSpectrumWaterfallDraw();
}
}
@@ -8673,6 +8678,10 @@ let waterfallGamma = 1.0;
const SPECTRUM_HEADROOM_DB = 20;
const SPECTRUM_SMOOTH_ALPHA = 0.42;
// Crosshair state (CSS coords relative to spectrum canvas).
let spectrumCrosshairX = null;
let spectrumCrosshairY = null;
// BW-strip drag state.
let _bwDragEdge = null; // "left" | "right" | null
let _bwDragStartX = 0;
@@ -8797,6 +8806,13 @@ function buildSpectrumPeakHoldBins(currentBins) {
return peakBins;
}
// Estimate noise floor as the 15th-percentile of visible bins (same heuristic as Auto).
function estimateNoiseFloorDb(bins) {
if (!Array.isArray(bins) || bins.length === 0) return null;
const sorted = bins.slice().sort((a, b) => a - b);
return sorted[Math.floor(sorted.length * 0.15)];
}
function buildSpectrumRenderData(frame) {
if (!frame || !Array.isArray(frame.bins)) return frame;
const prev = lastSpectrumRenderData;
@@ -8979,6 +8995,9 @@ function startSpectrumStreaming() {
overviewWaterfallRows = [];
overviewWaterfallPushCount = 0;
overviewWfResetTextureCache();
spectrumWfRows = [];
spectrumWfPushCount = 0;
spectrumWfTexReady = false;
scheduleOverviewDraw();
clearSpectrumCanvas();
updateRdsPsOverlay(null);
@@ -9011,6 +9030,7 @@ function startSpectrumStreaming() {
settlePendingSpectrumFrameWaiters(lastSpectrumData);
pushSpectrumPeakHoldFrame(lastSpectrumRenderData);
pushOverviewWaterfallFrame(lastSpectrumData);
pushSpectrumWaterfallFrame(lastSpectrumData);
refreshCenterFreqDisplay();
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
scheduleSpectrumDraw();
@@ -9069,6 +9089,9 @@ function stopSpectrumStreaming() {
overviewWaterfallRows = [];
overviewWaterfallPushCount = 0;
overviewWfResetTextureCache();
spectrumWfRows = [];
spectrumWfPushCount = 0;
spectrumWfTexReady = false;
scheduleOverviewDraw();
updateRdsPsOverlay(null);
clearSpectrumCanvas();
@@ -9341,6 +9364,7 @@ function scheduleSpectrumDraw() {
if (lastSpectrumRenderData) {
drawSpectrum(lastSpectrumRenderData);
if (overviewWaterfallRows.length > 0) scheduleOverviewDraw();
if (spectrumWfRows.length > 0) scheduleSpectrumWaterfallDraw();
}
});
}
@@ -9406,6 +9430,19 @@ function drawSpectrum(data) {
spectrumGl.drawPolyline(spectrumTmpFillPoints, cssColorToRgba(pal.spectrumLine), Math.max(1, dpr));
// ── Noise floor reference line ──
const noiseDb = estimateNoiseFloorDb(bins);
if (noiseDb != null && noiseDb >= DB_MIN && noiseDb <= DB_MAX) {
const noiseY = Math.round(H * (1 - (noiseDb - DB_MIN) / dbRange));
const nfSegments = [];
const dashLen = Math.max(4, Math.round(6 * dpr));
const gapLen = Math.max(3, Math.round(5 * dpr));
for (let x = 0; x < W; x += dashLen + gapLen) {
nfSegments.push(x, noiseY, Math.min(W, x + dashLen), noiseY);
}
spectrumGl.drawSegments(nfSegments, rgbaWithAlpha(pal.waveformPeak, 0.35), Math.max(1, dpr * 0.8));
}
const markerPeaks = visibleSpectrumPeakIndices(data);
if (markerPeaks.length > 0) {
spectrumTmpMarkerPoints.length = 0;
@@ -9415,11 +9452,200 @@ function drawSpectrum(data) {
spectrumGl.drawPoints(spectrumTmpMarkerPoints, Math.max(2, dpr * 1.6), cssColorToRgba(pal.waveformPeak));
}
// ── Peak frequency labels (top 5 strongest) ──
if (markerPeaks.length > 0) {
const topPeaks = markerPeaks.slice(0, 5);
const labelEl = document.getElementById("spectrum-peak-labels");
if (labelEl) {
labelEl.innerHTML = "";
const cssW = spectrumCanvas.clientWidth || 640;
const cssH = spectrumCanvas.clientHeight || 160;
for (const idx of topPeaks) {
const peakHz = loHz + (idx / (n - 1)) * fullSpanHz;
const peakDb = bins[idx];
if (peakDb < DB_MIN + 6) continue; // skip near-floor peaks
const xFrac = (peakHz - range.visLoHz) / range.visSpanHz;
if (xFrac < 0.02 || xFrac > 0.98) continue;
const yFrac = 1 - (Math.max(DB_MIN, Math.min(DB_MAX, peakDb)) - DB_MIN) / dbRange;
const span = document.createElement("span");
span.className = "spectrum-peak-label";
span.textContent = formatSpectrumFreq(peakHz);
span.style.left = (xFrac * cssW) + "px";
span.style.top = Math.max(2, yFrac * cssH - 16) + "px";
labelEl.appendChild(span);
}
}
}
// ── Crosshair lines ──
if (spectrumCrosshairX != null && spectrumCrosshairY != null) {
const cx = spectrumCrosshairX * dpr;
const cy = spectrumCrosshairY * dpr;
const chColor = rgbaWithAlpha(pal.spectrumLabel, 0.5);
spectrumGl.drawSegments([cx, 0, cx, H], chColor, Math.max(1, dpr * 0.6));
spectrumGl.drawSegments([0, cy, W, cy], chColor, Math.max(1, dpr * 0.6));
}
// ── Zoom indicator ──
const zoomEl = document.getElementById("spectrum-zoom-indicator");
if (zoomEl) {
if (spectrumZoom > 1.01) {
zoomEl.textContent = spectrumZoom.toFixed(1) + "x";
zoomEl.style.display = "block";
} else {
zoomEl.style.display = "none";
}
}
// ── Zoom minimap ──
const minimapEl = document.getElementById("spectrum-minimap");
if (minimapEl) {
if (spectrumZoom > 1.01) {
minimapEl.style.display = "block";
const viewFrac = 1 / spectrumZoom;
const halfVis = viewFrac / 2;
const panClamped = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis);
const viewL = panClamped - halfVis;
const viewR = panClamped + halfVis;
const inner = minimapEl.querySelector(".minimap-view");
if (inner) {
inner.style.left = (viewL * 100) + "%";
inner.style.width = ((viewR - viewL) * 100) + "%";
}
} else {
minimapEl.style.display = "none";
}
}
updateSpectrumFreqAxis(range);
updateBookmarkAxis(range);
drawSignalOverlay();
}
// ── Full waterfall panel below spectrum ───────────────────────────────────────
const spectrumWaterfallCanvas = document.getElementById("spectrum-waterfall-canvas");
const spectrumWaterfallGl = (typeof createTrxWebGlRenderer === "function" && spectrumWaterfallCanvas)
? createTrxWebGlRenderer(spectrumWaterfallCanvas, spectrumSnapshotGlOptions)
: null;
let spectrumWfRows = [];
let spectrumWfPushCount = 0;
let spectrumWfTexData = null;
let spectrumWfTexWidth = 0;
let spectrumWfTexHeight = 0;
let spectrumWfTexPushCount = 0;
let spectrumWfTexPalKey = "";
let spectrumWfTexReady = false;
let spectrumWfDrawPending = false;
const SPECTRUM_WF_TEX_MAX_W = 1024;
function pushSpectrumWaterfallFrame(data) {
if (!spectrumWaterfallCanvas || !data || !Array.isArray(data.bins) || data.bins.length === 0) return;
spectrumWfRows.push(data.bins.slice());
spectrumWfPushCount++;
trimSpectrumWaterfallRows();
scheduleSpectrumWaterfallDraw();
}
function trimSpectrumWaterfallRows() {
if (!spectrumWaterfallCanvas) return;
const dpr = window.devicePixelRatio || 1;
const maxRows = Math.max(1, Math.floor((spectrumWaterfallCanvas.clientHeight || 120) * dpr));
while (spectrumWfRows.length > maxRows) {
spectrumWfRows.shift();
}
}
function scheduleSpectrumWaterfallDraw() {
if (!spectrumWaterfallCanvas || spectrumWfDrawPending) return;
spectrumWfDrawPending = true;
requestAnimationFrame(() => {
spectrumWfDrawPending = false;
drawSpectrumWaterfall();
});
}
function drawSpectrumWaterfall() {
if (!spectrumWaterfallCanvas || !spectrumWaterfallGl || !spectrumWaterfallGl.ready) return;
if (!lastSpectrumData || spectrumWfRows.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const cssW = spectrumWaterfallCanvas.clientWidth || 640;
const cssH = spectrumWaterfallCanvas.clientHeight || 120;
spectrumWaterfallGl.ensureSize(cssW, cssH, dpr);
const W = spectrumWaterfallCanvas.width;
const H = spectrumWaterfallCanvas.height;
if (W <= 0 || H <= 0) return;
const pal = canvasPalette();
const maxVisible = Math.max(1, Math.floor(H));
const rows = spectrumWfRows.slice(-maxVisible);
if (rows.length === 0) return;
const iW = Math.max(96, Math.min(SPECTRUM_WF_TEX_MAX_W, Math.ceil(W / 2)));
const iH = Math.max(1, rows.length);
const minDb = Number.isFinite(spectrumFloor) ? spectrumFloor : -115;
const maxDb = minDb + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90);
const view = spectrumVisibleRange(lastSpectrumData);
const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`;
const palKey = `swf|${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
const rowStride = iW * 4;
const expectedSize = iW * iH * 4;
const newPushes = spectrumWfPushCount - spectrumWfTexPushCount;
const sizeChanged = spectrumWfTexWidth !== iW || spectrumWfTexHeight !== iH;
const palChanged = spectrumWfTexPalKey !== palKey;
const needsFull = !spectrumWfTexData || sizeChanged || palChanged || spectrumWfTexPushCount === 0;
let texUpdated = false;
if (!spectrumWfTexData || spectrumWfTexData.length !== expectedSize) {
spectrumWfTexData = new Uint8Array(expectedSize);
}
spectrumWfTexWidth = iW;
spectrumWfTexHeight = iH;
function renderRow(dstY, srcBins) {
if (!Array.isArray(srcBins) || srcBins.length === 0) return;
const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length);
const spanBins = Math.max(1, endIdx - startIdx);
const rowBase = dstY * rowStride;
for (let x = 0; x < iW; x++) {
const frac = x / Math.max(1, iW - 1);
const binIdx = Math.min(endIdx, startIdx + Math.floor(frac * spanBins));
const c = waterfallColorRgba(srcBins[binIdx], pal, minDb, maxDb);
const p = rowBase + x * 4;
spectrumWfTexData[p + 0] = Math.round(c[0] * 255);
spectrumWfTexData[p + 1] = Math.round(c[1] * 255);
spectrumWfTexData[p + 2] = Math.round(c[2] * 255);
spectrumWfTexData[p + 3] = Math.round(c[3] * 255);
}
}
if (needsFull) {
for (let y = 0; y < iH; y++) renderRow(y, rows[y]);
spectrumWfTexPushCount = spectrumWfPushCount;
spectrumWfTexPalKey = palKey;
texUpdated = true;
} else if (newPushes > 0) {
const newCount = Math.min(newPushes, iH);
if (newCount >= iH) {
for (let y = 0; y < iH; y++) renderRow(y, rows[y]);
} else {
const shiftBytes = newCount * rowStride;
spectrumWfTexData.copyWithin(0, shiftBytes);
const startRow = iH - newCount;
for (let y = startRow; y < iH; y++) renderRow(y, rows[y]);
}
spectrumWfTexPushCount = spectrumWfPushCount;
spectrumWfTexPalKey = palKey;
texUpdated = true;
}
if (texUpdated || !spectrumWfTexReady) {
spectrumWaterfallGl.uploadRgbaTexture("spectrum-waterfall", iW, iH, spectrumWfTexData, "linear");
spectrumWfTexReady = true;
}
spectrumWaterfallGl.drawTexture("spectrum-waterfall", 0, 0, W, H, 1, true);
}
function bmHexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
@@ -10008,6 +10234,46 @@ window.addEventListener("keydown", (event) => {
void captureSpectrumScreenshot();
return;
}
// Spectrum keyboard navigation
if (lastSpectrumData && spectrumCanvas) {
// Arrow Left/Right — pan spectrum
if (key === "arrowleft" || key === "arrowright") {
event.preventDefault();
const step = 0.1 / spectrumZoom;
spectrumPanFrac += key === "arrowleft" ? -step : step;
scheduleSpectrumDraw();
scheduleOverviewDraw();
return;
}
// +/= — zoom in
if (key === "+" || key === "=") {
event.preventDefault();
const cssW = spectrumCanvas.clientWidth || 640;
spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1.25);
scheduleSpectrumDraw();
scheduleOverviewDraw();
return;
}
// - — zoom out
if (key === "-") {
event.preventDefault();
const cssW = spectrumCanvas.clientWidth || 640;
spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1 / 1.25);
scheduleSpectrumDraw();
scheduleOverviewDraw();
return;
}
// 0 — reset zoom
if (key === "0") {
event.preventDefault();
spectrumZoom = 1;
spectrumPanFrac = 0.5;
scheduleSpectrumDraw();
scheduleOverviewDraw();
return;
}
}
}, { capture: true });
// ── Zoom helpers ──────────────────────────────────────────────────────────────
@@ -10069,6 +10335,19 @@ if (overviewCanvas) {
});
}
// Full waterfall panel interactions.
if (spectrumWaterfallCanvas) {
spectrumWaterfallCanvas.addEventListener("wheel", (e) => {
handleSpectrumWheel(e, spectrumWaterfallCanvas);
}, { passive: false });
spectrumWaterfallCanvas.addEventListener("click", (e) => {
handleSpectrumClick(e, spectrumWaterfallCanvas);
});
spectrumWaterfallCanvas.addEventListener("mousedown", (e) => {
onSpectrumMouseDown(e, spectrumWaterfallCanvas);
});
}
// ── BW strip edge hit-test (CSS pixels) ──────────────────────────────────────
function getBwEdgeHit(cssX, cssW, range) {
@@ -10293,10 +10572,20 @@ if (spectrumCanvas) {
if (tx + tw > rect.width) tx = cssX - tw - 10;
spectrumTooltip.style.left = tx + "px";
spectrumTooltip.style.top = Math.max(0, e.clientY - rect.top - 28) + "px";
// Update crosshair position
spectrumCrosshairX = cssX;
spectrumCrosshairY = e.clientY - rect.top;
scheduleSpectrumDraw();
});
spectrumCanvas.addEventListener("mouseleave", () => {
if (spectrumTooltip) spectrumTooltip.style.display = "none";
spectrumCanvas.style.cursor = "crosshair";
spectrumCrosshairX = null;
spectrumCrosshairY = null;
// Clear peak labels on leave
const labelEl = document.getElementById("spectrum-peak-labels");
if (labelEl) labelEl.innerHTML = "";
scheduleSpectrumDraw();
});
}
@@ -10333,6 +10622,18 @@ if (spectrumCenterRightBtn) {
});
}
const rangeInput = document.getElementById("spectrum-range-input");
if (rangeInput) {
rangeInput.value = spectrumRange;
rangeInput.addEventListener("change", () => {
const v = Number(rangeInput.value);
if (!isNaN(v) && v >= 10) {
spectrumRange = v;
if (lastSpectrumData) scheduleSpectrumDraw();
}
});
}
if (autoBtn) {
autoBtn.addEventListener("click", () => {
if (!lastSpectrumData) return;
@@ -10343,6 +10644,7 @@ if (spectrumCenterRightBtn) {
spectrumFloor = Math.floor(noise / 10) * 10 - 10;
spectrumRange = Math.max(60, Math.ceil((peak - spectrumFloor) / 10) * 10 + SPECTRUM_HEADROOM_DB);
if (floorInput) floorInput.value = spectrumFloor;
if (rangeInput) rangeInput.value = spectrumRange;
scheduleSpectrumDraw();
});
}
@@ -118,9 +118,13 @@
<div id="spectrum-bookmark-axis"></div>
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
<canvas id="spectrum-canvas"></canvas>
<div id="spectrum-peak-labels" aria-hidden="true"></div>
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
<div id="spectrum-db-axis" aria-hidden="true"></div>
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
<div id="spectrum-tooltip"></div>
<canvas id="spectrum-waterfall-canvas"></canvas>
<div id="spectrum-freq-axis">
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">&lsaquo;</button>
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">&rsaquo;</button>
@@ -149,11 +153,12 @@
</select>
</label>
<label id="spectrum-floor-label">Floor <input type="number" id="spectrum-floor-input" value="-115" step="5" /> dB</label>
<label id="spectrum-range-label">Range <input type="number" id="spectrum-range-input" value="90" step="10" min="10" /> dB</label>
<button id="spectrum-auto-btn" type="button">Auto</button>
<label id="spectrum-gamma-label">Contrast <input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value">1.0</span></label>
</div>
</div>
<div id="spectrum-hint" class="spectrum-hint-mouse">Scroll to zoom &middot; Ctrl+Scroll to tune &middot; Drag to pan &middot; Drag BW edges to resize</div>
<div id="spectrum-hint" class="spectrum-hint-mouse">Scroll to zoom &middot; Ctrl+Scroll to tune &middot; Drag to pan &middot; Drag BW edges to resize &middot; +/- zoom &middot; Arrows pan &middot; 0 reset</div>
<div id="spectrum-hint-touch" class="spectrum-hint-touch">Pinch to zoom &middot; Drag to pan &middot; Drag BW edges to resize</div>
</div>
<div id="signal-split-control" title="Set waterfall and waveform split" aria-label="Set waterfall and waveform split">
@@ -34,6 +34,7 @@
--card-base-max-width: 1280px;
--card-max-width: 1600px;
--card-bookmark-gutter: 9.5rem;
--spectrum-waterfall-height: 120px;
--spectrum-bookmark-side-width: 6.5rem;
--spectrum-bookmark-side-offset: 8.85rem;
}
@@ -2743,6 +2744,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
}
#spectrum-bw-label,
#spectrum-floor-label,
#spectrum-range-label,
#spectrum-peak-hold-label {
flex: 1 1 100%;
justify-content: space-between;
@@ -2757,6 +2759,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
}
#spectrum-bw-input,
#spectrum-floor-input,
#spectrum-range-input,
#overview-peak-hold {
flex: 1 1 auto;
min-width: 3rem;
@@ -3105,6 +3108,76 @@ button:focus-visible, input:focus-visible, select:focus-visible {
font-weight: 700;
pointer-events: none;
}
/* ── Peak labels on spectrum ── */
#spectrum-peak-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
height: var(--spectrum-plot-height);
pointer-events: none;
z-index: 6;
overflow: hidden;
}
.spectrum-peak-label {
position: absolute;
transform: translateX(-50%);
font-size: 0.58rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
pointer-events: none;
text-shadow: 0 1px 3px color-mix(in srgb, var(--bg) 80%, transparent);
opacity: 0.85;
}
/* ── Zoom indicator ── */
#spectrum-zoom-indicator {
display: none;
position: absolute;
top: 6px;
right: 3.5rem;
z-index: 9;
font-size: 0.68rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 700;
color: var(--text-muted);
background: color-mix(in srgb, var(--spectrum-bg) 75%, transparent);
padding: 1px 6px;
border-radius: 3px;
pointer-events: none;
}
/* ── Zoom minimap ── */
#spectrum-minimap {
display: none;
position: absolute;
bottom: 22px;
right: 3.5rem;
z-index: 9;
width: 80px;
height: 10px;
background: color-mix(in srgb, var(--spectrum-bg) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--border-light) 50%, transparent);
border-radius: 3px;
pointer-events: none;
overflow: hidden;
}
.minimap-view {
position: absolute;
top: 0;
height: 100%;
background: color-mix(in srgb, var(--accent-yellow) 40%, transparent);
border-radius: 2px;
}
/* ── Full waterfall canvas ── */
#spectrum-waterfall-canvas {
display: block;
width: 100%;
height: var(--spectrum-waterfall-height, 120px);
background: var(--spectrum-bg);
cursor: crosshair;
touch-action: none;
}
#spectrum-bookmark-axis {
position: absolute;
top: calc(-1 * var(--overview-plot-height));
@@ -3308,6 +3381,24 @@ button:focus-visible, input:focus-visible, select:focus-visible {
text-align: right;
height: 1.5rem;
}
#spectrum-range-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--text-muted);
}
#spectrum-range-input {
width: 3.4rem;
padding: 1px 4px;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--input-bg);
color: var(--text);
text-align: right;
height: 1.5rem;
}
#spectrum-auto-btn {
height: 1.5rem;
min-height: 0;