Add Tektronix 545A scope skin with switchable skin picker

Second oscilloscope skin: 1959 Type 545A with blue-green hammertone
panel, cream silk-screened labels, Bakelite knobs, deeper CRT recess,
and ventilation holes. Click the model name to cycle between 465 and
545A skins. Selection persists via localStorage.
This commit is contained in:
Ryan Malloy 2026-02-13 03:44:16 -07:00
parent b7a370c1f4
commit 08e0ee3cba
2 changed files with 221 additions and 7 deletions

View File

@ -13,10 +13,13 @@
---
<div class="scope-frame">
<!-- Top brand bar -->
<!-- Top brand bar — model name doubles as skin picker -->
<div class="scope-brand">
<span class="scope-brand-name">Tektronix</span>
<span class="scope-brand-model">mcltspice</span>
<div class="scope-brand-right">
<button class="scope-brand-model" id="scope-skin-picker" type="button" aria-label="Switch oscilloscope model">mcltspice</button>
<span class="scope-brand-sub" id="scope-brand-sub"></span>
</div>
</div>
<!-- Recessed CRT bay -->
@ -76,11 +79,88 @@
· <a href="https://gist.github.com/rsp2k/ac68b1bb290b8124e162987ed1df8d53" target="_blank" rel="noopener">rsp2k</a>
</div>
</div>
<!-- Ventilation holes (545A skin only — hidden by default) -->
<div class="scope-vent-holes" aria-hidden="true">
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
</div>
</div>
<audio id="scope-audio" src="/spirals_shrt.mp3" loop preload="none"></audio>
<script>
// ── Skin configuration ────────────────────────────────────
const SKINS: Record<string, { brand: string; model: string; sub: string; sections: string[] }> = {
'465': { brand: 'Tektronix', model: 'mcltspice', sub: '', sections: ['Vertical', 'Horizontal', 'Trigger'] },
'545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Vert Ampl', 'Sweep', 'Trigger'] },
};
const SKIN_ORDER = Object.keys(SKINS);
function initSkin() {
const frame = document.querySelector('.scope-frame') as HTMLElement | null;
const picker = document.getElementById('scope-skin-picker') as HTMLButtonElement | null;
const brandName = document.querySelector('.scope-brand-name') as HTMLElement | null;
const brandSub = document.getElementById('scope-brand-sub') as HTMLElement | null;
const sectionLabels = document.querySelectorAll('.scope-section-label');
if (!frame || !picker) return;
// Guard against double-init (DOMContentLoaded + astro:page-load both fire)
if (picker.dataset.skinInit) return;
picker.dataset.skinInit = 'true';
let currentSkin = localStorage.getItem('scope-skin') || '465';
if (!SKINS[currentSkin]) currentSkin = '465';
function applySkin(skinId: string) {
const skin = SKINS[skinId];
if (!skin) return;
// Remove all skin classes, then apply the active one
SKIN_ORDER.forEach(id => frame!.classList.remove(`scope-skin-${id}`));
if (skinId !== '465') {
frame!.classList.add(`scope-skin-${skinId}`);
}
frame!.dataset.skin = skinId;
// Update brand text
if (brandName) brandName.textContent = skin.brand;
picker!.textContent = skin.model;
if (brandSub) {
brandSub.textContent = skin.sub;
brandSub.style.display = skin.sub ? '' : 'none';
}
// Update section labels (Vertical/Vert Ampl, etc.)
sectionLabels.forEach((label, i) => {
if (skin.sections[i]) label.textContent = skin.sections[i];
});
localStorage.setItem('scope-skin', skinId);
currentSkin = skinId;
}
// Apply saved or default skin
applySkin(currentSkin);
// Cycle on click
picker.addEventListener('click', () => {
const idx = SKIN_ORDER.indexOf(currentSkin);
const next = SKIN_ORDER[(idx + 1) % SKIN_ORDER.length];
applySkin(next);
});
}
// ── Oscilloscope XY renderer ──────────────────────────────
// Uses AnalyserNode (modern Web Audio) instead of deprecated ScriptProcessor
function initScope() {
@ -244,13 +324,18 @@
ctx.fill();
}
// Init when DOM ready (works with Astro's page lifecycle)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initScope);
} else {
function initAll() {
initSkin();
initScope();
}
// Init when DOM ready (works with Astro's page lifecycle)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll);
} else {
initAll();
}
// Re-init on Astro page transitions
document.addEventListener('astro:page-load', initScope);
document.addEventListener('astro:page-load', initAll);
</script>

View File

@ -341,3 +341,132 @@
height: 5px;
}
}
/* ── Skin picker (model name → clickable) ──────────────── */
.scope-brand-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
}
button.scope-brand-model {
appearance: none;
background: none;
border: none;
padding: 0;
cursor: pointer;
line-height: inherit;
transition: opacity 0.15s;
}
button.scope-brand-model:hover {
opacity: 0.85;
}
button.scope-brand-model:focus-visible {
outline: 2px solid var(--scope-teal);
outline-offset: 2px;
border-radius: 2px;
}
.scope-brand-sub {
font-family: var(--sl-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;
}
/* ── Tektronix Type 545A skin (1959) ─────────────────────── */
/* Vacuum-tube era: blue-green hammertone, cream silk-screen,
Bakelite knobs, deeper CRT recess, ventilation holes */
.scope-skin-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 — larger dimpled dots, irregular placement */
.scope-skin-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 — tube-era instruments had heavier bezels */
.scope-skin-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-545a .scope-knob {
width: 32px;
height: 32px;
background: radial-gradient(circle at 40% 35%, #2e2218, var(--scope-knob));
}
.scope-skin-545a .scope-knob::after {
height: 8px;
background: #c8b890;
}
/* Cream-on-green attribution */
.scope-skin-545a .scope-attribution {
color: var(--scope-label);
opacity: 0.6;
}
.scope-skin-545a .scope-attribution a {
color: var(--scope-label);
}
.scope-skin-545a .scope-attribution a:hover {
color: #fff;
}
/* ── Ventilation holes (545A only) ──────────────────────── */
.scope-vent-holes {
display: none;
justify-content: center;
gap: 8px;
padding: 6px 14px 8px;
}
.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);
}
.scope-skin-545a .scope-vent-holes {
display: flex;
}
/* ── 545A responsive overrides ──────────────────────────── */
@media (max-width: 50rem) {
.scope-skin-545a .scope-knob {
width: 26px;
height: 26px;
}
.scope-skin-545a .scope-knob::after {
height: 6px;
}
.scope-vent-hole {
width: 5px;
height: 5px;
}
}