Add Tektronix oscilloscope skin for waveform viewer

Port 465/545A visual chrome from mcltspice docs into SpiceBook as a
toggleable waveform viewer skin. uPlot chart renders inside a CRT
screen with teal phosphor traces, graticule overlay, and scanlines.

Functional knobs control trace visibility (setSeries), X/Y zoom
(setScale) in standard 1-2-5 div steps. Digital readout bar shows
current trace, div values, and analysis type. Two switchable hardware
skins — 465 tan and 545A hammertone — persisted in localStorage.

New files: ScopeWaveformViewer.tsx, scope-skin.css
Modified: WaveformViewer.tsx (toggle), waveform-utils.ts (scope
palette, 1-2-5 sequence, stack-safe min/max), globals.css (scope vars)
This commit is contained in:
Ryan Malloy 2026-02-15 18:05:59 -07:00
parent b497d57890
commit 22d3e903db
5 changed files with 1013 additions and 1 deletions

View File

@ -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<HTMLDivElement>(null);
const uplotRef = useRef<uPlot | null>(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<number | null>(null);
const [yDiv, setYDiv] = useState<number | null>(null);
const [skinId, setSkinId] = useState<SkinId>(() => {
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 (
<div className="scope-skin-active">
<div className={`scope-frame${skinId === '545a' ? ' scope-hw-545a' : ''}`}>
{/* Brand bar */}
<div className="scope-brand">
<span className="scope-brand-name">{skin.brand}</span>
<div className="scope-brand-right">
<button
className="scope-brand-model"
type="button"
onClick={cycleSkin}
aria-label="Switch oscilloscope model"
>
{skin.model}
</button>
{skin.sub && <span className="scope-brand-sub">{skin.sub}</span>}
</div>
</div>
{/* CRT bay */}
<div className="scope-crt-bay">
<div className="scope-screen" ref={screenRef}>
{/* uPlot mounts here via ref */}
<div className="scope-graticule" />
<div className="scope-scanlines" />
</div>
</div>
{/* Digital readout */}
<div className="scope-readout">
<span className="scope-readout-item">{currentTraceName}</span>
<span className="scope-readout-divider">|</span>
<span className="scope-readout-item">{xDivLabel}</span>
<span className="scope-readout-divider">|</span>
<span className="scope-readout-item">{yDivLabel}</span>
<span className="scope-readout-divider">|</span>
<span className="scope-readout-item">{analysisLabel}</span>
</div>
{/* Control panel */}
<div className="scope-panel">
<div className="scope-controls-row">
{/* Source knob */}
<div className="scope-section">
<span className="scope-section-label">{skin.sections[0]}</span>
<button
className="scope-knob"
type="button"
onClick={cycleTrace}
style={{ '--knob-rotation': `${sourceRotation}deg` } as React.CSSProperties}
aria-label={`Trace: ${currentTraceName} (${activeTrace + 1} of ${traceNames.length})`}
/>
<span className="scope-knob-label">{currentTraceName}</span>
</div>
{/* Volts/Div knob */}
<div className="scope-section">
<span className="scope-section-label">{skin.sections[1]}</span>
<button
className="scope-knob"
type="button"
onClick={stepYDiv}
aria-label={`Vertical scale: ${yDivLabel}`}
/>
<span className="scope-knob-label">{yDivLabel}</span>
</div>
{/* Time/Div knob */}
<div className="scope-section">
<span className="scope-section-label">{skin.sections[2]}</span>
<button
className="scope-knob"
type="button"
onClick={stepXDiv}
aria-label={`Horizontal scale: ${xDivLabel}`}
/>
<span className="scope-knob-label">{xDivLabel}</span>
</div>
{/* Mode badge */}
<div className="scope-section">
<span className="scope-section-label">{skin.sections[3]}</span>
<button
className="scope-mode-badge"
type="button"
onClick={onExitScope}
aria-label="Exit scope mode"
>
{analysisLabel}
</button>
<div className="scope-led" aria-hidden="true" />
</div>
</div>
</div>
{/* Ventilation holes (545A only) */}
<div className="scope-vent-holes" aria-hidden="true">
{Array.from({ length: 12 }, (_, i) => (
<div key={i} className="scope-vent-hole" />
))}
</div>
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { useRef, useEffect, useState, useCallback } from 'react'; import { useRef, useEffect, useState, useCallback } from 'react';
import type { WaveformData } from '../../../lib/types'; import type { WaveformData } from '../../../lib/types';
import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils'; import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils';
import { ScopeWaveformViewer } from './ScopeWaveformViewer';
// uPlot is a vanilla JS lib; import it and its CSS // uPlot is a vanilla JS lib; import it and its CSS
import uPlot from 'uplot'; 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 (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke={active ? '#2dd4bf' : 'currentColor'}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{/* Monitor frame */}
<rect x="2" y="3" width="20" height="14" rx="2" />
{/* Sine wave trace */}
<path d="M5 12 C7 8, 9 8, 11 12 S15 16, 17 12 L19 12" />
{/* Stand */}
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
);
}
export function WaveformViewer({ waveform, className }: WaveformViewerProps) { export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
const [scopeMode, setScopeMode] = useState(() => {
try { return localStorage.getItem('spicebook-scope-mode') === 'true'; }
catch { return false; }
});
const updateWidth = useCallback(() => { const updateWidth = useCallback(() => {
if (containerRef.current) { if (containerRef.current) {
@ -226,16 +256,43 @@ export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
return () => observer.disconnect(); return () => observer.disconnect();
}, [updateWidth]); }, [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 traceCount = Object.keys(waveform.y_data).length;
const isEmpty = const isEmpty =
traceCount === 0 && !waveform.y_magnitude_db && !waveform.y_phase_deg; traceCount === 0 && !waveform.y_magnitude_db && !waveform.y_phase_deg;
return ( return (
<div ref={containerRef} className={className}> <div ref={containerRef} className={`relative ${className || ''}`}>
{/* Scope toggle button */}
{!isEmpty && (
<button
type="button"
onClick={toggleScope}
className="absolute top-1 right-1 z-10 p-1 rounded hover:bg-slate-700/50 transition-colors"
aria-label={scopeMode ? 'Exit oscilloscope view' : 'Switch to oscilloscope view'}
title={scopeMode ? 'Standard view' : 'Oscilloscope view'}
>
<ScopeToggleIcon active={scopeMode} />
</button>
)}
{isEmpty ? ( {isEmpty ? (
<div className="text-slate-500 text-sm py-4 text-center"> <div className="text-slate-500 text-sm py-4 text-center">
No waveform data to display. No waveform data to display.
</div> </div>
) : scopeMode ? (
<ScopeWaveformViewer
waveform={waveform}
width={width}
onExitScope={toggleScope}
/>
) : waveform.is_complex ? ( ) : waveform.is_complex ? (
<ACPlot waveform={waveform} width={width} /> <ACPlot waveform={waveform} width={width} />
) : ( ) : (

View File

@ -104,3 +104,83 @@ export const TRACE_COLORS = [
export function traceColor(index: number): string { export function traceColor(index: number): string {
return TRACE_COLORS[index % TRACE_COLORS.length]; 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, 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<string, number[]>): [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];
}

View File

@ -39,6 +39,14 @@
--color-wf-tick: #334155; --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 */ /* Base resets */
html { html {
background-color: var(--color-sb-bg); background-color: var(--color-sb-bg);

View File

@ -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;
}
}