/**
 * WaveformVisualizer — the scrolling waveform background (Phase 10 + Lava Reframe).
 *
 * What this renders: a *windowed* slice of a mix's loudness profile, scrolling
 * bottom-to-top, coupled to playback position. New audio enters at the bottom,
 * already-played audio exits off the top, and the "now" playhead sits at a fixed
 * line (vertical centre by default). This is a read-only, ambient lava-lamp
 * background — there is no seek, no click handling, no write-back to playback.
 *
 * Rendering tech: WebGL2, fragment-shader, plus (as of the Lava Reframe Wave R2) a
 * CPU-side per-frame PHYSICS step that drives the wax lava. The scroll/zoom geometry
 * and the loudness-datum-as-texture sampling carry forward from Phase 10 Waves 1–2;
 * the playhead wall-clock interpolation + jitter correction carry forward as-is.
 *
 * THE LAVA (Wave R2 — this revision):
 *   The rejected analytic-metaball "lava" (scripted blobs that read as giant
 *   disconnected circles) is replaced by a real Lagrangian wax-blob simulation:
 *     • 16–32 blobs carry position / velocity / temperature / radius and are
 *       integrated each frame with real dt (gravity, temperature-buoyancy, viscous
 *       damping, soft floor contact) — see stepPhysics().
 *     • 2D ELASTIC COLLISION: blob↔waveform (the ribbon is a read-only boundary the
 *       wax is pushed out of along its surface normal) and blob↔blob, both with a
 *       soft↔hard strength dial — see stepPhysics()'s collision passes.
 *     • The blobs upload as a uBlobs[] uniform array; the fragment shader unions them
 *       with smin metaballs + the waveform SDF into one liquid surface (liquidSdf).
 *   Colour (Wave R3) is the OKLab THREE-COLOUR gradient: A→B linear from the centre line
 *   outward, A and B rotating among three theme anchors (navy/moss/off-white) at the
 *   gradient-rotation dial's rate, with a per-segment mix-time sinusoid (colour "waves"
 *   baked per segment) and a per-bar curve shift (A-dominant low → B-dominant high). OKLab
 *   keeps the blend faithful — no HSL cyan excursion. No glass, no screen-space noise (R1).
 *
 * The Blazor component owns the canvas element and the inputs (datum, playback,
 * scroll speed, theme, the control dials); this module owns the requestAnimationFrame loop,
 * the physics step, and all the GL math. The component drives it through the handle
 * returned by `create`. As of Phase 10 the handle exposes EIGHT dedicated control setters
 * (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setFluidAmount /
 * setFluidViscosity / setCollisionStrength / setWaveformWidth) — the single density knob is split into
 * fluid-amount + fluid-viscosity. As of Wave R3 the
 * gradient-rotation setter is LIVE: it drives the OKLab three-colour gradient's anchor rotation.
 *
 * PAUSE BEHAVIOR (Wave R4 Part C): the rAF loop runs CONTINUOUSLY while the component is alive and
 * the tab is visible — it is no longer gated on playback. The fluid sim keeps convecting while audio
 * is paused; only the waveform scroll/playhead freezes (effectivePlayhead() holds the static pushed
 * position while !isPlaying). The loop stops only on tab-hidden (visibilitychange) and dispose.
 */

// ── Tuning anchors (see spec §B). These are the load-bearing constants. ──────────

/**
 * Hard anchor: at maximum zoom the window shows exactly one quarter note at
 * 180 BPM = 60 / 180 s = 0.333 s of audio, top to bottom. This is a fixed
 * requirement, not a tunable.
 */
export const MIN_VISIBLE_SECONDS = 60 / 180; // 0.3333… s — quarter note @ 180 BPM

/** Slow end of the zoom range — how much of the mix is visible at minimum zoom. Tunable. */
export const MAX_VISIBLE_SECONDS = 30;

/** Default opening window when a mix is first opened. Tunable. */
export const DEFAULT_VISIBLE_SECONDS = 10;

// ── Control tuning anchors. These mirror the C#-side defaults in ──────────────────
// WaveformVisualizerControlState.cs — keep the two in sync, exactly as the
// DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All seven are
// normalized [0,1] (scroll speed is mapped to a visible time-span on the C# side before it
// reaches setScrollSpeed; it arrives here already in seconds).
//
// Phase 10 — the EIGHT dedicated controls. Each knob drives its own physics/colour dial. The
// single "bubbles"/density knob is split into fluid-amount + fluid-viscosity (Phase 10 §5). Mapping:
//   • Scroll speed              → visible time-span / scroll rate (setScrollSpeed)
//   • Gradient rotation speed   → colour anchor-rotation rate (setGradientRotationSpeed) — LIVE
//                                 as of Wave R3; drives the OKLab gradient's anchor rotation
//   • Lava gravity              → gravity dial (setLavaGravity)
//   • Lava heat                 → heat dial (setLavaHeat)
//   • Fluid amount              → blob count + per-blob volume (setFluidAmount)
//   • Fluid viscosity/cohesion  → sphere-restoration: smin blend + wobble (setFluidViscosity)
//   • Collision strength        → collision hardness dial (setCollisionStrength)
//   • Waveform width            → ribbon half-width uniform (setWaveformWidth)
// The defaults below are Daniel's feel-anchors (~20% gravity, ~100% heat sweet spot, §4c) — he
// tunes on screen from here.

/** Default GRAVITY dial. Mirrors C# DefaultLavaGravity.
 *  Tuned to Daniel's sweet spot (~20% gravity): the wax is buoyant-dominated and flows. */
export const DEFAULT_LAVA_GRAVITY = 0.2;

/** Default HEAT dial. Mirrors C# DefaultLavaHeat.
 *  Tuned to Daniel's sweet spot (~100% heat): lots of small, lively, turbulent bubbles. */
export const DEFAULT_LAVA_HEAT = 1.0;

/** Default COLLISION-STRENGTH dial. Mirrors C# DefaultCollisionStrength.
 *  Mid soft↔hard: elastic enough to throw bubbles up+out, not so hard it reads as marbles. */
export const DEFAULT_COLLISION_STRENGTH = 0.5;

/** Default FLUID AMOUNT. Mirrors C# DefaultFluidAmount. The "bubbles" knob's first half (Phase 10
 *  split): how much wax is in the container — blob count + per-blob volume. 0 = few small blobs,
 *  1 = many larger blobs (more fluid). */
export const DEFAULT_FLUID_AMOUNT = 0.4;

/** Default FLUID VISCOSITY / COHESION. Mirrors C# DefaultFluidViscosity. The "bubbles" knob's second
 *  half (Phase 10 split): how strongly the wax holds a spherical shape. 1 = high cohesion (crisp
 *  spheres that snap back), 0 = low cohesion (deforms freely, stays gooey/merged under inertia).
 *  Default leans cohesive so the at-rest look is rounded wax. */
export const DEFAULT_FLUID_VISCOSITY = 0.6;

/**
 * Default GRADIENT-ROTATION-SPEED dial. Mirrors C# DefaultGradientRotationSpeed. Normalized
 * [0,1] → slow→fast anchor rotation. LIVE as of Wave R3: it drives Motion 1 (the rate at
 * which the gradient's two anchors A and B rotate among the three theme colours X/Y/Z).
 */
export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.45;

/**
 * Anchor-rotation rate at dial = 1, in ring-units per second (one ring-unit = one anchor
 * step X→Y, so 3 ring-units is a full X→Y→Z→X cycle). 0.18 → a full three-colour cycle in
 * ~16.7 s at full speed — slow and meditative at the high end, near-static at the low end.
 * Daniel tunes the feel here; dial 0 still creeps (RATE_MIN) so the field never freezes dead.
 */
// Phase 10 colour retune (Daniel: "the rotation appears to do nothing"). The old 0.18 max → a full
// three-colour cycle took ~17 s at full dial and ~49 s at the 0.3 default — below the threshold of
// "this is moving". Raised so the dial has obvious effect: 0.6 → a full cycle in ~5 s at full speed,
// and the default (now 0.45) cycles in ~7 s — clearly rotating, still meditative not strobing.
const GRADIENT_ROTATION_RATE_MAX = 0.6;
const GRADIENT_ROTATION_RATE_MIN = 0.03;

/**
 * Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. The knob maps onto the useful
 * 10%–95% ribbon-extent band (Phase 10 §3.7 — see effectiveWaveformWidth); 0.5 opens mid-band.
 */
export const DEFAULT_WAVEFORM_WIDTH = 0.5;

/**
 * Where the "now" line sits within the window, as a fraction from the top.
 * 0.5 = vertical centre (default): a short lead-in below, a short trail-out above.
 * Tunable. NOTE: kept in sync with the GLSL constant NOW_ANCHOR_FROM_TOP below.
 */
const NOW_ANCHOR_FROM_TOP = 0.5;

/**
 * Half-width of the ribbon at full loudness, as a fraction of half the canvas
 * width (the predecessor used 0.92). Mirrors the Canvas `maxHalfWidth` factor.
 */
const RIBBON_HALF_WIDTH_FRAC = 0.92;

/**
 * Cap device-pixel-ratio at 2. Beyond that the extra fragments cost frame time for
 * no visible gain on a soft glassy backdrop — this is the graceful-degrade lever
 * (spec §5.1): drop internal resolution before dropping frames.
 */
const MAX_DPR = 2;

// ════════════════════════════════════════════════════════════════════════════════════
//  R2 — the wax-blob lava physics (CPU step + uniform upload). The lava is now a real
//  Lagrangian particle system integrated each frame on the JS side and rendered as
//  smin metaballs in the fragment shader. EVERYTHING below is the tuning surface for
//  the lava look; Daniel reads + tunes these.
//
//  Coordinate convention (shared with the shader): physics runs in HEIGHT-NORMALIZED
//  space — every position/velocity/radius is in units of (pixel / canvasHeight). So
//  y ∈ [0, 1] top→bottom, x ∈ [0, W/H], and the FLOOR (footer / lava rest line) is the
//  bottom edge of the canvas at y = 1 (the canvas is already CSS-clipped to the footer
//  top in R1, so its own bottom edge IS the footer line — no extra clip uniform needed).
//  Using one isotropic unit keeps blobs round and collisions correct at any aspect.
// ════════════════════════════════════════════════════════════════════════════════════

/**
 * Hard upper bound on the simulated/rendered blob population. The per-fragment shader
 * loops to this constant, so it caps GPU cost; the CPU step is O(MAX_BLOBS²) for
 * blob↔blob (≤ ~1k pair tests — trivial). 32 is the spec's upper band (§4g). The live
 * count varies with the density dial but never exceeds this.
 */
const MAX_BLOBS = 32;

/** Lower end of the live blob count (density dial 0 → a few large lazy blobs, §4e). */
const MIN_BLOB_COUNT = 16;

/** Blob radius band in height-normalized units. 0.025·H ≈ 20px and 0.13·H ≈ 100px on a
 *  ~760px-tall canvas — the spec's ~20–100px range (§4b). Each blob picks a fixed radius
 *  in this band at construction; the density dial biases the average. */
const BLOB_RADIUS_MIN = 0.025;
const BLOB_RADIUS_MAX = 0.13;

/**
 * Gravity acceleration at the gravity dial = 1, in height-units / s². Downward (+y).
 * Tuned so wax at full gravity falls back to the floor in well under a second from
 * mid-screen (0.5 height) — a firm settle — while still letting buoyancy win when hot.
 * The dial scales this 0→1 linearly (dial 0 = near-weightless float, §4d).
 */
const GRAVITY_ACCEL_MAX = 2.2;
/** Floor of gravity even at dial 0, so wax never becomes truly weightless (always settles). */
const GRAVITY_ACCEL_MIN = 0.15;

/**
 * Buoyancy lift coefficient: upward accel = BUOYANCY_COEFF · heatScale · (T − T_ambient).
 * Hot wax (T high) rises; cool wax (T low) sinks. This is the OTHER half of the lamp's
 * core tension against gravity. Tuned against GRAVITY_ACCEL_MAX so that at full heat a
 * hot blob (T≈1) overcomes mid-gravity and climbs, and at heat 0 (heatScale 0) buoyancy
 * vanishes entirely → wax just obeys gravity and rests on the floor (spec §4c endpoint).
 */
const BUOYANCY_COEFF = 4.0;
const TEMP_AMBIENT = 0.5; // the neutral temperature; above it lifts, below it sinks

/**
 * Heat transfer rates (per second), the engine of the convection cycle:
 *   - near the FLOOR a blob HEATS toward 1 (the "lamp bulb" at the bottom),
 *   - near the TOP it COOLS toward 0 (loses heat at the cold cap),
 *   - everywhere it relaxes gently toward ambient.
 * heatScale (the heat dial's transfer-function output, see heatScaleFromDial) gates the
 * floor-heating rate: at dial 0 the floor adds NO heat, so nothing ever becomes buoyant
 * and the pool rests; at dial 1 the floor pumps heat fast → many blobs go buoyant and
 * rise per second (the busy roiling lamp, §4c max endpoint).
 */
const HEAT_FLOOR_RATE = 1.6;   // °/s toward T=1 when sitting on the floor (× heatScale)
const HEAT_TOP_RATE = 1.2;     // °/s toward T=0 when near the top
const HEAT_AMBIENT_RATE = 0.25; // °/s relaxation toward ambient everywhere
const HEAT_FLOOR_ZONE = 0.16;  // height-fraction above the floor counted as "hot zone"
const HEAT_TOP_ZONE = 0.16;    // height-fraction below the top counted as "cold zone"

/**
 * Viscous (linear) velocity damping per second. Applied as v *= exp(−DAMPING·dt) each
 * step, so it is frame-rate independent.
 *
 * R2 tuning (Daniel #2 — "too viscous, needs to melt into a single fluid"): dropped from
 * 1.4 to 0.55 so the wax is markedly LESS stiff and flows together instead of holding as
 * distinct globs. Combined with the larger smin coalescence (BLOB_SMOOTHMIN_K below) and
 * the elastic throw, the surface now reads as a unified fluid body rather than separate
 * stiff blobs. Still > water so it stays a lazy lava, not a splash.
 */
const VISCOUS_DAMPING = 0.55;

/**
 * Hard speed clamp in height-units/s, applied after every substep. R2 jitter fix (Daniel
 * #5): with the lower viscosity + higher elasticity, a deep overlap resolved by the elastic
 * impulse could occasionally fling a blob fast enough to tunnel and re-collide next step —
 * the buzz. Capping the per-axis speed keeps the integrator stable (no explosive feedback)
 * while being far above any speed real convection produces, so it never throttles the look.
 */
const MAX_BLOB_SPEED = 2.5;

/**
 * Soft floor contact: instead of a hard clamp that jitters, a resting blob is pushed up
 * by a spring proportional to its penetration below the floor, and its downward velocity
 * is killed on contact so pooled wax flattens and settles rather than bouncing forever.
 */
const FLOOR_SPRING = 26.0;      // restoring accel per unit penetration (height-units/s²)
const FLOOR_CONTACT_DAMPING = 6.0; // extra damping applied while in floor contact (settle)

/**
 * Blob↔blob collision: the soft↔hard knob (collision strength) blends a penalty SPRING
 * (soft displacement, blobs squish and partially overlap then ease apart) toward an
 * elastic IMPULSE (hard, crisp restitution along the centre line). These are the two
 * endpoints the strength dial interpolates (§5c). Restitution is the bounciness of the
 * hard end; the spring stiffness is the firmness of the soft end.
 */
const BLOB_COLLIDE_SPRING = 9.0;     // soft penalty stiffness (height-units/s² per overlap)
const BLOB_RESTITUTION_HARD = 1.15;  // elastic restitution at strength = 1 — over-unity = the springy "throw" (Daniel #6)
const BLOB_RESTITUTION_SOFT = 0.05;  // residual restitution at strength = 0 (almost pure mush, Daniel #3)

/**
 * Blob↔waveform collision (always on, independent of heat — §5b). The waveform's
 * half-width at a blob's row is sampled CPU-side each frame; a blob whose centre is
 * within (halfWidth + radius) of the centre line is penetrating the ribbon and is pushed
 * out along the surface normal. Same soft↔hard blend as blob↔blob: a penalty spring at
 * the soft end → elastic reflection of the inward velocity at the hard end. The waveform
 * is read-only authority: it pushes the fluid, the fluid never moves it.
 */
// Phase 10 collision retune (Daniel: "less explosive, more bouncy", no jitter, no stuck wax).
// Restitution is now SUB-unity: a real bounce conserves-or-loses energy, never adds it —
// over-unity (the old 1.1) injected energy each contact and read as "explosive". 0.85 at the hard end
// is lively/springy; the soft end stays near-zero (mush).
const WAVE_COLLIDE_SPRING = 10.0;    // soft penalty stiffness pushing wax off the ribbon (slightly softer)
const WAVE_RESTITUTION_HARD = 0.85;  // springy but energy-bounded reflection at full hardness (no explosion)
const WAVE_RESTITUTION_SOFT = 0.05;  // near-pure mush at the soft end (Daniel #3)

/**
 * Waveform UPWARD throw (Daniel #4 — "throw bubbles up AND out, not just out"). When wax penetrates
 * the ribbon we add a small UPWARD (−y) nudge so loud transients lift bubbles toward the surface
 * rather than only shoving them sideways.
 *
 * Phase 10 retune (Daniel: "less explosive"): the old 26.0, applied every substep × penetration ×
 * hardness × dt, accumulated on a sustained loud passage and launched bubbles off-screen — the
 * "explosive" feel. Cut to a gentle lift and CAPPED per contact (see the clamp in stepPhysics) so a
 * deep/sustained overlap can't pump unbounded upward speed. Reads as a bouncy bob, not a rocket.
 */
const WAVE_THROW_UP = 9.0;
/** Hard cap on the per-contact upward throw velocity (height-units/s) so a sustained loud transient
 *  can never accumulate into an off-screen launch. Well above a natural bob, far below escape speed. */
const WAVE_THROW_UP_MAX = 0.6;

/**
 * Max physics timestep, seconds. rAF can stall (tab blur, GC); a huge dt would let a
 * blob tunnel through the floor or another blob in one step (explosive overlap). We clamp
 * dt so the integrator stays stable — a long stall just means the sim advances a little
 * slowly that frame, which is invisible. (We also sub-step within this cap below.)
 */
const PHYSICS_MAX_DT = 1 / 30;
/**
 * Sub-steps per frame: splitting dt makes the spring/penalty collisions stiffer-stable.
 * R2 jitter fix (Daniel #5): raised 2 → 4. The lower viscosity + higher restitution make
 * the contact response stiffer relative to the step, so each frame's dt is now resolved in
 * four smaller passes — the collisions settle smoothly instead of buzzing. 4 × ≤32² pair
 * tests ≈ 4k/frame, still trivial, and the frame budget is untouched (FPS holds at 60).
 */
const PHYSICS_SUBSTEPS = 4;

/**
 * Energy-coupled dynamics (Daniel #7 — "at higher heat, bubbles are SMALLER and move with
 * MORE TURBULENCE"). Heat (the heatScale transfer output) drives two effects each step:
 *
 *  • SIZE: a blob's effective radius shrinks toward HEAT_RADIUS_MIN_SCALE of its base radius
 *    as it heats. High heat ⇒ a swarm of small lively bubbles; low heat ⇒ fewer, larger,
 *    calmer wax. The shrink is applied to the SIMULATED radius (so collisions match what's
 *    drawn) and tracks temperature continuously, so a blob grows back as it cools at the top.
 *
 *  • TURBULENCE: a divergence-free-ish curl of value-noise injects a small random velocity
 *    each step, scaled by heatScale × the blob's own temperature, so only HOT wax churns.
 *    This is what makes high heat read as turbulent and low heat as a calm pool.
 */
const HEAT_RADIUS_MIN_SCALE = 0.45;  // hottest blob shrinks to 45% of its base radius
const TURBULENCE_ACCEL = 3.2;        // peak turbulent accel (height-units/s²) at full heat × full temp
const TURBULENCE_RATE = 1.9;         // how fast the turbulence field evolves (rad/s scale)

/**
 * Playhead-correction smoothing time constant, in seconds. Governs how fast the
 * rendered playhead absorbs a re-anchor discontinuity at each ~10 Hz push.
 *
 * The problem: the player's position reports are irregular at startup (buffering /
 * playback ramp-up), so each push lands a position that doesn't match where the
 * wall-clock interpolation had advanced to. Hard-anchoring to each push (the prior
 * behaviour) made that gap a visible snap every push — the startup jitter.
 *
 * The fix (classic netcode-style entity reconciliation): the player stays the sole
 * source of truth, but instead of rendering the authoritative position directly, we
 * render authoritative + a small *correction offset* that decays toward zero every
 * frame. On each push we fold the re-anchor discontinuity into that offset so the
 * rendered playhead is continuous across the push, then bleed the offset off over
 * ~this time constant. This eases the snap into a sub-perceptible glide.
 *
 * Why an offset that decays to zero, not an absolute lerp toward target: a lerp
 * toward the target leaves a steady-state lag proportional to velocity (the render
 * always trailing real playback). Decaying the *error* to zero converges the
 * rendered playhead back onto the authoritative one, so once pushes steady the
 * offset is ~0 and behaviour is identical to the old hard-anchor — no lag, and
 * steady-state is unchanged as required.
 *
 * 0.12 s is a sensible default: long enough to dissolve the worst startup snaps
 * (tens of ms of position error), short enough that the correction is imperceptible
 * and the render never trails real playback by more than a few ms. Tunable.
 */
const PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS = 0.12;

/**
 * Below this absolute correction (seconds) we snap the offset to 0 and stop easing —
 * an exponential decay never mathematically reaches zero, and carrying a sub-ms
 * residual forever is pointless. ~0.5 ms is well under one frame of motion at any
 * real zoom, so collapsing it is invisible.
 */
const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005;

// ── Diagnostics ──────────────────────────────────────────────────────────────────
//
// Set true to surface the init/draw/datum/playback seams to the browser console
// (all prefixed `[WaveformVisualizer]`). The error/warn paths fire regardless of this
// flag — they only trigger on the abnormal path. The verbose `log` paths (datum
// received/uploaded, first-draw dimensions, GL error after first draw) are gated
// here so they can be silenced once the renderer is confirmed healthy. Leave it on
// while the runtime fix is being verified through the browser.
// NOTE: defaults to false; set true temporarily to surface verbose seams in-browser.
const DEBUG = false;

const TAG = '[WaveformVisualizer]';
function debugLog(...args: unknown[]): void {
    if (DEBUG) console.log(TAG, ...args);
}

// ── Theme: the THREE colour anchors (X, Y, Z), read live from the active palette. ──
//
// The shader cannot resolve `var(--mud-palette-*)` directly — uniforms are plain
// floats. So we read the computed `--mud-palette-*` custom properties straight off
// the canvas element (which inherits them from the page), parse them to RGB, and
// upload them as vec3 colour uniforms. The bespoke light/dark themes swap those vars
// when dark mode toggles, so re-reading + re-uploading them re-themes the field with
// no reload. The component just calls `refreshTheme()` after a dark-mode change.
//
// Wave R3 binding — the THREE anchors of the OKLab gradient (spec §6a/§6b motion 1).
// The gradient's two stops (A = centre/root, B = outer/edge) ROTATE among these three
// over time. The palette's signature triad is navy / moss / off-white — the identity
// `DeepDrftPalettes` is built on (see the class doc comment there). All three are read
// from the live palette vars (single source of truth — spec §6a): no hardcoded hexes.
//   - X = NAVY  (the dark ground / navy-mid)
//   - Y = MOSS  (the interactive green)
//   - Z = OFF-WHITE (the warm paper ground) — the chosen third anchor (surfaced in handoff)
//
// The cross-mode problem (spec §6a, explicit): navy / moss / off-white are NOT a single
// stable set of CSS vars across both palettes. Navy is `--mud-palette-primary` in LIGHT
// but the `--mud-palette-background` ground in DARK; moss is `--mud-palette-secondary` in
// LIGHT but `--mud-palette-primary` in DARK (where green IS primary); off-white is the
// `--mud-palette-background` ground in LIGHT but `--mud-palette-secondary` in DARK. No one
// var holds a given identity in both modes. So we detect the mode in JS (by the luminance
// of the page background — the bespoke dark ground #0D1B2A is near-black, the light ground
// #FAFAF8 is near-white) and bind the three anchors per mode. refreshTheme re-runs this on
// a dark-mode toggle, so the field re-themes live.

interface ResolvedTheme {
    /** Navy anchor (X) RGB [0,1] — uploaded to uColorNavy. */
    navy: [number, number, number];
    /** Moss-green anchor (Y) RGB [0,1] — uploaded to uColorMoss. */
    moss: [number, number, number];
    /** Off-white anchor (Z) RGB [0,1] — uploaded to uColorPaper. */
    paper: [number, number, number];
}

/** sRGB relative luminance (cheap Rec.709 weights) of a normalized RGB triple. */
function luminance([r, g, b]: [number, number, number]): number {
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

/**
 * Read a CSS custom property off an element, falling back if it is empty/undefined.
 * An empty value means the var did not inherit onto the canvas (e.g. the palette is
 * scoped to a wrapper the canvas isn't under), which would silently swap the ribbon
 * colour for the hardcoded default — so warn on it when diagnosing.
 */
function readVar(el: Element, name: string, fallback: string): string {
    const v = getComputedStyle(el).getPropertyValue(name).trim();
    if (v.length === 0) {
        if (DEBUG) console.warn(`${TAG} CSS var '${name}' did not resolve off the canvas — using fallback '${fallback}'; ribbon colour may be wrong.`);
        return fallback;
    }
    return v;
}

/**
 * Parse a CSS colour string to normalized [0,1] RGB. Handles #rgb / #rrggbb and
 * rgb()/rgba() — the only forms MudBlazor emits for these palette vars. Falls back
 * to mid-grey on anything unrecognised so a parse miss degrades to a visible
 * ribbon rather than black.
 */
function parseColor(css: string): [number, number, number] {
    const s = css.trim();
    if (s.startsWith('#')) {
        let hex = s.slice(1);
        if (hex.length === 3) {
            hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
        }
        if (hex.length >= 6) {
            const r = parseInt(hex.slice(0, 2), 16);
            const g = parseInt(hex.slice(2, 4), 16);
            const b = parseInt(hex.slice(4, 6), 16);
            if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) {
                return [r / 255, g / 255, b / 255];
            }
        }
    }
    const m = s.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
    if (m) {
        return [Number(m[1]) / 255, Number(m[2]) / 255, Number(m[3]) / 255];
    }
    return [0.5, 0.5, 0.5];
}

// ── Datum: the pre-downloaded loudness profile (spec §F). ────────────────────────

interface Datum {
    /**
     * GPU texture holding the loudness samples (R8). Laid out as a 2-D grid that
     * respects GL_MAX_TEXTURE_SIZE (see uploadDatum) rather than a 1×N row, which
     * blows past the max texture width for any mix over ~49 s at the ~333 samples/s
     * datum density. The shader reads it with texelFetch (integer addressing), so no
     * hardware filtering is used — see sampleAt for the manual interpolation.
     */
    texture: WebGLTexture;
    /** Texture width in texels (samples per row). */
    texWidth: number;
    /** Texture height in texels (number of rows). */
    texHeight: number;
    /** Number of real samples in the datum (≤ texWidth*texHeight; the tail row is padded). */
    sampleCount: number;
    /** Total mix duration in seconds — needed to map time <-> sample index. */
    durationSeconds: number;
    /**
     * The decoded loudness bytes [0,255], retained for CPU-side sampling by the physics
     * step (the waveform-collision boundary is sampled per blob per frame — R2 §5). The
     * GPU has its own copy in `texture`; this is the CPU mirror, kept because re-reading
     * the texture back from the GPU each frame would be a stall.
     */
    samples: Uint8Array;
}

interface Playback {
    /**
     * Last playback head pushed from Blazor, in seconds. This is the *authoritative*
     * position the player last reported — it updates only on the ~10 Hz setPlayback
     * push, NOT every frame. The per-frame scroll uses the interpolated
     * effectivePlayhead (see draw()), anchored on this value.
     */
    positionSeconds: number;
    /** Whether audio is actively playing. Gates whether the playhead ADVANCES (scroll) or HOLDS
     *  (freeze) — NOT whether the rAF loop runs (the loop is continuous now, Part C). */
    isPlaying: boolean;
    /**
     * performance.now() (ms) captured when positionSeconds was pushed. The rAF loop
     * advances the playhead by wall-clock elapsed since this anchor so the ribbon
     * scrolls smoothly at the display refresh rate between the sparse ~10 Hz pushes,
     * instead of stepping once per push (the ~10 FPS smoothness bug). Re-anchored on
     * every push, so each push is a small correction rather than a hard reset.
     */
    pushWallClockMs: number;
}

export interface WaveformVisualizerHandle {
    setDatum(samplesBase64: string, durationSeconds: number): void;
    setPlayback(positionSeconds: number, isPlaying: boolean): void;
    /** Visible time-span in seconds — the scroll-speed control, mapped from [0,1] on the C# side. */
    setScrollSpeed(visibleSeconds: number): void;
    /** [0,1]. Colour anchor-rotation rate — drives the OKLab gradient's Motion 1 (live, R3). */
    setGradientRotationSpeed(value: number): void;
    /** [0,1]. Downward force on the wax. */
    setLavaGravity(value: number): void;
    /** [0,1]. Energy into the lava system (0 = rest-at-bottom, 1 = roiling). */
    setLavaHeat(value: number): void;
    /** [0,1]. Amount of wax — blob count + per-blob volume. */
    setFluidAmount(value: number): void;
    /** [0,1]. Fluid viscosity / cohesion — how strongly wax restores to a sphere (1) vs stays
     *  deformed/gooey (0). Drives the metaball smin blend + wobble; no per-fragment cost change. */
    setFluidViscosity(value: number): void;
    /** [0,1]. Collision hardness (0 = soft mush, 1 = hard up-and-out throw). */
    setCollisionStrength(value: number): void;
    /** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */
    setWaveformWidth(value: number): void;
    /**
     * Enable/disable the LAVA subsystem (Phase 15). When disabled the wax is genuinely NOT rendered:
     * the per-frame physics step is skipped and zero blobs are uploaded (uBlobCount = 0), so the
     * shader's blob loop unions nothing — no render cost, not a dimmed/visible=false uniform (§10.1).
     */
    setLavaEnabled(enabled: boolean): void;
    /**
     * Enable/disable the WAVEFORM-ribbon subsystem (Phase 15). When disabled the ribbon SDF is skipped
     * in the shader (uWaveformEnabled = 0 makes waveformSdf return "fully outside") and its CPU
     * collision boundary is dropped (sampleLoudnessAt reads 0), so the ribbon contributes nothing to
     * the surface and the wax stops bouncing off an invisible wall — a genuine skip, not a dim (§10.1).
     */
    setWaveformEnabled(enabled: boolean): void;
    /** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
    refreshTheme(): void;
    dispose(): void;
}

/**
 * Decode the base64 loudness datum (bytes [0,255]) into a Uint8Array suitable for
 * direct upload as an R8 texture. Done once per datum, off the animation path. We
 * keep the bytes as [0,255] and let the GPU normalize to [0,1] on sample (R8
 * UNORM), which mirrors the predecessor's /255 and avoids a CPU float pass.
 */
function decodeSamples(base64: string): Uint8Array {
    const binary = atob(base64);
    const out = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) {
        out[i] = binary.charCodeAt(i);
    }
    return out;
}

// ── Shaders. ─────────────────────────────────────────────────────────────────────
//
// Vertex: trivial pass-through. We draw a single triangle that more than covers the
// clip-space box ([-1,1]²) so every pixel of the canvas is rasterized once. (One
// oversized triangle is the standard full-screen trick — cheaper than two and has
// no diagonal seam.) gl_FragCoord in the fragment shader then gives us the pixel's
// screen position directly.

const VERTEX_SHADER = `#version 300 es
// Three clip-space vertices forming one big triangle covering [-1,1]².
const vec2 POSITIONS[3] = vec2[3](
    vec2(-1.0, -1.0),
    vec2( 3.0, -1.0),
    vec2(-1.0,  3.0)
);
void main() {
    gl_Position = vec4(POSITIONS[gl_VertexID], 0.0, 1.0);
}
`;

// Fragment: THE SCROLL + ZOOM MATH (spec §A, §B), ported intact from the Canvas
// predecessor's per-row loop into a per-fragment evaluation. Read this top to
// bottom to follow how a quarter-note-@-180-BPM becomes 0.333 s becomes a texture
// coordinate becomes a lit pixel.
//
// Coordinate model (matches the Canvas predecessor exactly):
//   - gl_FragCoord.xy is in device pixels, origin BOTTOM-left in WebGL. The Canvas
//     used a TOP-left origin with Y increasing downward. We flip Y once up front
//     (screenYTop) so all the time math below reads identically to the Canvas
//     version: screenYTop = 0 at the top edge, = uResolution.y at the bottom.
//   - The "now" line is a fixed screen Y: nowY = height * NOW_ANCHOR_FROM_TOP.
//   - Audio flows UP: newer audio is drawn lower and scrolls up past the now line.
//       * audio BELOW the now line (screenYTop > nowY) is the lead-in (not yet played)
//       * audio ABOVE the now line (screenYTop < nowY) is the trail-out (just played)
//
// Zoom -> time-span -> pixels:
//   - uVisibleSeconds is the whole window's time span, top to bottom. At max zoom
//     this is MIN_VISIBLE_SECONDS (0.333 s); at min zoom MAX_VISIBLE_SECONDS.
//   - pixelsPerSecond = height / uVisibleSeconds. Smaller visibleSeconds => more px
//     per second => the same audio sweeps the window faster at a fixed playback
//     rate. That IS the Guitar-Hero coupling: apparent scroll speed falls straight
//     out of the zoom, with no separate speed control.
//
// Time at a given screen Y:
//   - At nowY the time is uPlayheadSeconds.
//   - Moving DOWN by 1 px (screenYTop +1) adds (1 / pixelsPerSecond) seconds.
//   - So:  timeAt(y) = playhead + (screenYTop - nowY) / pixelsPerSecond
//
// Sample at a given time:
//   - time / durationSeconds is the normalized position along the mix; multiplied by
//     the sample count it becomes a continuous sample index. sampleAt interpolates
//     between the two bracketing samples by hand (texelFetch + fract lerp) — see the
//     note on its definition for WHY we can't use hardware LINEAR with the 2-D layout.
//   - Outside [0, durationSeconds] we force loudness to 0. That is what gives the
//     "scrolls in from empty / out to empty" behaviour at the very start and end of
//     the mix (spec §A) with no special-casing. (CLAMP_TO_EDGE on the texture would
//     otherwise repeat the edge sample, so we gate explicitly here.)

const FRAGMENT_SHADER = `#version 300 es
precision highp float;

uniform vec2  uResolution;       // canvas size in device pixels
uniform float uPlayheadSeconds;  // current playback position (per-frame)
uniform float uTimeSeconds;      // monotonic clock (per-frame) — drives field morph
uniform float uVisibleSeconds;   // zoom: window time-span (per change)
uniform float uWaveformWidth;    // [0,1] R2: scales the ribbon half-width (narrow the band for lava room)
uniform float uWaveformEnabled;  // [0,1] Phase 15: 1 = ribbon drawn, 0 = ribbon subsystem skipped (no
                                 // contribution to the surface — see waveformSdf's early-out)
uniform float uCohesion;         // [0,1] Phase 10: fluid viscosity/cohesion — high = crisp spheres,
                                 // low = gooey/deformed (drives the smin blend width + wobble below)
// NOTE: the lava physics params (gravity/heat/collision/density) are NOT shader uniforms
// in R2 — they drive the CPU physics step, which uploads the resulting uBlobs[]. The old
// uBubblyness/uDetach/uColorShiftSpeed uniforms are gone from the shader for that reason;
// the JS handle still receives those control values and routes them to the physics (the
// R2 TEMP knob re-mapping documented at the control-default consts above).
uniform float uDurationSeconds;  // mix length (per datum)
// R3 — the three OKLab gradient anchors (X/Y/Z), read live from the palette (per theme).
uniform vec3  uColorNavy;        // X — navy anchor
uniform vec3  uColorMoss;        // Y — moss anchor
uniform vec3  uColorPaper;       // Z — off-white anchor
// R3 — gradient anchor-rotation PHASE (radians), integrated CPU-side from the same
// uTimeSeconds clock at the rotation-speed dial's rate (so a speed change never snaps the
// phase). Drives Motion 1: which two of the three anchors A and B are right now (spec §6b).
uniform float uGradientPhase;
uniform float uHasDatum;         // 1.0 when a datum texture is bound, else 0.0
uniform sampler2D uDatum;        // loudness profile, R8, 2-D grid, NEAREST (texelFetch)
uniform int   uDatumWidth;       // datum texture width in texels (samples per row)
uniform int   uDatumSampleCount; // number of real samples (tail row is padded)

// ── R2 wax-blob uniforms (the CPU physics step uploads these every frame). ──────────
// Each blob is packed as a vec4: xy = centre in HEIGHT-NORMALIZED space (pixel/H, so
// y is 0 at the top edge and 1 at the footer/floor, x spans [0, W/H]); z = radius in
// the SAME height-normalized unit (so circles are round on screen); w = temperature
// 0..1 (drives the warm tint on hot rising wax). uBlobCount is how many of the
// MAX_BLOBS slots are live this frame. Working in height-normalized units keeps the
// metaball SDF isotropic regardless of the canvas aspect ratio.
const int MAX_BLOBS = ${MAX_BLOBS};
uniform vec4 uBlobs[MAX_BLOBS];
uniform int  uBlobCount;

out vec4 fragColor;

const float NOW_ANCHOR_FROM_TOP = ${NOW_ANCHOR_FROM_TOP.toFixed(4)};
const float RIBBON_HALF_WIDTH_FRAC = ${RIBBON_HALF_WIDTH_FRAC.toFixed(4)};

// ── R2 in-shader tuning constants (Daniel tunes by editing here). ───────────────────

// Background opacity of the wax + waveform fill. Kept simple/serviceable for R2 — the
// beautiful OKLab three-colour gradient is Wave R3. Just enough to read the physics.
const float RIBBON_OPACITY_R2 = 0.62;

// smin blend radius for the wax metaball union, in height-normalized units. Larger = the
// "necks" where two blobs merge are fatter → a gooier, more-connected wax that splits and
// recombines (the organic non-circular look the spec wants, §4b). This + varied radii are
// what kill the "giant disconnected circles" failure.
// R2 (Daniel #2 — "melt into a single fluid"): raised 0.045 → 0.085 so neighbouring blobs
// fuse into one continuous fluid body across wider gaps rather than reading as separate globs.
const float BLOB_SMOOTHMIN_K = 0.085;

// smin blend radius for merging the wax into the WAVEFORM ribbon, so resting/pooled wax
// reads as continuous with the ribbon surface rather than a disc sitting on a wall.
// R2: raised 0.03 → 0.055 to match the fattier wax-union neck (continuous fluid surface).
const float WAVE_SMOOTHMIN_K = 0.055;

// Low-frequency, blob-tied radius wobble: a slow per-blob breathing so each wax shape is
// organic, not a perfect circle (§4b). This is FLUID-tied noise (keyed to blob identity +
// the wall clock), NOT the screen-space "dirt" R1 removed (§3) — it travels with the wax.
const float BLOB_WOBBLE_AMOUNT = 0.12; // ± fraction of radius
const float BLOB_WOBBLE_RATE   = 0.7;  // breathing speed (rad/s scale)

// Warm tint on hot, rising wax. A hot blob (temperature → 1) shifts slightly toward a
// warm highlight so the eye reads "this one is rising"; cool wax stays the cool field
// colour. Subordinate to the R3 gradient (spec §4f) — kept subtle.
const vec3  HOT_TINT        = vec3(0.95, 0.72, 0.45); // warm amber the hottest wax leans toward
const float HOT_TINT_AMOUNT = 0.25; // max lean at temperature 1 (above ambient)

// ── R3 colour-gradient tuning (the three motions, spec §6b). Daniel tunes by editing here. ──

// Motion 1 — the phase OFFSET between anchor A (centre/root) and anchor B (outer/edge) as
// they rotate among the three anchors. A non-zero offset means A and B sit at different
// points on the X→Y→Z ring, so the gradient always spans two distinct colours rather than
// collapsing to one. 1.0 = a full one-anchor lead (e.g. A on navy while B is on moss).
const float GRADIENT_AB_PHASE_OFFSET = 1.0;

// Motion 2 — per-bar sinusoidal variation, KEYED TO MIX-TIME (spec §6b motion 2, decided
// realization). Because mix-time is fixed for a given segment, the sinusoid is a pure
// function of mix-time and therefore baked-per-segment by construction: it travels with the
// segment as it scrolls, no ring buffer. AMOUNT is the ± phase wobble it adds to the anchor
// rotation (in ring units); FREQ is how many colour "waves" pack into one second of mix.
const float SEG_WAVE_AMOUNT = 0.35;  // ± ring-phase wobble per segment
const float SEG_WAVE_FREQ   = 1.7;   // colour waves per second of mix-time

// Motion 3 — per-bar gradient CURVE shift with scroll height (spec §6b motion 3). A bar is
// mostly A at the bottom and mostly B by the top: we bias the centre→outer A→B mix toward A
// low on screen and toward B high on screen, so colour appears to climb outward as the bar
// scrolls up. This is the max ± shift applied to the A→B mix fraction across the screen.
const float CURVE_SHIFT_AMOUNT = 0.45;

// Fetch one raw sample by its linear index, mapping the 1-D index onto the 2-D
// texture grid (col = i mod width, row = i / width). texelFetch ignores filtering
// and wrap modes — it reads the exact texel — so the row-wrap layout is invisible
// to the caller.
float fetchSample(int i) {
    int col = i % uDatumWidth;
    int row = i / uDatumWidth;
    return texelFetch(uDatum, ivec2(col, row), 0).r;
}

// Loudness at an absolute mix time, or 0 outside the mix (drives scroll-in/out).
//
// Interpolation note: we cannot lean on hardware LINEAR filtering here. The datum
// is laid across a 2-D grid (1×N would exceed GL_MAX_TEXTURE_SIZE past ~49 s of
// mix), and a hardware 2D-LINEAR read would blend across the row-wrap seam at the
// end of every row — sample[width-1] would wrongly bleed into sample[width] of the
// next row, and bilinear would also pull in the row above/below. So we do the
// linear interpolation by hand along the TIME axis only: bracket the fractional
// sample position with the two neighbouring texels, texelFetch each (each correctly
// mapped to its own 2-D texel), and lerp. Exact, no seam artifact.
//
// Texel-centre convention: this reproduces the predecessor's 1-D LINEAR read bit for
// bit. There, u = t/duration sampled an N-texel LINEAR texture, whose texel centres
// sit at (i+0.5)/N — so u maps to texel-space position u*N - 0.5, interpolating
// between floor() and floor()+1 of that, with CLAMP_TO_EDGE at the ends. We mirror
// exactly that here: the -0.5 and the index clamps to [0, N-1] are the CLAMP_TO_EDGE
// behaviour at both extremes.
float sampleAt(float timeSeconds) {
    if (uHasDatum < 0.5) return 0.0;
    if (timeSeconds < 0.0 || timeSeconds >= uDurationSeconds) return 0.0;
    float n = float(uDatumSampleCount);
    // Continuous texel-space position, half-texel shifted to match LINEAR centres.
    float p = (timeSeconds / uDurationSeconds) * n - 0.5;
    int i0 = clamp(int(floor(p)),     0, uDatumSampleCount - 1);
    int i1 = clamp(int(floor(p)) + 1, 0, uDatumSampleCount - 1);
    float f = clamp(p - floor(p), 0.0, 1.0);
    // Smootherstep (C1-continuous Hermite) blend between the two bracketing samples instead of a
    // straight linear lerp. Linear reconstruction connects samples with straight segments, so the
    // ribbon edge reads as faceted polygons; the Hermite ease gives a smooth sinusoid-shaped contour
    // between samples with zero slope at each sample point (Phase 10 tuning — smooth, not polygonal).
    float fs = f * f * (3.0 - 2.0 * f);
    return mix(fetchSample(i0), fetchSample(i1), fs);
}

// ════════════════════════════════════════════════════════════════════════════════════
//  R2 — wax metaballs + waveform SDF. The blobs are integrated on the CPU (see the JS
//  physics step) and uploaded as uBlobs[]; the shader composites them with smin and the
//  waveform ribbon into one liquid surface, then shades it with a simple theme fill.
// ════════════════════════════════════════════════════════════════════════════════════

// ── Value-noise (used now only for the organic, blob-tied radius wobble). ────────────
// A standard hash → smooth value-noise. Cheap (a few mixes), no texture lookup, and
// continuous. Fed blob-identity + the wall clock it gives each wax shape its own slow
// breathing so the silhouette is organic rather than a perfect circle (§4b).
float hash21(vec2 p) {
    p = fract(p * vec2(123.34, 345.45));
    p += dot(p, p + 34.345);
    return fract(p.x * p.y);
}
float valueNoise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    // Smoothstep (Hermite) interpolation of the four lattice corners — C1-continuous.
    vec2 u = f * f * (3.0 - 2.0 * f);
    float a = hash21(i + vec2(0.0, 0.0));
    float b = hash21(i + vec2(1.0, 0.0));
    float c = hash21(i + vec2(0.0, 1.0));
    float d = hash21(i + vec2(1.0, 1.0));
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

// ── Signed-distance primitives + smooth-min (the metaball machinery). ───────────────
// Circle SDF (a metaball centre) — the wax blob primitive.
float sdCircle(vec2 p, float r) {
    return length(p) - r;
}
// Polynomial smooth-min (Inigo Quilez). Blends two SDFs into one continuous surface —
// the metaball union. k controls the blend radius (the size of the liquid "neck" where
// two blobs merge). As k→0 it becomes a hard min (discrete shapes); larger k fuses them.
float smin(float a, float b, float k) {
    float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
    return mix(b, a, h) - k * h * (1.0 - h);
}

// ── OKLab colour interpolation (spec §6c — replaces the rejected HSL mixHsl/vivify). ─
//
// OKLab is a perceptually-uniform space (Björn Ottosson). A straight line between two
// colours in OKLab stays perceptually faithful — no hue drift, no saturation pumping, no
// rainbow excursion. That is the structural fix for the navy→moss cyan bug: HSL hue-lerp
// between blue and green passes through cyan; OKLab does not. We convert each anchor
// linear-sRGB → OKLab, mix() in OKLab, convert back — all per-fragment (cheap: a cube
// root and two 3×3 matmuls each way).
//
// The uColor* uniforms arrive as GAMMA sRGB [0,1] (parsed straight from the CSS hex), so
// we linearise on the way in and re-encode gamma on the way out.
float srgbToLinear(float c) {
    return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4);
}
float linearToSrgb(float c) {
    return c <= 0.0031308 ? c * 12.92 : 1.055 * pow(c, 1.0 / 2.4) - 0.055;
}
vec3 srgbToLinear3(vec3 c) {
    return vec3(srgbToLinear(c.r), srgbToLinear(c.g), srgbToLinear(c.b));
}
vec3 linearToSrgb3(vec3 c) {
    return vec3(linearToSrgb(c.r), linearToSrgb(c.g), linearToSrgb(c.b));
}
// linear-sRGB → OKLab (Ottosson's standard matrices).
vec3 linearToOklab(vec3 c) {
    float l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;
    float m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;
    float s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;
    float l_ = pow(l, 1.0 / 3.0);
    float m_ = pow(m, 1.0 / 3.0);
    float s_ = pow(s, 1.0 / 3.0);
    return vec3(
        0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
        1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
        0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
    );
}
// OKLab → linear-sRGB (the inverse).
vec3 oklabToLinear(vec3 lab) {
    float l_ = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z;
    float m_ = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z;
    float s_ = lab.x - 0.0894841775 * lab.y - 1.2914855480 * lab.z;
    float l = l_ * l_ * l_;
    float m = m_ * m_ * m_;
    float s = s_ * s_ * s_;
    return vec3(
        4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
        -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
        -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
    );
}
// Chroma (vibrancy) boost in OKLab (Phase 10 — Daniel: "colours too muted, more punch"). OKLab's L
// is lightness; (a,b) is the chroma vector. Scaling (a,b) about the neutral axis raises saturation
// while preserving hue (the a:b ratio) and lightness (L untouched), so the palette-sourced navy/moss/
// off-white stay themselves — just more vivid. No hardcoded hexes: the anchors remain the live palette
// vars (spec §6a), this only amplifies their existing chroma. >1 = more punch.
const float CHROMA_BOOST = 1.45;
vec3 vivifyOklab(vec3 lab) {
    return vec3(lab.x, lab.y * CHROMA_BOOST, lab.z * CHROMA_BOOST);
}
// Mix two GAMMA-sRGB colours perceptually: linearise → OKLab → boost chroma → lerp → back to gamma
// sRGB. The chroma boost gives the gradient punch (Phase 10) while OKLab keeps the blend faithful.
vec3 mixOklab(vec3 a, vec3 b, float t) {
    vec3 la = vivifyOklab(linearToOklab(srgbToLinear3(a)));
    vec3 lb = vivifyOklab(linearToOklab(srgbToLinear3(b)));
    vec3 m = mix(la, lb, t);
    return clamp(linearToSrgb3(oklabToLinear(m)), 0.0, 1.0);
}
// One of the three anchors as a continuous function of a phase in [0,3): a triangular
// blend around the ring X→Y→Z→X so the picked anchor travels smoothly through all three
// (OKLab-interpolated, so the transitions stay faithful). phase need not be wrapped —
// we fract it to the ring here.
vec3 anchorAtPhase(float phase) {
    float p = fract(phase / 3.0) * 3.0;  // [0,3)
    float seg = floor(p);                // 0,1,2
    float f = p - seg;                   // [0,1) within the segment
    if (seg < 0.5)      return mixOklab(uColorNavy, uColorMoss, f);
    else if (seg < 1.5) return mixOklab(uColorMoss, uColorPaper, f);
    else                return mixOklab(uColorPaper, uColorNavy, f);
}

// ── The waveform ribbon SDF, in HEIGHT-NORMALIZED space (negative inside). ──────────
//
// The waveform is the same symmetric ±loudness ribbon about the centre line as before,
// but evaluated in height-normalized coords (pixel/H) so it shares one space with the
// wax blobs. p = (x, y) where x ∈ [0, W/H] across the canvas and y ∈ [0, 1] top→bottom.
// We map the row's mix-time → loudness → a half-width about the centre x, and return the
// distance to that vertical ribbon band. Loudness at neighbour rows is NOT re-stacked
// here (the per-row geometry from Wave 1 is already smooth); the band is the ribbon.
float waveformSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight) {
    // Phase 15: ribbon subsystem off → return "fully outside" so the smin union ignores it entirely
    // (a far positive distance never pulls the surface toward the centre line). This is the genuine
    // skip — the ribbon contributes nothing, rather than being drawn-then-hidden.
    if (uWaveformEnabled < 0.5) return 1e9;
    // Mix-time at this row: rows below the now-line are future audio, above are past.
    float t = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight;
    float amp = sampleAt(t);                         // loudness 0..1 at this row
    float centreX = aspect * 0.5;                    // canvas centre x in height-norm units
    // Ribbon half-width here, scaled by the waveform-width dial (R2 #8): at width 1 it is the
    // full prior band; lower narrows it so the lava fluid gets more room on loud songs.
    float halfW = amp * (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * uWaveformWidth;
    // Distance to a centred vertical band of half-width halfW: |x − centre| − halfW.
    // Negative inside the band, positive outside. (A pure horizontal band; the vertical
    // extent is the whole column, which is what the scrolling ribbon is.)
    return abs(p.x - centreX) - halfW;
}

// ── The combined wax + waveform liquid SDF at a height-normalized point. ─────────────
//
// Unions all live wax blobs (smin metaballs) and the waveform ribbon into one continuous
// surface. The blob radii carry a slow blob-tied wobble so each is organic, not a perfect
// circle. Returns the signed distance and, via out params, the nearest-blob temperature
// (for the warm hot-wax tint) and whether the point is dominated by wax vs. ribbon.
float liquidSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight,
                out float hotOut) {
    // Waveform ribbon first — the always-present base surface.
    float field = waveformSdf(p, aspect, nowYn, secondsPerHeight);

    float hotAccum = 0.0;
    float hotWeight = 0.0;

    // Phase 10 cohesion (viscosity knob): low cohesion → a wider smin neck (blobs fuse and stay
    // gooey/deformed) and more wobble (less sphere-like); high cohesion → a tight neck and minimal
    // wobble (crisp spheres that read as "snapped back to round"). Pure uniform scaling of the two
    // existing constants — no extra per-fragment loop iterations, so weaker hardware is unaffected.
    // Range chosen so cohesion 1 still keeps a small organic neck/wobble (never a hard-edged circle).
    float blobK = BLOB_SMOOTHMIN_K * (1.0 + (1.0 - uCohesion) * 1.4);   // ×1.0 (crisp) → ×2.4 (gooey)
    float wobbleAmt = BLOB_WOBBLE_AMOUNT * (0.35 + (1.0 - uCohesion) * 1.4); // less wobble when cohesive

    // Union every live wax blob. Bounded loop to MAX_BLOBS; uBlobCount gates the live set.
    for (int i = 0; i < MAX_BLOBS; i++) {
        if (i >= uBlobCount) break;
        vec4 b = uBlobs[i];
        vec2 c = b.xy;       // centre, height-norm
        float r = b.z;       // radius, height-norm
        float temp = b.w;    // temperature 0..1

        // Organic radius wobble: a slow per-blob breathing (blob-tied + wall clock), so
        // the silhouette is never a clean circle. Fluid-tied, not screen-space (§3 ok).
        // Amount scaled by cohesion (low cohesion deforms more — Phase 10 viscosity split).
        float wob = (valueNoise(vec2(float(i) * 1.37, uTimeSeconds * BLOB_WOBBLE_RATE)) - 0.5)
                    * 2.0 * wobbleAmt;
        float rr = r * (1.0 + wob);

        float blob = sdCircle(p - c, rr);
        field = smin(field, blob, blobK);

        // Weight this blob's temperature by proximity so the tint follows the nearest wax.
        float prox = clamp(1.0 - (blob / max(rr, 1e-3)), 0.0, 1.0);
        hotAccum += temp * prox;
        hotWeight += prox;
    }

    hotOut = hotWeight > 1e-3 ? hotAccum / hotWeight : 0.0;
    return field;
}

void main() {
    float w = uResolution.x;
    float h = uResolution.y;

    // Empty backdrop when there is no datum (no thin-centre-line artifact — Wave 1 note).
    if (uHasDatum < 0.5) {
        fragColor = vec4(0.0);
        return;
    }

    // Height-normalized fragment coordinate (pixel / H), top-left origin, y down. This is
    // the shared space the CPU physics works in — the blob uniforms are already in it.
    float aspect = w / h;                       // canvas width in height units
    vec2 p = vec2(gl_FragCoord.x / h, (h - gl_FragCoord.y) / h);

    float nowYn = NOW_ANCHOR_FROM_TOP;          // now-line, height-norm (y ∈ [0,1])
    float secondsPerHeight = uVisibleSeconds;   // one full height spans uVisibleSeconds

    // ── Evaluate the combined liquid SDF only (no normal/gradient shading this wave). ──
    // R2 cone fix (Daniel #1): the previous build derived a surface NORMAL from the SDF
    // gradient and shaded the fill by it (mix(0.82, 1.12, lightUp)). On a metaball the
    // gradient points outward from each blob centre, so that shading lit a bright spot at
    // every centre and darkened the rims — the wax read as a CONE with a pointed bright tip,
    // not a flat fluid surface. We DROP the normal shading entirely: a metaball / fluid
    // surface is FLAT, distinguished only by a soft anti-aliased edge. (Form/colour is the
    // Wave R3 job; here we only flatten.) This also removes the 4 extra SDF evaluations the
    // central-difference gradient cost — a small frame-budget win.
    float hot;
    float d = liquidSdf(p, aspect, nowYn, secondsPerHeight, hot);

    // Inside-ness: SDF negative = inside. Feather ~1.2px (in height-norm units) for an
    // anti-aliased edge instead of a hard chart line (no blur — spec §2/§3). This soft edge
    // is the ONLY shading on the surface now — the body is flat.
    float pxFeather = 1.2 / h;
    float inside = 1.0 - smoothstep(-pxFeather, pxFeather, d);
    if (inside <= 0.0) { fragColor = vec4(0.0); return; }

    // ── R3 — the three-colour OKLab gradient (the three combined motions, spec §6b). ──
    //
    // The static structure is a LINEAR A→B from the centre line outward (A at the root, B at
    // the extended edge), with A and B drawn from the rotating three-anchor ring. On top of
    // that, three motions combine — all OKLab-interpolated, so no rainbow/cyan excursion.

    // Centre-outward fraction [0,1] for this fragment: 0 at the centre line (root), 1 at the
    // canvas edge. This is the axis the A→B linear runs along (spec §6b: "from the 0 centre
    // line outward along the waveform").
    float xnAbs = clamp(abs(p.x - aspect * 0.5) / (aspect * 0.5), 0.0, 1.0);

    // Motion 2 — per-bar sinusoid keyed to MIX-TIME (spec §6b motion 2). The mix-time at this
    // fragment's row is identical to the waveform's row-time, so the sinusoid is fixed for a
    // given segment and travels up with it as it scrolls — baked-per-segment by construction,
    // no ring buffer. It nudges the anchor-ring phase, so neighbouring segments sit on slightly
    // different colours: "waves" of colour across the waveform rather than one uniform gradient.
    float segTime = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight;
    float segWave = sin(segTime * SEG_WAVE_FREQ * 6.2831853) * SEG_WAVE_AMOUNT;

    // Motion 1 — anchor rotation among X/Y/Z (spec §6b motion 1). uGradientPhase advances at
    // the gradient-rotation-speed dial's rate (CPU-integrated from uTimeSeconds). A leads B by
    // a fixed ring offset so the gradient always spans two distinct anchors. The per-segment
    // wave (Motion 2) is folded into the phase so each segment is offset on the ring.
    float phaseA = uGradientPhase + segWave;
    float phaseB = uGradientPhase + GRADIENT_AB_PHASE_OFFSET + segWave;
    vec3 colorA = anchorAtPhase(phaseA);  // centre/root colour
    vec3 colorB = anchorAtPhase(phaseB);  // outer/edge colour

    // Motion 3 — per-bar curve shift with scroll height (spec §6b motion 3). p.y is 0 at the
    // top and 1 at the floor; (0.5 - p.y) is +0.5 at the top, −0.5 at the floor. We shift the
    // A→B mix fraction toward A (negative) low on screen and toward B (positive) high on
    // screen, so a bar is mostly A at the bottom and mostly B by the top — colour climbs
    // outward as the bar scrolls up.
    float curveShift = (0.5 - p.y) * 2.0 * CURVE_SHIFT_AMOUNT;
    float mixFrac = clamp(xnAbs + curveShift, 0.0, 1.0);

    vec3 fill = mixOklab(colorA, colorB, mixFrac);

    // Warm tint on hot, rising wax so the eye reads convection (subordinate to the gradient,
    // spec §4f). A flat per-blob temperature lean — no spatial falloff, so no cone is reintroduced.
    float hotLean = clamp((hot - ${TEMP_AMBIENT.toFixed(2)}) * 2.0, 0.0, 1.0) * HOT_TINT_AMOUNT;
    fill = mix(fill, HOT_TINT, hotLean);

    float alpha = inside * RIBBON_OPACITY_R2;
    fragColor = vec4(fill * alpha, alpha); // pre-multiplied for ONE/ONE_MINUS_SRC_ALPHA
}
`;

/** Compile one shader stage, throwing with the info log on failure. */
function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
    const shader = gl.createShader(type);
    if (!shader) throw new Error('WaveformVisualizer: gl.createShader returned null.');
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        const log = gl.getShaderInfoLog(shader);
        gl.deleteShader(shader);
        throw new Error(`WaveformVisualizer: shader compile failed: ${log}`);
    }
    return shader;
}

/** Link the vertex + fragment shaders into a program, throwing on failure. */
function linkProgram(gl: WebGL2RenderingContext): WebGLProgram {
    const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
    const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
    const program = gl.createProgram();
    if (!program) throw new Error('WaveformVisualizer: gl.createProgram returned null.');
    gl.attachShader(program, vert);
    gl.attachShader(program, frag);
    gl.linkProgram(program);
    // Shaders can be deleted after link — the program retains the compiled code.
    gl.deleteShader(vert);
    gl.deleteShader(frag);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        const log = gl.getProgramInfoLog(program);
        gl.deleteProgram(program);
        throw new Error(`WaveformVisualizer: program link failed: ${log}`);
    }
    return program;
}

/** The no-op handle returned when WebGL2 is unavailable or setup fails. */
function noopHandle(): WaveformVisualizerHandle {
    return {
        setDatum() {},
        setPlayback() {},
        setScrollSpeed() {},
        setGradientRotationSpeed() {},
        setLavaGravity() {},
        setLavaHeat() {},
        setFluidAmount() {},
        setFluidViscosity() {},
        setCollisionStrength() {},
        setWaveformWidth() {},
        setLavaEnabled() {},
        setWaveformEnabled() {},
        refreshTheme() {},
        dispose() {},
    };
}

export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
    // premultipliedAlpha so the translucent ribbon composites correctly over the
    // page; antialias off (the soft-edge smoothstep handles AA in-shader, and MSAA
    // would cost fill rate we don't need for a backdrop).
    const maybeGl = canvas.getContext('webgl2', {
        alpha: true,
        premultipliedAlpha: true,
        antialias: false,
    });
    if (!maybeGl) {
        // No WebGL2 (old engine / disabled): hand back a no-op handle so the
        // component still functions as a plain backdrop (mirrors the predecessor's
        // no-2d-context fallback, now guarding against no-WebGL2).
        console.error(`${TAG} getContext('webgl2') returned null — WebGL2 unavailable; rendering a plain backdrop.`);
        return noopHandle();
    }
    // Non-null binding so the closures below keep the narrowing (TS does not carry
    // control-flow narrowing of a captured `const` into nested functions).
    const gl: WebGL2RenderingContext = maybeGl;

    // GL_MAX_TEXTURE_SIZE is a per-context constant — query it once. The datum is
    // laid out across a 2-D grid no wider than this (see uploadDatum); a 1×N row
    // would exceed it for any mix over ~49 s at the ~333 samples/s datum density,
    // and texImage2D would reject the upload (the bug this fix addresses).
    const maxTextureSize: number = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number;

    let program: WebGLProgram;
    try {
        program = linkProgram(gl);
    } catch (err) {
        // A compile/link failure on an exotic driver should degrade to the plain
        // backdrop, not crash the page. Log for diagnosis; return the no-op handle.
        console.error(`${TAG} shader compile/link failed; rendering a plain backdrop.`, err);
        return noopHandle();
    }

    // An empty VAO is still required in WebGL2 core to issue a draw; the vertex
    // shader sources its positions from gl_VertexID, so no attribute buffers.
    const vao = gl.createVertexArray();

    // Cache uniform locations once. A null here for a uniform we actually upload
    // means either the name is misspelled or the GLSL compiler dead-stripped it
    // (it isn't reachable in the shader) — both of which silently break a uniform's
    // effect, so surface them. No reserved-unused exemptions remain: every uniform
    // below is genuinely consumed by the R2 shader (the old inert Wave-3 control
    // uniforms are gone — the lava params drive the CPU physics, not the shader).
    const u = {
        resolution: gl.getUniformLocation(program, 'uResolution'),
        playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'),
        timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'),
        visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'),
        waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'),
        waveformEnabled: gl.getUniformLocation(program, 'uWaveformEnabled'),
        cohesion: gl.getUniformLocation(program, 'uCohesion'),
        durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
        colorNavy: gl.getUniformLocation(program, 'uColorNavy'),
        colorMoss: gl.getUniformLocation(program, 'uColorMoss'),
        colorPaper: gl.getUniformLocation(program, 'uColorPaper'),
        gradientPhase: gl.getUniformLocation(program, 'uGradientPhase'),
        hasDatum: gl.getUniformLocation(program, 'uHasDatum'),
        datum: gl.getUniformLocation(program, 'uDatum'),
        datumWidth: gl.getUniformLocation(program, 'uDatumWidth'),
        datumSampleCount: gl.getUniformLocation(program, 'uDatumSampleCount'),
        blobs: gl.getUniformLocation(program, 'uBlobs'),
        blobCount: gl.getUniformLocation(program, 'uBlobCount'),
    };
    for (const [name, loc] of Object.entries(u)) {
        if (loc === null) {
            console.warn(`${TAG} uniform '${name}' resolved to null — it will have no effect (misspelled or dead-stripped from the shader).`);
        }
    }

    // ── Mutable state, fed by the component through the handle. ──────────────────
    let datum: Datum | null = null;
    let playback: Playback = { positionSeconds: 0, isPlaying: false, pushWallClockMs: performance.now() };
    let visibleSeconds = DEFAULT_VISIBLE_SECONDS;

    // ── Lava physics control values (Wave R4 — each its own dedicated knob; see the control-default
    // consts at the top of this file). These are the dials the seven knobs feed, routed here by the
    // handle setters. The lava dials drive the CPU physics step below; waveformWidth is a shader
    // uniform; gradientRotationSpeed drives the OKLab gradient's anchor rotation (live as of R3).
    let lavaHeat = DEFAULT_LAVA_HEAT;
    let lavaGravity = DEFAULT_LAVA_GRAVITY;
    let collisionStrength = DEFAULT_COLLISION_STRENGTH;
    // Phase 10 — the split "bubbles" knob: fluidAmount drives count + per-blob volume; fluidViscosity
    // (cohesion) drives the shader's sphere-restoration (smin blend + wobble) via uCohesion.
    let fluidAmount = DEFAULT_FLUID_AMOUNT;
    let fluidViscosity = DEFAULT_FLUID_VISCOSITY;
    let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
    // LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1).
    let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED;
    // Phase 15 — subsystem on/off. Default ON (mirrors C# DefaultLavaEnabled / DefaultWaveformEnabled),
    // so out of the box both subsystems run exactly as before. "Off" is a genuine draw-skip: lava off
    // skips stepPhysics + uploads zero blobs; waveform off skips the ribbon SDF (uWaveformEnabled) and
    // its CPU collision boundary. With both off, draw() short-circuits to a clear — no SDF eval at all.
    let lavaEnabled = true;
    let waveformEnabled = true;

    /** Effective ribbon-width fraction for the current width knob (Phase 10 §3.7): the knob's [0,1]
     *  travel maps onto the useful 10%–95% band (full-width 100% read too wide; sub-10% vanished).
     *  Both the shader uniform and the CPU collision boundary read this so they stay aligned. */
    function effectiveWaveformWidth(): number {
        return 0.10 + waveformWidth * 0.85;
    }

    // ── R3 gradient-rotation phase (Motion 1). Integrated from the SAME uTimeSeconds clock the
    // shader uses (NOT a new wall-clock — spec R3 guidance): each frame we advance the phase by
    // rate·dt, where dt is the delta of (performance.now()−startTimeMs)/1000 (== uTimeSeconds).
    // Integrating rate·dt (rather than computing phase = t·rate in the shader) keeps the phase
    // CONTINUOUS when the dial changes — a rate change alters the slope, never snaps the value.
    let gradientPhase = 0;
    let lastGradientClockSeconds = 0;

    /**
     * The *authoritative* playhead for this instant: the last pushed position advanced
     * by wall-clock elapsed since the push while playing, or the pushed position while
     * idle. The player remains the sole source of truth — this is display-only and is
     * never written back (read-only contract, spec §D / §5.10). This is the target the
     * rendered playhead converges onto; the shader uploads the *rendered* value (see
     * renderedPlayhead) so a re-anchor at a push doesn't snap on screen.
     */
    function effectivePlayhead(): number {
        if (!playback.isPlaying) return playback.positionSeconds;
        const elapsedSeconds = (performance.now() - playback.pushWallClockMs) / 1000;
        return playback.positionSeconds + elapsedSeconds;
    }

    // ── Rendered-playhead reconciliation (startup-jitter fix). ───────────────────────
    //
    // The shader scrolls to renderedPlayhead() = effectivePlayhead() + correctionOffset,
    // where correctionOffset decays exponentially toward 0 each frame (time constant
    // PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS). At a push, setPlayback re-anchors the
    // authoritative target; without correction that re-anchor would teleport the
    // rendered playhead. Instead we *preserve the rendered position across the push* by
    // folding the discontinuity into correctionOffset (see setPlayback), then bleed it
    // off — turning each snap into a brief, sub-perceptible glide.
    //
    // Steady-state: when pushes are regular, the authoritative target barely moves at a
    // push, so the folded discontinuity is ~0 and correctionOffset stays ~0 — behaviour
    // is then identical to uploading effectivePlayhead() directly (the prior renderer).
    let correctionOffset = 0;
    let lastRenderWallClockMs = performance.now();

    /**
     * The playhead the shader actually scrolls to this frame. Equals the authoritative
     * effectivePlayhead() plus a correction offset that decays to zero, so the rendered
     * motion is continuous across the irregular startup pushes. Advances the decay by
     * real elapsed time since the previous render, making it frame-rate-independent
     * (same convergence on a 60 Hz and a 144 Hz display). Call exactly once per drawn
     * frame — it mutates the decay state.
     */
    function renderedPlayhead(): number {
        const nowMs = performance.now();
        const dtSeconds = Math.max(0, (nowMs - lastRenderWallClockMs) / 1000);
        lastRenderWallClockMs = nowMs;

        // Exponential decay of the error toward 0: offset *= e^(-dt/tau). Frame-rate
        // independent — the fraction retained depends only on wall-clock dt, not frame
        // count. Snap tiny residuals to 0 (an exponential never reaches it).
        if (correctionOffset !== 0) {
            correctionOffset *= Math.exp(-dtSeconds / PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS);
            if (Math.abs(correctionOffset) < PLAYHEAD_CORRECTION_SNAP_SECONDS) correctionOffset = 0;
        }

        return effectivePlayhead() + correctionOffset;
    }

    /**
     * Resolve the three colour anchors (navy / moss / off-white) from the live palette
     * vars on the canvas — the single source of truth (spec §6a).
     *
     * Detects light vs dark by the page background luminance, then binds each anchor to
     * the var that carries that identity in the active palette (see the note above the
     * ResolvedTheme interface for why no single var works across both modes):
     *   LIGHT: navy = --mud-palette-primary (#17283f), moss = --mud-palette-secondary (#3D7A68),
     *          off-white = --mud-palette-background (#FAFAF8)
     *   DARK:  navy = --mud-palette-background (#0D1B2A), moss = --mud-palette-primary (#3D7A68),
     *          off-white = --mud-palette-secondary (#FAFAF8)
     * This yields the navy / moss / off-white triad the gradient rotates among in either theme.
     */
    function readTheme(): ResolvedTheme {
        const background = parseColor(readVar(canvas, '--mud-palette-background', '#FAFAF8'));
        const isDark = luminance(background) < 0.5;

        const navy = isDark
            ? background // the dark ground (#0D1B2A) IS the navy anchor on dark
            : parseColor(readVar(canvas, '--mud-palette-primary', '#17283f'));
        const moss = isDark
            ? parseColor(readVar(canvas, '--mud-palette-primary', '#3D7A68'))
            : parseColor(readVar(canvas, '--mud-palette-secondary', '#3D7A68'));
        const paper = isDark
            ? parseColor(readVar(canvas, '--mud-palette-secondary', '#FAFAF8'))
            : background; // the light ground (#FAFAF8) IS the off-white anchor on light

        const resolved: ResolvedTheme = { navy, moss, paper };
        // Report all THREE anchors the OKLab gradient rotates among, as 0-255 RGB + relative
        // luminance — confirms the navy / moss / off-white triad resolved off the canvas vars
        // in the active mode (no hardcoded hexes; a dark-mode toggle re-themes live).
        const fmt = (c: [number, number, number]) =>
            `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}) lum=${luminance(c).toFixed(2)}`;
        debugLog(`theme resolved (${isDark ? 'dark' : 'light'}) — NAVY(X)=${fmt(navy)}  MOSS(Y)=${fmt(moss)}  PAPER(Z)=${fmt(paper)}.`);
        return resolved;
    }

    let theme: ResolvedTheme = readTheme();

    // ════════════════════════════════════════════════════════════════════════════════
    //  R2 — the CPU wax-blob physics. Integrated each frame (real dt), then packed into
    //  `blobUpload` and sent to the shader as uBlobs[]. Allocation-free per frame: the
    //  blob pool and the upload buffer are built once here and mutated in place.
    //
    //  Space: height-normalized (pixel / canvasHeight). y ∈ [0,1] top→floor, x ∈ [0, aspect]
    //  where aspect = canvasWidth/canvasHeight. The FLOOR is y = 1 (the canvas bottom edge,
    //  already CSS-clipped to the footer top in R1). One isotropic unit → round blobs.
    // ════════════════════════════════════════════════════════════════════════════════

    interface Blob {
        x: number; y: number;   // centre, height-norm
        vx: number; vy: number; // velocity, height-norm/s
        r0: number;             // UNBIASED base radius, height-norm (fixed per blob — the blob's
                                //   identity size; the density dial scales it LIVE into r each frame)
        r: number;              // DENSITY-biased base radius this step = r0 × density bias (Daniel
                                //   #1: density's "size" half is live — recomputed each frame, not
                                //   baked at seed, so turning the dial visibly resizes live wax)
        er: number;             // EFFECTIVE radius this step = r shrunk by heat (Daniel #7); used by
                                //   collisions AND uploaded to the shader so the two always agree
        temp: number;           // temperature 0..1
        noiseSeed: number;      // fixed per-blob phase offset so each blob's turbulence is decorrelated
    }

    // The blob pool — MAX_BLOBS slots, all constructed once. liveCount (≤ MAX_BLOBS,
    // driven by the density dial) decides how many we simulate + upload this frame.
    const blobs: Blob[] = [];
    // The packed upload buffer (vec4 per blob). Reused every frame — no per-frame alloc.
    const blobUpload = new Float32Array(MAX_BLOBS * 4);

    /** Cheap deterministic PRNG (mulberry32) so blob spawn is varied but reproducible. */
    function makeRng(seed: number): () => number {
        let s = seed >>> 0;
        return () => {
            s = (s + 0x6d2b79f5) >>> 0;
            let t = s;
            t = Math.imul(t ^ (t >>> 15), t | 1);
            t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
            return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
        };
    }
    const rng = makeRng(0x1a2b3c4d);

    /** The fluid-amount dial's effect on blob SIZE (Phase 10): more fluid → larger wax. Applied LIVE
     *  each frame to the blob's unbiased base radius (r0 → r), so turning the dial resizes already-live
     *  blobs, not just how many spawn. One source so seed + per-frame agree. amount 0 → ×0.6 (lean),
     *  amount 1 → ×1.15 (fat, lots of wax). */
    function fluidSizeBias(): number {
        return 0.6 + fluidAmount * 0.55;
    }

    /** Construct (or re-seed) one blob at a random spot near the floor, ready to be heated. */
    function seedBlob(b: Blob, aspect: number): void {
        // Pick the blob's UNBIASED identity radius once; the density dial scales it live each frame.
        const r0 = BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN);
        const r = r0 * fluidSizeBias();
        b.r0 = r0;
        b.r = r;
        b.er = r;                                           // starts at full size (cool); shrinks as it heats
        b.x = r + rng() * Math.max(aspect - 2 * r, 0.001); // somewhere across the width
        b.y = 1 - r - rng() * 0.1;                          // pooled near the floor
        b.vx = 0;
        b.vy = 0;
        b.temp = rng() * 0.3;                               // cool to start (heats at the floor)
        b.noiseSeed = rng() * 100;                          // decorrelate this blob's turbulence field
    }

    /** (Re)build the whole pool — called once at setup and whenever the canvas aspect is first known. */
    function initBlobs(aspect: number): void {
        blobs.length = 0;
        for (let i = 0; i < MAX_BLOBS; i++) {
            const b: Blob = { x: 0, y: 0, vx: 0, vy: 0, r0: 0, r: 0, er: 0, temp: 0, noiseSeed: 0 };
            seedBlob(b, aspect);
            blobs.push(b);
        }
    }
    let blobsInitialized = false;

    /** Live blob count for the current fluid-amount dial, within [MIN_BLOB_COUNT, MAX_BLOBS]. */
    function liveBlobCount(): number {
        return Math.round(MIN_BLOB_COUNT + fluidAmount * (MAX_BLOBS - MIN_BLOB_COUNT));
    }

    /**
     * CPU loudness sample at an absolute mix time, in [0,1], or 0 outside the mix. This
     * mirrors the shader's sampleAt() (same texel-centre convention) so the CPU collision
     * boundary matches the rendered waveform exactly. Reads the retained datum.samples.
     */
    function sampleLoudnessAt(timeSeconds: number): number {
        // Phase 15: waveform off → no ribbon boundary. Reporting zero loudness collapses the collision
        // half-width to 0, so wax never bounces off an invisible wall (matches the skipped ribbon draw).
        if (!waveformEnabled) return 0;
        const d = datum;
        if (!d || timeSeconds < 0 || timeSeconds >= d.durationSeconds) return 0;
        const n = d.sampleCount;
        const p = (timeSeconds / d.durationSeconds) * n - 0.5;
        const i0 = Math.min(Math.max(Math.floor(p), 0), n - 1);
        const i1 = Math.min(Math.max(Math.floor(p) + 1, 0), n - 1);
        const f = Math.min(Math.max(p - Math.floor(p), 0), 1);
        const s0 = d.samples[i0] / 255;
        const s1 = d.samples[i1] / 255;
        // Smootherstep (Hermite) blend — mirrors the shader's sampleAt so the CPU collision boundary
        // follows the same smooth sinusoid contour the ribbon is drawn with (no faceted mismatch).
        const fs = f * f * (3 - 2 * f);
        return s0 + (s1 - s0) * fs;
    }

    /** The heat dial's transfer function: dial 0..1 → how hard the floor pumps heat in.
     *  Designed so dial 0 = NO floor heating (wax rests, collision-only — §4c endpoint) and
     *  dial 1 = vigorous heating (many blobs go buoyant per second). A slight ease-in (square
     *  toe) keeps the low end gentle so small dial moves near 0 don't suddenly erupt. */
    function heatScaleFromDial(dial: number): number {
        const d = Math.min(Math.max(dial, 0), 1);
        // Smoothstep toe (gentle at 0) scaled by 1.2 — Phase 10 §3.4: uniform +20% across the curve
        // (every non-zero dial position is raised by 20%; the shape of the toe is preserved but the
        // overall output is higher at every point).
        return d * d * (3 - 2 * d) * 1.2;
    }

    /** The collision-strength transfer: dial 0 = soft (penalty-spring, absorptive),
     *  dial 1 = hard (elastic, high restitution). Returns the restitution coefficient to
     *  use; the penalty-spring stiffness is held constant and the IMPULSE is scaled by the
     *  same dial so soft = mostly spring/no-bounce, hard = full elastic reflection (§5c). */
    function restitution(soft: number, hard: number): number {
        const d = Math.min(Math.max(collisionStrength, 0), 1);
        return soft + (hard - soft) * d;
    }

    /**
     * Cheap continuous 1-D-ish noise in [−1,1] for the turbulence field (Daniel #7). A pair of
     * out-of-phase sines is enough for an organic, smoothly-evolving churn — far cheaper than a
     * lattice value-noise and we only need it twice per hot blob per substep. Decorrelated per
     * blob via the seed so neighbouring bubbles don't churn in lock-step.
     */
    function turbNoise(seed: number, t: number): number {
        return Math.sin(seed + t) * 0.6 + Math.sin(seed * 1.7 + t * 0.53) * 0.4;
    }

    /**
     * Advance the physics by dt seconds. Sub-stepped for spring stability. The collision
     * model: blob↔floor (soft contact), blob↔waveform (elastic deflect off the ribbon
     * surface normal + an upward throw, always on), blob↔blob (elastic, soft↔hard via the
     * strength dial). Heat shrinks each blob's effective radius and injects turbulence so
     * high heat reads as a swarm of small lively bubbles (Daniel #7).
     */
    function stepPhysics(dtTotal: number): void {
        if (canvas.height <= 0) return;
        const aspect = canvas.width / canvas.height;

        if (!blobsInitialized) {
            initBlobs(aspect);
            blobsInitialized = true;
        }

        const count = liveBlobCount();
        const sizeBias = fluidSizeBias(); // fluid-amount dial → live size scale (Phase 10, recomputed each step)
        const heatScale = heatScaleFromDial(lavaHeat);
        // Gravity range remap (Phase 10 §3.3): the knob's full [0,1] travel now covers only the useful
        // 0%–75% of the old gravity span — the top quarter was too heavy (wax slammed down). So the dial
        // is scaled to 0.75 before mapping onto [MIN, MAX], keeping the low/mid feel and dropping the slam.
        const gravityDial = lavaGravity * 0.75;
        const gravity = GRAVITY_ACCEL_MIN + gravityDial * (GRAVITY_ACCEL_MAX - GRAVITY_ACCEL_MIN);
        const collideRest = restitution(BLOB_RESTITUTION_SOFT, BLOB_RESTITUTION_HARD);
        const waveRest = restitution(WAVE_RESTITUTION_SOFT, WAVE_RESTITUTION_HARD);
        const collideHardness = Math.min(Math.max(collisionStrength, 0), 1);

        // Mix-time mapping at the current playhead (the waveform a blob's row sits over).
        const nowYn = NOW_ANCHOR_FROM_TOP;
        const secondsPerHeight = visibleSeconds;
        const centreX = aspect * 0.5;
        // Match the shader's width-dialled ribbon so the collision boundary lines up with what
        // is drawn (R2 #8): a narrower waveform must also collide narrower. Uses the SAME remapped
        // effective width as the uniform (Phase 10 §3.7) so the boundary never drifts from the ribbon.
        const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * effectiveWaveformWidth();
        const playhead = effectivePlayhead();

        const dt = Math.min(dtTotal, PHYSICS_MAX_DT) / PHYSICS_SUBSTEPS;
        // Wall-clock seconds for the turbulence field (separate from the playhead/scroll).
        const turbTime = (performance.now() - startTimeMs) / 1000 * TURBULENCE_RATE;

        for (let s = 0; s < PHYSICS_SUBSTEPS; s++) {
            // ── Per-blob: heat exchange, size, buoyancy, gravity, turbulence, damping, floor. ──
            for (let i = 0; i < count; i++) {
                const b = blobs[i];

                // Heat exchange: floor heats (× heat dial), top cools, all relax to ambient.
                const distFromFloor = 1 - b.y;
                const distFromTop = b.y;
                if (distFromFloor < HEAT_FLOOR_ZONE) {
                    const near = 1 - distFromFloor / HEAT_FLOOR_ZONE; // 1 at floor → 0 at zone edge
                    b.temp += (1 - b.temp) * HEAT_FLOOR_RATE * heatScale * near * dt;
                }
                if (distFromTop < HEAT_TOP_ZONE) {
                    const near = 1 - distFromTop / HEAT_TOP_ZONE;
                    b.temp += (0 - b.temp) * HEAT_TOP_RATE * near * dt;
                }
                b.temp += (TEMP_AMBIENT - b.temp) * HEAT_AMBIENT_RATE * dt;
                b.temp = Math.min(Math.max(b.temp, 0), 1);

                // Fluid amount → SIZE (Phase 10): scale the blob's identity radius by the live fluid-
                // amount bias EACH STEP, so turning the dial visibly resizes already-live wax (the
                // "size" half is not baked at seed). r feeds the heat-shrink below and the
                // collisions/upload via er, so the dial moves the actual drawn + simulated size.
                b.r = b.r0 * sizeBias;

                // Energy → SIZE (Daniel #7): the hotter the wax, the smaller it shrinks. Driven by
                // heatScale × temperature so it only shrinks when the lamp is actually hot AND this
                // blob is hot — at heat 0 every blob stays full size. Tracks temp continuously, so a
                // bubble grows back as it cools near the top. Effective radius feeds both collision
                // and the upload, so the sim and the render never disagree.
                const shrink = 1 - heatScale * b.temp * (1 - HEAT_RADIUS_MIN_SCALE);
                b.er = b.r * shrink;

                // Forces: gravity down (+y), buoyancy from temperature (up = −y when hot).
                const buoyancy = BUOYANCY_COEFF * heatScale * (b.temp - TEMP_AMBIENT);
                b.vy += (gravity - buoyancy) * dt;

                // Energy → TURBULENCE (Daniel #7): a small churn injected into hot wax only, scaled
                // by heatScale × temperature. High heat ⇒ lots of small bubbles jittering with life;
                // low heat ⇒ a calm pool. Two decorrelated noise reads give an x/y churn vector.
                const turbAmp = TURBULENCE_ACCEL * heatScale * b.temp;
                if (turbAmp > 0) {
                    b.vx += turbNoise(b.noiseSeed, turbTime) * turbAmp * dt;
                    b.vy += turbNoise(b.noiseSeed + 50.0, turbTime) * turbAmp * dt;
                }

                // Viscous damping (lazy wax): frame-rate-independent exponential decay.
                const damp = Math.exp(-VISCOUS_DAMPING * dt);
                b.vx *= damp;
                b.vy *= damp;

                // Velocity clamp (Daniel #5 jitter fix): keep the integrator stable under the
                // lower viscosity + over-unity restitution so a deep overlap can't fling a blob
                // fast enough to tunnel and re-collide (the buzz). Far above real convection speed.
                if (b.vx > MAX_BLOB_SPEED) b.vx = MAX_BLOB_SPEED; else if (b.vx < -MAX_BLOB_SPEED) b.vx = -MAX_BLOB_SPEED;
                if (b.vy > MAX_BLOB_SPEED) b.vy = MAX_BLOB_SPEED; else if (b.vy < -MAX_BLOB_SPEED) b.vy = -MAX_BLOB_SPEED;

                // Integrate position.
                b.x += b.vx * dt;
                b.y += b.vy * dt;

                // Boundaries use the EFFECTIVE radius so shrunk hot bubbles sit correctly.
                // Floor: soft contact spring + extra damping so resting wax pools and flattens.
                const floorY = 1 - b.er;
                if (b.y > floorY) {
                    const pen = b.y - floorY;
                    b.vy -= FLOOR_SPRING * pen * dt;     // spring pushes up out of the floor
                    if (b.vy > 0) b.vy *= Math.exp(-FLOOR_CONTACT_DAMPING * dt); // kill the downward drive
                    b.y = floorY + pen * 0.5;            // ease the penetration out (soft, no snap)
                }
                // Ceiling: a gentle clamp so a very hot blob doesn't fly off-screen — it cools
                // at the top and falls back; just keep it inside the box.
                const ceilY = b.er;
                if (b.y < ceilY) { b.y = ceilY; if (b.vy < 0) b.vy = 0; }
                // Side walls: reflect softly so wax stays on screen.
                if (b.x < b.er) { b.x = b.er; if (b.vx < 0) b.vx = -b.vx * 0.3; }
                if (b.x > aspect - b.er) { b.x = aspect - b.er; if (b.vx > 0) b.vx = -b.vx * 0.3; }
            }

            // ── Blob ↔ waveform boundary (always on, independent of heat — §5b). ──
            // The waveform is a centred vertical band of half-width = loudness(row). A blob
            // whose centre is within (halfWidth + r) of the centre line penetrates it and is
            // pushed out along the band's surface normal (horizontal). Read-only authority:
            // the waveform is never moved, only the wax responds.
            for (let i = 0; i < count; i++) {
                const b = blobs[i];
                const t = playhead + (b.y - nowYn) * secondsPerHeight;
                const amp = sampleLoudnessAt(t);
                if (amp <= 0) continue;
                const halfW = amp * maxHalf;
                const dx = b.x - centreX;
                const sideSign = dx >= 0 ? 1 : -1;        // outward surface normal (in x)
                const penetration = halfW + b.er - Math.abs(dx);
                if (penetration <= 0) continue;

                // Capture the inward velocity ONCE up front (Daniel #5 jitter fix). The prior
                // build read b.vx for the elastic term AFTER the spring had already mutated it,
                // so the spring and reflection fought each other within one pass — a buzz source.
                // Now each contribution reads the same pre-collision state and they sum cleanly.
                const inwardSpeed = -sideSign * b.vx;     // >0 means moving toward the centre line

                // Soft penalty spring (soft end of the dial): a gentle outward shove proportional
                // to penetration. Softened for R2 (Daniel #3) so the low end genuinely mushes the
                // wax around. The (1 − hardness) factor hands the work to the elastic term as the
                // dial climbs, so we never double-drive at the hard end.
                b.vx += sideSign * WAVE_COLLIDE_SPRING * penetration * dt * (1 - collideHardness);

                // Hard elastic reflection (hard end): bounce the inward velocity back out, scaled
                // by restitution × hardness. Restitution is sub-unity (≤ 0.85) — bounded reflection,
                // no energy added.
                if (inwardSpeed > 0) {
                    b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness;
                }

                // UPWARD throw (Daniel #4): a gentle upward lift on contact so loud transients bob
                // bubbles toward the surface. CAPPED per contact (Phase 10 — "less explosive"): the
                // accumulated upward velocity from this contact can't exceed WAVE_THROW_UP_MAX, so a
                // sustained/deep overlap lifts firmly but never launches the bubble off-screen.
                const throwUp = Math.min(WAVE_THROW_UP * penetration * dt * collideHardness, WAVE_THROW_UP_MAX);
                b.vy -= throwUp;

                // Positional push-out: ejects the wax progressively out of the ribbon along the normal
                // (converges across substeps, so no stuck wax). The soft end eases it out gently
                // (mushy), the hard end snaps it clean.
                b.x += sideSign * penetration * (0.5 + 0.5 * collideHardness);
            }

            // ── Blob ↔ blob (elastic 2D, soft↔hard via the strength dial — §5a). ──
            // O(count²) ≤ ~1k pair tests — trivial. Mass ∝ er² so big blobs shove small ones, and
            // hot shrunk bubbles are correspondingly lighter (Daniel #7). Geometry uses effective
            // radii so collisions match what's drawn.
            for (let i = 0; i < count; i++) {
                const a = blobs[i];
                for (let j = i + 1; j < count; j++) {
                    const c = blobs[j];
                    const dx = c.x - a.x;
                    const dy = c.y - a.y;
                    const dist = Math.hypot(dx, dy);
                    const minDist = a.er + c.er;
                    if (dist >= minDist || dist <= 1e-6) continue;

                    const nx = dx / dist, ny = dy / dist; // collision normal a→c
                    const overlap = minDist - dist;
                    const ma = a.er * a.er, mc = c.er * c.er;  // mass ∝ area
                    const invSum = 1 / (ma + mc);

                    // Capture the approach velocity ONCE from pre-collision state (Daniel #5 jitter
                    // fix): the prior build read the relative velocity for the elastic impulse AFTER
                    // the penalty spring had already mutated it, so spring and impulse fought within
                    // one pass — buzz. Now both read the same state and sum cleanly.
                    const velAlongNormal = (c.vx - a.vx) * nx + (c.vy - a.vy) * ny;

                    // Positional separation along the normal, mass-weighted (split the overlap).
                    // Soft at low strength (Daniel #3: gentle, blobs squish and overlap), firm at high.
                    const sep = overlap * (0.15 + 0.6 * collideHardness);
                    a.x -= nx * sep * (mc * invSum);
                    a.y -= ny * sep * (mc * invSum);
                    c.x += nx * sep * (ma * invSum);
                    c.y += ny * sep * (ma * invSum);

                    // Soft penalty spring along the normal (gentle shove, dominant at the soft end).
                    const springAcc = BLOB_COLLIDE_SPRING * overlap * (1 - collideHardness) * dt;
                    a.vx -= nx * springAcc; a.vy -= ny * springAcc;
                    c.vx += nx * springAcc; c.vy += ny * springAcc;

                    // Elastic impulse along the normal (hard end), with restitution + mass. Over-unity
                    // restitution at full hardness gives the springy throw (Daniel #6).
                    if (velAlongNormal < 0) { // approaching
                        const e = collideRest * collideHardness;
                        const impulse = -(1 + e) * velAlongNormal * invSum;
                        a.vx -= impulse * mc * nx; a.vy -= impulse * mc * ny;
                        c.vx += impulse * ma * nx; c.vy += impulse * ma * ny;
                    }
                }
            }
        }
    }

    /** Pack the live blobs into the upload buffer. Returns the live count. */
    function packBlobs(): number {
        const count = liveBlobCount();
        for (let i = 0; i < count; i++) {
            const b = blobs[i];
            const o = i * 4;
            blobUpload[o] = b.x;
            blobUpload[o + 1] = b.y;
            blobUpload[o + 2] = b.er; // effective (heat-shrunk) radius — matches the collision geometry
            blobUpload[o + 3] = b.temp;
        }
        return count;
    }

    let rafId: number | null = null;
    let disposed = false;
    const startTimeMs = performance.now();
    // Wall-clock anchor for the physics dt (separate from the playhead decay clock).
    let lastPhysicsMs = performance.now();

    // FPS diagnostic (verification aid — gated on DEBUG). Counts actual rAF callbacks and logs the
    // rate ~once/sec while the loop runs (which is now continuously, playing or paused — Part C). A
    // rate near the display refresh (~60) confirms the continuous loop holds frame rate; a paused-but-
    // foregrounded lamp should still read ~60 (the cheap sim + one draw), confirming the power cost of
    // running while paused is acceptable. Reset when the loop (re)starts.
    let fpsFrameCount = 0;
    let fpsWindowStartMs = 0;

    // One-shot diagnostics: log the canvas dimensions + a post-draw gl.getError() the
    // first time we actually draw at a non-degenerate size. A 1×1 (or 300×150 default)
    // backing store here means the canvas had no layout box when the first draw ran —
    // the ResizeObserver will correct it, but the first paint would be degenerate.
    let firstRealDrawLogged = false;

    // Backing-store size in device pixels, tracked so we only resize the canvas
    // (which clears it) when the CSS box actually changed.
    let cssWidth = 0;
    let cssHeight = 0;
    let dpr = 1;

    // ── One-time GL pipeline setup. ──────────────────────────────────────────────
    gl.useProgram(program);
    gl.disable(gl.DEPTH_TEST);
    // Pre-multiplied alpha blend: src already carries colour*alpha (see frag shader).
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
    // The datum lives in texture unit 0 for the program's lifetime.
    gl.uniform1i(u.datum, 0);

    // ── ResizeObserver: one-shot redraw when the container changes while idle. ────
    //
    // The ResizeObserver is the SOLE size writer (spec §2e): we never call
    // getBoundingClientRect per frame. While playing, the rAF loop redraws every
    // frame anyway and picks up the new backing-store size set here. While idle, the
    // observer fires only on an actual size change and triggers a single redraw.
    const resizeObserver = new ResizeObserver((entries) => {
        if (disposed) return;
        const entry = entries[0];
        // contentBoxSize is the modern, layout-thrash-free size source. Fall back to
        // contentRect for engines that don't populate it.
        const box = entry.contentBoxSize?.[0];
        const nextCssWidth = box ? box.inlineSize : entry.contentRect.width;
        const nextCssHeight = box ? box.blockSize : entry.contentRect.height;
        applySize(nextCssWidth, nextCssHeight);
        // The continuous loop redraws on its next tick. Only force a still frame if the loop is
        // stopped (tab hidden) so a resize while hidden is reflected when the tab returns.
        if (rafId === null) redrawOnce();
    });
    resizeObserver.observe(canvas);

    /**
     * Update the backing store to a CSS size × devicePixelRatio (capped at MAX_DPR)
     * and the GL viewport. Only resizes when something changed — resizing clears the
     * drawing buffer, so we avoid needless churn. This is the only place the canvas
     * size is written (fed by the ResizeObserver, never by a per-frame measure).
     */
    function applySize(nextCssWidth: number, nextCssHeight: number): void {
        const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
        if (nextCssWidth === cssWidth && nextCssHeight === cssHeight && nextDpr === dpr) {
            return;
        }
        cssWidth = nextCssWidth;
        cssHeight = nextCssHeight;
        dpr = nextDpr;
        canvas.width = Math.max(1, Math.round(cssWidth * dpr));
        canvas.height = Math.max(1, Math.round(cssHeight * dpr));
        gl.viewport(0, 0, canvas.width, canvas.height);
    }

    /**
     * Issue one GL draw with the current uniforms. The fragment shader does all the
     * scroll/zoom/ribbon work; here we just push the per-frame uniforms and draw the
     * full-screen triangle.
     */
    function draw(): void {
        if (canvas.width <= 0 || canvas.height <= 0) return;

        gl.clearColor(0, 0, 0, 0);
        gl.clear(gl.COLOR_BUFFER_BIT);

        // Phase 15 — both subsystems off: there is nothing to draw. Short-circuit past the physics
        // step, the blob upload, and the full-screen SDF evaluation entirely — a genuine no-render-cost
        // empty field (§10.1), not a shader that runs and outputs transparent. The cleared (transparent)
        // buffer above is the result. The gradient/playhead clocks are not advanced while fully off;
        // they resume from their held value when a subsystem is turned back on (no visible snap, since
        // an off field shows nothing to snap).
        if (!lavaEnabled && !waveformEnabled) return;

        gl.useProgram(program);
        gl.bindVertexArray(vao);

        // Per-frame uniforms. The playhead is the wall-clock-interpolated value, not
        // the raw last-pushed position — that is what makes the scroll advance every
        // animation frame instead of stepping at Blazor's ~10 Hz push cadence.
        const clockSeconds = (performance.now() - startTimeMs) / 1000;
        gl.uniform2f(u.resolution, canvas.width, canvas.height);
        gl.uniform1f(u.playheadSeconds, renderedPlayhead());
        gl.uniform1f(u.timeSeconds, clockSeconds);

        // Advance the gradient-rotation phase (Motion 1) off the SAME clock as uTimeSeconds — the
        // delta since the last drawn frame, scaled by the dial's rate. Integrating rate·dt keeps
        // the phase continuous across a dial change (no snap). Idle one-shot redraws advance it by
        // their real dt too, so the field keeps morphing while paused (the loop runs continuously).
        const gradientDt = Math.max(0, clockSeconds - lastGradientClockSeconds);
        lastGradientClockSeconds = clockSeconds;
        const rotationRate = GRADIENT_ROTATION_RATE_MIN
            + gradientRotationSpeed * (GRADIENT_ROTATION_RATE_MAX - GRADIENT_ROTATION_RATE_MIN);
        gradientPhase += gradientDt * rotationRate;

        // Per-change / per-theme / per-datum uniforms (cheap to set every frame; no
        // separate dirty-tracking needed for scalars/vec3s).
        gl.uniform1f(u.visibleSeconds, visibleSeconds);
        gl.uniform1f(u.waveformWidth, effectiveWaveformWidth());
        gl.uniform1f(u.waveformEnabled, waveformEnabled ? 1 : 0);
        gl.uniform1f(u.cohesion, fluidViscosity);
        gl.uniform1f(u.gradientPhase, gradientPhase);
        gl.uniform3fv(u.colorNavy, theme.navy);
        gl.uniform3fv(u.colorMoss, theme.moss);
        gl.uniform3fv(u.colorPaper, theme.paper);

        // Advance the wax-blob physics by the real elapsed time, then upload the blobs.
        // Stepping here (rather than in the loop) means idle one-shot redraws also advance
        // the sim by their actual dt — clamped by PHYSICS_MAX_DT, so a long paused gap just
        // means the lamp barely moves while paused (it animates with playback, spec §E).
        const nowMs = performance.now();
        const physicsDt = Math.max(0, (nowMs - lastPhysicsMs) / 1000);
        lastPhysicsMs = nowMs;
        // Phase 15 — lava off: skip the CPU physics step AND upload zero blobs. The shader's blob loop
        // (`for … if (i >= uBlobCount) break;`) then unions nothing, so no wax is drawn and no physics
        // runs — a genuine subsystem skip (§10.1), not a hidden-but-simulated field. The wax keeps its
        // last positions for free (we just stop integrating); turning lava back on resumes from there.
        let liveCount = 0;
        if (lavaEnabled) {
            stepPhysics(physicsDt);
            liveCount = packBlobs();
        }
        gl.uniform4fv(u.blobs, blobUpload);
        gl.uniform1i(u.blobCount, liveCount);

        if (datum) {
            gl.uniform1f(u.hasDatum, 1);
            gl.uniform1f(u.durationSeconds, datum.durationSeconds);
            gl.uniform1i(u.datumWidth, datum.texWidth);
            gl.uniform1i(u.datumSampleCount, datum.sampleCount);
            gl.activeTexture(gl.TEXTURE0);
            gl.bindTexture(gl.TEXTURE_2D, datum.texture);
        } else {
            gl.uniform1f(u.hasDatum, 0);
            gl.uniform1f(u.durationSeconds, 1);
            // Keep the divisor safe even though sampleAt early-outs on uHasDatum<0.5.
            gl.uniform1i(u.datumWidth, 1);
            gl.uniform1i(u.datumSampleCount, 1);
        }

        // One full-screen triangle (3 vertices), positions from gl_VertexID.
        gl.drawArrays(gl.TRIANGLES, 0, 3);
        gl.bindVertexArray(null);

        // First draw at a real (laid-out) size: report dimensions and any accumulated
        // GL error. We hold this log until cssWidth/cssHeight are populated so the
        // dimensions Daniel sees are the meaningful ones, not a pre-layout 1×1.
        // gl.getError() is a pipeline stall, so we only call it once, never per frame.
        if (!firstRealDrawLogged && cssWidth > 0 && cssHeight > 0) {
            firstRealDrawLogged = true;
            debugLog(
                `first draw — backing store ${canvas.width}x${canvas.height} px (css ${cssWidth}x${cssHeight} @ dpr ${dpr}), hasDatum=${datum ? 1 : 0}`,
            );
            const glErr = gl.getError();
            if (glErr !== gl.NO_ERROR) {
                console.error(`${TAG} gl.getError() after first draw: 0x${glErr.toString(16)} — the draw did not complete cleanly.`);
            }
        }
    }

    // ── rAF loop lifecycle (lava reframe Part C: sim animates while paused; only scroll freezes). ─
    //
    // DESIGN (changed in Wave R4): the loop runs whenever the component is ALIVE and the tab is
    // VISIBLE — it is NO LONGER gated on playback.isPlaying. A real lava lamp keeps convecting
    // regardless of the music, so the fluid sim (physics + render) keeps animating while audio is
    // paused; only the waveform SCROLL / playhead freezes. That freeze falls straight out of
    // effectivePlayhead(): while !isPlaying it returns the static last-pushed position, so the
    // waveform holds at its paused row while the physics dt clock (lastPhysicsMs in draw()) keeps
    // advancing the wax. Power-saving is preserved by stopping the loop on tab-hidden (visibilitychange)
    // and on dispose — just not merely because audio paused. A foregrounded-but-paused lamp runs only
    // the cheap CPU sim + one GL draw per frame, which holds 60 FPS comfortably.
    //
    // Smoothness (spec §2e / §5.4): while playing, the scroll must advance every animation frame, not
    // step at Blazor's ~10 Hz playback-push cadence. We achieve that by interpolating the playhead on
    // the wall clock — each frame uploads renderedPlayhead() (= effectivePlayhead() + the decaying
    // jitter-correction offset), which advances the last pushed position by real time elapsed since the
    // push and blends out any accumulated timing error. (The separate uTimeSeconds monotonic clock
    // drives the blob-radius wobble in the shader; the CPU physics uses its own wall-clock dt — neither
    // drives the scroll, which is the playhead alone, and the playhead is frozen while paused.)

    /** Draw one still frame immediately, without scheduling a new rAF. */
    function redrawOnce(): void {
        if (disposed) return;
        draw();
    }

    /** Start the rAF loop. No-op if already running or disposed (rafId guard). */
    function startLoop(): void {
        if (disposed || rafId !== null) return;
        // Reset the FPS window so the first measured second reflects the run we're
        // starting, not a stale count from a previous play session.
        fpsFrameCount = 0;
        fpsWindowStartMs = performance.now();
        // Re-base the decay clock to now so the first frame's dt is one frame, not the
        // (possibly long) idle gap since the last redrawOnce — otherwise a stale dt
        // would collapse the offset in one step. (Offset is 0 at play-start today, so
        // this is belt-and-braces, but it keeps the decay honest if that ever changes.)
        lastRenderWallClockMs = performance.now();
        // Re-base the physics clock too, so the first frame's dt is one frame, not the idle
        // gap since the last redraw (which would advance the lamp by a clamped jump on resume).
        lastPhysicsMs = performance.now();
        rafId = requestAnimationFrame(frame);
    }

    /** Stop the rAF loop. Safe to call when already stopped. */
    function stopLoop(): void {
        if (rafId !== null) {
            cancelAnimationFrame(rafId);
            rafId = null;
        }
    }

    /**
     * The animation loop. Runs continuously while the component is alive and the tab is visible
     * (lava reframe Part C) — NOT gated on playback. Each frame advances the wax physics and draws.
     * While playing, it draws at the wall-clock-interpolated playhead (effectivePlayhead, advancing
     * smoothly between the ~10 Hz pushes); while paused, effectivePlayhead() holds the static pushed
     * position so the waveform freezes in place while the lava keeps convecting. It reschedules itself
     * every frame; the only things that stop it are dispose() and the tab going hidden (the
     * visibilitychange handler calls stopLoop). A backgrounded tab also gets rAF throttled by the
     * browser, and we stop the loop entirely when hidden, so a backgrounded lamp burns no frames.
     */
    function frame(): void {
        if (disposed) {
            rafId = null;
            return;
        }
        draw();

        // FPS tally: count this callback, and once per elapsed second emit the rate.
        // performance.now() is cheap (no GPU stall, unlike gl.getError); the gated log
        // fires at most once/sec, so this adds no meaningful per-frame cost.
        if (DEBUG) {
            fpsFrameCount++;
            const nowMs = performance.now();
            const windowMs = nowMs - fpsWindowStartMs;
            if (windowMs >= 1000) {
                const fps = (fpsFrameCount * 1000) / windowMs;
                debugLog(`FPS ${fps.toFixed(1)} (${fpsFrameCount} frames in ${windowMs.toFixed(0)}ms) — playhead ${effectivePlayhead().toFixed(2)}s.`);
                // Lava diagnostic: the dials in play + how many blobs are currently buoyant
                // (temp above ambient) and how many are pooled on the floor. Daniel watches
                // this to confirm heat 0 = all-resting and heat-up = rising count climbs.
                const live = liveBlobCount();
                let buoyant = 0;
                let pooled = 0;
                let avgTemp = 0;
                let avgShrink = 0; // mean effective/base radius ratio — shows the heat→size coupling
                for (let i = 0; i < live; i++) {
                    const b = blobs[i];
                    avgTemp += b.temp;
                    avgShrink += b.r > 0 ? b.er / b.r : 1;
                    if (b.temp > TEMP_AMBIENT) buoyant++;
                    if (b.y > 1 - b.er - 0.04) pooled++;
                }
                debugLog(
                    `lava — heat=${lavaHeat.toFixed(2)} gravity=${lavaGravity.toFixed(2)} ` +
                    `collision=${collisionStrength.toFixed(2)} width=${waveformWidth.toFixed(2)} ` +
                    `fluidAmount=${fluidAmount.toFixed(2)} viscosity=${fluidViscosity.toFixed(2)} | ` +
                    `blobs=${live} buoyant=${buoyant} pooled=${pooled} ` +
                    `avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)} avgSize=${(avgShrink / Math.max(live, 1)).toFixed(2)}.`,
                );
                // Colour diagnostic (R3): the rotation dial + the live gradient phase. Daniel watches
                // phase advance (faster at a higher dial, near-static at the low end) to confirm Motion 1
                // is live, and that the dial visibly changes the rate. phase mod 3 = the ring position.
                debugLog(
                    `colour — rotationSpeed=${gradientRotationSpeed.toFixed(2)} ` +
                    `gradientPhase=${gradientPhase.toFixed(2)} (ring ${(gradientPhase % 3).toFixed(2)}/3).`,
                );
                fpsFrameCount = 0;
                fpsWindowStartMs = nowMs;
            }
        }

        // Reschedule unconditionally — the loop runs continuously now (lava reframe Part C); it is
        // stopped only by dispose() or the tab going hidden, never by audio pausing.
        rafId = requestAnimationFrame(frame);
    }

    // ── Tab-visibility gating (lava reframe Part C power-saving). ────────────────────
    // The loop runs continuously while alive, but a HIDDEN tab should not animate at all
    // (the browser throttles rAF anyway, but we stop outright to be sure). On becoming
    // visible again we restart the loop; startLoop re-bases the dt clocks so the wax
    // doesn't lurch by the whole hidden gap on the first resumed frame.
    function onVisibilityChange(): void {
        if (disposed) return;
        if (document.hidden) {
            stopLoop();
        } else {
            startLoop();
        }
    }
    document.addEventListener('visibilitychange', onVisibilityChange);

    // Read the initial size synchronously (one getBoundingClientRect at setup is
    // fine — it is the ResizeObserver that must not measure per-frame), draw a still
    // frame so the canvas isn't blank, then START the continuous loop (Part C: the lava
    // animates from the moment the visualizer mounts, paused or playing) — unless the tab
    // is already hidden, in which case the visibilitychange handler will start it later.
    {
        const rect = canvas.getBoundingClientRect();
        applySize(rect.width, rect.height);
        redrawOnce();
        if (!document.hidden) startLoop();
    }

    /**
     * Upload the loudness samples as a 2-D R8 texture that respects
     * GL_MAX_TEXTURE_SIZE, returning the Datum (with the grid dimensions the shader
     * needs to map a sample index → texel) or null on empty/invalid input.
     *
     * Why 2-D and not 1×N: the mix datum runs at ~333 samples/s, so any mix over
     * ~49 s produces more samples than GL_MAX_TEXTURE_SIZE (commonly 4096–16384),
     * and `texImage2D(…, width=N, height=1, …)` is rejected outright
     * ("Requested size at this level is unsupported"), leaving the waveform texture
     * uncreated and the ribbon blank. Laying the N samples row-major across a grid
     * of width = min(N, safeWidth) keeps every dimension well within the limit.
     *
     * Filtering: the shader reads with texelFetch and does its own time-axis
     * interpolation (see sampleAt), so NEAREST is correct here — hardware LINEAR on
     * a 2-D grid would bleed across the row-wrap seam. The final row is zero-padded
     * (texture init is zero-filled, then we overwrite the real samples); padding is
     * never read because sampleAt clamps the index to sampleCount-1.
     */
    function uploadDatum(samplesBase64: string, durationSeconds: number): Datum | null {
        if (durationSeconds <= 0 || !samplesBase64) {
            // Expected before the player reports a duration: the bridge pushes an empty
            // datum until then. Not an error, but worth seeing while diagnosing.
            debugLog(`uploadDatum skipped — durationSeconds=${durationSeconds}, base64 length=${samplesBase64?.length ?? 0}.`);
            return null;
        }
        const samples = decodeSamples(samplesBase64);
        const sampleCount = samples.length;
        if (sampleCount === 0) {
            console.warn(`${TAG} uploadDatum: decoded 0 samples from a non-empty base64 string — datum will not render.`);
            return null;
        }

        // Width = min(N, a safe power-of-two cap). The power-of-two cap (4096) is well
        // under every real GL_MAX_TEXTURE_SIZE and keeps row arithmetic clean; we
        // still clamp it to the actual max in case a driver reports something smaller.
        const SAFE_WIDTH = 4096;
        const texWidth = Math.min(sampleCount, Math.min(SAFE_WIDTH, maxTextureSize));
        const texHeight = Math.ceil(sampleCount / texWidth);
        debugLog(
            `uploadDatum — ${sampleCount} samples for ${durationSeconds.toFixed(2)}s mix ` +
            `(${(sampleCount / durationSeconds).toFixed(1)} samples/s); ` +
            `datum texture ${texWidth}x${texHeight} for N=${sampleCount} samples, maxTextureSize=${maxTextureSize}.`,
        );

        // Pad the final partial row with zeros so the full grid uploads in one call.
        const padded = texWidth * texHeight === sampleCount
            ? samples
            : (() => {
                const buf = new Uint8Array(texWidth * texHeight);
                buf.set(samples);
                return buf;
            })();

        const texture = gl.createTexture();
        if (!texture) return null;
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, texture);
        // R8 rows are 1-byte-per-texel and texWidth is not guaranteed 4-aligned;
        // relax the default 4-byte unpack alignment so rows aren't read with stride
        // padding the source array doesn't have.
        gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
        gl.texImage2D(
            gl.TEXTURE_2D, 0, gl.R8,
            texWidth, texHeight, 0,
            gl.RED, gl.UNSIGNED_BYTE, padded,
        );
        // NEAREST: texelFetch ignores the filter anyway, but be honest about it — the
        // shader interpolates manually to avoid the row-wrap seam (see sampleAt).
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.bindTexture(gl.TEXTURE_2D, null);

        return { texture, texWidth, texHeight, sampleCount, durationSeconds, samples };
    }

    return {
        setDatum(samplesBase64: string, durationSeconds: number): void {
            debugLog(`setDatum received — base64 length ${samplesBase64?.length ?? 0}, durationSeconds ${durationSeconds}.`);
            // Free the previous datum's GPU texture before replacing it (no leak
            // across re-pushes / mix changes — spec §5.11).
            if (datum) {
                gl.deleteTexture(datum.texture);
                datum = null;
            }
            datum = uploadDatum(samplesBase64, durationSeconds);
            // New datum changes what is drawn — the continuous loop picks it up next frame. Only force
            // a still frame if the loop is stopped (tab hidden) so a datum that arrives while hidden is
            // reflected the moment the tab becomes visible-and-draws.
            if (rafId === null) redrawOnce();
        },

        setPlayback(positionSeconds: number, isPlaying: boolean): void {
            const wasPlaying = playback.isPlaying;

            // Preserve on-screen continuity across the re-anchor. The rendered playhead
            // right now is effectivePlayhead() (old anchor) + correctionOffset; capture
            // it before we replace the anchor. We read effectivePlayhead() without going
            // through renderedPlayhead() so we don't advance the decay clock here — the
            // decay belongs to the render loop, ticked once per drawn frame.
            const renderedBefore = effectivePlayhead() + correctionOffset;

            // Anchor the pushed position to wall-clock NOW: the rAF loop interpolates
            // forward from here each frame (effectivePlayhead), so the scroll advances
            // smoothly between these ~10 Hz pushes. While paused, effectivePlayhead()
            // returns this static position, so the waveform freezes here (Part C) — the
            // continuous loop keeps animating the lava, but the scroll holds.
            playback = { positionSeconds, isPlaying, pushWallClockMs: performance.now() };

            // Fold the re-anchor discontinuity into the correction offset so the rendered
            // playhead doesn't jump: choose offset such that effectivePlayhead() (new
            // anchor) + offset == renderedBefore. The render loop then decays this offset
            // to zero over PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS, converging onto the
            // authoritative position. When pushes are regular the gap is ~0, so offset is
            // ~0 and steady-state matches the prior hard-anchor behaviour exactly.
            //
            // Only smooth while continuously playing. On a play/pause edge or while paused
            // we want the exact authoritative position, not a glide from a stale render:
            // a resume should land on the real position, and a paused frame must be
            // truthful (read-only contract — never show a position the player isn't at).
            if (isPlaying && wasPlaying) {
                correctionOffset = renderedBefore - effectivePlayhead();
            } else {
                correctionOffset = 0;
            }

            // NOTE (Part C): we do NOT start/stop the rAF loop on the play/pause edge anymore — the
            // loop runs continuously while the tab is visible so the lava keeps convecting when paused.
            // The play-state only changes whether effectivePlayhead() advances (scroll) or holds
            // (freeze); the loop itself is owned by setup + the visibilitychange handler + dispose.
            if (isPlaying !== wasPlaying) {
                debugLog(`playback ${isPlaying ? 'resumed' : 'paused'} — position ${positionSeconds.toFixed(2)}s; scroll ${isPlaying ? 'advancing' : 'frozen'}, lava keeps animating.`);
            }
        },

        // ── Wave R4 — the seven dedicated control setters. Each routes its value to the one dial it
        // drives; no more R2 temp-remapping. The lava loop now runs continuously (see startLoop /
        // the visibility handling), so a paused tweak is already picked up by the next frame — but we
        // keep a redrawOnce() guard for the rare fully-stopped case (loop not running, e.g. tab
        // hidden) so a tweak still lands a still frame when it resumes-and-draws.

        // Scroll speed: arrives already mapped to a visible time-span (seconds) on the C# side. Clamp
        // into the supported span so a stray value can't break the scroll math.
        setScrollSpeed(seconds: number): void {
            visibleSeconds = Math.min(MAX_VISIBLE_SECONDS, Math.max(MIN_VISIBLE_SECONDS, seconds));
            debugLog(`setScrollSpeed — visibleSeconds ${visibleSeconds.toFixed(3)}s.`);
            if (rafId === null) redrawOnce();
        },

        // Gradient rotation speed: LIVE as of Wave R3 — sets the anchor-rotation rate (Motion 1).
        // The phase integrator (draw()) reads this; changing it alters the slope, never snaps the
        // phase, so the gradient speeds up/slows down smoothly. redrawOnce guards the fully-stopped
        // (tab-hidden) case so a tweak still lands a still frame when it resumes-and-draws.
        setGradientRotationSpeed(value: number): void {
            gradientRotationSpeed = Math.min(1, Math.max(0, value));
            debugLog(`setGradientRotationSpeed → ${gradientRotationSpeed.toFixed(3)}.`);
            if (rafId === null) redrawOnce();
        },

        setLavaGravity(value: number): void {
            lavaGravity = Math.min(1, Math.max(0, value));
            debugLog(`setLavaGravity → ${lavaGravity.toFixed(3)}.`);
            if (rafId === null) redrawOnce();
        },

        setLavaHeat(value: number): void {
            lavaHeat = Math.min(1, Math.max(0, value));
            debugLog(`setLavaHeat → ${lavaHeat.toFixed(3)}.`);
            if (rafId === null) redrawOnce();
        },

        // Fluid amount (Phase 10 — first half of the split density knob): drives count (liveBlobCount)
        // AND per-blob size (fluidSizeBias applied to every blob's radius each physics step). Turning it
        // visibly adds/removes wax and resizes the already-live blobs.
        setFluidAmount(value: number): void {
            fluidAmount = Math.min(1, Math.max(0, value));
            debugLog(`setFluidAmount → ${fluidAmount.toFixed(3)}.`);
            if (rafId === null) redrawOnce();
        },

        // Fluid viscosity / cohesion (Phase 10 — second half of the split knob): drives the shader's
        // uCohesion, which scales the metaball smin blend + wobble. High = crisp spheres that snap back;
        // low = gooey/deformed wax. Uniform-only — no per-fragment cost change, weaker hardware unaffected.
        setFluidViscosity(value: number): void {
            fluidViscosity = Math.min(1, Math.max(0, value));
            debugLog(`setFluidViscosity → ${fluidViscosity.toFixed(3)}.`);
            if (rafId === null) redrawOnce();
        },

        setCollisionStrength(value: number): void {
            collisionStrength = Math.min(1, Math.max(0, value));
            debugLog(`setCollisionStrength → ${collisionStrength.toFixed(3)}.`);
            if (rafId === null) redrawOnce();
        },

        setWaveformWidth(value: number): void {
            waveformWidth = Math.min(1, Math.max(0, value));
            debugLog(`setWaveformWidth → ${waveformWidth.toFixed(3)}.`);
            if (rafId === null) redrawOnce();
        },

        // Phase 15 — subsystem enables. "Off" is a genuine draw-skip (§10.1): lava off stops the physics
        // step + uploads zero blobs (handled in draw()); waveform off skips the ribbon SDF + collision
        // boundary. redrawOnce guards the fully-stopped (tab-hidden) case so the toggle lands a still
        // frame when the loop resumes — including the both-off → cleared empty field.
        setLavaEnabled(enabled: boolean): void {
            lavaEnabled = enabled;
            debugLog(`setLavaEnabled → ${enabled}.`);
            if (rafId === null) redrawOnce();
        },

        setWaveformEnabled(enabled: boolean): void {
            waveformEnabled = enabled;
            debugLog(`setWaveformEnabled → ${enabled}.`);
            if (rafId === null) redrawOnce();
        },

        refreshTheme(): void {
            theme = readTheme();
            if (rafId === null) redrawOnce();
        },

        dispose(): void {
            disposed = true;
            stopLoop();
            document.removeEventListener('visibilitychange', onVisibilityChange);
            resizeObserver.disconnect();
            // Release all GL resources so nothing leaks on navigation (spec §5.11).
            if (datum) {
                gl.deleteTexture(datum.texture);
                datum = null;
            }
            if (vao) gl.deleteVertexArray(vao);
            gl.deleteProgram(program);
        },
    };
}
