Compare commits

...

2 Commits

Author SHA1 Message Date
BenStullsBets e862fb498b feat(sim): audio on/off toggle -> 0-10 level dial; first video lifts it to 3/10
Operator request. Replaces the Audio checkbox with a 0-10 range slider that sets the
master soundtrack gain (audioVol = level/10), scaling both the per-scale rest volume
and the two-track scrub crossfade. Level 0 = silent. Turning Video on for the first
time now raises audio to 3/10 (was: full on) so the experience opens gently; raising
the slider from 0 unlocks playback in-gesture (Safari). E2E: dial is range 0-10
default 0, and first video-on lifts it to 3/10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:02:19 -07:00
BenStullsBets ea530b3df6 fix(sim): double-buffer video so morph->loop handoff doesn't stall
Operator eyeball: a jolt/pause when the morph ends and the next altitude's loop
loads. Root cause: swapping vid.src to the base + seeking to the tail reloaded the
ON-SCREEN element (decode + seek stall). Now a dedicated #vid-loop element carries
the steady-state base loop; during a scrub it is PRELOADED to the clip we're heading
toward, seeked to the tail and paused on that exact frame. The paint shader reads the
active source (busy ? morph : loop), so landing is an instant source swap — no reload,
no seek. E2E asserts the loop element is armed + playing from ~3s after landing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:58:08 -07:00
4 changed files with 145 additions and 67 deletions
+32 -10
View File
@@ -29,13 +29,13 @@ async function wheelOnStage(page: Page, deltaY: number) {
await page.locator("#stage").dispatchEvent("wheel", { deltaY });
}
// The Audio toggle is a visually-hidden checkbox styled as a switch; set it + fire
// `change` directly (headless Chromium relaxes the autoplay gesture requirement).
// The Audio control is a 010 level slider; set it to full + fire `input` directly
// (headless Chromium relaxes the autoplay gesture requirement).
async function enableAudio(page: Page) {
await page.evaluate(() => {
const c = document.querySelector("#audio") as HTMLInputElement;
c.checked = true;
c.dispatchEvent(new Event("change", { bubbles: true }));
c.value = "10";
c.dispatchEvent(new Event("input", { bubbles: true }));
});
}
@@ -55,6 +55,27 @@ test("two audio elements exist for crossfade", async ({ page }) => {
expect(n).toBe(2);
});
test("audio is a 0-10 level dial, defaulting to 0", async ({ page }) => {
await boot(page);
const ctl = await page.evaluate(() => {
const c = document.querySelector("#audio") as HTMLInputElement;
return { type: c.type, min: c.min, max: c.max, value: c.value };
});
expect(ctl.type).toBe("range");
expect(ctl.min).toBe("0");
expect(ctl.max).toBe("10");
expect(ctl.value).toBe("0"); // silent until raised
});
test("turning video on for the first time lifts audio to 3/10", async ({ page }) => {
await boot(page);
expect(await page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("0");
await enableVideo(page); // first video-on couples audio at a gentle level
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3");
// The level label reflects it.
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3");
});
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
await boot(page);
await enableAudio(page); // hidden custom toggle → set + dispatch change
@@ -93,19 +114,20 @@ test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)",
const start = (await page.locator("#scale-name").textContent())!;
await wheelOnStage(page, 60);
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
// After landing, the base loop is armed (loopTail="1") and seeked to the tail
// (~3s) so it continues the morph's last frame instead of restarting at 0.
// After landing, the dedicated loop element (#vid-loop) is armed (loopTail="1") and
// playing from the tail (~3s) — preloaded during the scrub, so the landing was a
// source swap, not a reload that restarts at 0.
await page.waitForFunction(
() => { const v = document.querySelector("video") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.currentTime >= 2.5; },
() => { const v = document.querySelector("#vid-loop") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.currentTime >= 2.5; },
null,
{ timeout: 10000 },
);
const v = await page.evaluate(() => {
const el = document.querySelector("video") as HTMLVideoElement;
return { t: el.currentTime, loopTail: el.dataset.loopTail, morph: el.dataset.morph || "" };
const el = document.querySelector("#vid-loop") as HTMLVideoElement;
return { t: el.currentTime, loopTail: el.dataset.loopTail, paused: el.paused };
});
expect(v.morph).toBe(""); // on the base loop, not a morph
expect(v.loopTail).toBe("1"); // tail-loop armed
expect(v.paused).toBe(false); // the loop is actually playing
expect(v.t).toBeGreaterThanOrEqual(2.5); // looping from ~3s, not 0
});
+99 -52
View File
@@ -7,8 +7,22 @@
const $ = (id) => document.getElementById(id);
const vid = $("vid"), paint = $("paint"), tint = $("tint"), overlay = $("overlay"),
black = $("black"), readout = $("readout");
const loopVid = $("vid-loop"); // steady-state base loop element (double-buffer; #vid carries the scrub morphs)
const affectLayer = $("affect");
// The currently-displayed source element: the morph (#vid) while scrubbing (busy),
// else the preloaded base loop (#vid-loop). The paint shader + fallback read this so
// a landing is a source swap, never a reload of the on-screen element.
function displayVid() { return busy ? vid : loopVid; }
// Show the active source, hide the other (matters only in the WebGL-less fallback;
// with the paint canvas it composites the active source anyway).
function showActiveSource() {
const a = displayVid();
a.style.opacity = "1";
(a === vid ? loopVid : vid).style.opacity = "0";
}
// Right-dream state, read by the painterly render loop each frame. update() (debounced
// on knob moves) writes these; the WebGL loop renders the live video continuously.
let dreamIntensity = 0; // 0..1 — painterly strength (and dream pastel/luminous)
@@ -222,31 +236,39 @@ function preloadChip() {
// to the loop's first frame, and the morph-covered intro never re-shows at rest.
const LOOP_TAIL_S = 3.0;
function seekToLoopStart() {
try { vid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable yet */ }
// Load a clip's base into the LOOP element (#vid-loop), seeked to the tail and PAUSED
// on that exact frame. Idempotent per clip. Called during a scrub to PRELOAD the clip
// we're about to land on, so settle just plays an already-decoded element at the morph's
// last frame — no reload, no seek, no stall. `playLoop()` starts it at landing.
function loadLoop(clip) {
if (!clip || loopVid.dataset.clip === clip.id) return;
loopVid.dataset.clip = clip.id;
loopVid.dataset.loopTail = "1"; // arm the [LOOP_TAIL_S, duration] wrap handler
loopVid.src = mediaUrl(clip.base_file);
loopVid.loop = false; // native loop restarts at 0; we loop from the tail instead
loopVid.muted = true;
const seek = () => { try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable yet */ } };
if (loopVid.readyState >= 1) seek();
else loopVid.addEventListener("loadedmetadata", seek, { once: true });
}
function playLoop() {
if (loopVid.paused) loopVid.play().catch(() => {});
}
function ensureClipMedia() {
const clip = activeClip();
if (!clip || clip.id === currentClipId) return;
if (!clip) return;
currentClipId = clip.id;
delete vid.dataset.morph; // this is a base loop, not a morph
vid.dataset.loopTail = "1"; // arm the [LOOP_TAIL_S, duration] loop handler
vid.src = mediaUrl(clip.base_file);
vid.loop = false; // native loop restarts at 0; we loop from the tail instead
vid.muted = true;
if (vid.readyState >= 1) seekToLoopStart();
else vid.addEventListener("loadedmetadata", seekToLoopStart, { once: true });
vid.play().catch(() => {});
vid.style.opacity = "1";
loadLoop(clip); // idempotent: a no-op if the scrub already preloaded this clip
playLoop(); // steady state runs the loop
}
// The custom loop: when a base loop reaches the end, jump back to the tail offset
// (not 0). Inert during a morph (loopTail cleared in rebuildSegment), where the
// scrub drives currentTime directly.
vid.addEventListener("timeupdate", () => {
if (vid.dataset.loopTail === "1" && vid.duration && vid.currentTime >= vid.duration - 0.08) {
seekToLoopStart();
// The custom loop on the LOOP element: when a base loop reaches the end, jump back to
// the tail offset (not 0). The morph element (#vid) is driven directly by the scrub.
loopVid.addEventListener("timeupdate", () => {
if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) {
try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ }
}
});
@@ -263,8 +285,8 @@ function applyVideoLook(tone, dream) {
gradeFilter = `brightness(${bright.toFixed(3)}) saturate(${sat.toFixed(3)}) sepia(${sepia.toFixed(3)})`;
dreamIntensity = dream;
tint.style.opacity = (cool * 0.6).toFixed(3);
// WebGL-less fallback: the canvas is hidden, so grade the visible #vid directly.
if (!paintOK) vid.style.filter = gradeFilter;
// WebGL-less fallback: the canvas is hidden, so grade the visible source directly.
if (!paintOK) displayVid().style.filter = gradeFilter;
}
// --- Right dream: real-time painterly restyle (WebGL2 Kuwahara) ---
@@ -352,11 +374,12 @@ function initPaint() {
return true;
}
function paintLoop() {
if (paintOK && vid.readyState >= 2 && vid.videoWidth) {
const w = vid.videoWidth, h = vid.videoHeight;
const src = displayVid(); // morph while scrubbing, else the preloaded base loop
if (paintOK && src.readyState >= 2 && src.videoWidth) {
const w = src.videoWidth, h = src.videoHeight;
if (paint.width !== w || paint.height !== h) { paint.width = w; paint.height = h; }
gl.viewport(0, 0, w, h);
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, vid); } catch (_) {}
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, src); } catch (_) {}
// No painterly restyle during a ring transition (busy) — show it raw.
gl.uniform1f(uAmt, busy ? 0 : dreamIntensity);
gl.uniform2f(uTexel, 1 / w, 1 / h);
@@ -440,7 +463,8 @@ function boxAt(a, t) {
// Loop-normalized playback time of the base video (0..1), for track interpolation.
function loopT() {
return vid && vid.duration ? (vid.currentTime % vid.duration) / vid.duration : 0;
const v = displayVid();
return v && v.duration ? (v.currentTime % v.duration) / v.duration : 0;
}
// --- Progressive labels & emotions (content-pipeline §11.3 / §11.4) ---
@@ -561,7 +585,7 @@ function controls() {
const mood = +$("mood").value;
return {
visual: $("visual").checked ? "on" : "off",
audio: $("audio").checked ? "soundtrack" : "off",
audio: audioLevel() > 0 ? "soundtrack" : "off",
// Back-compat: a server process predating the visual/audio split still requires
// a 7-way `content` and would 422 the new payload. Send both — the current
// server ignores `content`, an old one ignores visual/audio.
@@ -591,11 +615,12 @@ async function update() {
// the <video>/<canvas> are GPU-composited and can otherwise show through the
// overlay on a real GPU (Safari/Chrome).
black.style.opacity = "1"; black.classList.remove("hidden");
vid.style.opacity = "0"; paint.style.opacity = "0";
vid.style.opacity = "0"; loopVid.style.opacity = "0"; paint.style.opacity = "0";
return;
}
black.classList.add("hidden");
vid.style.opacity = ""; paint.style.opacity = ""; // restore the video layers
paint.style.opacity = ""; // restore the painterly canvas
showActiveSource(); // show the active source (WebGL-less fallback)
try {
ensureClipMedia();
@@ -660,6 +685,7 @@ let dialDrag = null; // {lastAng, accum, moved, restPos, target} while
let pos = 0; // continuous knob position; rest value == ringIndex
let activeSeg = null; // { lo, clipLo, clipHi, file } for the segment being scrubbed
let seekPending = false; // throttle: at most one currentTime seek per animation frame
let scrubDir = 1; // last travel direction (+1 descend / -1 ascend) — which end we'll land on
// Diagnostic seam (sibling of window.__hefMorphs): the committed altitude state, so
// E2E can assert the locked clip mid-scrub when #scale-name (settle-only) is stale.
@@ -755,8 +781,6 @@ function rebuildSegment(lo, enteredFrom) {
tint.style.opacity = "0";
vid.style.filter = "none";
vid.loop = false;
vid.dataset.loopTail = "0"; // morph scrub drives currentTime; disarm the base loop handler
vid.style.opacity = "1";
if (vid.dataset.morph !== file) {
vid.dataset.morph = file;
vid.src = mediaUrl(file);
@@ -774,6 +798,8 @@ function rebuildSegment(lo, enteredFrom) {
function setPos(next) {
if (!ring || ring.scales.length < 2) return;
const n = ring.scales.length;
const dir = next > pos ? 1 : (next < pos ? -1 : scrubDir); // travel direction (for which end we'll land on)
scrubDir = dir;
for (const c of HEFScrub.integerCrossings(pos, next)) {
ringIndex = HEFScrub.wrapIndex(c.index, n);
if (activeSeg) {
@@ -787,15 +813,22 @@ function setPos(next) {
busy = false;
delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base)
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
playLoop(); // the loop element was preloaded to this clip @ tail during the scrub → instant, no reload
showActiveSource();
setNeedle(ringIndex * dialStep());
renderScaleReadout();
update(); // ensureClipMedia loads + loops the locked clip
update(); // ensureClipMedia: idempotent loadLoop + keep the loop running
restAudio(ringIndex);
refreshReachablePreload(); // warm the morphs now reachable from the locked clip
return;
}
busy = true; // mid-morph: block update() from reloading the base clip under us
if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex);
// Preload the clip we're heading toward into the LOOP element (paused @ tail) so the
// landing is a swap, not a reload+seek. dir>0 lands on clipHi, dir<0 on clipLo.
const headingId = dir > 0 ? activeSeg.clipHi : activeSeg.clipLo;
loadLoop(clipsById[headingId]);
showActiveSource(); // morph element is the live source while scrubbing
setNeedle(pos * dialStep());
blendAudio(lo, frac);
if (activeSeg.file && !seekPending) { // throttle seeks to one per frame (avoid seek thrash)
@@ -942,8 +975,9 @@ function renderDevStats() {
const total = preloadList().length;
const cached = Object.keys(mediaBlobs).length;
const fromMem = !!(c && mediaBlobs[c.base_file]);
const res = vid.videoWidth ? `${vid.videoWidth}×${vid.videoHeight}` : "—";
const dur = vid.duration ? `${vid.duration.toFixed(1)}s` : "—";
const dv = displayVid();
const res = dv.videoWidth ? `${dv.videoWidth}×${dv.videoHeight}` : "—";
const dur = dv.duration ? `${dv.duration.toFixed(1)}s` : "—";
const loop = vid.duration ? `${Math.round(loopT() * 100)}%` : "—";
dl.innerHTML =
`<dt>cache</dt><dd>${cached}/${total} cached</dd>` +
@@ -1000,6 +1034,15 @@ const audB = $("aud-b"); // second element: the two crossfade by
let audLastErr = ""; // last play() rejection / element error (for the readout)
const FADE_MS = 500;
// Audio is a 010 level dial (was an on/off toggle). 0 = silent. The level is the
// MASTER gain the per-scale soundtrack / crossfade is scaled by.
function audioLevel() { return +$("audio").value || 0; }
function audioVol() { return HEFScrub.clamp01(audioLevel() / 10); }
function updateAudioLevelLabel() {
const el = $("audio-level-val");
if (el) el.textContent = String(audioLevel());
}
// The element currently carrying the most signal — for the diagnostic readout,
// which used to assume a single `aud`. With two crossfading elements either may be
// the audible one, so report on whichever is louder (ties → aud).
@@ -1008,11 +1051,11 @@ function activeAudEl() { return audB.volume > aud.volume ? audB : aud; }
// --- Live audio status readout (diagnostic) — so a "no sound" report is legible ---
const AUD_ERR = ["", "aborted", "network", "decode", "format-not-supported"];
function audioStatusText() {
if (!$("audio").checked) return "audio: off";
if (audioLevel() === 0) return "audio: off (level 0)";
const s = ring && ring.scales[ringIndex];
const url = soundtrackUrl();
const el = activeAudEl();
const head = `ON · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
const head = `L${audioLevel()}/10 · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
if (!url) return "audio: " + head + " ← no soundtrack URL for this scale";
if (el.error) return `audio: ${head} · MEDIA ERROR ${el.error.code} (${AUD_ERR[el.error.code] || "?"})`;
if (audLastErr) return `audio: ${head} · ${audLastErr}`;
@@ -1024,9 +1067,10 @@ function updateAudioStatus() {
if (!el) return;
el.textContent = audioStatusText();
const a = activeAudEl();
const playing = $("audio").checked && !!soundtrackUrl() && !a.paused && !a.error && !audLastErr;
const on = audioLevel() > 0;
const playing = on && !!soundtrackUrl() && !a.paused && !a.error && !audLastErr;
el.classList.toggle("playing", playing);
el.classList.toggle("blocked", $("audio").checked && (!soundtrackUrl() || !!a.error || !!audLastErr));
el.classList.toggle("blocked", on && (!soundtrackUrl() || !!a.error || !!audLastErr));
}
aud.addEventListener("error", updateAudioStatus);
audB.addEventListener("error", updateAudioStatus);
@@ -1098,22 +1142,23 @@ function assignElements(urlLo, urlHi) {
return { elLo: aud, elHi: audB };
}
// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac).
// No-op when the Audio toggle is off.
// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac),
// both scaled by the master audio level. No-op when the level is 0.
function blendAudio(loIndex, frac) {
if (!$("audio").checked) return;
const lvl = audioVol();
if (lvl === 0) return;
const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1);
const g = HEFScrub.crossfadeGains(frac);
const { elLo, elHi } = assignElements(urlLo, urlHi);
ensurePlaying(elLo, urlLo, g.from);
ensurePlaying(elHi, urlHi, g.to);
ensurePlaying(elLo, urlLo, g.from * lvl);
ensurePlaying(elHi, urlHi, g.to * lvl);
updateAudioStatus();
}
// At rest on a single altitude: the element already holding it stays at full gain,
// the other fades out. Off → both fade to silence.
// At rest on a single altitude: the element holding it plays at the master level, the
// other fades out. Level 0 → both fade to silence.
function restAudio(index) {
if (!$("audio").checked) {
if (audioLevel() === 0) {
fadeVolume(aud, 0, FADE_MS, () => aud.pause());
fadeVolume(audB, 0, FADE_MS, () => audB.pause());
return;
@@ -1121,7 +1166,7 @@ function restAudio(index) {
const url = scaleAudioUrl(index);
const active = (audB.dataset.url === url) ? audB : aud;
const idle = active === audB ? aud : audB;
ensurePlaying(active, url, 1);
ensurePlaying(active, url, audioVol());
fadeVolume(idle, 0, FADE_MS);
updateAudioStatus();
}
@@ -1154,16 +1199,18 @@ async function main() {
renderScaleReadout();
// Sliders stream on "input"; the toggles fire on "change".
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
// Audio toggle: start/stop the soundtrack SYNCHRONOUSLY in this gesture so it
// unlocks + plays on Safari (which blocks play() outside a user gesture).
$("audio").addEventListener("change", () => { applyAudio(); debounced(); });
// Video toggle: the FIRST time video turns on, bring audio with it (one flip = the
// full experience). Audio toggled on its own first stays audio-only. The audio is
// set + played in THIS gesture so it unlocks on Safari.
// Audio level dial: set the master soundtrack gain. Reconcile SYNCHRONOUSLY in this
// input gesture so the first raise from 0 unlocks + plays on Safari (which blocks
// play() outside a user gesture).
$("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); });
updateAudioLevelLabel();
// Video toggle: the FIRST time video turns on, bring audio up to 3/10 with it (one
// flip = the full experience at a gentle level). If audio is already raised, leave it.
// Set + played in THIS gesture so it unlocks on Safari.
$("visual").addEventListener("change", () => {
if ($("visual").checked && !videoEverOn) {
videoEverOn = true;
if (!$("audio").checked) { $("audio").checked = true; applyAudio(); }
if (audioLevel() === 0) { $("audio").value = "3"; updateAudioLevelLabel(); applyAudio(); }
}
debounced();
});
+4 -4
View File
@@ -18,6 +18,7 @@
<section class="stage" id="stage">
<div class="screen">
<video id="vid" loop muted playsinline></video>
<video id="vid-loop" loop muted playsinline></video>
<audio id="aud" loop preload="auto"></audio>
<audio id="aud-b" loop preload="auto"></audio>
<canvas id="paint"></canvas>
@@ -36,10 +37,9 @@
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label">Video</span>
</label>
<label class="dev-switch" for="audio">
<input type="checkbox" id="audio" />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label">Audio</span>
<label class="audio-level">Audio
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
<span id="audio-level-val">0</span>/10
</label>
</fieldset>
+10 -1
View File
@@ -7,7 +7,11 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
.stage { flex: 1 1 640px; position: sticky; top: 1rem; }
.screen { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000;
border-radius: 6px; overflow: hidden; }
#vid { width: 100%; height: 100%; object-fit: cover; transition: opacity 0.15s ease; }
/* Two stacked source videos: #vid plays the scrub morphs, #vid-loop the steady-state
base loop, preloaded during a scrub so landing is a swap (no reload stall). The
paint canvas (WebGL) composites whichever is active; in the WebGL-less fallback the
active one is shown via opacity. */
#vid, #vid-loop { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: opacity 0.15s ease; }
/* Right-dream painterly canvas: a WebGL Kuwahara restyle of the LIVE video frames,
drawn over the base. Edge-preserving, so motion stays as crisp as the source
the dream is the same footage, just stylized. The mood grade rides as a CSS
@@ -93,6 +97,11 @@ input[type=range], select { width: 100%; }
.dev-switch input:checked + .dev-switch-track .dev-switch-thumb { transform: translateX(16px); background: #4e9; }
.dev-switch input:focus-visible + .dev-switch-track { outline: 2px solid #9af; outline-offset: 1px; }
.dev-switch-label { color: #9af; font-weight: 600; letter-spacing: 0.3px; }
/* Audio level dial (010): label · slider · value on one row, matching the switch look. */
.audio-level { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.45rem;
color: #9af; font-weight: 600; letter-spacing: 0.3px; }
.audio-level input[type=range] { flex: 1; width: auto; }
#audio-level-val { font-variant-numeric: tabular-nums; min-width: 1.2em; text-align: right; }
.dev-panel { display: flex; flex-direction: column; gap: 0.8rem; }
/* Beat the generic `.hidden` (defined earlier, equal specificity): two classes
win, so the panel truly collapses when Dev Mode is off. */