Compare commits

..

3 Commits

Author SHA1 Message Date
ac82068b98 Merge feature/scope-skin: Tektronix oscilloscope waveform viewer 2026-02-15 18:11:21 -07:00
22d3e903db 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)
2026-02-15 18:05:59 -07:00
b497d57890 Auto-generate schematics on mount, wire routing for grid layout
SPICE cells now auto-trigger schematic generation when they mount
without an existing diagram, so the schematic leads the UI. Template
and empty cells are skipped.

Grid fallback layout replaced with wire-connected rendering: BFS-based
node tier classification places components vertically between supply
and ground rails, then routes wires by node type (power/ground bus
rails, L-shaped signal wires, star topology for 3+ connections).
2026-02-15 14:40:25 -07:00
7 changed files with 1408 additions and 31 deletions

View File

@ -56,6 +56,16 @@ class ActiveLayout:
unplaced: list[SpiceComponent]
@dataclass
class PlacedComponent:
"""A component placed in the grid with recorded terminal positions."""
comp: SpiceComponent
element: object # SchemDraw placed element
column: int
terminal_positions: dict[int, tuple[float, float]] = field(default_factory=dict)
# ── Ground / Supply Detection ───────────────────────────────────
GROUND_NAMES = {"0", "gnd"}
@ -678,50 +688,396 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
return d.get_imagedata("svg").decode()
# ── Grid Layout Renderer ──────────────────────────────────────
# ── Grid Layout: Constants & Helpers ─────────────────────────
# Vertical tier positions (schemdraw y-axis: up is positive)
_GRID_SUPPLY_Y = 0.0
_GRID_UPPER_Y = -3.0
_GRID_MID_Y = -5.5
_GRID_LOWER_Y = -8.0
_GRID_GROUND_Y = -11.0
_GRID_X_SPACING = 6.0
_GRID_MIN_COMP_LEN = 2.0 # minimum component length to avoid zero-height placement
def _classify_node_tiers(
node_map: dict[str, list[tuple[SpiceComponent, int]]],
) -> dict[str, float]:
"""Assign vertical Y positions to nodes via BFS distance from ground/supply.
Ground and supply nodes are pinned to fixed tiers. Signal nodes get
Y-positions blended between supply (top) and ground (bottom) based on
their BFS hop distance from each rail, then snapped to the nearest
discrete tier for clean vertical alignment.
"""
from collections import deque
tiers: dict[str, float] = {}
snap_levels = [_GRID_SUPPLY_Y, _GRID_UPPER_Y, _GRID_MID_Y, _GRID_LOWER_Y, _GRID_GROUND_Y]
# Pin ground and supply nodes
for node in node_map:
if _is_ground(node):
tiers[node] = _GRID_GROUND_Y
elif _is_supply(node):
tiers[node] = _GRID_SUPPLY_Y
# BFS from ground nodes
dist_from_gnd: dict[str, int] = {}
queue: deque[tuple[str, int]] = deque()
for node in node_map:
if _is_ground(node):
dist_from_gnd[node] = 0
queue.append((node, 0))
while queue:
current, dist = queue.popleft()
for comp, idx in node_map.get(current, []):
for i, other_node in enumerate(comp.nodes):
if i != idx and other_node not in dist_from_gnd:
dist_from_gnd[other_node] = dist + 1
queue.append((other_node, dist + 1))
# BFS from supply nodes
dist_from_sup: dict[str, int] = {}
queue = deque()
for node in node_map:
if _is_supply(node):
dist_from_sup[node] = 0
queue.append((node, 0))
while queue:
current, dist = queue.popleft()
for comp, idx in node_map.get(current, []):
for i, other_node in enumerate(comp.nodes):
if i != idx and other_node not in dist_from_sup:
dist_from_sup[other_node] = dist + 1
queue.append((other_node, dist + 1))
# Assign signal nodes: blend by relative BFS distance, snap to tier
for node in node_map:
if node in tiers:
continue
dg = dist_from_gnd.get(node, 999)
ds = dist_from_sup.get(node, 999)
if ds == 999 and dg == 999:
continuous_y = _GRID_MID_Y
elif ds == 999:
continuous_y = _GRID_LOWER_Y
elif dg == 999:
continuous_y = _GRID_UPPER_Y
else:
ratio = ds / (ds + dg)
continuous_y = _GRID_SUPPLY_Y + ratio * (_GRID_GROUND_Y - _GRID_SUPPLY_Y)
tiers[node] = min(snap_levels, key=lambda t: abs(t - continuous_y))
return tiers
def _assign_columns(
components: list[SpiceComponent],
node_map: dict[str, list[tuple[SpiceComponent, int]]],
) -> list[SpiceComponent]:
"""Order placeable components left-to-right via BFS through signal nodes.
Supply sources (DC V-source between supply rail and ground) are filtered
out they become rail symbols instead of drawn components.
"""
from collections import deque
placeable = [c for c in components if not _is_supply_source(c)]
if not placeable:
return []
# Build component adjacency through shared signal nodes
comp_names = {c.name for c in placeable}
comp_lookup = {c.name: c for c in placeable}
adj: dict[str, set[str]] = {c.name: set() for c in placeable}
for node, connections in node_map.items():
if _is_ground(node) or _is_supply(node):
continue
node_comps = [c.name for c, _ in connections if c.name in comp_names]
for i, a in enumerate(node_comps):
for b in node_comps[i + 1:]:
adj[a].add(b)
adj[b].add(a)
# BFS from first voltage source (or first component if none)
start = next((c for c in placeable if c.prefix == "V"), None)
if start is None:
start = placeable[0]
ordered: list[str] = []
visited: set[str] = set()
queue: deque[str] = deque([start.name])
visited.add(start.name)
while queue:
name = queue.popleft()
ordered.append(name)
for neighbor in sorted(adj.get(name, [])):
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
# Append any disconnected components not reached by BFS
for c in placeable:
if c.name not in visited:
ordered.append(c.name)
return [comp_lookup[name] for name in ordered]
def _get_terminal_positions(
placed_elem: object,
comp: SpiceComponent,
) -> dict[int, tuple[float, float]]:
"""Extract (x, y) terminal positions from a placed SchemDraw element."""
positions: dict[int, tuple[float, float]] = {}
if comp.prefix in ("Q", "M") and len(comp.nodes) >= 3:
anchors = (
["collector", "base", "emitter"]
if comp.prefix == "Q"
else ["drain", "gate", "source"]
)
for i, anchor_name in enumerate(anchors):
if hasattr(placed_elem, anchor_name):
pos = getattr(placed_elem, anchor_name)
positions[i] = (float(pos[0]), float(pos[1]))
else:
if hasattr(placed_elem, "start"):
positions[0] = (float(placed_elem.start[0]), float(placed_elem.start[1]))
if hasattr(placed_elem, "end"):
idx = min(1, len(comp.nodes) - 1)
positions[idx] = (float(placed_elem.end[0]), float(placed_elem.end[1]))
return positions
def _find_supply_value(
components: list[SpiceComponent],
supply_node: str,
) -> tuple[str, str]:
"""Find the DC voltage value for a supply rail. Returns (source_name, formatted_value)."""
for comp in components:
if comp.prefix == "V" and len(comp.nodes) == 2:
n0, n1 = comp.nodes
if (n0.lower() == supply_node.lower() and _is_ground(n1)) or \
(n1.lower() == supply_node.lower() and _is_ground(n0)):
return (comp.name, _format_value(comp.value))
return ("", "")
def _draw_ground_rail(d, positions: list[tuple[float, float]]) -> None:
"""Draw horizontal ground bus with vertical stubs and Ground symbol."""
import schemdraw.elements as elm
if not positions:
return
sorted_pos = sorted(positions, key=lambda p: p[0])
# Horizontal bus line across all ground-connected terminals
if len(sorted_pos) > 1:
d.add(elm.Line().at(sorted_pos[0]).to(sorted_pos[-1]))
# Vertical stubs from each terminal down to the bus
for px, py in sorted_pos:
if abs(py - _GRID_GROUND_Y) > 0.1:
d.add(elm.Line().at((px, py)).to((px, _GRID_GROUND_Y)))
# Ground symbol at the bus midpoint
mid_x = sum(p[0] for p in sorted_pos) / len(sorted_pos)
d.add(elm.Ground().at((mid_x, _GRID_GROUND_Y)))
def _draw_supply_rail(
d,
positions: list[tuple[float, float]],
label: str,
value: str,
) -> None:
"""Draw horizontal supply bus with vertical stubs and Vdd symbol."""
import schemdraw.elements as elm
if not positions:
return
sorted_pos = sorted(positions, key=lambda p: p[0])
if len(sorted_pos) > 1:
d.add(elm.Line().at(sorted_pos[0]).to(sorted_pos[-1]))
for px, py in sorted_pos:
if abs(py - _GRID_SUPPLY_Y) > 0.1:
d.add(elm.Line().at((px, py)).to((px, _GRID_SUPPLY_Y)))
mid_x = sum(p[0] for p in sorted_pos) / len(sorted_pos)
rail_label = label.upper()
if value:
rail_label += f" {value}"
d.add(elm.Vdd().at((mid_x, _GRID_SUPPLY_Y)).label(rail_label))
def _draw_signal_wire(
d,
pos_a: tuple[float, float],
pos_b: tuple[float, float],
) -> None:
"""Draw an L-shaped or straight wire between two terminal positions."""
import schemdraw.elements as elm
x1, y1 = pos_a
x2, y2 = pos_b
# Skip near-zero-length wires
if abs(x1 - x2) < 0.1 and abs(y1 - y2) < 0.1:
return
if abs(x1 - x2) < 0.1 or abs(y1 - y2) < 0.1:
# Straight wire (aligned on one axis)
d.add(elm.Line().at((x1, y1)).to((x2, y2)))
else:
# L-shaped: horizontal first, then vertical
d.add(elm.Line().at((x1, y1)).to((x2, y1)))
d.add(elm.Line().at((x2, y1)).to((x2, y2)))
def _draw_star_wires(
d,
positions: list[tuple[float, float]],
) -> None:
"""Draw star-topology wiring with junction dot for 3+ connections."""
import schemdraw.elements as elm
if len(positions) < 2:
return
# Junction at the median position (reduces total wire length)
xs = sorted(p[0] for p in positions)
ys = sorted(p[1] for p in positions)
jx = xs[len(xs) // 2]
jy = ys[len(ys) // 2]
for px, py in positions:
if abs(px - jx) < 0.1 and abs(py - jy) < 0.1:
continue
_draw_signal_wire(d, (px, py), (jx, jy))
d.add(elm.Dot().at((jx, jy)))
# ── Grid Layout Renderer ────────────────────────────────────
def _render_grid(parsed: ParsedNetlist) -> str:
"""Render components in a labeled grid layout.
"""Render components in a wire-connected grid layout.
Used for complex circuits where topological layout isn't feasible.
Components are arranged in columns by type with terminal node labels.
Places components vertically between BFS-classified node tiers, then
routes wires between terminals sharing the same net: power/ground get
horizontal bus rails with symbols, 2-connection signals get L-shaped
wires, and 3+ connections get star wiring with junction dots.
"""
import schemdraw
import schemdraw.elements as elm
node_map = _build_node_map(parsed.components)
tiers = _classify_node_tiers(node_map)
ordered = _assign_columns(parsed.components, node_map)
if not ordered:
d = schemdraw.Drawing(fontsize=11)
d.add(elm.Label().label("(empty circuit)"))
return d.get_imagedata("svg").decode()
d = schemdraw.Drawing(fontsize=11)
# Group by role for logical ordering
sources = [c for c in parsed.components if c.prefix in ("V", "I")]
passives = [c for c in parsed.components if c.prefix in ("R", "C", "L")]
active = [c for c in parsed.components if c.prefix in ("D", "Q", "M")]
other = [
c for c in parsed.components
if c.prefix not in ("V", "I", "R", "C", "L", "D", "Q", "M")
]
ordered = sources + passives + active + other
cols = min(4, max(2, len(ordered)))
x_spacing = 8
y_spacing = 5
for i, comp in enumerate(ordered):
row = i // cols
col = i % cols
x = col * x_spacing
y = -row * y_spacing
# ── Phase 1: Place components vertically between node tiers ──
placed_list: list[PlacedComponent] = []
for col, comp in enumerate(ordered):
x = col * _GRID_X_SPACING
elem = _get_element(comp, parsed.models)
# 3+ terminal devices (BJT, MOSFET) need special handling
if comp.prefix in ("Q", "M") and len(comp.nodes) >= 3:
placed = d.add(elem.at((x, y)).label(_component_label(comp)))
_label_multiterminal(d, placed, comp)
else:
placed = d.add(
elem.at((x, y)).right().label(_component_label(comp))
# Multi-terminal device: center between top/bottom node tiers
node_ys = [tiers.get(n, _GRID_MID_Y) for n in comp.nodes[:3]]
center_y = (max(node_ys) + min(node_ys)) / 2
placed_elem = d.add(
elem.at((x, center_y)).label(_component_label(comp))
)
elif len(comp.nodes) >= 2:
# 2-terminal: orient vertically between the two node tiers
y0 = tiers.get(comp.nodes[0], _GRID_UPPER_Y)
y1 = tiers.get(comp.nodes[1], _GRID_LOWER_Y)
length = max(abs(y0 - y1), _GRID_MIN_COMP_LEN)
if y0 >= y1:
# node[0] higher → go down so start=node[0], end=node[1]
placed_elem = d.add(
elem.at((x, y0)).down().length(length)
.label(_component_label(comp), loc="right")
)
else:
# node[0] lower → go up so start=node[0], end=node[1]
placed_elem = d.add(
elem.at((x, y0)).up().length(length)
.label(_component_label(comp), loc="right")
)
else:
# Fallback: single-node or unusual component
placed_elem = d.add(
elem.at((x, _GRID_MID_Y)).down().length(_GRID_MIN_COMP_LEN)
.label(_component_label(comp), loc="right")
)
positions = _get_terminal_positions(placed_elem, comp)
placed_list.append(PlacedComponent(
comp=comp, element=placed_elem, column=col,
terminal_positions=positions,
))
# ── Phase 2: Collect terminal positions grouped by node name ──
node_positions: dict[str, list[tuple[float, float]]] = {}
for pc in placed_list:
for i, node in enumerate(pc.comp.nodes):
if i in pc.terminal_positions:
node_positions.setdefault(node, []).append(
pc.terminal_positions[i]
)
# ── Phase 3: Route wires by node type ────────────────────────
for node, positions in node_positions.items():
if len(positions) < 2:
# Single connection: open dot with node label
if positions and not _is_ground(node) and not _is_supply(node):
px, py = positions[0]
d.add(
elm.Dot(open=True).at((px, py))
.label(node, loc="right", fontsize=9)
)
continue
if _is_ground(node):
_draw_ground_rail(d, positions)
elif _is_supply(node):
_, src_val = _find_supply_value(parsed.components, node)
_draw_supply_rail(d, positions, node, src_val)
elif len(positions) == 2:
_draw_signal_wire(d, positions[0], positions[1])
# Label at the midpoint of the wire
mx = (positions[0][0] + positions[1][0]) / 2
my = (positions[0][1] + positions[1][1]) / 2
d.add(elm.Label().at((mx, my)).label(node, loc="right", fontsize=9))
else:
# 3+ connections: star wiring with junction dot
_draw_star_wires(d, positions)
xs = sorted(p[0] for p in positions)
ys = sorted(p[1] for p in positions)
d.add(
elm.Label().at((xs[len(xs) // 2], ys[len(ys) // 2]))
.label(node, loc="right", fontsize=9)
)
_label_two_terminal(d, placed, comp)
return d.get_imagedata("svg").decode()

View File

@ -72,6 +72,15 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
[cell.id, cell.source, updateCellSource],
);
// Auto-generate schematic on first render if none exists
const hasAutoGenerated = useRef(false);
useEffect(() => {
if (schematicSvg || hasAutoGenerated.current) return;
if (!cell.source.trim() || cell.source.trim() === '* SPICE Netlist\n\nR1 in out 1k\nV1 in 0 DC 5\n\n.op\n.end') return;
hasAutoGenerated.current = true;
generateSchematic(cell.id);
}, [cell.id, cell.source, schematicSvg, generateSchematic]);
// Debounced auto-redraw: regenerate schematic 800ms after source changes.
// Track the source at last generation to avoid retriggering from our own SVG updates.
const lastGeneratedSource = useRef<string | null>(null);

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 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 (
<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) {
const containerRef = useRef<HTMLDivElement>(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 (
<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 ? (
<div className="text-slate-500 text-sm py-4 text-center">
No waveform data to display.
</div>
) : scopeMode ? (
<ScopeWaveformViewer
waveform={waveform}
width={width}
onExitScope={toggleScope}
/>
) : waveform.is_complex ? (
<ACPlot waveform={waveform} width={width} />
) : (

View File

@ -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, 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;
}
/* 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);

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