diff --git a/frontend/src/components/notebook/output/ScopeWaveformViewer.tsx b/frontend/src/components/notebook/output/ScopeWaveformViewer.tsx new file mode 100644 index 0000000..4cb9642 --- /dev/null +++ b/frontend/src/components/notebook/output/ScopeWaveformViewer.tsx @@ -0,0 +1,383 @@ +import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import type { WaveformData } from '../../../lib/types'; +import { + formatEng, + SCOPE_TRACE_COLORS, + generate125Sequence, + nearest125, + step125, + arrayMinMax, + tracesMinMax, +} from '../../../lib/waveform-utils'; +import uPlot from 'uplot'; +import 'uplot/dist/uPlot.min.css'; +import '../../../styles/scope-skin.css'; + +/* ── Constants ──────────────────────────────────────────── */ + +const TIME_SEQ = generate125Sequence(-9, 1); // 1ns → 5s +const VOLT_SEQ = generate125Sequence(-3, 2); // 1mV → 50V +const FREQ_SEQ = generate125Sequence(0, 9); // 1Hz → 5GHz +const DB_SEQ = generate125Sequence(-1, 2); // 0.1dB/div → 50dB/div +const DEG_SEQ = [5, 10, 15, 20, 30, 45, 90, 180]; // degrees/div + +const SKINS = { + '465': { brand: 'Tektronix', model: '465', sub: '', sections: ['Source', 'Volts/Div', 'Time/Div', 'Mode'] }, + '545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Input', 'Vert Ampl', 'Sweep', 'Mode'] }, +} as const; +type SkinId = keyof typeof SKINS; +const SKIN_ORDER: SkinId[] = ['465', '545a']; + +const NUM_H_DIVS = 10; +const NUM_V_DIVS = 8; + +/* ── Safe localStorage ──────────────────────────────────── */ + +function safeGetItem(key: string): string | null { + try { return localStorage.getItem(key); } catch { return null; } +} + +function safeSetItem(key: string, value: string): void { + try { localStorage.setItem(key, value); } catch { /* noop */ } +} + +/* ── Helpers ──────────────────────────────────────────── */ + +function autoDiv(min: number, max: number, nDivs: number, seq: number[]): number { + const range = max - min; + if (range <= 0) return seq[Math.floor(seq.length / 2)]; + const ideal = range / nDivs; + return nearest125(ideal, seq); +} + +function divUnit(axisType: string): string { + switch (axisType) { + case 'time': return 's'; + case 'frequency': return 'Hz'; + case 'voltage': return 'V'; + case 'current': return 'A'; + case 'dB': return 'dB'; + case 'degrees': case 'phase': return '\u00B0'; + default: return ''; + } +} + +function xSeqForType(xType: string): number[] { + return xType === 'frequency' ? FREQ_SEQ : TIME_SEQ; +} + +function ySeqForType(yType: string): number[] { + if (yType === 'dB') return DB_SEQ; + if (yType === 'degrees' || yType === 'phase') return DEG_SEQ; + return VOLT_SEQ; +} + +/* ── Props ──────────────────────────────────────────────── */ + +interface ScopeWaveformViewerProps { + waveform: WaveformData; + width: number; + onExitScope: () => void; +} + +/* ── Component ──────────────────────────────────────────── */ + +export function ScopeWaveformViewer({ waveform, width, onExitScope }: ScopeWaveformViewerProps) { + const screenRef = useRef(null); + const uplotRef = useRef(null); + + // Determine analysis type and trace names + const isAC = waveform.is_complex; + const analysisLabel = isAC ? 'AC' : 'TRAN'; + + // For AC, use magnitude traces; for transient, use y_data + const traceNames = useMemo(() => { + if (isAC) { + return waveform.y_magnitude_db ? Object.keys(waveform.y_magnitude_db) : []; + } + return Object.keys(waveform.y_data); + }, [waveform, isAC]); + + const xType = waveform.x_type; + const yType = useMemo(() => { + if (isAC) return 'dB'; + return waveform.variables.length > 1 ? waveform.variables[1].type : 'voltage'; + }, [waveform, isAC]); + + // State + const [activeTrace, setActiveTrace] = useState(0); + const [xDiv, setXDiv] = useState(null); + const [yDiv, setYDiv] = useState(null); + const [skinId, setSkinId] = useState(() => { + const stored = safeGetItem('spicebook-scope-skin') as SkinId | null; + return stored && SKINS[stored] ? stored : '465'; + }); + + const skin = SKINS[skinId]; + + // Compute data range using loop-based min/max (safe for large arrays) + const { xMin, xMax, yMin, yMax } = useMemo(() => { + const [xLo, xHi] = arrayMinMax(waveform.x_data); + const yData = isAC && waveform.y_magnitude_db + ? waveform.y_magnitude_db + : waveform.y_data; + const [yLo, yHi] = tracesMinMax(yData); + return { xMin: xLo, xMax: xHi, yMin: yLo, yMax: yHi }; + }, [waveform, isAC]); + + // Initialize div values from data range + useEffect(() => { + setXDiv(autoDiv(xMin, xMax, NUM_H_DIVS, xSeqForType(xType))); + setYDiv(autoDiv(yMin, yMax, NUM_V_DIVS, ySeqForType(yType))); + }, [xMin, xMax, yMin, yMax, xType, yType]); + + // Build and mount uPlot (only on data/size changes, NOT on trace/div changes) + useEffect(() => { + if (!screenRef.current || width <= 0 || xDiv === null || yDiv === null) return; + + const el = screenRef.current; + const rect = el.getBoundingClientRect(); + const plotW = rect.width || width; + const plotH = rect.height || Math.round(width * 3 / 5); + + const xData = new Float64Array(waveform.x_data); + let yTraceData: Float64Array[]; + let seriesLabels: string[]; + + if (isAC && waveform.y_magnitude_db) { + const mag = waveform.y_magnitude_db; + const names = Object.keys(mag); + yTraceData = names.map(n => new Float64Array(mag[n])); + seriesLabels = names.map(n => `|${n}|`); + } else { + const names = Object.keys(waveform.y_data); + yTraceData = names.map(n => new Float64Array(waveform.y_data[n])); + seriesLabels = names; + } + + const data: uPlot.AlignedData = [ + xData as unknown as number[], + ...yTraceData.map(td => td as unknown as number[]), + ]; + + const series: uPlot.Series[] = [ + { label: xType === 'frequency' ? 'Frequency' : 'Time' }, + ...seriesLabels.map((label, i) => ({ + label, + stroke: SCOPE_TRACE_COLORS[i % SCOPE_TRACE_COLORS.length], + width: 2, + show: i === activeTrace, + })), + ]; + + // Center the view around the data midpoint using current div settings + const xMid = (xMin + xMax) / 2; + const yMid = (yMin + yMax) / 2; + const xRange = xDiv * NUM_H_DIVS; + const yRange = yDiv * NUM_V_DIVS; + + const opts: uPlot.Options = { + width: plotW, + height: plotH, + series, + axes: [ + { show: false }, + { show: false }, + ], + scales: { + x: xType === 'frequency' + ? { distr: 3, min: xMin, max: xMax } + : { min: xMid - xRange / 2, max: xMid + xRange / 2 }, + y: { min: yMid - yRange / 2, max: yMid + yRange / 2 }, + }, + cursor: { + drag: { x: true, y: true, setScale: true }, + }, + legend: { show: false }, + }; + + const u = new uPlot(opts, data, el); + uplotRef.current = u; + + return () => { + u.destroy(); + uplotRef.current = null; + }; + // Intentionally exclude activeTrace, xDiv, yDiv — handled by dedicated effects below + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [waveform, width, isAC, xMin, xMax, yMin, yMax, xType]); + + // Update trace visibility via setSeries (no chart rebuild) + useEffect(() => { + const u = uplotRef.current; + if (!u) return; + for (let i = 1; i < u.series.length; i++) { + u.setSeries(i, { show: i - 1 === activeTrace }); + } + }, [activeTrace]); + + // Update X/Y scale via setScale (no chart rebuild) + useEffect(() => { + const u = uplotRef.current; + if (!u || xDiv === null || yDiv === null) return; + + const xMid = (xMin + xMax) / 2; + const yMid = (yMin + yMax) / 2; + + if (xType !== 'frequency') { + const xRange = xDiv * NUM_H_DIVS; + u.setScale('x', { min: xMid - xRange / 2, max: xMid + xRange / 2 }); + } + + const yRange = yDiv * NUM_V_DIVS; + u.setScale('y', { min: yMid - yRange / 2, max: yMid + yRange / 2 }); + }, [xDiv, yDiv, xMin, xMax, yMin, yMax, xType]); + + // Knob handlers + const cycleTrace = useCallback(() => { + setActiveTrace(prev => (prev + 1) % traceNames.length); + }, [traceNames.length]); + + const stepXDiv = useCallback(() => { + setXDiv(prev => { + if (prev === null) return prev; + const seq = xSeqForType(xType); + const next = step125(prev, seq, 1); + if (next === prev) return seq[0]; + return next; + }); + }, [xType]); + + const stepYDiv = useCallback(() => { + setYDiv(prev => { + if (prev === null) return prev; + const seq = ySeqForType(yType); + const next = step125(prev, seq, 1); + if (next === prev) return seq[0]; + return next; + }); + }, [yType]); + + const cycleSkin = useCallback(() => { + setSkinId(prev => { + const idx = SKIN_ORDER.indexOf(prev); + const next = SKIN_ORDER[(idx + 1) % SKIN_ORDER.length]; + safeSetItem('spicebook-scope-skin', next); + return next; + }); + }, []); + + // Knob rotation from trace index + const sourceRotation = traceNames.length > 1 + ? (activeTrace / traceNames.length) * 270 - 135 + : 0; + + const currentTraceName = traceNames[activeTrace] || '\u2014'; + const xDivLabel = xDiv !== null ? formatEng(xDiv, divUnit(xType) + '/div') : '\u2014'; + const yDivLabel = yDiv !== null ? formatEng(yDiv, divUnit(yType) + '/div') : '\u2014'; + + return ( +
+
+ {/* Brand bar */} +
+ {skin.brand} +
+ + {skin.sub && {skin.sub}} +
+
+ + {/* CRT bay */} +
+
+ {/* uPlot mounts here via ref */} +
+
+
+
+ + {/* Digital readout */} +
+ {currentTraceName} + | + {xDivLabel} + | + {yDivLabel} + | + {analysisLabel} +
+ + {/* Control panel */} +
+
+ {/* Source knob */} +
+ {skin.sections[0]} +
+ + {/* Volts/Div knob */} +
+ {skin.sections[1]} +
+ + {/* Time/Div knob */} +
+ {skin.sections[2]} +
+ + {/* Mode badge */} +
+ {skin.sections[3]} + + +
+
+ + {/* Ventilation holes (545A only) */} + +
+ ); +} diff --git a/frontend/src/components/notebook/output/WaveformViewer.tsx b/frontend/src/components/notebook/output/WaveformViewer.tsx index cc08699..8370cee 100644 --- a/frontend/src/components/notebook/output/WaveformViewer.tsx +++ b/frontend/src/components/notebook/output/WaveformViewer.tsx @@ -1,6 +1,7 @@ import { useRef, useEffect, useState, useCallback } from 'react'; import type { WaveformData } from '../../../lib/types'; import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils'; +import { ScopeWaveformViewer } from './ScopeWaveformViewer'; // uPlot is a vanilla JS lib; import it and its CSS import uPlot from 'uplot'; @@ -207,9 +208,38 @@ function ACPlot({ ); } +/* Scope mode toggle icon — inline SVG of a simple oscilloscope shape */ +function ScopeToggleIcon({ active }: { active: boolean }) { + return ( + + ); +} + export function WaveformViewer({ waveform, className }: WaveformViewerProps) { const containerRef = useRef(null); const [width, setWidth] = useState(0); + const [scopeMode, setScopeMode] = useState(() => { + try { return localStorage.getItem('spicebook-scope-mode') === 'true'; } + catch { return false; } + }); const updateWidth = useCallback(() => { if (containerRef.current) { @@ -226,16 +256,43 @@ export function WaveformViewer({ waveform, className }: WaveformViewerProps) { return () => observer.disconnect(); }, [updateWidth]); + const toggleScope = useCallback(() => { + setScopeMode(prev => { + const next = !prev; + try { localStorage.setItem('spicebook-scope-mode', String(next)); } catch { /* noop */ } + return next; + }); + }, []); + const traceCount = Object.keys(waveform.y_data).length; const isEmpty = traceCount === 0 && !waveform.y_magnitude_db && !waveform.y_phase_deg; return ( -
+
+ {/* Scope toggle button */} + {!isEmpty && ( + + )} + {isEmpty ? (
No waveform data to display.
+ ) : scopeMode ? ( + ) : waveform.is_complex ? ( ) : ( diff --git a/frontend/src/lib/waveform-utils.ts b/frontend/src/lib/waveform-utils.ts index 25a24d6..d72659c 100644 --- a/frontend/src/lib/waveform-utils.ts +++ b/frontend/src/lib/waveform-utils.ts @@ -104,3 +104,83 @@ export const TRACE_COLORS = [ export function traceColor(index: number): string { return TRACE_COLORS[index % TRACE_COLORS.length]; } + +/** Teal phosphor palette for scope mode. + * Real CRTs use a single phosphor color — traces are distinguished + * by brightness/alpha rather than hue. */ +export const SCOPE_TRACE_COLORS = [ + '#2dd4bf', // bright teal (primary trace) + '#5eead4', // lighter teal (second trace) + '#14b8a6', // medium teal + '#0d9488', // darker teal + '#99f6e4', // very light teal + '#0f766e', // deep teal + '#2dd4bf80', // bright teal 50% (ghost trace) + '#5eead480', // light teal 50% +]; + +/** Standard oscilloscope 1-2-5 step sequence. + * Returns sorted array of values spanning the full SI range. */ +export function generate125Sequence(minExp: number, maxExp: number): number[] { + const steps: number[] = []; + for (let exp = minExp; exp <= maxExp; exp++) { + const base = Math.pow(10, exp); + steps.push(1 * base, 2 * base, 5 * base); + } + return steps; +} + +/** Find the nearest 1-2-5 step for a given value. + * Guards against zero, negative, and NaN inputs. */ +export function nearest125(value: number, sequence: number[]): number { + if (!isFinite(value) || value <= 0) return sequence[Math.floor(sequence.length / 2)]; + let best = sequence[0]; + let bestDist = Math.abs(Math.log10(value) - Math.log10(best)); + for (const s of sequence) { + const dist = Math.abs(Math.log10(value) - Math.log10(s)); + if (dist < bestDist) { + bestDist = dist; + best = s; + } + } + return best; +} + +/** Step up or down in a 1-2-5 sequence. + * Uses tolerance-based search to handle floating-point inexactness. */ +export function step125(current: number, sequence: number[], direction: 1 | -1): number { + const idx = sequence.findIndex(s => + s === current || Math.abs(s - current) < Math.abs(current) * 1e-9, + ); + if (idx === -1) return nearest125(current, sequence); + const next = idx + direction; + if (next < 0) return sequence[0]; + if (next >= sequence.length) return sequence[sequence.length - 1]; + return sequence[next]; +} + +/** Compute min and max of a numeric array in a single pass. + * Safe for arbitrarily large arrays (no stack overflow from spread). */ +export function arrayMinMax(arr: ArrayLike): [number, number] { + if (arr.length === 0) return [0, 0]; + let min = arr[0]; + let max = arr[0]; + for (let i = 1; i < arr.length; i++) { + if (arr[i] < min) min = arr[i]; + if (arr[i] > max) max = arr[i]; + } + return [min, max]; +} + +/** Compute min and max across multiple trace arrays without intermediate allocation. */ +export function tracesMinMax(traces: Record): [number, number] { + let min = Infinity; + let max = -Infinity; + for (const arr of Object.values(traces)) { + for (let i = 0; i < arr.length; i++) { + if (arr[i] < min) min = arr[i]; + if (arr[i] > max) max = arr[i]; + } + } + return isFinite(min) ? [min, max] : [-1, 1]; +} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 4c183ba..dccb5de 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -39,6 +39,14 @@ --color-wf-tick: #334155; } +/* Scope mode waveform overrides */ +:root { + --color-wf-scope-axis: #2dd4bf; + --color-wf-scope-grid: rgba(45, 212, 191, 0.07); + --color-wf-scope-tick: rgba(45, 212, 191, 0.15); + --color-wf-scope-bg: #0a0a0a; +} + /* Base resets */ html { background-color: var(--color-sb-bg); diff --git a/frontend/src/styles/scope-skin.css b/frontend/src/styles/scope-skin.css new file mode 100644 index 0000000..87eefbf --- /dev/null +++ b/frontend/src/styles/scope-skin.css @@ -0,0 +1,484 @@ +/* Tektronix oscilloscope skin for SpiceBook waveform viewer + * Ported from mcltspice docs OscilloscopeDisplay.astro + oscilloscope.css + * Adapted: landscape CRT (5:3), 10×8 graticule, no audio/easter-egg styles, + * SpiceBook font vars, .scope-skin-active scoping, uPlot overrides */ + +/* ── Outer chassis ───────────────────────────────────────── */ +.scope-skin-active .scope-frame { + --scope-teal: #2dd4bf; + --scope-teal-dim: rgba(45, 212, 191, 0.12); + --scope-teal-glow: rgba(45, 212, 191, 0.18); + --scope-panel: #b5a48a; + --scope-panel-light: #c7b89e; + --scope-panel-dark: #9e8f78; + --scope-crt-bg: #0a0a0a; + --scope-label: #3b3428; + --scope-knob: #2a2a2d; + --scope-knob-ring: #1e1e20; + --scope-section-line: rgba(59, 52, 40, 0.25); + + position: relative; + background: + /* subtle metallic grain */ + url("data:image/svg+xml,%3Csvg width='4' height='4' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='1' height='1' x='0' y='0' fill='rgba(0,0,0,0.03)'/%3E%3Crect width='1' height='1' x='2' y='2' fill='rgba(255,255,255,0.02)'/%3E%3C/svg%3E"), + linear-gradient(175deg, var(--scope-panel-light), var(--scope-panel), var(--scope-panel-dark)); + border-radius: 6px; + padding: 0; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.35), + 0 1px 3px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.15), + inset 0 -1px 0 rgba(0, 0, 0, 0.1); + width: 100%; + overflow: hidden; +} + +/* ── Top brand bar ───────────────────────────────────────── */ +.scope-skin-active .scope-brand { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 8px 14px 6px; + border-bottom: 1px solid var(--scope-section-line); +} + +.scope-skin-active .scope-brand-name { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--scope-label); + opacity: 0.9; +} + +.scope-skin-active .scope-brand-model { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.55rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--scope-label); + opacity: 0.78; +} + +/* ── Skin picker (model name → clickable) ──────────────── */ +.scope-skin-active .scope-brand-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1px; +} + +.scope-skin-active button.scope-brand-model { + appearance: none; + background: none; + border: none; + padding: 0; + cursor: pointer; + line-height: inherit; + transition: opacity 0.15s; +} + +.scope-skin-active button.scope-brand-model:hover { + opacity: 0.85; +} + +.scope-skin-active button.scope-brand-model:focus-visible { + outline: 2px solid var(--scope-teal); + outline-offset: 2px; + border-radius: 2px; +} + +.scope-skin-active .scope-brand-sub { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.4rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--scope-label); + opacity: 0.4; +} + +/* ── CRT bay (recessed dark area) ────────────────────────── */ +.scope-skin-active .scope-crt-bay { + margin: 10px 12px 0; + background: #1a1816; + border-radius: 4px; + padding: 6px; + box-shadow: + inset 0 2px 6px rgba(0, 0, 0, 0.5), + inset 0 0 0 1px rgba(0, 0, 0, 0.2); +} + +/* ── CRT screen ──────────────────────────────────────────── */ +.scope-skin-active .scope-screen { + position: relative; + background: var(--scope-crt-bg); + border-radius: 3px; + overflow: hidden; + aspect-ratio: 5 / 3; + box-shadow: + 0 0 15px var(--scope-teal-glow), + inset 0 1px 4px rgba(0, 0, 0, 0.4); +} + +/* uPlot chart fills the CRT screen */ +.scope-skin-active .scope-screen .uplot { + position: absolute; + inset: 0; +} + +/* ── Graticule overlay (10 horizontal × 8 vertical) ──────── */ +.scope-skin-active .scope-graticule { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 2; + /* 10 vertical divisions (10% each), 8 horizontal divisions (12.5% each) */ + background-image: + repeating-linear-gradient( + 90deg, + transparent, + transparent calc(10% - 0.5px), + rgba(45, 212, 191, 0.07) calc(10% - 0.5px), + rgba(45, 212, 191, 0.07) calc(10% + 0.5px), + transparent calc(10% + 0.5px) + ), + repeating-linear-gradient( + 0deg, + transparent, + transparent calc(12.5% - 0.5px), + rgba(45, 212, 191, 0.07) calc(12.5% - 0.5px), + rgba(45, 212, 191, 0.07) calc(12.5% + 0.5px), + transparent calc(12.5% + 0.5px) + ); +} + +/* Center crosshair tick marks */ +.scope-skin-active .scope-graticule::before, +.scope-skin-active .scope-graticule::after { + content: ''; + position: absolute; +} + +.scope-skin-active .scope-graticule::before { + /* horizontal center tick */ + top: 50%; + left: calc(50% - 8px); + width: 16px; + height: 1px; + background: rgba(45, 212, 191, 0.18); +} + +.scope-skin-active .scope-graticule::after { + /* vertical center tick */ + left: 50%; + top: calc(50% - 8px); + width: 1px; + height: 16px; + background: rgba(45, 212, 191, 0.18); +} + +/* ── Scanline overlay ────────────────────────────────────── */ +.scope-skin-active .scope-scanlines { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 3; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.04) 2px, + rgba(0, 0, 0, 0.04) 4px + ); + mix-blend-mode: multiply; +} + +/* ── Digital readout bar ─────────────────────────────────── */ +.scope-skin-active .scope-readout { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 5px 12px; + background: #0a0a0a; + margin: 0 12px; + border-radius: 0 0 3px 3px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.65rem; + letter-spacing: 0.04em; + color: var(--scope-teal); + text-shadow: 0 0 4px var(--scope-teal-glow); +} + +.scope-skin-active .scope-readout-item { + white-space: nowrap; +} + +.scope-skin-active .scope-readout-divider { + color: rgba(45, 212, 191, 0.25); +} + +/* ── Control panel area ──────────────────────────────────── */ +.scope-skin-active .scope-panel { + padding: 8px 12px 6px; + border-top: 1px solid var(--scope-section-line); + margin-top: 10px; +} + +.scope-skin-active .scope-controls-row { + display: flex; + align-items: flex-start; + gap: 2px; +} + +/* ── Control section (labeled group) ─────────────────────── */ +.scope-skin-active .scope-section { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 4px 2px; + position: relative; +} + +/* Section divider lines */ +.scope-skin-active .scope-section + .scope-section::before { + content: ''; + position: absolute; + left: -1px; + top: 0; + bottom: 0; + width: 1px; + background: var(--scope-section-line); +} + +.scope-skin-active .scope-section-label { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.5rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--scope-label); + opacity: 0.88; + line-height: 1; +} + +/* ── Rotary knob ─────────────────────────────────────────── */ +/* Reset native button styling so knobs render as pure circles */ +.scope-skin-active button.scope-knob { + appearance: none; + padding: 0; + font: inherit; + color: inherit; + line-height: 1; +} + +.scope-skin-active .scope-knob { + width: 28px; + height: 28px; + border-radius: 50%; + background: radial-gradient(circle at 40% 35%, #3a3a3e, var(--scope-knob)); + border: 2px solid var(--scope-knob-ring); + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.35), + inset 0 1px 1px rgba(255, 255, 255, 0.06); + position: relative; + cursor: pointer; + transition: transform 0.15s; + transform: rotate(var(--knob-rotation, 0deg)); +} + +/* Knob indicator line */ +.scope-skin-active .scope-knob::after { + content: ''; + position: absolute; + top: 3px; + left: 50%; + width: 1.5px; + height: 7px; + background: #d4d0c8; + border-radius: 1px; + transform: translateX(-50%); +} + +.scope-skin-active .scope-knob:hover { + transform: rotate(var(--knob-rotation, 0deg)) scale(1.08); +} + +.scope-skin-active .scope-knob:active { + transform: rotate(var(--knob-rotation, 0deg)) scale(0.95); +} + +.scope-skin-active .scope-knob:focus-visible { + outline: 2px solid var(--scope-teal); + outline-offset: 2px; +} + +.scope-skin-active .scope-knob-label { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.45rem; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--scope-label); + opacity: 0.78; + line-height: 1; + max-width: 60px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} + +/* ── Mode badge (TRAN / AC) ──────────────────────────────── */ +.scope-skin-active .scope-mode-badge { + appearance: none; + background: var(--scope-knob); + border: 2px solid var(--scope-knob-ring); + border-radius: 6px; + color: var(--scope-teal); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.55rem; + font-weight: 700; + letter-spacing: 0.06em; + padding: 5px 8px; + cursor: pointer; + transition: color 0.2s, box-shadow 0.2s; + text-transform: uppercase; + line-height: 1; + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.3), + 0 0 6px var(--scope-teal-glow); +} + +.scope-skin-active .scope-mode-badge:hover { + color: #5eead4; +} + +.scope-skin-active .scope-mode-badge:focus-visible { + outline: 2px solid var(--scope-teal); + outline-offset: 2px; +} + +/* ── Power LED ───────────────────────────────────────────── */ +.scope-skin-active .scope-led { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--scope-teal); + border: 1px solid rgba(0, 0, 0, 0.3); + box-shadow: 0 0 6px var(--scope-teal-glow); +} + +/* ── Ventilation holes (545A only) ──────────────────────── */ +.scope-skin-active .scope-vent-holes { + display: none; + justify-content: center; + gap: 8px; + padding: 6px 14px 8px; +} + +.scope-skin-active .scope-vent-hole { + width: 6px; + height: 6px; + border-radius: 50%; + background: radial-gradient(circle, rgba(0, 0, 0, 0.4) 40%, rgba(0, 0, 0, 0.15) 100%); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); +} + +/* ── Tektronix Type 545A skin (1959) ─────────────────────── */ +.scope-skin-active .scope-frame.scope-hw-545a { + --scope-panel: #4a6e64; + --scope-panel-light: #5a7e72; + --scope-panel-dark: #3a5a50; + --scope-label: #e0d8c8; + --scope-knob: #1c1814; + --scope-knob-ring: #141210; + --scope-section-line: rgba(224, 216, 200, 0.15); +} + +/* Hammertone texture */ +.scope-skin-active .scope-frame.scope-hw-545a { + background: + url("data:image/svg+xml,%3Csvg width='8' height='8' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='2' cy='2' r='1.2' fill='rgba(0,0,0,0.06)'/%3E%3Ccircle cx='6' cy='5' r='0.8' fill='rgba(255,255,255,0.04)'/%3E%3Ccircle cx='4' cy='7' r='1' fill='rgba(0,0,0,0.04)'/%3E%3Ccircle cx='7' cy='1' r='0.6' fill='rgba(255,255,255,0.03)'/%3E%3C/svg%3E"), + linear-gradient(175deg, var(--scope-panel-light), var(--scope-panel), var(--scope-panel-dark)); +} + +/* Deeper CRT bay recess */ +.scope-skin-active .scope-frame.scope-hw-545a .scope-crt-bay { + box-shadow: + inset 0 3px 8px rgba(0, 0, 0, 0.6), + inset 0 0 0 1px rgba(0, 0, 0, 0.3); +} + +/* Larger Bakelite-era knobs */ +.scope-skin-active .scope-frame.scope-hw-545a .scope-knob { + width: 32px; + height: 32px; + background: radial-gradient(circle at 40% 35%, #2e2218, var(--scope-knob)); +} + +.scope-skin-active .scope-frame.scope-hw-545a .scope-knob::after { + height: 8px; + background: #c8b890; +} + +/* 545A shows vent holes */ +.scope-skin-active .scope-frame.scope-hw-545a .scope-vent-holes { + display: flex; +} + +/* ── uPlot CRT overrides ─────────────────────────────────── */ +.scope-skin-active .scope-screen .uplot canvas { + filter: drop-shadow(0 0 3px var(--scope-teal-glow)); +} + +.scope-skin-active .scope-screen .u-legend { + display: none; /* legend info is in the digital readout bar */ +} + +/* Hide uPlot axes — the graticule replaces them */ +.scope-skin-active .scope-screen .u-axis { + display: none; +} + +/* ── Reduced motion ──────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .scope-skin-active .scope-scanlines { + display: none; + } +} + +/* ── Responsive ──────────────────────────────────────────── */ +@media (max-width: 50rem) { + .scope-skin-active .scope-knob { + width: 24px; + height: 24px; + } + + .scope-skin-active .scope-knob::after { + height: 5px; + } + + .scope-skin-active .scope-readout { + font-size: 0.55rem; + gap: 8px; + } + + .scope-skin-active .scope-frame.scope-hw-545a .scope-knob { + width: 26px; + height: 26px; + } + + .scope-skin-active .scope-frame.scope-hw-545a .scope-knob::after { + height: 6px; + } + + .scope-skin-active .scope-vent-hole { + width: 5px; + height: 5px; + } +}