Add Outer Limits easter egg to oscilloscope knobs

Clicking the Vertical or Horizontal knobs triggers The Outer Limits
(1963) opening narration as a typewriter overlay on the CRT display,
with Web Speech API narration. Dismiss via click, Escape, or knob
re-click. Works on both 465 and 545A skins.
This commit is contained in:
Ryan Malloy 2026-02-13 06:46:46 -07:00
parent be88ea53b7
commit ad630ff471
2 changed files with 147 additions and 2 deletions

View File

@ -35,6 +35,10 @@
></canvas> ></canvas>
<div class="scope-graticule"></div> <div class="scope-graticule"></div>
<div class="scope-scanlines"></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>
</div>
</div> </div>
</div> </div>
@ -52,14 +56,16 @@
<!-- VERTICAL section --> <!-- VERTICAL section -->
<div class="scope-section"> <div class="scope-section">
<span class="scope-section-label">Vertical</span> <span class="scope-section-label">Vertical</span>
<div class="scope-knob" aria-hidden="true"></div> <div class="scope-knob scope-easter-knob" role="button" tabindex="0"
aria-label="Vertical control"></div>
<span class="scope-knob-label">Volts/Div</span> <span class="scope-knob-label">Volts/Div</span>
</div> </div>
<!-- HORIZONTAL section --> <!-- HORIZONTAL section -->
<div class="scope-section"> <div class="scope-section">
<span class="scope-section-label">Horizontal</span> <span class="scope-section-label">Horizontal</span>
<div class="scope-knob" aria-hidden="true"></div> <div class="scope-knob scope-easter-knob" role="button" tabindex="0"
aria-label="Horizontal control"></div>
<span class="scope-knob-label">Time/Div</span> <span class="scope-knob-label">Time/Div</span>
</div> </div>
@ -429,9 +435,102 @@
ctx.fill(); 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');
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 = '';
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) 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();
}
}
});
});
// 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() { function initAll() {
initSkin(); initSkin();
initScope(); initScope();
initOuterLimits();
} }
// Init when DOM ready (works with Astro's page lifecycle) // Init when DOM ready (works with Astro's page lifecycle)

View File

@ -359,6 +359,52 @@
color: #1a1610; 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;
align-items: center;
justify-content: center;
padding: 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;
color: var(--scope-teal);
text-align: center;
text-shadow: 0 0 8px var(--scope-teal-glow);
margin: 0;
}
/* ── 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 ──────────────────────────────────────────── */ /* ── Idle state ──────────────────────────────────────────── */
.scope-screen[data-idle="true"] .scope-canvas { .scope-screen[data-idle="true"] .scope-canvas {
opacity: 0.6; opacity: 0.6;