Add Tektronix 465-inspired oscilloscope hero to docs landing page
XY-mode Lissajous display renders stereo audio on a canvas inside a warm champagne bezel with recessed CRT bay, labeled control sections (Vertical/Horizontal/Trigger), rotary knobs, and power LED. Uses modern AnalyserNode + rAF pipeline instead of deprecated ScriptProcessor. Audio: "Spirals" by Jerobeam Fenderson (CC BY-NC-SA 4.0) Visual: Nick Watton, adapted from gist by rsp2k
This commit is contained in:
parent
3c2345282f
commit
b7a370c1f4
@ -10,6 +10,9 @@ export default defineConfig({
|
||||
starlight({
|
||||
title: 'mcltspice',
|
||||
tagline: 'LTspice circuit simulation via MCP',
|
||||
components: {
|
||||
Hero: './src/components/Hero.astro',
|
||||
},
|
||||
social: [
|
||||
{ icon: 'external', label: 'Gitea', href: 'https://git.supported.systems/MCP/mcltspice' },
|
||||
{ icon: 'external', label: 'PyPI', href: 'https://pypi.org/project/mcltspice/' },
|
||||
|
||||
BIN
docs/public/spirals_shrt.mp3
Normal file
BIN
docs/public/spirals_shrt.mp3
Normal file
Binary file not shown.
156
docs/src/components/Hero.astro
Normal file
156
docs/src/components/Hero.astro
Normal file
@ -0,0 +1,156 @@
|
||||
---
|
||||
/**
|
||||
* Custom Hero override for Starlight.
|
||||
* On splash pages: injects the oscilloscope display in the image area.
|
||||
* On other pages: delegates to the default Starlight Hero.
|
||||
*/
|
||||
import { Image } from 'astro:assets';
|
||||
import { LinkButton } from '@astrojs/starlight/components';
|
||||
import OscilloscopeDisplay from './OscilloscopeDisplay.astro';
|
||||
|
||||
const PAGE_TITLE_ID = '_top';
|
||||
|
||||
const { data } = Astro.locals.starlightRoute.entry;
|
||||
const { title = data.title, tagline, image, actions = [] } = data.hero || {};
|
||||
const isSplash = data.template === 'splash';
|
||||
|
||||
const imageAttrs = {
|
||||
loading: 'eager' as const,
|
||||
decoding: 'async' as const,
|
||||
width: 400,
|
||||
height: 400,
|
||||
alt: image?.alt || '',
|
||||
};
|
||||
|
||||
let darkImage: ImageMetadata | undefined;
|
||||
let lightImage: ImageMetadata | undefined;
|
||||
let rawHtml: string | undefined;
|
||||
|
||||
if (image) {
|
||||
if ('file' in image) {
|
||||
darkImage = image.file;
|
||||
} else if ('dark' in image) {
|
||||
darkImage = image.dark;
|
||||
lightImage = image.light;
|
||||
} else {
|
||||
rawHtml = image.html;
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<div class="hero">
|
||||
{isSplash ? (
|
||||
<div class="hero-html sl-flex">
|
||||
<OscilloscopeDisplay />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{darkImage && (
|
||||
<Image
|
||||
src={darkImage}
|
||||
{...imageAttrs}
|
||||
class:list={{ 'light:sl-hidden': Boolean(lightImage) }}
|
||||
/>
|
||||
)}
|
||||
{lightImage && <Image src={lightImage} {...imageAttrs} class="dark:sl-hidden" />}
|
||||
{rawHtml && <div class="hero-html sl-flex" set:html={rawHtml} />}
|
||||
</>
|
||||
)}
|
||||
<div class="sl-flex stack">
|
||||
<div class="sl-flex copy">
|
||||
<h1 id={PAGE_TITLE_ID} data-page-title set:html={title} />
|
||||
{tagline && <div class="tagline" set:html={tagline} />}
|
||||
</div>
|
||||
{actions.length > 0 && (
|
||||
<div class="sl-flex actions">
|
||||
{actions.map(
|
||||
({ attrs: { class: className, ...attrs } = {}, icon, link: href, text, variant }) => (
|
||||
<LinkButton {href} {variant} icon={icon?.name} class:list={[className]} {...attrs}>
|
||||
{text}
|
||||
{icon?.html && <Fragment set:html={icon.html} />}
|
||||
</LinkButton>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@layer starlight.core {
|
||||
.hero {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero > img,
|
||||
.hero > .hero-html {
|
||||
object-fit: contain;
|
||||
width: min(70%, 20rem);
|
||||
height: auto;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.stack {
|
||||
flex-direction: column;
|
||||
gap: clamp(1.5rem, calc(1.5rem + 1vw), 2rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.copy {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copy > * {
|
||||
max-width: 50ch;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(var(--sl-text-3xl), calc(0.25rem + 5vw), var(--sl-text-6xl));
|
||||
line-height: var(--sl-line-height-headings);
|
||||
font-weight: 600;
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: clamp(var(--sl-text-base), calc(0.0625rem + 2vw), var(--sl-text-xl));
|
||||
color: var(--sl-color-gray-2);
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: 1rem 2rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
.hero {
|
||||
grid-template-columns: 7fr 4fr;
|
||||
gap: 3%;
|
||||
padding-block: clamp(2.5rem, calc(1rem + 10vmin), 10rem);
|
||||
}
|
||||
|
||||
.hero > img,
|
||||
.hero > .hero-html {
|
||||
order: 2;
|
||||
width: min(100%, 25rem);
|
||||
}
|
||||
|
||||
.stack {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.copy {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
256
docs/src/components/OscilloscopeDisplay.astro
Normal file
256
docs/src/components/OscilloscopeDisplay.astro
Normal file
@ -0,0 +1,256 @@
|
||||
---
|
||||
/**
|
||||
* 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 -->
|
||||
<div class="scope-brand">
|
||||
<span class="scope-brand-name">Tektronix</span>
|
||||
<span class="scope-brand-model">mcltspice</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control panel -->
|
||||
<div class="scope-panel">
|
||||
<div class="scope-controls-row">
|
||||
<!-- VERTICAL section -->
|
||||
<div class="scope-section">
|
||||
<span class="scope-section-label">Vertical</span>
|
||||
<div class="scope-knob" aria-hidden="true"></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" aria-hidden="true"></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">
|
||||
"<a href="http://oscilloscopemusic.com/" target="_blank" rel="noopener">Spirals</a>" by Jerobeam Fenderson
|
||||
(<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener license">CC BY-NC-SA</a>)
|
||||
· 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio id="scope-audio" src="/spirals_shrt.mp3" loop preload="none"></audio>
|
||||
|
||||
<script>
|
||||
// ── 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);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Init when DOM ready (works with Astro's page lifecycle)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initScope);
|
||||
} else {
|
||||
initScope();
|
||||
}
|
||||
|
||||
// Re-init on Astro page transitions
|
||||
document.addEventListener('astro:page-load', initScope);
|
||||
</script>
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
@import "@astrojs/starlight-tailwind";
|
||||
@import "tailwindcss";
|
||||
@import "./oscilloscope.css";
|
||||
|
||||
:root {
|
||||
--sl-color-accent-low: #083344; /* teal-950 */
|
||||
|
||||
343
docs/src/styles/oscilloscope.css
Normal file
343
docs/src/styles/oscilloscope.css
Normal file
@ -0,0 +1,343 @@
|
||||
/* Oscilloscope display -- Tektronix 465 inspired
|
||||
* Tan/champagne panel, recessed CRT, teal phosphor
|
||||
* Designed for mcltspice docs hero section */
|
||||
|
||||
/* ── 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: 340px;
|
||||
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.75;
|
||||
}
|
||||
|
||||
.scope-brand-model {
|
||||
font-family: var(--sl-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.55;
|
||||
}
|
||||
|
||||
/* ── 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(--sl-font-mono, ui-monospace, monospace);
|
||||
font-size: 0.45rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--scope-label);
|
||||
opacity: 0.6;
|
||||
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(--sl-font-mono, ui-monospace, monospace);
|
||||
font-size: 0.4rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--scope-label);
|
||||
opacity: 0.5;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── 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(--sl-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(--sl-font-mono, ui-monospace, monospace);
|
||||
font-size: 0.45rem;
|
||||
color: var(--scope-panel-dark);
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── 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: 280px;
|
||||
}
|
||||
|
||||
.scope-knob {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.scope-knob::after {
|
||||
height: 5px;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user