Replace static hero scope with interactive XY-mode oscilloscope
Port the OscilloscopeDisplay from mcltspice docs — Web Audio API renders stereo audio as Lissajous patterns on a canvas with teal phosphor persistence. 6 tracks from Jerobeam Fenderson, volume knob, skin switcher (465/545A), and the Outer Limits easter egg. Audio: CC BY-NC-SA 4.0, oscilloscopemusic.com Visual: Nick Watton (codepen.io/2Mogs), rsp2k
This commit is contained in:
parent
72cfd8191a
commit
4b2ce896b5
BIN
frontend/public/n-spheres/attractor.flac
Normal file
BIN
frontend/public/n-spheres/attractor.flac
Normal file
Binary file not shown.
BIN
frontend/public/n-spheres/core.flac
Normal file
BIN
frontend/public/n-spheres/core.flac
Normal file
Binary file not shown.
BIN
frontend/public/n-spheres/flux.flac
Normal file
BIN
frontend/public/n-spheres/flux.flac
Normal file
Binary file not shown.
BIN
frontend/public/n-spheres/function.flac
Normal file
BIN
frontend/public/n-spheres/function.flac
Normal file
Binary file not shown.
BIN
frontend/public/n-spheres/intersect.flac
Normal file
BIN
frontend/public/n-spheres/intersect.flac
Normal file
Binary file not shown.
BIN
frontend/public/spirals_shrt.mp3
Normal file
BIN
frontend/public/spirals_shrt.mp3
Normal file
Binary file not shown.
601
frontend/src/components/OscilloscopeDisplay.astro
Normal file
601
frontend/src/components/OscilloscopeDisplay.astro
Normal file
@ -0,0 +1,601 @@
|
||||
---
|
||||
/**
|
||||
* XY-mode audio oscilloscope display.
|
||||
* Visual style inspired by the Tektronix 465 (1972).
|
||||
* Renders stereo audio as Lissajous patterns on a canvas.
|
||||
*
|
||||
* Audio: "Spirals" by Jerobeam Fenderson (oscilloscopemusic.com)
|
||||
* License: CC BY-NC-SA 4.0
|
||||
* Original visual: Nick Watton (codepen.io/2Mogs)
|
||||
* Gist: rsp2k (gist.github.com/rsp2k/ac68b1bb290b8124e162987ed1df8d53)
|
||||
* Modernized: ScriptProcessor → AnalyserNode + rAF
|
||||
*/
|
||||
---
|
||||
|
||||
<div class="scope-frame">
|
||||
<!-- Top brand bar — model name doubles as skin picker -->
|
||||
<div class="scope-brand">
|
||||
<span class="scope-brand-name">Tektronix</span>
|
||||
<div class="scope-brand-right">
|
||||
<button class="scope-brand-model" id="scope-skin-picker" type="button" aria-label="Switch oscilloscope model">SpiceBook</button>
|
||||
<span class="scope-brand-sub" id="scope-brand-sub"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recessed CRT bay -->
|
||||
<div class="scope-crt-bay">
|
||||
<div class="scope-screen" data-idle="true">
|
||||
<canvas
|
||||
class="scope-canvas"
|
||||
id="scope-canvas"
|
||||
width="512"
|
||||
height="512"
|
||||
role="img"
|
||||
aria-label="XY oscilloscope displaying Lissajous patterns from stereo audio"
|
||||
></canvas>
|
||||
<div class="scope-graticule"></div>
|
||||
<div class="scope-scanlines"></div>
|
||||
<!-- Outer Limits easter egg overlay -->
|
||||
<div class="scope-outer-limits" id="scope-outer-limits" aria-hidden="true">
|
||||
<p class="scope-ol-text" id="scope-ol-text"></p>
|
||||
<p class="scope-ol-closing" id="scope-ol-closing" data-visible="false">
|
||||
We now return control of your television set to
|
||||
<a href="https://oscilloscopemusic.com" target="_blank" rel="noopener"
|
||||
class="scope-ol-link" id="scope-ol-link">oscilloscopemusic.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control panel -->
|
||||
<div class="scope-panel">
|
||||
<div class="scope-controls-row">
|
||||
<!-- SOURCE section -->
|
||||
<div class="scope-section">
|
||||
<span class="scope-section-label">Source</span>
|
||||
<div class="scope-knob scope-source-knob" id="scope-source-knob"
|
||||
role="button" tabindex="0" aria-label="Select signal source"></div>
|
||||
<span class="scope-knob-label scope-source-name" id="scope-source-name">Spirals</span>
|
||||
</div>
|
||||
|
||||
<!-- LEVEL / ATTENUATION section -->
|
||||
<div class="scope-section">
|
||||
<span class="scope-section-label">Level</span>
|
||||
<div class="scope-knob scope-volume-knob" id="scope-volume-knob"
|
||||
role="button" tabindex="0" aria-label="Signal attenuation level"></div>
|
||||
<span class="scope-knob-label scope-volume-label" id="scope-volume-label">20%</span>
|
||||
</div>
|
||||
|
||||
<!-- VERTICAL section -->
|
||||
<div class="scope-section">
|
||||
<span class="scope-section-label">Vertical</span>
|
||||
<div class="scope-knob scope-easter-knob" role="button" tabindex="0"
|
||||
aria-label="Vertical control"></div>
|
||||
<span class="scope-knob-label">Volts/Div</span>
|
||||
</div>
|
||||
|
||||
<!-- HORIZONTAL section -->
|
||||
<div class="scope-section">
|
||||
<span class="scope-section-label">Horizontal</span>
|
||||
<div class="scope-knob scope-easter-knob" role="button" tabindex="0"
|
||||
aria-label="Horizontal control"></div>
|
||||
<span class="scope-knob-label">Time/Div</span>
|
||||
</div>
|
||||
|
||||
<!-- TRIGGER / POWER section -->
|
||||
<div class="scope-section">
|
||||
<span class="scope-section-label">Trigger</span>
|
||||
<button
|
||||
class="scope-toggle"
|
||||
id="scope-toggle"
|
||||
type="button"
|
||||
data-active="false"
|
||||
aria-label="Start oscilloscope audio"
|
||||
>⏻ on</button>
|
||||
<div class="scope-led" id="scope-led" data-on="false" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attribution -->
|
||||
<div class="scope-attribution-bar">
|
||||
<div class="scope-attribution" id="scope-attribution"></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>
|
||||
// ── Signal configuration ──────────────────────────────────
|
||||
const SIGNALS = [
|
||||
{ id: 'spirals', name: 'Spirals', src: '/spirals_shrt.mp3', artist: 'Jerobeam Fenderson', license: 'CC BY-NC-SA 4.0', link: 'http://oscilloscopemusic.com/' },
|
||||
{ id: 'function', name: 'Function', src: '/n-spheres/function.flac', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
|
||||
{ id: 'intersect', name: 'Intersect', src: '/n-spheres/intersect.flac', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
|
||||
{ id: 'attractor', name: 'Attractor', src: '/n-spheres/attractor.flac', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
|
||||
{ id: 'flux', name: 'Flux', src: '/n-spheres/flux.flac', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
|
||||
{ id: 'core', name: 'Core', src: '/n-spheres/core.flac', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
|
||||
] as const;
|
||||
|
||||
// ── Skin configuration ────────────────────────────────────
|
||||
const SKINS: Record<string, { brand: string; model: string; sub: string; sections: string[] }> = {
|
||||
'465': { brand: 'Tektronix', model: 'SpiceBook', sub: '', sections: ['Source', 'Level', 'Vertical', 'Horizontal', 'Trigger'] },
|
||||
'545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Input', 'Atten', '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 (Source/Input, 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);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Signal source selector ────────────────────────────────
|
||||
function initSignal(audio: HTMLAudioElement, screen: HTMLElement) {
|
||||
const sourceKnob = document.getElementById('scope-source-knob');
|
||||
const sourceName = document.getElementById('scope-source-name');
|
||||
|
||||
if (!sourceKnob || !sourceName) return;
|
||||
// Guard against double-init
|
||||
if (sourceKnob.dataset.signalInit) return;
|
||||
sourceKnob.dataset.signalInit = 'true';
|
||||
|
||||
let signalIndex = parseInt(localStorage.getItem('scope-signal') || '0', 10);
|
||||
if (signalIndex < 0 || signalIndex >= SIGNALS.length) signalIndex = 0;
|
||||
|
||||
function applySignal(idx: number, shouldPlay: boolean) {
|
||||
const signal = SIGNALS[idx];
|
||||
signalIndex = idx;
|
||||
|
||||
// Update source name label
|
||||
sourceName!.textContent = signal.name;
|
||||
|
||||
// Rotate knob indicator to reflect position (custom prop composes with CSS hover scale)
|
||||
const rotation = (idx / SIGNALS.length) * 270 - 135;
|
||||
sourceKnob!.style.setProperty('--knob-rotation', `${rotation}deg`);
|
||||
|
||||
// Update attribution
|
||||
renderAttribution(signal);
|
||||
|
||||
// Persist selection
|
||||
localStorage.setItem('scope-signal', String(idx));
|
||||
|
||||
// Update audio source
|
||||
const wasPlaying = !audio.paused;
|
||||
if (wasPlaying || shouldPlay) {
|
||||
screen.dataset.loading = 'true';
|
||||
audio.pause();
|
||||
}
|
||||
|
||||
audio.src = signal.src;
|
||||
audio.load();
|
||||
|
||||
if (wasPlaying || shouldPlay) {
|
||||
const onCanPlay = () => {
|
||||
audio.removeEventListener('canplay', onCanPlay);
|
||||
screen.dataset.loading = 'false';
|
||||
audio.play().catch(e => console.error('Signal playback failed:', e));
|
||||
};
|
||||
audio.addEventListener('canplay', onCanPlay);
|
||||
|
||||
// Timeout fallback — don't leave loading state indefinitely
|
||||
setTimeout(() => {
|
||||
screen.dataset.loading = 'false';
|
||||
}, 15000);
|
||||
}
|
||||
}
|
||||
|
||||
// Click cycles to next signal
|
||||
sourceKnob.addEventListener('click', () => {
|
||||
const next = (signalIndex + 1) % SIGNALS.length;
|
||||
applySignal(next, false);
|
||||
});
|
||||
|
||||
// Keyboard: Enter/Space to cycle
|
||||
sourceKnob.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const next = (signalIndex + 1) % SIGNALS.length;
|
||||
applySignal(next, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply saved signal on init (don't auto-play)
|
||||
applySignal(signalIndex, false);
|
||||
|
||||
return { applySignal, getIndex: () => signalIndex };
|
||||
}
|
||||
|
||||
function renderAttribution(signal: typeof SIGNALS[number]) {
|
||||
const el = document.getElementById('scope-attribution');
|
||||
if (!el) return;
|
||||
|
||||
const audioSite = '<a href="https://oscilloscopemusic.com" target="_blank" rel="noopener">oscilloscopemusic.com</a>';
|
||||
const visualCredit = '· Visual: <a href="https://codepen.io/2Mogs" target="_blank" rel="noopener">Nick Watton</a> · <a href="https://gist.github.com/rsp2k/ac68b1bb290b8124e162987ed1df8d53" target="_blank" rel="noopener">rsp2k</a>';
|
||||
|
||||
if ('license' in signal && signal.license) {
|
||||
el.innerHTML = `“<a href="${signal.link}" target="_blank" rel="noopener">${signal.name}</a>” by ${signal.artist} (<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener license">CC BY-NC-SA</a>) ${visualCredit}`;
|
||||
} else {
|
||||
el.innerHTML = `“${signal.name}” from <em>${('album' in signal) ? signal.album : ''}</em> by ${signal.artist} · ${audioSite} ${visualCredit}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Oscilloscope XY renderer ──────────────────────────────
|
||||
// Uses AnalyserNode (modern Web Audio) instead of deprecated ScriptProcessor
|
||||
function initScope() {
|
||||
const canvas = document.getElementById('scope-canvas') as HTMLCanvasElement | null;
|
||||
const toggle = document.getElementById('scope-toggle') as HTMLButtonElement | null;
|
||||
const audio = document.getElementById('scope-audio') as HTMLAudioElement | null;
|
||||
const screen = canvas?.closest('.scope-screen') as HTMLElement | null;
|
||||
const led = document.getElementById('scope-led') as HTMLElement | null;
|
||||
|
||||
if (!canvas || !toggle || !audio || !screen) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Draw idle state: faint center dot
|
||||
drawIdle(ctx, canvas.width, canvas.height);
|
||||
|
||||
// Init signal source selector
|
||||
initSignal(audio, screen);
|
||||
|
||||
let audioCtx: AudioContext | null = null;
|
||||
let analyserL: AnalyserNode | null = null;
|
||||
let analyserR: AnalyserNode | null = null;
|
||||
let animId: number | null = null;
|
||||
let isRunning = false;
|
||||
let source: MediaElementAudioSourceNode | null = null;
|
||||
|
||||
// Check reduced motion preference
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
toggle.addEventListener('click', async () => {
|
||||
if (isRunning) {
|
||||
stop();
|
||||
} else {
|
||||
await start();
|
||||
}
|
||||
});
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
// Create audio context on user gesture (required by browsers)
|
||||
if (!audioCtx) {
|
||||
audioCtx = new AudioContext();
|
||||
|
||||
// Connect: audio → source → splitter → 2x analyser → destination
|
||||
source = audioCtx.createMediaElementSource(audio!);
|
||||
const splitter = audioCtx.createChannelSplitter(2);
|
||||
|
||||
analyserL = audioCtx.createAnalyser();
|
||||
analyserR = audioCtx.createAnalyser();
|
||||
|
||||
// 2048 samples gives smooth Lissajous curves
|
||||
analyserL.fftSize = 2048;
|
||||
analyserR.fftSize = 2048;
|
||||
|
||||
source.connect(splitter);
|
||||
splitter.connect(analyserL, 0);
|
||||
splitter.connect(analyserR, 1);
|
||||
|
||||
// Also connect to destination so we hear the audio
|
||||
source.connect(audioCtx.destination);
|
||||
}
|
||||
|
||||
if (audioCtx.state === 'suspended') {
|
||||
await audioCtx.resume();
|
||||
}
|
||||
|
||||
await audio!.play();
|
||||
isRunning = true;
|
||||
screen!.dataset.idle = 'false';
|
||||
toggle.dataset.active = 'true';
|
||||
toggle.innerHTML = '⏻ off';
|
||||
toggle.setAttribute('aria-label', 'Stop oscilloscope audio');
|
||||
if (led) led.dataset.on = 'true';
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
renderLoop();
|
||||
} else {
|
||||
// Reduced motion: render a single static frame after a brief delay
|
||||
setTimeout(() => renderFrame(), 200);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Oscilloscope audio failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
audio!.pause();
|
||||
isRunning = false;
|
||||
screen!.dataset.idle = 'true';
|
||||
toggle.dataset.active = 'false';
|
||||
toggle.innerHTML = '⏻ on';
|
||||
toggle.setAttribute('aria-label', 'Start oscilloscope audio');
|
||||
if (led) led.dataset.on = 'false';
|
||||
|
||||
if (animId !== null) {
|
||||
cancelAnimationFrame(animId);
|
||||
animId = null;
|
||||
}
|
||||
|
||||
// Fade to idle
|
||||
drawIdle(ctx!, canvas!.width, canvas!.height);
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
if (!isRunning) return;
|
||||
renderFrame();
|
||||
animId = requestAnimationFrame(renderLoop);
|
||||
}
|
||||
|
||||
function renderFrame() {
|
||||
if (!analyserL || !analyserR || !ctx) return;
|
||||
|
||||
const bufLen = analyserL.frequencyBinCount;
|
||||
const dataL = new Float32Array(bufLen);
|
||||
const dataR = new Float32Array(bufLen);
|
||||
|
||||
analyserL.getFloatTimeDomainData(dataL);
|
||||
analyserR.getFloatTimeDomainData(dataR);
|
||||
|
||||
const w = canvas!.width;
|
||||
const h = canvas!.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const scale = w * 0.42;
|
||||
|
||||
// Fade previous frame (persistence of phosphor)
|
||||
ctx.fillStyle = 'rgba(10, 10, 10, 0.25)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Draw dots -- teal phosphor (#2dd4bf)
|
||||
const dotSize = 1.5;
|
||||
for (let i = 0; i < bufLen; i++) {
|
||||
const x = cx + dataR[i] * scale;
|
||||
const y = cy - dataL[i] * scale;
|
||||
|
||||
// Brighter center, dimmer edges (simulate beam intensity)
|
||||
const dist = Math.sqrt(dataL[i] * dataL[i] + dataR[i] * dataR[i]);
|
||||
const alpha = Math.max(0.3, 1.0 - dist * 0.4);
|
||||
|
||||
ctx.fillStyle = `rgba(45, 212, 191, ${alpha})`;
|
||||
ctx.fillRect(x - dotSize / 2, y - dotSize / 2, dotSize, dotSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawIdle(ctx: CanvasRenderingContext2D, w: number, h: number) {
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Faint center dot
|
||||
ctx.fillStyle = 'rgba(45, 212, 191, 0.15)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2, h / 2, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── Volume / attenuation control ─────────────────────────
|
||||
function initVolume() {
|
||||
const knob = document.getElementById('scope-volume-knob');
|
||||
const label = document.getElementById('scope-volume-label');
|
||||
const audio = document.getElementById('scope-audio') as HTMLAudioElement | null;
|
||||
if (!knob || !label || !audio) return;
|
||||
if (knob.dataset.volumeInit) return;
|
||||
knob.dataset.volumeInit = 'true';
|
||||
|
||||
const LEVELS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40];
|
||||
|
||||
let levelIndex = parseInt(localStorage.getItem('scope-volume') || '3', 10);
|
||||
if (levelIndex < 0 || levelIndex >= LEVELS.length) levelIndex = 3;
|
||||
|
||||
function applyLevel(idx: number) {
|
||||
levelIndex = idx;
|
||||
const vol = LEVELS[idx];
|
||||
audio!.volume = vol;
|
||||
label!.textContent = `${Math.round(vol * 100)}%`;
|
||||
const rotation = (idx / LEVELS.length) * 270 - 135;
|
||||
knob!.style.setProperty('--knob-rotation', `${rotation}deg`);
|
||||
localStorage.setItem('scope-volume', String(idx));
|
||||
}
|
||||
|
||||
knob.addEventListener('click', () => {
|
||||
applyLevel((levelIndex + 1) % LEVELS.length);
|
||||
});
|
||||
|
||||
knob.addEventListener('keydown', (e: Event) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key === 'Enter' || ke.key === ' ') {
|
||||
ke.preventDefault();
|
||||
applyLevel((levelIndex + 1) % LEVELS.length);
|
||||
}
|
||||
});
|
||||
|
||||
applyLevel(levelIndex);
|
||||
}
|
||||
|
||||
// ── Outer Limits easter egg ──────────────────────────────
|
||||
function initOuterLimits() {
|
||||
const knobs = document.querySelectorAll('.scope-easter-knob');
|
||||
const overlay = document.getElementById('scope-outer-limits');
|
||||
const textEl = document.getElementById('scope-ol-text');
|
||||
const closingEl = document.getElementById('scope-ol-closing');
|
||||
const linkEl = document.getElementById('scope-ol-link');
|
||||
if (!knobs.length || !overlay || !textEl) return;
|
||||
|
||||
// Guard against double-init
|
||||
if (overlay.dataset.olInit) return;
|
||||
overlay.dataset.olInit = 'true';
|
||||
|
||||
const quote = 'There is nothing wrong with your television set. Do not attempt to adjust the picture. We are controlling transmission. We will control the horizontal. We will control the vertical. For the next hour, sit quietly and we will control all that you see and hear.';
|
||||
|
||||
let typeTimer: number | null = null;
|
||||
let utterance: SpeechSynthesisUtterance | null = null;
|
||||
|
||||
function show() {
|
||||
overlay!.dataset.active = 'true';
|
||||
overlay!.setAttribute('aria-hidden', 'false');
|
||||
textEl!.textContent = '';
|
||||
if (closingEl) closingEl.dataset.visible = 'false';
|
||||
typeText(quote, 0);
|
||||
speak(quote);
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (typeTimer) { clearTimeout(typeTimer); typeTimer = null; }
|
||||
overlay!.dataset.active = 'false';
|
||||
overlay!.setAttribute('aria-hidden', 'true');
|
||||
if (utterance && window.speechSynthesis.speaking) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
function typeText(text: string, i: number) {
|
||||
if (i >= text.length) {
|
||||
// Quote done — fade in the closing narration after a dramatic beat
|
||||
if (closingEl) setTimeout(() => { closingEl.dataset.visible = 'true'; }, 1200);
|
||||
return;
|
||||
}
|
||||
textEl!.textContent += text[i];
|
||||
typeTimer = window.setTimeout(() => typeText(text, i + 1), 30);
|
||||
}
|
||||
|
||||
function speak(text: string) {
|
||||
if (!('speechSynthesis' in window)) return;
|
||||
window.speechSynthesis.cancel();
|
||||
utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.rate = 0.85;
|
||||
utterance.pitch = 0.7;
|
||||
const audio = document.getElementById('scope-audio') as HTMLAudioElement | null;
|
||||
utterance.volume = Math.min(0.8, (audio?.volume ?? 0.2) * 2);
|
||||
|
||||
// Prefer a deep English voice if available
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
const preferred = voices.find(v =>
|
||||
v.lang.startsWith('en') && /male|daniel|david|james|google uk/i.test(v.name)
|
||||
) || voices.find(v => v.lang.startsWith('en'));
|
||||
if (preferred) utterance.voice = preferred;
|
||||
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
// Ensure voices are loaded (some browsers load async)
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.getVoices();
|
||||
window.speechSynthesis.addEventListener('voiceschanged', () => {});
|
||||
}
|
||||
|
||||
// Knob clicks toggle the overlay
|
||||
knobs.forEach(knob => {
|
||||
knob.addEventListener('click', () => {
|
||||
if (overlay!.dataset.active === 'true') {
|
||||
dismiss();
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
});
|
||||
knob.addEventListener('keydown', (e: Event) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key === 'Enter' || ke.key === ' ') {
|
||||
ke.preventDefault();
|
||||
if (overlay!.dataset.active === 'true') {
|
||||
dismiss();
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Let the closing link open without dismissing the overlay
|
||||
if (linkEl) linkEl.addEventListener('click', (e: Event) => e.stopPropagation());
|
||||
|
||||
// Dismiss on overlay click or Escape
|
||||
overlay.addEventListener('click', dismiss);
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && overlay!.dataset.active === 'true') dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
function initAll() {
|
||||
initSkin();
|
||||
initScope();
|
||||
initVolume();
|
||||
initOuterLimits();
|
||||
}
|
||||
|
||||
// Init when DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initAll);
|
||||
} else {
|
||||
initAll();
|
||||
}
|
||||
|
||||
// Re-init on Astro page transitions
|
||||
document.addEventListener('astro:page-load', initAll);
|
||||
</script>
|
||||
@ -4,6 +4,7 @@ import NotebookLayout from '../layouts/NotebookLayout.astro';
|
||||
import NotebookGallery from '../components/NotebookGallery';
|
||||
import PipelineStrip from '../components/PipelineStrip.astro';
|
||||
import FeaturedNotebooks from '../components/FeaturedNotebooks.astro';
|
||||
import OscilloscopeDisplay from '../components/OscilloscopeDisplay.astro';
|
||||
import { fetchNotebookList } from '../lib/server-api';
|
||||
import type { NotebookSummary } from '../lib/types';
|
||||
|
||||
@ -23,6 +24,7 @@ try {
|
||||
>
|
||||
<style>
|
||||
@import '../styles/homepage.css';
|
||||
@import '../styles/oscilloscope.css';
|
||||
</style>
|
||||
|
||||
<!-- Hero: Split layout — text left, animated scope visual right -->
|
||||
@ -57,83 +59,9 @@ try {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Animated oscilloscope visual -->
|
||||
<!-- Right: Interactive XY-mode oscilloscope -->
|
||||
<div class="flex justify-center md:justify-end">
|
||||
<a href="/notebook/rc-lowpass-filter" class="hero-scope" aria-label="Open RC Low-Pass Filter notebook">
|
||||
<div class="hero-scope-frame">
|
||||
|
||||
<!-- Brand bar -->
|
||||
<div class="hero-scope-brand">
|
||||
<span class="hero-scope-brand-name">Tektronix</span>
|
||||
<span class="hero-scope-brand-model">465</span>
|
||||
</div>
|
||||
|
||||
<!-- CRT bay -->
|
||||
<div class="hero-scope-crt-bay">
|
||||
<div class="hero-scope-screen">
|
||||
<!-- Waveform SVG: RC step response -->
|
||||
<svg viewBox="0 0 500 300" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" aria-hidden="true">
|
||||
<!-- Input: square wave (dim blue) -->
|
||||
<polyline
|
||||
class="hero-scope-trace-input"
|
||||
fill="none"
|
||||
stroke="rgba(96,165,250,0.25)"
|
||||
stroke-width="1.5"
|
||||
points="0,240 0,60 125,60 125,240 250,240 250,60 375,60 375,240 500,240"
|
||||
/>
|
||||
<!-- Output: RC exponential response (bright teal) -->
|
||||
<path
|
||||
class="hero-scope-trace-output"
|
||||
fill="none"
|
||||
stroke="#2dd4bf"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
d="M0,240
|
||||
C31,105 62,68 125,62
|
||||
C156,195 188,232 250,238
|
||||
C281,105 312,68 375,62
|
||||
C406,195 438,232 500,238"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Graticule + scanlines -->
|
||||
<div class="hero-scope-graticule"></div>
|
||||
<div class="hero-scope-scanlines"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Digital readout -->
|
||||
<div class="hero-scope-readout">
|
||||
<span>V(out)</span>
|
||||
<span class="hero-scope-readout-divider">|</span>
|
||||
<span>200μs/div</span>
|
||||
<span class="hero-scope-readout-divider">|</span>
|
||||
<span>500mV/div</span>
|
||||
</div>
|
||||
|
||||
<!-- Control panel -->
|
||||
<div class="hero-scope-panel">
|
||||
<div class="hero-scope-controls">
|
||||
<div class="hero-scope-section">
|
||||
<span class="hero-scope-section-label">Volts</span>
|
||||
<div class="hero-scope-knob" data-pos="2"></div>
|
||||
</div>
|
||||
<div class="hero-scope-section">
|
||||
<span class="hero-scope-section-label">Time</span>
|
||||
<div class="hero-scope-knob" data-pos="3"></div>
|
||||
</div>
|
||||
<div class="hero-scope-section">
|
||||
<span class="hero-scope-section-label">Trigger</span>
|
||||
<div class="hero-scope-knob" data-pos="1"></div>
|
||||
</div>
|
||||
<div class="hero-scope-section">
|
||||
<div class="hero-scope-mode">TRAN</div>
|
||||
<div class="hero-scope-led"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</a>
|
||||
<OscilloscopeDisplay />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -30,362 +30,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Hero Oscilloscope Visual — Pure CSS + SVG
|
||||
Adapted from scope-skin.css, fully independent (hero-scope- prefix)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Color palette */
|
||||
.hero-scope {
|
||||
--hs-teal: #2dd4bf;
|
||||
--hs-teal-dim: rgba(45, 212, 191, 0.12);
|
||||
--hs-teal-glow: rgba(45, 212, 191, 0.18);
|
||||
--hs-panel: #b5a48a;
|
||||
--hs-panel-light: #c7b89e;
|
||||
--hs-panel-dark: #9e8f78;
|
||||
--hs-crt-bg: #0a0a0a;
|
||||
--hs-label: #3b3428;
|
||||
--hs-knob: #2a2a2d;
|
||||
--hs-knob-ring: #1e1e20;
|
||||
--hs-section-line: rgba(59, 52, 40, 0.25);
|
||||
|
||||
display: block;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Outer frame ──────────────────────────────────────────── */
|
||||
.hero-scope-frame {
|
||||
position: relative;
|
||||
background:
|
||||
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(--hs-panel-light), var(--hs-panel), var(--hs-panel-dark));
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 8px 30px rgba(0, 0, 0, 0.45),
|
||||
0 2px 6px rgba(0, 0, 0, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ── Brand bar ────────────────────────────────────────────── */
|
||||
.hero-scope-brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px 6px;
|
||||
border-bottom: 1px solid var(--hs-section-line);
|
||||
}
|
||||
|
||||
.hero-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(--hs-label);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hero-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(--hs-label);
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
/* ── CRT bay ──────────────────────────────────────────────── */
|
||||
.hero-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 ───────────────────────────────────────────── */
|
||||
.hero-scope-screen {
|
||||
position: relative;
|
||||
background: var(--hs-crt-bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 5 / 3;
|
||||
box-shadow:
|
||||
0 0 15px var(--hs-teal-glow),
|
||||
inset 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* SVG fills the CRT */
|
||||
.hero-scope-screen svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ── Graticule overlay (10×8 grid) ────────────────────────── */
|
||||
.hero-scope-graticule {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
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 ticks */
|
||||
.hero-scope-graticule::before,
|
||||
.hero-scope-graticule::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.hero-scope-graticule::before {
|
||||
top: 50%;
|
||||
left: calc(50% - 8px);
|
||||
width: 16px;
|
||||
height: 1px;
|
||||
background: rgba(45, 212, 191, 0.18);
|
||||
}
|
||||
|
||||
.hero-scope-graticule::after {
|
||||
left: 50%;
|
||||
top: calc(50% - 8px);
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(45, 212, 191, 0.18);
|
||||
}
|
||||
|
||||
/* ── Scanlines ────────────────────────────────────────────── */
|
||||
.hero-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;
|
||||
}
|
||||
|
||||
/* ── SVG trace animation ──────────────────────────────────── */
|
||||
.hero-scope-trace-input {
|
||||
stroke-dasharray: 1200;
|
||||
stroke-dashoffset: 1200;
|
||||
animation: hero-trace-draw 1.8s ease-out 0.3s forwards;
|
||||
}
|
||||
|
||||
.hero-scope-trace-output {
|
||||
stroke-dasharray: 1200;
|
||||
stroke-dashoffset: 1200;
|
||||
animation: hero-trace-draw 1.8s ease-out 0.6s forwards;
|
||||
}
|
||||
|
||||
@keyframes hero-trace-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Phosphor glow pulse after draw completes */
|
||||
.hero-scope-trace-output {
|
||||
animation:
|
||||
hero-trace-draw 1.8s ease-out 0.6s forwards,
|
||||
hero-phosphor-pulse 3s ease-in-out 2.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes hero-phosphor-pulse {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 2px var(--hs-teal-glow));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 6px rgba(45, 212, 191, 0.35));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Digital readout bar ──────────────────────────────────── */
|
||||
.hero-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(--hs-teal);
|
||||
text-shadow: 0 0 4px var(--hs-teal-glow);
|
||||
}
|
||||
|
||||
.hero-scope-readout-divider {
|
||||
color: rgba(45, 212, 191, 0.25);
|
||||
}
|
||||
|
||||
/* ── Control panel ────────────────────────────────────────── */
|
||||
.hero-scope-panel {
|
||||
padding: 8px 12px 8px;
|
||||
border-top: 1px solid var(--hs-section-line);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.hero-scope-controls {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.hero-scope-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Section dividers */
|
||||
.hero-scope-section + .hero-scope-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--hs-section-line);
|
||||
}
|
||||
|
||||
.hero-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(--hs-label);
|
||||
opacity: 0.88;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Decorative knobs ─────────────────────────────────────── */
|
||||
.hero-scope-knob {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 40% 35%, #3a3a3e, var(--hs-knob));
|
||||
border: 2px solid var(--hs-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;
|
||||
}
|
||||
|
||||
/* Knob indicator line */
|
||||
.hero-scope-knob::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 50%;
|
||||
width: 1.5px;
|
||||
height: 7px;
|
||||
background: #d4d0c8;
|
||||
border-radius: 1px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Knob rotation variants */
|
||||
.hero-scope-knob[data-pos="1"] { transform: rotate(-60deg); }
|
||||
.hero-scope-knob[data-pos="2"] { transform: rotate(-20deg); }
|
||||
.hero-scope-knob[data-pos="3"] { transform: rotate(30deg); }
|
||||
.hero-scope-knob[data-pos="4"] { transform: rotate(70deg); }
|
||||
|
||||
/* ── Mode badge ───────────────────────────────────────────── */
|
||||
.hero-scope-mode {
|
||||
background: var(--hs-knob);
|
||||
border: 2px solid var(--hs-knob-ring);
|
||||
border-radius: 6px;
|
||||
color: var(--hs-teal);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 5px 8px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.3),
|
||||
0 0 6px var(--hs-teal-glow);
|
||||
}
|
||||
|
||||
/* ── Power LED ────────────────────────────────────────────── */
|
||||
.hero-scope-led {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--hs-teal);
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 0 6px var(--hs-teal-glow);
|
||||
}
|
||||
|
||||
/* ── Accessibility: reduced motion ────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-scope-trace-input,
|
||||
.hero-scope-trace-output {
|
||||
stroke-dasharray: none;
|
||||
stroke-dashoffset: 0;
|
||||
animation: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.hero-scope-scanlines {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────── */
|
||||
@media (max-width: 50rem) {
|
||||
.hero-scope-knob {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.hero-scope-knob::after {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.hero-scope-readout {
|
||||
font-size: 0.55rem;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Gallery card decorative header strip ─────────────────── */
|
||||
.notebook-card-strip {
|
||||
height: 3rem;
|
||||
|
||||
649
frontend/src/styles/oscilloscope.css
Normal file
649
frontend/src/styles/oscilloscope.css
Normal file
@ -0,0 +1,649 @@
|
||||
/* Oscilloscope display -- Tektronix 465 inspired
|
||||
* Tan/champagne panel, recessed CRT, teal phosphor
|
||||
* Ported from mcltspice docs for SpiceBook hero section
|
||||
*
|
||||
* Audio: Jerobeam Fenderson (oscilloscopemusic.com)
|
||||
* Original visual: Nick Watton (codepen.io/2Mogs)
|
||||
* Gist: rsp2k (gist.github.com/rsp2k/ac68b1bb290b8124e162987ed1df8d53)
|
||||
*/
|
||||
|
||||
/* ── Outer chassis ───────────────────────────────────────── */
|
||||
.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 10px 30px rgba(0, 0, 0, 0.55),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.1);
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Top brand bar ───────────────────────────────────────── */
|
||||
.scope-brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px 6px;
|
||||
border-bottom: 1px solid var(--scope-section-line);
|
||||
}
|
||||
|
||||
.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-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;
|
||||
}
|
||||
|
||||
/* ── CRT bay (recessed dark area) ────────────────────────── */
|
||||
.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-screen {
|
||||
position: relative;
|
||||
background: var(--scope-crt-bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1;
|
||||
box-shadow:
|
||||
0 0 15px var(--scope-teal-glow),
|
||||
inset 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ── Canvas ──────────────────────────────────────────────── */
|
||||
.scope-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ── Graticule overlay (8x10 grid, like the 465) ─────────── */
|
||||
.scope-graticule {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
/* 10 horizontal, 8 vertical — classic Tek grid */
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
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)
|
||||
),
|
||||
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-graticule::before,
|
||||
.scope-graticule::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.scope-graticule::before {
|
||||
/* horizontal center tick */
|
||||
top: 50%;
|
||||
left: calc(50% - 8px);
|
||||
width: 16px;
|
||||
height: 1px;
|
||||
background: rgba(45, 212, 191, 0.18);
|
||||
}
|
||||
|
||||
.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-scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Control panel area ──────────────────────────────────── */
|
||||
.scope-panel {
|
||||
padding: 8px 12px 6px;
|
||||
border-top: 1px solid var(--scope-section-line);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.scope-controls-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ── Control section (labeled group) ─────────────────────── */
|
||||
.scope-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Section divider lines */
|
||||
.scope-section + .scope-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--scope-section-line);
|
||||
}
|
||||
|
||||
.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 ─────────────────────────────────────────── */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Knob indicator line */
|
||||
.scope-knob::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 50%;
|
||||
width: 1.5px;
|
||||
height: 7px;
|
||||
background: #d4d0c8;
|
||||
border-radius: 1px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ── Source knob (interactive) ───────────────────────────── */
|
||||
/* Uses --knob-rotation custom prop so JS rotation composes with CSS scale */
|
||||
.scope-source-knob {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
transform: rotate(var(--knob-rotation, 0deg));
|
||||
}
|
||||
|
||||
.scope-source-knob:hover {
|
||||
transform: rotate(var(--knob-rotation, 0deg)) scale(1.08);
|
||||
}
|
||||
|
||||
.scope-source-knob:active {
|
||||
transform: rotate(var(--knob-rotation, 0deg)) scale(0.95);
|
||||
}
|
||||
|
||||
.scope-source-knob:focus-visible {
|
||||
outline: 2px solid var(--scope-teal);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Volume / attenuation knob (interactive) ──────────── */
|
||||
.scope-volume-knob {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
transform: rotate(var(--knob-rotation, 0deg));
|
||||
}
|
||||
|
||||
.scope-volume-knob:hover {
|
||||
transform: rotate(var(--knob-rotation, 0deg)) scale(1.08);
|
||||
}
|
||||
|
||||
.scope-volume-knob:active {
|
||||
transform: rotate(var(--knob-rotation, 0deg)) scale(0.95);
|
||||
}
|
||||
|
||||
.scope-volume-knob:focus-visible {
|
||||
outline: 2px solid var(--scope-teal);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Volume label ─────────────────────────────────────── */
|
||||
.scope-volume-label {
|
||||
max-width: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Signal name label ──────────────────────────────────── */
|
||||
.scope-source-name {
|
||||
max-width: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Loading state overlay ──────────────────────────────── */
|
||||
.scope-screen[data-loading="true"]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(10, 10, 10, 0.7);
|
||||
z-index: 5;
|
||||
animation: scope-loading-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes scope-loading-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* ── Power toggle ────────────────────────────────────────── */
|
||||
.scope-toggle {
|
||||
appearance: none;
|
||||
background: var(--scope-knob);
|
||||
border: 2px solid var(--scope-knob-ring);
|
||||
border-radius: 6px;
|
||||
color: #8a8880;
|
||||
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);
|
||||
}
|
||||
|
||||
.scope-toggle:hover {
|
||||
color: #c4c0b8;
|
||||
}
|
||||
|
||||
.scope-toggle[data-active="true"] {
|
||||
color: var(--scope-teal);
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.3),
|
||||
0 0 6px var(--scope-teal-glow);
|
||||
}
|
||||
|
||||
.scope-toggle:focus-visible {
|
||||
outline: 2px solid var(--scope-teal);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Power LED ───────────────────────────────────────────── */
|
||||
.scope-led {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #3a3632;
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
transition: background 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.scope-led[data-on="true"] {
|
||||
background: var(--scope-teal);
|
||||
box-shadow: 0 0 6px var(--scope-teal-glow);
|
||||
}
|
||||
|
||||
/* ── Attribution bar ─────────────────────────────────────── */
|
||||
.scope-attribution-bar {
|
||||
padding: 5px 14px 7px;
|
||||
border-top: 1px solid var(--scope-section-line);
|
||||
}
|
||||
|
||||
.scope-attribution {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.48rem;
|
||||
color: var(--scope-label);
|
||||
opacity: 0.72;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scope-attribution a {
|
||||
color: var(--scope-label);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.scope-attribution a:hover {
|
||||
color: #1a1610;
|
||||
}
|
||||
|
||||
/* ── Outer Limits easter egg overlay ─────────────────────── */
|
||||
.scope-outer-limits {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(10, 10, 10, 0.92);
|
||||
z-index: 10;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10% 12%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scope-outer-limits[data-active="true"] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.scope-ol-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 0.65rem;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
/* Animated rainbow gradient text */
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#ff6b6b, #ffb347, #ffd93d, #a3ff6b,
|
||||
#2dd4bf, #6bb5ff, #c084fc, #ff6b9d, #ff6b6b
|
||||
);
|
||||
background-size: 300% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: scope-rainbow 6s linear infinite;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
@keyframes scope-rainbow {
|
||||
0% { background-position: 0% center; }
|
||||
100% { background-position: 300% center; }
|
||||
}
|
||||
|
||||
/* Closing narration + plug -- fades in after typewriter finishes */
|
||||
.scope-ol-closing {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 0.55rem;
|
||||
line-height: 1.5;
|
||||
color: var(--scope-teal);
|
||||
text-align: center;
|
||||
text-shadow: 0 0 8px var(--scope-teal-glow);
|
||||
margin: 1em 0 0;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transition: opacity 1.5s ease-in, transform 1.5s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scope-ol-closing[data-visible="true"] {
|
||||
opacity: 0.85;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.scope-ol-link {
|
||||
color: var(--scope-teal);
|
||||
text-decoration: none;
|
||||
font-style: italic;
|
||||
font-size: 0.65rem;
|
||||
text-shadow: 0 0 12px var(--scope-teal-glow), 0 0 4px var(--scope-teal-glow);
|
||||
transition: text-shadow 0.2s;
|
||||
}
|
||||
|
||||
.scope-ol-link:hover {
|
||||
text-decoration: underline;
|
||||
text-shadow: 0 0 16px var(--scope-teal), 0 0 6px var(--scope-teal-glow);
|
||||
}
|
||||
|
||||
/* ── Easter egg knobs (Vertical / Horizontal) ────────────── */
|
||||
.scope-easter-knob {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.scope-easter-knob:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.scope-easter-knob:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.scope-easter-knob:focus-visible {
|
||||
outline: 2px solid var(--scope-teal);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Idle state ──────────────────────────────────────────── */
|
||||
.scope-screen[data-idle="true"] .scope-canvas {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Reduced motion ──────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scope-scanlines {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────── */
|
||||
@media (max-width: 50rem) {
|
||||
.scope-frame {
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.scope-knob {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.scope-knob::after {
|
||||
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(--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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user