Replace disconnected bottom link with the show's closing narration subverted: "We now return control of your television set to oscilloscopemusic.com" — fades in after typewriter finishes with a dramatic 1.2s delay. Also add oscilloscopemusic.com to the always-visible attribution bar for N-Spheres tracks.
562 lines
20 KiB
Plaintext
562 lines
20 KiB
Plaintext
---
|
|
/**
|
|
* 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">mcltspice</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>
|
|
|
|
<!-- 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: 'mcltspice', sub: '', sections: ['Source', 'Vertical', 'Horizontal', 'Trigger'] },
|
|
'545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Input', '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);
|
|
}
|
|
}
|
|
|
|
// Cleanup on navigation (Astro view transitions or SPA)
|
|
document.addEventListener('astro:before-swap', () => {
|
|
stop();
|
|
if (audioCtx) {
|
|
audioCtx.close();
|
|
audioCtx = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// ── 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;
|
|
utterance.volume = 0.8;
|
|
|
|
// 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();
|
|
initOuterLimits();
|
|
}
|
|
|
|
// 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', initAll);
|
|
</script>
|