training/dashboard(theme): per-theme settings · vaporwave overhaul

Per-theme settings panel
- New section in the theme panel that auto-shows the active theme's
  knobs (and only the active theme). Built dynamically from a
  THEMES spec; new params drop in by adding one entry.
- drift: blob count (3-12), size, blur, opacity
- lava: bubble count (4-16), mean size, σ spread, goo merge
  strength, goo blur σ. Bubbles are now JS-generated with sizes
  drawn from N(mean, σ) so the spread slider produces real
  variance, not just one-size-fits-all.
- vaporwave: grid cell, horizon angle, sun size, horizon position,
  blind width.
- laser: beam count, thickness, blur, opacity. Beams JS-generated.

Vaporwave overhaul (was: a single perspective grid + radial sun)
- Layered scene: gradient sky → palette-blended sun with venetian-
  blind stripes on the lower half → glowing horizon line → palette
  perspective floor → CRT-style scanline overlay
- Sun box-shadow gives a halo; horizon has multi-stop glow

Live SVG goo filter
- The lava merge strength / blur sliders now mutate the inline
  <feGaussianBlur stdDeviation> and <feColorMatrix values>
  attributes via JS (CSS vars don't work inside SVG filter primitives).

Settings persist in localStorage; new params are merged in over
older snapshots so prior reloads don't break.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Max Gorog 2026-05-07 22:57:26 -05:00
parent fe0ba239ed
commit a027be72e7
3 changed files with 359 additions and 107 deletions

View file

@ -75,56 +75,26 @@ body[data-theme="vaporwave"] .bg-vaporwave { display: block; }
body[data-theme="laser"] .bg-laser { display: block; }
/* drift soft, blurred radial blobs floating up and back down.
Renamed from the previous "lava" implementation. */
The blobs are JS-generated; styling here uses theme variables
(--drift-blur, --drift-opacity) so live slider changes don't
need a DOM rebuild. */
.drift-blob {
position: absolute; width: 36vw; height: 36vw; max-width: 520px; max-height: 520px;
border-radius: 50%; filter: blur(70px); opacity: 0.55;
position: absolute; border-radius: 50%;
filter: blur(var(--drift-blur, 70px));
opacity: var(--drift-opacity, 0.55);
mix-blend-mode: screen;
}
.drift-blob.db1 { left: 6%; bottom: -20%; background: var(--c1);
animation: drift-rise calc(16s / var(--anim-speed, 1)) ease-in-out infinite; }
.drift-blob.db2 { left: 28%; bottom: -30%; background: var(--c2);
animation: drift-rise calc(22s / var(--anim-speed, 1)) ease-in-out -4s infinite; }
.drift-blob.db3 { left: 52%; bottom: -25%; background: var(--c3);
animation: drift-rise calc(19s / var(--anim-speed, 1)) ease-in-out -8s infinite; }
.drift-blob.db4 { left: 70%; bottom: -35%; background: var(--c4);
animation: drift-rise calc(25s / var(--anim-speed, 1)) ease-in-out -2s infinite; }
.drift-blob.db5 { left: 86%; bottom: -22%; background: var(--c5);
animation: drift-rise calc(18s / var(--anim-speed, 1)) ease-in-out -10s infinite; }
.drift-blob.db6 { left: 40%; bottom: -40%; background: var(--c1);
animation: drift-rise calc(28s / var(--anim-speed, 1)) ease-in-out -14s infinite; }
@keyframes drift-rise {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-130vh) scale(1.4); }
}
/* lava proper lava-lamp metaballs via SVG goo filter. The filter
is a Gaussian blur followed by an alpha threshold that creates a
gooey edge; bubbles that overlap merge into a single glowing
shape. Defined inline in the HTML <svg> at the top. */
.goo-container {
position: absolute; inset: 0;
filter: url(#goo);
}
.goo-bubble {
position: absolute; border-radius: 50%; opacity: 0.95;
}
.goo-bubble.gb1 { width: 8vw; height: 8vw; left: 18%; bottom: -10vw; background: var(--c1);
animation: goo-rise calc(14s / var(--anim-speed, 1)) ease-in-out -1s infinite; }
.goo-bubble.gb2 { width: 11vw; height: 11vw; left: 30%; bottom: -16vw; background: var(--c2);
animation: goo-rise calc(20s / var(--anim-speed, 1)) ease-in-out -7s infinite; }
.goo-bubble.gb3 { width: 7vw; height: 7vw; left: 44%; bottom: -10vw; background: var(--c3);
animation: goo-rise calc(17s / var(--anim-speed, 1)) ease-in-out -3s infinite; }
.goo-bubble.gb4 { width: 13vw; height: 13vw; left: 56%; bottom: -20vw; background: var(--c4);
animation: goo-rise calc(24s / var(--anim-speed, 1)) ease-in-out -11s infinite; }
.goo-bubble.gb5 { width: 9vw; height: 9vw; left: 70%; bottom: -12vw; background: var(--c5);
animation: goo-rise calc(19s / var(--anim-speed, 1)) ease-in-out -5s infinite; }
.goo-bubble.gb6 { width: 6vw; height: 6vw; left: 80%; bottom: -8vw; background: var(--c1);
animation: goo-rise calc(15s / var(--anim-speed, 1)) ease-in-out -9s infinite; }
.goo-bubble.gb7 { width: 10vw; height: 10vw; left: 8%; bottom: -14vw; background: var(--c2);
animation: goo-rise calc(22s / var(--anim-speed, 1)) ease-in-out -13s infinite; }
.goo-bubble.gb8 { width: 7vw; height: 7vw; left: 90%; bottom: -10vw; background: var(--c3);
animation: goo-rise calc(18s / var(--anim-speed, 1)) ease-in-out -2s infinite; }
/* lava metaballs via SVG goo filter. The filter values
(stdDeviation, alpha-threshold matrix entries) are updated by
JS as the goo strength / blur sliders move. Bubbles are
JS-generated so their statistical-distribution sliders work. */
.goo-container { position: absolute; inset: 0; filter: url(#goo); }
.goo-bubble { position: absolute; border-radius: 50%; opacity: 0.95; }
@keyframes goo-rise {
0% { transform: translateY(0) scale(0.9); }
45% { transform: translateY(-65vh) scale(1.05); }
@ -132,51 +102,108 @@ body[data-theme="laser"] .bg-laser { display: block; }
100% { transform: translateY(-130vh) scale(0.85); }
}
/* vaporwave — perspective grid crawling toward the viewer + a sun. */
.bg-vw-grid {
position: absolute; left: -50%; right: -50%; top: 60%; bottom: -10%;
background-image:
linear-gradient(transparent 96%, var(--c1) 96%),
linear-gradient(90deg, transparent 98%, var(--c2) 98%);
background-size: 100% 80px, 80px 100%;
transform: perspective(700px) rotateX(62deg);
transform-origin: top center;
animation: vw-grid calc(4s / var(--anim-speed, 1)) linear infinite;
opacity: 0.6;
/* Vaporwave (overhauled)
Layered: gradient sky palette-blended sun with venetian-blind
stripes glowing horizon perspective grid floor CRT scanlines.
Knobs (CSS vars set by sliders): --vw-horizon, --vw-grid-size,
--vw-perspective, --vw-sun-size, --vw-blind. */
.bg-vaporwave {
position: absolute; inset: 0;
background: radial-gradient(
ellipse 100% 70% at 50% var(--vw-horizon, 55%),
transparent 0%, transparent 60%,
rgba(0, 0, 0, 0.6) 100%);
}
@keyframes vw-grid {
from { background-position: 0 0, 0 0; }
to { background-position: 0 80px, 0 0; }
.vw-sky {
position: absolute; left: 0; right: 0; top: 0;
height: var(--vw-horizon, 55%);
background: linear-gradient(
to bottom,
oklch(15% 0.20 var(--theme-h, 250)) 0%,
var(--c1) 60%,
var(--c2) 100%);
}
.bg-vw-sun {
position: absolute; left: 50%; top: 35%;
width: 50vmin; height: 50vmin; transform: translate(-50%, 0);
.vw-sun {
position: absolute; left: 50%;
top: var(--vw-horizon, 55%);
width: var(--vw-sun-size, 50vmin);
height: var(--vw-sun-size, 50vmin);
transform: translate(-50%, -78%);
border-radius: 50%;
background: radial-gradient(circle at 50% 50%, var(--c3), var(--c4) 60%, transparent 75%);
opacity: 0.55; filter: blur(2px);
background: linear-gradient(
to bottom,
var(--c3) 0%,
var(--c4) 50%,
var(--c5) 100%);
overflow: hidden;
box-shadow: 0 0 80px var(--c4), 0 0 160px var(--c3);
}
/* Venetian-blind stripes on the lower 50% black-bg occluders, so
the sun's gradient peeks through the gaps. */
.vw-sun-blinds {
position: absolute; left: 0; right: 0; bottom: 0;
height: 50%;
background: repeating-linear-gradient(
to bottom,
transparent 0 calc(var(--vw-blind, 11px) - 4px),
rgba(0, 0, 0, 0.92) calc(var(--vw-blind, 11px) - 4px) var(--vw-blind, 11px));
}
.vw-horizon {
position: absolute; left: 0; right: 0;
top: var(--vw-horizon, 55%);
height: 1px; background: var(--c1);
box-shadow:
0 0 20px var(--c1),
0 0 40px var(--c1),
0 0 80px var(--c2);
}
.vw-floor {
position: absolute; left: -50%; right: -50%;
top: var(--vw-horizon, 55%); bottom: -10%;
perspective: 800px; overflow: hidden;
}
.vw-floor::before {
content: ''; position: absolute; inset: 0 0 -50% 0;
background-image:
linear-gradient(transparent calc(100% - 1px), var(--c1) calc(100% - 1px)),
linear-gradient(90deg, transparent calc(100% - 1px), var(--c2) calc(100% - 1px));
background-size: 100% var(--vw-grid-size, 80px), var(--vw-grid-size, 80px) 100%;
transform: rotateX(var(--vw-perspective, 62deg));
transform-origin: top center;
animation: vw-floor calc(4s / var(--anim-speed, 1)) linear infinite;
}
@keyframes vw-floor {
from { background-position: 0 0, 0 0; }
to { background-position: 0 var(--vw-grid-size, 80px), 0 0; }
}
.vw-scanlines {
position: absolute; inset: 0; pointer-events: none;
background: repeating-linear-gradient(
to bottom,
transparent 0 2px,
rgba(0, 0, 0, 0.18) 2px 4px);
mix-blend-mode: multiply;
}
/* laser show — long beams rotating from screen center, palette colors. */
/* laser show long beams rotating from screen center, palette colors.
Beams JS-generated so count/thickness/blur sliders work. */
.laser-beam {
position: absolute; left: 50%; top: 50%;
width: 200vmax; height: 3px; transform-origin: left center;
filter: blur(2px); opacity: 0.55; mix-blend-mode: screen;
height: var(--laser-thickness, 3px);
width: 200vmax; transform-origin: left center;
filter: blur(var(--laser-blur, 2px));
opacity: var(--laser-opacity, 0.55);
mix-blend-mode: screen;
}
.laser-beam.beam1 { background: linear-gradient(90deg, transparent, var(--c1), transparent);
animation: laser-sweep calc(9s / var(--anim-speed, 1)) linear infinite; }
.laser-beam.beam2 { background: linear-gradient(90deg, transparent, var(--c2), transparent);
animation: laser-sweep calc(13s / var(--anim-speed, 1)) linear infinite reverse; }
.laser-beam.beam3 { background: linear-gradient(90deg, transparent, var(--c3), transparent);
animation: laser-sweep calc(11s / var(--anim-speed, 1)) linear infinite; }
.laser-beam.beam4 { background: linear-gradient(90deg, transparent, var(--c4), transparent);
animation: laser-sweep calc(17s / var(--anim-speed, 1)) linear infinite reverse; }
.laser-beam.beam5 { background: linear-gradient(90deg, transparent, var(--c5), transparent);
animation: laser-sweep calc(15s / var(--anim-speed, 1)) linear infinite; }
@keyframes laser-sweep {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ─── Per-theme settings section ───────────────────────────────────── */
.theme-bg-section { display: none; }
.theme-bg-section.is-active { display: block; }
/* Continuous-harmony hint line */
.theme-harmony-hint {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;

View file

@ -250,17 +250,73 @@
// Animations across the bg-canvas read --anim-speed; the bg blur
// reads --bg-blur. State persists in localStorage.
(function () {
// Per-theme settings spec. Each entry becomes a slider in the
// panel under the active-theme section. `step` rounds the value
// and decides how many decimals to show.
const THEMES = {
drift: {
label: 'drift settings',
params: {
count: { default: 6, min: 3, max: 12, step: 1, label: 'blobs', rebuild: true },
sizeVw: { default: 36, min: 12, max: 60, step: 1, label: 'size (vw)', rebuild: true },
blur: { default: 70, min: 20, max: 150, step: 1, label: 'blur (px)' },
opacity: { default: 0.55, min: 0.1, max: 1, step: 0.05, label: 'opacity' },
},
},
lava: {
label: 'lava settings',
params: {
count: { default: 8, min: 4, max: 16, step: 1, label: 'bubbles', rebuild: true },
sizeMean: { default: 9, min: 3, max: 18, step: 0.5, label: 'mean size (vw)', rebuild: true },
sizeSpread: { default: 4, min: 0, max: 8, step: 0.5, label: 'size σ (spread)', rebuild: true },
gooStr: { default: 26, min: 8, max: 50, step: 1, label: 'merge strength' },
gooBlur: { default: 22, min: 8, max: 40, step: 1, label: 'merge blur (σ)' },
},
},
vaporwave: {
label: 'vaporwave settings',
params: {
gridSize: { default: 80, min: 30, max: 200, step: 5, label: 'grid cell (px)' },
perspective: { default: 62, min: 30, max: 80, step: 1, label: 'horizon angle (°)' },
sunSize: { default: 50, min: 20, max: 80, step: 1, label: 'sun size (vmin)' },
horizonPct: { default: 55, min: 35, max: 70, step: 1, label: 'horizon position (%)' },
blindWidth: { default: 11, min: 6, max: 30, step: 1, label: 'blind width (px)' },
},
},
laser: {
label: 'laser settings',
params: {
count: { default: 5, min: 2, max: 12, step: 1, label: 'beams', rebuild: true },
thickness: { default: 3, min: 1, max: 12, step: 1, label: 'thickness (px)' },
blur: { default: 2, min: 0, max: 10, step: 1, label: 'blur (px)' },
opacity: { default: 0.55, min: 0.1, max: 1, step: 0.05, label: 'opacity' },
},
},
};
function defaultThemeSettings() {
const out = {};
for (const t in THEMES) {
out[t] = {};
for (const p in THEMES[t].params) {
out[t][p] = THEMES[t].params[p].default;
}
}
return out;
}
const DEFAULTS = {
background: 'black',
L: 70, C: 0.15, H: 250,
count: 3,
spread: 60,
offsets: null, // populated by regenerate() at boot
offsets: null,
lVar: 0,
cVar: 0,
animSpeed: 1.0,
bgBlur: 0,
tint: 0.10,
themes: defaultThemeSettings(),
};
const state = Object.assign({}, DEFAULTS);
@ -281,18 +337,22 @@
try {
const stored = JSON.parse(localStorage.getItem('cis490-theme') || '{}');
// Migrate older state shapes (had `harmony` + multiplicative
// spread). Drop those fields silently.
delete stored.harmony;
delete stored.harmony; // discrete-harmony field (gone)
Object.assign(state, stored);
if (!Array.isArray(state.offsets) || state.offsets.length !== state.count) {
state.offsets = evenOffsets(state.count, state.spread);
}
// Coerce spread back to angular if it looks like a stale multiplier.
if (typeof state.spread === 'number' && state.spread <= 5) {
state.spread = 60;
state.offsets = evenOffsets(state.count, state.spread);
}
// Merge in theme defaults for any missing per-theme params
// (state shape grows over time as new sliders ship).
const fresh = defaultThemeSettings();
state.themes = state.themes || {};
for (const t in fresh) {
state.themes[t] = Object.assign({}, fresh[t], state.themes[t] || {});
}
} catch {}
if (!state.offsets) state.offsets = evenOffsets(state.count, state.spread);
@ -363,6 +423,39 @@
root.setProperty('--bg-blur', `${state.bgBlur}px`);
root.setProperty('--tint-strength', state.tint);
document.body.dataset.theme = state.background;
applyThemeSpecificVars();
}
// Per-theme CSS variables derived from state.themes.<name>. Only
// the variables for the currently-active theme need to be set,
// but it's cheap to set them all so the dropdown switch is
// instant.
function applyThemeSpecificVars() {
const root = document.documentElement.style;
const t = state.themes;
// drift
root.setProperty('--drift-blur', `${t.drift.blur}px`);
root.setProperty('--drift-opacity', t.drift.opacity);
// vaporwave
root.setProperty('--vw-grid-size', `${t.vaporwave.gridSize}px`);
root.setProperty('--vw-perspective', `${t.vaporwave.perspective}deg`);
root.setProperty('--vw-sun-size', `${t.vaporwave.sunSize}vmin`);
root.setProperty('--vw-horizon', `${t.vaporwave.horizonPct}%`);
root.setProperty('--vw-blind', `${t.vaporwave.blindWidth}px`);
// laser
root.setProperty('--laser-thickness', `${t.laser.thickness}px`);
root.setProperty('--laser-blur', `${t.laser.blur}px`);
root.setProperty('--laser-opacity', t.laser.opacity);
// lava goo filter — has to be set on the SVG element directly
const fb = document.querySelector('#goo feGaussianBlur');
const fc = document.querySelector('#goo feColorMatrix');
if (fb) fb.setAttribute('stdDeviation', String(t.lava.gooBlur));
if (fc) {
const a = t.lava.gooStr;
const b = Math.round(a / 2 + 1);
fc.setAttribute('values',
`1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${a} -${b}`);
}
}
function applyForm() {
@ -428,14 +521,97 @@
ms.forEach((m, i) => positionMarker(m, i));
}
function apply({ rebuildMarkers = false } = {}) {
function apply({ rebuildMarkers = false, rebuildBg = false } = {}) {
applyCSSVars();
applyForm();
if (rebuildMarkers) renderMarkersFull();
else updateMarkers();
if (rebuildBg) rebuildBackgroundElements();
showActiveBgSection();
try { localStorage.setItem('cis490-theme', JSON.stringify(state)); } catch {}
}
// ── Per-theme background element generation ───────────────────
// drift / lava / laser each have collections of small DOM nodes
// whose count and individual properties depend on per-theme
// settings. Rebuild only when count or statistical-spread params
// change; styling-only updates (blur, opacity, thickness) flow
// through CSS variables and don't need a rebuild.
// Box-Muller for size variance.
function gauss(mean, std) {
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return mean + std * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function rebuildDrift() {
const root = document.getElementById('bg-drift');
if (!root) return;
root.innerHTML = '';
const t = state.themes.drift;
for (let i = 0; i < t.count; i++) {
const b = document.createElement('span');
b.className = 'drift-blob';
b.style.width = b.style.height = `${t.sizeVw}vw`;
b.style.left = `${(Math.random() * 90).toFixed(1)}%`;
b.style.bottom = `-${(15 + Math.random() * 25).toFixed(0)}%`;
b.style.background = `var(--c${(i % 5) + 1})`;
const dur = (14 + Math.random() * 16).toFixed(1);
const delay = (-Math.random() * parseFloat(dur)).toFixed(1);
b.style.animation =
`drift-rise calc(${dur}s / var(--anim-speed, 1)) ease-in-out ${delay}s infinite`;
root.appendChild(b);
}
}
function rebuildLava() {
const root = document.getElementById('bg-lava-bubbles');
if (!root) return;
root.innerHTML = '';
const t = state.themes.lava;
for (let i = 0; i < t.count; i++) {
const b = document.createElement('span');
b.className = 'goo-bubble';
const sz = Math.max(2, Math.min(28, gauss(t.sizeMean, t.sizeSpread)));
b.style.width = b.style.height = `${sz.toFixed(1)}vw`;
b.style.left = `${(Math.random() * 92).toFixed(1)}%`;
b.style.bottom = `-${(sz + 4).toFixed(1)}vw`;
b.style.background = `var(--c${(i % 5) + 1})`;
const dur = (14 + Math.random() * 14).toFixed(1);
const delay = (-Math.random() * parseFloat(dur)).toFixed(1);
b.style.animation =
`goo-rise calc(${dur}s / var(--anim-speed, 1)) ease-in-out ${delay}s infinite`;
root.appendChild(b);
}
}
function rebuildLaser() {
const root = document.getElementById('bg-laser-beams');
if (!root) return;
root.innerHTML = '';
const t = state.themes.laser;
for (let i = 0; i < t.count; i++) {
const b = document.createElement('span');
b.className = 'laser-beam';
b.style.background =
`linear-gradient(90deg, transparent, var(--c${(i % 5) + 1}), transparent)`;
const dur = (8 + Math.random() * 12).toFixed(1);
const delay = (-Math.random() * parseFloat(dur)).toFixed(1);
const dir = i % 2 === 0 ? 'normal' : 'reverse';
b.style.animation =
`laser-sweep calc(${dur}s / var(--anim-speed, 1)) linear ${delay}s infinite ${dir}`;
root.appendChild(b);
}
}
function rebuildBackgroundElements() {
rebuildDrift();
rebuildLava();
rebuildLaser();
}
// ── Drag handling ──────────────────────────────────────────────
// Primary marker (idx 0): drag rotates the entire palette
// (changes state.H). Other markers: drag moves just that
@ -512,6 +688,61 @@
els.bg.addEventListener('change', e => { state.background = e.target.value; apply(); });
// ── Per-theme settings panel ──────────────────────────────────
// Build one section per theme (each a <details>) inside
// #theme-bg-settings. CSS hides all but the active one (via the
// `.is-active` class managed by showActiveBgSection).
const $bgSettings = document.getElementById('theme-bg-settings');
for (const themeName in THEMES) {
const spec = THEMES[themeName];
const section = document.createElement('details');
section.className = 'theme-advanced theme-bg-section';
section.dataset.theme = themeName;
section.open = true;
const summary = document.createElement('summary');
summary.textContent = spec.label;
section.appendChild(summary);
const sliders = document.createElement('div');
sliders.className = 'theme-sliders';
for (const key in spec.params) {
const p = spec.params[key];
const id = `theme-${themeName}-${key}`;
const v = state.themes[themeName][key];
const fmt = p.step >= 1 ? Math.round(v) : v.toFixed(p.step >= 0.1 ? 2 : 3);
const label = document.createElement('label');
label.innerHTML =
`${p.label} · <span id="${id}-val">${fmt}</span>` +
`<input type="range" id="${id}" min="${p.min}" max="${p.max}" ` +
`value="${v}" step="${p.step}">`;
sliders.appendChild(label);
}
section.appendChild(sliders);
$bgSettings.appendChild(section);
// Bind inputs after they're in the DOM.
for (const key in spec.params) {
const p = spec.params[key];
const id = `theme-${themeName}-${key}`;
const input = document.getElementById(id);
const valSpan = document.getElementById(`${id}-val`);
if (!input || !valSpan) continue;
input.addEventListener('input', e => {
const v = parseFloat(e.target.value);
state.themes[themeName][key] = v;
valSpan.textContent = p.step >= 1 ? Math.round(v) :
v.toFixed(p.step >= 0.1 ? 2 : 3);
apply({ rebuildBg: !!p.rebuild });
});
}
}
function showActiveBgSection() {
document.querySelectorAll('.theme-bg-section').forEach(s => {
s.classList.toggle('is-active', s.dataset.theme === state.background);
});
}
// count + spread together regenerate offsets to an evenly-fanned
// distribution. Any prior per-marker drags are overwritten —
// the user is asking for a clean palette by touching these.
@ -543,7 +774,7 @@
els.panel.addEventListener('click', e => e.stopPropagation());
els.panel.addEventListener('pointerdown', e => e.stopPropagation());
apply({ rebuildMarkers: true });
apply({ rebuildMarkers: true, rebuildBg: true });
})();
// ────────────────────────────────────────────────────────────────

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CIS490 — live</title>
<link rel="stylesheet" href="/static/dashboard.css?v=d28553bc">
<link rel="stylesheet" href="/static/dashboard.css?v=bb44f27a">
</head>
<body>
<!-- SVG filter defs for the lava-lamp goo effect. Width/height 0
@ -25,37 +25,27 @@
</svg>
<!-- Theme background layers — exactly one is visible at a time,
selected by body[data-theme]. Colors come from palette CSS
variables (--c1..c5) computed by the theme machinery from the
OKLCH base and harmony rule. The bg-tint vignette below
applies on every theme so even "black" picks up palette
character. -->
selected by body[data-theme]. The blobs / bubbles / beams
inside drift / lava / laser are generated by JS so the count
and statistical-distribution sliders actually take effect. -->
<div class="bg-canvas" id="bg-canvas" aria-hidden="true">
<div class="bg-tint"></div>
<!-- Drift: the original soft-blob version of "lava lamp" -->
<div class="bg-drift">
<span class="drift-blob db1"></span><span class="drift-blob db2"></span>
<span class="drift-blob db3"></span><span class="drift-blob db4"></span>
<span class="drift-blob db5"></span><span class="drift-blob db6"></span>
</div>
<div class="bg-drift" id="bg-drift"></div>
<!-- Lava: SVG-goo metaballs that merge as bubbles approach -->
<div class="bg-lava">
<div class="goo-container">
<span class="goo-bubble gb1"></span><span class="goo-bubble gb2"></span>
<span class="goo-bubble gb3"></span><span class="goo-bubble gb4"></span>
<span class="goo-bubble gb5"></span><span class="goo-bubble gb6"></span>
<span class="goo-bubble gb7"></span><span class="goo-bubble gb8"></span>
</div>
<div class="goo-container" id="bg-lava-bubbles"></div>
</div>
<div class="bg-vaporwave"><div class="bg-vw-grid"></div><div class="bg-vw-sun"></div></div>
<div class="bg-laser">
<span class="laser-beam beam1"></span><span class="laser-beam beam2"></span>
<span class="laser-beam beam3"></span><span class="laser-beam beam4"></span>
<span class="laser-beam beam5"></span>
<div class="bg-vaporwave">
<div class="vw-sky"></div>
<div class="vw-sun"><div class="vw-sun-blinds"></div></div>
<div class="vw-horizon"></div>
<div class="vw-floor"></div>
<div class="vw-scanlines"></div>
</div>
<div class="bg-laser" id="bg-laser-beams"></div>
</div>
<!-- Floating theme panel, toggled by `t`. -->
@ -116,7 +106,7 @@
</div>
<details class="theme-advanced" open>
<summary>animation</summary>
<summary>animation · global</summary>
<div class="theme-sliders">
<label>speed · <span id="theme-speed-val">1.00</span>×
<input type="range" id="theme-speed" min="0.1" max="4" value="1" step="0.05"></label>
@ -127,6 +117,10 @@
</div>
</details>
<!-- Per-theme settings — dynamically built by JS from the THEMES
spec; only the section matching state.background is shown. -->
<div id="theme-bg-settings"></div>
<div class="theme-meta-row">
<code id="theme-meta">oklch(70% 0.15 250)</code>
<button id="theme-reset" class="ghost">reset</button>
@ -483,6 +477,6 @@
</article>
</div>
<script src="/static/dashboard.js?v=a8db3285"></script>
<script src="/static/dashboard.js?v=0de4be30"></script>
</body>
</html>