# Mix Visualizer — WebGL2 Fragment-Shader Renderer (Design Spec)

Status: **design-complete, implementation-ready.** Author: product-designer. Date: 2026-06-15
(interview answers captured 2026-06-15). **No code has been written by this doc.**

This is the successor to the landed Canvas 2D Mix visualizer (8.K, `COMPLETED.md`). It replaces the
renderer wholesale; it is not a new feature bolted on. A future implementation wave can be dispatched
straight from this spec.

Cross-references:
- `product-notes/phase-9-mix-visualizer-redesign.md` — the 8.K spec the Canvas 2D renderer was built
  from. **The motion model (§A), zoom/Guitar-Hero coupling (§B), read-only contract (§D), and the
  datum-resolution analysis (§F, the 333ms-quarter-note-at-180BPM framework) all carry forward
  unchanged.** This spec does not re-derive them — it references them and builds the new renderer on
  top.
- `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — the Canvas 2D module being replaced.
- `DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor[.cs/.css]` — the Blazor bridge, **largely
  preserved** (see §2).
- `DeepDrftPublic.Client/Services/MixVisualizerZoomState.cs` — the session-scoped persistence pattern
  the new controls mirror (see §3).
- `DeepDrftPublic.Client/Controls/MixZoomMapping.cs` — the log-space slider↔seconds mapping, reused as-is.
- `DeepDrftShared.Client/Common/DeepDrftPalettes.cs` — the source of truth for the navy/moss theme
  tokens (see §4c).

---

## Why a renderer swap, not an iteration

A staff-engineer analysis concluded the Canvas 2D approach **cannot afford the planned effects**. The
current renderer already strains at 1–2 FPS; the three per-frame killers are full-viewport `shadowBlur`,
the CSS `backdrop-filter: blur()` layer, and per-frame `getBoundingClientRect()`. More decisively: the
effects Daniel wants — bulge, detach, a morphing 2D color field, glass — are **all-dynamic, per-pixel,
per-frame** effects. That is precisely the workload Canvas 2D is worst at and a fragment shader is best
at: in a shader the effects become GLSL evaluated across every pixel in parallel on the GPU, and they
get *cheaper relative to Canvas* as they get fancier.

**Decision (Daniel, explicit): rebuild as a WebGL2 fragment-shader renderer. No Canvas 2D stopgap.**
"WebGL as step 1, no pussyfooting." This supersedes the 8.K spec's §E recommendation (which defaulted to
Canvas 2D and reserved WebGL only as a fallback) — that recommendation has been overtaken by the effect
roadmap. The 8.K spec's §E "no tricks, well-commented, industry-standard" *intent* still holds; it now
means **textbook WebGL2 with a well-commented shader**, not Canvas.

---

## 1. Goal and scope boundary

**Goal.** Replace the Canvas 2D Mix visualizer with a WebGL2 fragment-shader renderer that holds a
smooth **60 FPS** while carrying four live, per-pixel visual effects — bulge/bubble shaping, lava-lamp
detachment, a morphing navy↔moss 2D color field, and an in-shader glass treatment — driven by four
persistent continuous-slider controls. The visualizer remains a **read-only, playback-coupled,
windowed, bottom-to-top scrolling** background element on the Mix detail page.

**In scope.**
- A new WebGL2 fragment-shader rendering module replacing `MixVisualizer.ts` (the rAF loop, scroll/zoom
  math, and theme handling move into / alongside the GL pipeline).
- A new **controls row** above the mix details: four continuous sliders (resolution, bubblyness, detach,
  color-shift speed), all persistent across page visits.
- The four visual effects, in-shader.
- Preserving the existing Blazor↔JS bridge contract as far as the new inputs allow (§2).

**Out of scope / unchanged.**
- **No playback-control changes.** The visualizer stays strictly read-only (8.K §D). One-way
  `PlaybackPosition` input; no seek, no scrub, no write-back. The dropped `OnSeek` seam stays dropped.
- **No datum format change.** The duration-derived ~333 samples/sec capture (8.K §F, Wave 1, landed)
  already feeds the renderer correctly and is the right resolution for the max-zoom anchor. The new
  renderer consumes the *same* `WaveformProfileDto` (base64 loudness bytes + duration-from-player). The
  only change is that the datum now lands in the GPU as a **texture** rather than a JS array walked on
  the CPU (§4b).
- **No change to where the visualizer sits** (full-page background behind `.mix-detail-foreground`),
  except that the new controls row sits *above the mix details, below the back button* (§3).
- **Control-range guards** (resolution × bubblyness combinations that look unpleasant) — deferred per
  Daniel; he tunes bad ranges by hand once it is on screen.
- **Motion-speed coupling to bubblyness** — deferred. Bubblyness controls *shape/amount only* for now;
  scroll speed remains a pure function of zoom (8.K §B), independent of the bubble controls.

---

## 2. Renderer architecture (spec level)

The implementation choices below the seam are staff-engineer's. This section fixes the *contract* and
the *data-flow shape*, not the GLSL.

### 2a. Pipeline shape

A single full-window WebGL2 quad (two triangles covering the canvas), rasterized once per frame, with
**all the visual work in the fragment shader**. Each fragment (pixel) computes: which mix-time it maps
to (from its screen Y, the scroll offset, and the zoom), samples the loudness datum, decides whether it
is inside the ribbon / a bubble / a detached blob, and shades itself with the morphing gradient + glass.
The vertex shader is trivial (pass-through quad); the art is entirely fragment-side. This is the
textbook "shadertoy-style" full-screen-fragment pattern — well-trodden, well-documented, no exotic
tricks. Comment the shader so Daniel can follow it (8.K §E discipline carries forward).

**This is a deliberate inversion of the Canvas model.** Canvas walked screen rows on the CPU and built a
path; the shader instead asks, per pixel in parallel on the GPU, "am I inside the shape, and what color
am I?" The scroll/zoom math from `MixVisualizer.ts` (time-at-screen-Y, sample-at-time) moves essentially
intact into GLSL — the same equations, evaluated per-fragment instead of per-row. Preserve the
well-commented derivation; it is the part Daniel reads to follow the thing.

### 2b. How the waveform datum feeds in

**As a 1-D texture (or a 2-D texture one row tall), not a uniform array.** The datum is up to ~1.8 MB
(~1.8M samples for a 90-min mix at 333/s); uniform arrays are far too small and the wrong tool. A
texture is the standard vehicle: upload the normalized loudness samples once when the datum arrives (the
`setDatum` bridge call), and let the fragment shader sample it with the GPU's built-in linear filtering —
which gives the **smooth, no-stair-stepping interpolation at every zoom** the 8.K spec wanted, for free,
in hardware. Texture coordinate = `mixTime / durationSeconds`; the GPU interpolates between samples.

Upload happens once per datum (off the animation path), exactly mirroring today's once-per-datum decode.
The per-frame path touches no large buffer — it only updates small uniforms (§2c). This preserves the
existing optimization where a per-tick playback push never re-decodes the datum.

### 2c. Uniforms — what changes per frame vs. per change

The fragment shader is parameterized entirely by uniforms. Group them by update cadence:

- **Per-frame (updated in the rAF loop):**
  - `playheadSeconds` — current playback position (drives the scroll).
  - `timeSeconds` — a monotonic clock for the always-shifting color field and bubble animation (so the
    field morphs and blobs drift even when scroll is slow). This is the one input that makes the field
    "never static."
- **Per control change (updated when a slider moves):**
  - `visibleSeconds` — the zoom (8.K §B), drives window time-span and apparent scroll speed.
  - `bubblyness` — bulge amount (§5b).
  - `detach` — lava-lamp detachment amount (§5c).
  - `colorShiftSpeed` — gradient morph rate (§5d). (Multiplies `timeSeconds` inside the shader.)
- **Per theme change:** `colorNavy`, `colorMoss` (and any derived stops) — the gradient endpoints, read
  from the live palette on a dark-mode toggle (§4c).
- **Per resize:** canvas resolution / aspect.
- **Once per datum:** the datum texture + `durationSeconds` + `samplesPerSecond`.

### 2d. The Blazor bridge — preserved

The existing `MixWaveformVisualizer.razor.cs` bridge is **largely reusable** and should be preserved.
What stays:
- Self-fetch of the datum from `ReleaseId` via `IReleaseDataService.GetMixWaveform` (unchanged).
- The cascaded-player coupling: `IStreamingPlayerService.StateChanged` subscription, `IsActivePlayer`
  gating on `TrackId`, `CurrentPositionSeconds` / `IsPlaying` / `PlayerDurationSeconds` derivation
  (unchanged — this is the playback signal, renderer-agnostic).
- The idempotent `PushDatumAsync` guard (don't re-upload the datum every tick) — even more important now
  that the datum is a GPU texture upload.
- The `refreshTheme` push on dark-mode toggle.
- `IAsyncDisposable` teardown of the module handle.
- The module-handle shape (`create(canvas)` → handle with `setDatum` / `setPlayback` / `setZoom` /
  `refreshTheme` / `dispose`). **Extend** it with `setBubblyness`, `setDetach`, `setColorShiftSpeed`;
  do not redesign it.

What changes: `create` initializes a WebGL2 context (`canvas.getContext('webgl2')`) instead of `'2d'`,
compiles the shader program, and uploads the datum as a texture. The no-context fallback (return a no-op
handle so the component still renders a plain backdrop) carries forward — now guarding against *no WebGL2*
rather than *no 2D context*.

### 2e. Animation lives in the rAF/shader loop — NOT in Blazor re-renders

**Load-bearing constraint.** Today `OnPlayerStateChanged` calls `StateHasChanged()` on every player tick
(~10×/sec) to keep the slider/visibility in sync. That re-render cadence must **not** become the
animation driver — 10 Blazor re-renders/sec is both far below 60 FPS and the wrong layer for animation.
The animation is entirely the GPU rAF loop's job: the shader's `timeSeconds` uniform advances every frame
and the field morphs / blobs drift continuously between player ticks. Blazor pushes *playhead position*
on its tick (cheap uniform write); the *smooth motion between ticks* is the shader interpolating on its
own clock. The rAF loop stays gated on `isPlaying` (cool when paused — 8.K §E), with the same one-shot
redraw-while-idle behavior for zoom/theme/datum/resize changes.

---

## 3. The controls row

A new horizontal controls section for the visualizer, placed **above the rest of the mix details and
below the "back" button** on the Mix detail page. It holds the four controls in a row. All are
**continuous sliders** (MudSlider, `Step` fine enough to feel continuous — the existing zoom slider uses
`Step="0.001"`, follow that), and all are **persistent across page visits** within a listening session.

### 3a. The four controls, ranges and defaults

Daniel asked for sensible default ranges with the explicit note that he will tune them on screen.
Recommendations:

| # | Control | What it does | Slider range (normalized) | Default | Notes |
|---|---------|--------------|---------------------------|---------|-------|
| 1 | **Resolution** | Visible time-span / zoom (the existing 8.K control, relocated into this row) | 0 → 1, log-mapped to 30 s → 0.333 s | ~10 s window | Reuse `MixZoomMapping` and `MixVisualizerZoomState` exactly as-is. Just move the slider's home into the new row. |
| 2 | **Bubblyness** | Bulge amount of each bar (§5b) | 0 → 1 | ~0.35 | 0 = straight rectangular scrolling bars; 1 = fully rounded liquid silhouettes that bulge from the zero-line outward. Bars stay **attached** at max. |
| 3 | **Detach** ("unleash the lava lamp") | How much bubbles detach from the bar and rise independently (§5c) | 0 → 1 | 0 | 0 = fully attached (whatever bubblyness produced); 1 = blobs separate and float upward freely. Separate axis from bubblyness. |
| 4 | **Color-shift speed** | How fast the gradient field morph cycles (§5d) | 0 → 1, mapped to a slow→quick cycle-rate range | ~0.3 | Map normalized 0→1 onto a cycle period of roughly **60 s (very slow drift) → 4 s (pretty quick)**. Default ~0.3 lands around a ~20 s cycle — alive but not busy. Never fully zero (the field is "always shifting, never static" — §4b); the slow end is "barely perceptible drift," not "frozen." |

These are starting numbers for feel. The max-zoom 0.333 s anchor for control 1 is fixed (8.K §B); the
other three ranges are Daniel's to tune.

### 3b. Continuous-slider UX

All four are bare continuous sliders (no tick marks, no snap), `Size.Small`, `Color.Primary`, each with
an `aria-label`. Lay them in a row with compact labels (icon or short text caption). On mobile the row
may wrap or scroll horizontally — a layout call for staff-engineer, but the controls must remain
present (they are the one thing that affects the visualizer, and none of them is a seek surface — 8.K
§D holds).

### 3c. Persistence — mirror MixVisualizerZoomState

The existing `MixVisualizerZoomState` is a DI-**scoped** holder: it survives SPA navigation within one
WASM app instance (open a second mix, the slider keeps its place) and resets to default on a fresh page
load (F5). That is the "persist within session, reset on fresh load" model from 8.K §B, and it is the
pattern to mirror for **all four** controls.

**Decision (Daniel): widen the single state object rather than spawning four.** Rename/widen
`MixVisualizerZoomState` into a `MixVisualizerControlState` (scoped) holding `VisibleSeconds`,
`Bubblyness`, `Detach`, `ColorShiftSpeed`, each with a `const` default mirrored to the TS tuning anchors.
One injected dependency, one persistence story, four properties. (If Daniel later wants cross-session
persistence — "this mix should remember my lava-lamp settings" — that is a cookie/localStorage upgrade on
this one object, deferred and out of scope now, same as the 8.K note.)

Keep the C#-side defaults and the TS-side tuning anchors in sync, as the existing
`MixVisualizerZoomState.DefaultVisibleSeconds` / `DEFAULT_VISIBLE_SECONDS` pair already does (comment the
contract on both sides).

---

## 4. The four visual effects

> **SUPERSEDED (2026-06-16) by `product-notes/phase-10-mix-visualizer-lava-reframe.md` (the Phase 10
> reframe, Waves R1–R4).** Daniel tested
> the landed effects end-to-end and rejected the visual result — the lava read as "giant disconnected
> circles," the color drifted to cyan (an HSL saturation-boost artifact), and the waveform and lava read
> as two unrelated things. The reframe replaces this entire effects layer: the per-bar bulge + detach
> blobs become a **CPU-physics wax-blob lava with real 2D collision** (the waveform pushes the fluid out
> of its way), the HSL navy↔moss treatment becomes a **three-color OKLab gradient with three combined
> motions**, the separate "glass" effect folds into the blob shading, and the static noise/frost layer is
> **removed**. The renderer *infrastructure* (pipeline, datum texture, playhead interp, bridge) is reused;
> the *art* below is replaced. See the reframe spec §3–§6. The four-effect text below is retained as the
> record of what the rejected Wave 3 shipped.

Described as **intended look + shader-side approach in conceptual terms**. The exact GLSL is
staff-engineer's. Each effect is a continuous function of its control value, so the whole range from
"off" to "maxed" is reachable by dragging one slider.

### 4a. Waveform geometry (the substrate all effects sit on)

Traditional **−1..+1 amplitude with 0 at the center line**; peaks extend symmetrically above and below
the zero-line. The waveform **scrolls bottom-to-top** (new audio enters at the bottom, played audio exits
the top — 8.K §A, unchanged). Per-fragment: map screen Y → mix-time (via playhead + zoom), sample the
datum texture for loudness at that time, and the loudness defines the half-width of the symmetric ribbon
about the horizontal center. The "now" line sits at a fixed screen Y (center by default — 8.K §A). This
is the same geometry the Canvas renderer drew; in the shader it becomes a signed-distance test ("is this
pixel inside the ribbon silhouette?").

### 4b. The 2D morphing navy↔moss gradient field (the headline visual)

**Replace the boring grey with a living color field built from the two theme colors — navy blue and moss
green.** Two coupled dimensions:

1. **Per-bar / along-the-bar (the vertical / amplitude axis).** Color varies along each bar's height —
   the zero-line and the peak differ. This gives each bar internal structure: a gradient from center-line
   out to the peak.
2. **Along the scroll/time axis (the second dimension).** The mix between navy and moss **shifts along
   time** — more green at some moments, more blue at others — and **always shifts, never static**, driven
   by the `timeSeconds` clock × `colorShiftSpeed` (§3a control 4). This is what makes it a *field* and not
   a fixed gradient.

**The tension to capture (Daniel's words):** it should read as **a continuous 2D color field** — coherent
across the whole window, neighbors related — *yet each bar should "feel like its own living thing."**
Shader approach to hold both at once:
- A smooth, low-frequency base field (navy↔moss) over (time-axis, amplitude-axis) gives the **field-like
  coherence** — sampled with a flowing noise or layered sinusoids in the time dimension so it morphs
  continuously. This is the "2D color field" layer.
- A higher-frequency per-bar modulation — keyed off each bar's own index/phase and its instantaneous
  loudness — perturbs the field locally so adjacent bars are *related but not identical*, and a loud bar
  reads brighter/more-saturated than its quiet neighbor. This is the "own living thing" layer.
- The two compose: coherent field + per-bar liveness = the intended tension.

Pull the **exact endpoints from the theme tokens** (§4c) so a dark-mode toggle re-themes the field live.

### 4c. Theme tokens — the concrete colors

The bespoke palettes live in `DeepDrftShared.Client/Common/DeepDrftPalettes.cs` (the "Charleston in the
Day" / "Lowcountry Summer Nights" identities map onto these). The navy/moss pair:

- **Navy:** `#17283f` (Primary, light) / `#0D1B2A` (the dark ground) / `#162437` (Surface, dark, elevated).
- **Moss / green:** `#3D7A68` (Secondary light / Primary dark — the interactive green) / `#429d6a`
  (Tertiary light, green-interactive) / `#2A5C4F` (PrimaryDarken dark).

As in the Canvas renderer, the shader cannot resolve `var(--mud-palette-*)` directly — read the computed
CSS custom properties off the canvas element on theme refresh and pass them as `vec3` color uniforms.
**Recommended endpoints:** navy = `--mud-palette-primary` in light / the dark ground in dark; moss =
`--mud-palette-secondary` (light) / `--mud-palette-primary` (dark, where green *is* primary). Staff-
engineer picks the precise var bindings that give the richest navy↔moss spread per palette; the design
intent is "the two theme signature colors, navy and moss, as the two poles of the field." Re-read on
`refreshTheme` so the toggle re-themes live (preserve the existing mechanism).

### 4d. Bulge / bubble shaping — "bubblyness" (control 2)

**Intent.** At **minimum**: straight rectangular bars that only scroll bottom-to-top — a clean,
hard-edged waveform. As bubblyness rises: bars **gain space and bulge into rounded, liquid silhouettes
that swell from the center zero-line outward toward the peak** — like a lava-lamp bubble growing. At
**maximum**: fully rounded liquid blobs, but still **attached** to the bar (detachment is a separate
axis — §4e). Bubblyness controls **bulge shape/amount only**, not motion speed (deferred).

**Shader-side approach.** This is a signed-distance / metaball move. Each bar contributes a field
contribution; `bubblyness` interpolates the per-bar silhouette from a **box SDF** (sharp rectangle, bulge
= 0) toward a **rounded capsule / metaball SDF** (bulge = 1) that swells outward from the zero-line. As
bubblyness rises, increase the rounding radius and let neighboring bars' fields blend (metaball
union with a smooth-min) so the silhouette reads as continuous liquid rather than discrete bars. The
"swell from the center outward" is the SDF growing its radius along the amplitude axis with loudness. The
GPU evaluates the SDF per-pixel — this is exactly the per-pixel work that was unaffordable on Canvas and
is cheap in a shader.

### 4e. Detach — "unleash the lava lamp" (control 3)

**Intent.** A **separate** control from bubblyness. At 0, bubbles stay attached (whatever shape
bubblyness produced). As detach rises, the bulging bubbles **separate from the bar and rise independently**
— blobs pinch off and float upward on their own, the true lava-lamp experience. At max, freely floating
detached blobs drifting up through the window.

**Shader-side approach.** Building on the metaball field from §4d: as `detach` rises, displace a fraction
of each bar's bubble mass **upward** by an amount that grows over the bubble's life (the `timeSeconds`
clock gives the rise animation), and **weaken the smooth-min link** between the detaching blob and its
parent bar so the metaball connection thins and pinches off (classic metaball separation — two metaballs
pulling apart show the liquid "neck" thinning then breaking). Detached blobs become independent metaball
centers with their own upward velocity and slight horizontal drift, fading as they near the top. Keep the
count and motion bounded so it stays a hypnotic drift, not a particle storm — detach amount scales *how
much* detaches and *how far it rises*, not an unbounded spawn rate.

This effect is the clearest argument for the WebGL move: independent floating metaballs with smooth-min
merging, evaluated per-pixel every frame, is the canonical fragment-shader lava-lamp and is essentially
free on the GPU while being impossible at 60 FPS on Canvas 2D.

### 4f. Glass treatment (delegated to design judgment, in-shader)

Daniel named refraction/distortion of what's behind, specular highlights/sheen on the bars, frosted
translucency, and a wet glossy surface — and explicitly delegated the aesthetic: "use your terrific
claude design judgment for maximum style." **Hard constraint: achieve glass entirely in the shader. Do
NOT reintroduce a per-frame CPU `backdrop-filter: blur()`** — that was a confirmed perf killer in the
Canvas renderer and is exactly the cost the GPU move exists to eliminate.

**Recommended concrete, shippable glass treatment (all fragment-shader, all 60-FPS-affordable):**

1. **Refraction of what's behind.** The visualizer is a full-page background behind the mix-detail
   content. True refraction of arbitrary DOM behind a WebGL canvas isn't directly available, so achieve
   the *read* of refraction cheaply: (a) where a blob/ribbon's surface curves (high SDF gradient), warp
   the gradient-field sampling coordinates by the surface normal — so the color field appears bent
   through the glass at the edges, the hallmark of looking through a curved lens; and (b) keep the page
   background showing through via translucency (below). This gives "refraction" as an internal distortion
   of the field, which reads correctly without needing the actual DOM pixels.
2. **Specular highlights / sheen.** Treat each blob's surface as lit by a fixed virtual light: compute a
   surface normal from the SDF gradient and add a sharp specular hotspot (Blinn-Phong-style highlight)
   plus a soft broad sheen along the upper edge of each bulge. Highlights drift as blobs move — this is
   what sells "wet glossy surface." Cheap: a couple of dot products per fragment.
3. **Frosted translucency.** The ribbon/blobs render at partial alpha over the page (carry forward the
   RIBBON_OPACITY-style backdrop intent — it stays a backdrop, not a chart) with a subtle internal
   softening (a small in-shader blur of the field, or a frosted noise modulation of alpha) so edges read
   soft and lit rather than hard. This is the "frosted glass" read, done in-shader with no CSS filter.
4. **Fresnel edge glow.** Add a Fresnel term (brighter at grazing angles / silhouette edges) so the rims
   of blobs catch light — the single most effective "this is glass / lit volume" cue, and one cheap
   `pow(1 - dot(n, viewDir), k)` per fragment.

Net look: **lit, wet, frosted glass blobs of navy-and-moss light drifting upward** — hypnotic, ambient,
unmistakably "lava lamp made of theme-colored glass." The glass is layered *on top of* the color field
and geometry, so all four effects compose into one coherent surface.

---

## 5. Acceptance criteria (observable)

1. **Frame rate.** The visualizer holds **a smooth 60 FPS** on a mid-range desktop while playing, with
   bubblyness, detach, and color-shift-speed all at non-trivial values simultaneously. (The whole point
   of the swap — measure it, don't assume it.) Graceful degrade on weaker/mobile devices: drop internal
   resolution or effect cost before dropping frames (8.K §E carries forward).
2. **Renderer.** Rendering is WebGL2 fragment-shader; there is no Canvas 2D path and no CSS
   `backdrop-filter` on the visualizer. (The old per-frame killers — `shadowBlur`, `backdrop-filter`,
   per-frame `getBoundingClientRect`-driven layout — are gone.)
3. **Motion.** A windowed slice scrolls bottom-to-top, coupled to playback; scroll holds when paused; no
   scroll when nothing is playing; scrolls in-from-empty at the start and out-to-empty at the end (8.K §A
   preserved). The rAF loop is gated on `isPlaying` and burns no frames while paused.
4. **Animation independence.** The color field visibly morphs and detached blobs visibly drift **between**
   Blazor player ticks — i.e. motion is smooth at 60 FPS, not stepping at the ~10 Hz `StateHasChanged`
   cadence. Animation is driven by the shader clock, not Blazor re-renders.
5. **Controls.** Four continuous sliders sit in a row above the mix details / below the back button. Each
   visibly and continuously affects its target as it is dragged: resolution changes the visible
   time-span and apparent scroll speed; bubblyness sweeps straight bars → attached liquid bulges; detach
   sweeps attached → free-floating rising blobs; color-shift-speed sweeps barely-drifting → briskly
   morphing field.
6. **Persistence.** All four control positions survive SPA navigation to another mix within a session and
   reset to defaults on a fresh page load (mirrors `MixVisualizerZoomState`).
7. **Gradient.** Bars are filled with a navy↔moss gradient that (a) varies along each bar's height and
   (b) shifts along the time axis and never sits fully static; the field reads as coherent across the
   window while individual bars read as distinct/alive. Toggling dark mode re-themes the field live with
   no reload.
8. **Geometry.** Amplitude is symmetric about a center zero-line (−1..+1), peaks extending both ways.
9. **Glass.** Blobs/ribbon read as lit, frosted, glossy glass — visible specular highlights, soft frosted
   edges, edge/Fresnel glow, field distortion at curved surfaces — with no CPU backdrop-filter.
10. **Read-only.** No seek, no scrub, no write-back; the only inputs that affect the visualizer are
    playback position (one-way) and the four sliders. No control is a seek surface.
11. **Bridge intact.** The component still self-fetches its datum from `ReleaseId`, couples to the
    cascaded player via `TrackId` gating, and tears down its GL resources on dispose without leaking.

---

## 6. Suggested phasing / waves

The work decomposes naturally into a **parity swap first, effects second** sequence. This de-risks the
hardest architectural step (getting WebGL2 on screen at all, holding 60 FPS, bridge intact) before any
art is layered on, and gives Daniel a working renderer to tune effects against.

### Wave 1 — Renderer swap at parity (the load-bearing step)
Stand up the WebGL2 pipeline reproducing **today's visual** (the scrolling navy/moss-ish ribbon) but on
the GPU: full-window quad, datum-as-texture, scroll/zoom math ported into the fragment shader, rAF loop
gated on `isPlaying`, bridge contract preserved (`setDatum`/`setPlayback`/`setZoom`/`refreshTheme`/
`dispose`), no-WebGL2 fallback to a plain backdrop. **Acceptance:** §5 criteria 1 (at parity workload),
2, 3, 4, 10, 11 — a 60-FPS GPU renderer at visual parity with the Canvas version. No new effects yet.
This is where the architecture is proven; everything after is shading.

### Wave 2 — Controls row + state
Add the four-control row above the mix details, widen `MixVisualizerZoomState` →
`MixVisualizerControlState` (the three new properties + persistence), wire the three new
uniforms (`bubblyness`, `detach`, `colorShiftSpeed`) through the bridge as no-op-until-shader-uses-them
inputs. Resolution relocates here and keeps working. **Acceptance:** §5 criterion 6 and the slider half
of 5 (sliders present, persistent, wired) — even before the shader consumes them visibly.
(Wave 2 can begin once Wave 1's bridge is stable; the controls are inert visually until Wave 3 but the
plumbing is independent.)

### Wave 3 — The four effects in the shader
Layer the effects onto the proven pipeline, in increasing order of risk:
1. **Gradient field** (§4b) — replace the parity fill with the morphing 2D navy↔moss field. (§5.7)
2. **Bubblyness** (§4d) — box→metaball SDF interpolation. (§5.5 bubblyness)
3. **Detach** (§4e) — metaball pinch-off + rising blobs. (§5.5 detach)
4. **Glass** (§4f) — specular + Fresnel + frosted + field distortion, composed on top. (§5.9)
**Acceptance:** §5 criteria 5 (visual half), 7, 8, 9 — all four effects live and slider-driven at 60 FPS.

**Dependency shape:** `Wave 1 → (Wave 2 ‖ Wave 3 plumbing) → Wave 3 art`. Wave 1 is the prerequisite for
everything. Wave 2 (controls/state) and Wave 3 (effects) both depend on Wave 1's bridge but the four
effects within Wave 3 are independently shippable and independently tunable — land and tune one slider's
effect at a time. Color-range and bubblyness-range guards stay deferred throughout (Daniel tunes by
hand).

---

---

## 7. Wave 4 — Detail-page polish + controls rework (presentation only)

> **PARTIALLY SUPERSEDED (2026-06-16) by `product-notes/phase-10-mix-visualizer-lava-reframe.md` (the
> Phase 10 reframe, Waves R1–R4).**
> **Kept:** the lava-lamp icon-button trigger top-right of the body across from the back link (§7c, §7f
> — `DDIcons.LavaLamp`, landed; the **glyph itself is redrawn** by the reframe §7f).
> **Kept:** the widened Mix body (`MudContainer MaxWidth="Large"`, §7g, landed).
> **Superseded:** the four-knob **popover** becomes a six-knob **inline collapse/expand knob-bar** — NOT a
> popover or drawer (the reframe adds two controls —
> lava gravity, blob density/size, collision strength — and drops/recasts others: resolution/zoom is
> removed in favor of scroll speed; bubblyness/detach are replaced by the physical lava model). The
> controls become an `@if`-guarded animated flex row of six RadialKnobs inline in the controls area,
> toggled by the lava-lamp icon — see the reframe spec §7b. See the reframe spec §7 for the six controls,
> the inline knob-bar, and the NowPlaying-hero aesthetic target. The §7
> text below is retained as the record of what the four-knob popover shipped.

Status: **design-complete, implementation-ready.** Added 2026-06-15. **Depends on Wave 3 being merged**
(the knobs in this wave drive the four effects that Wave 3 makes real). **This wave supersedes the §3
always-visible controls-row design** — the row moves into a popover and the four MudSliders become four
`RadialKnob`s. The renderer, the control *values*, the `MixVisualizerControlState`, and the
`Changed`-event bridge seam are all **unchanged**; this is a widget/placement/width rework, not a
behavior change.

Files this wave touches (for orientation; staff-engineer's to implement):
- `DeepDrftPublic.Client/Controls/MixVisualizerControls.razor[.cs/.css]` — the four controls; sliders → knobs.
- `DeepDrftPublic.Client/Pages/MixDetail.razor[.css]` — container width; the new icon-button + popover placement.
- `DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor[.cs]` — a new top-right action slot (see §7c).
- `DeepDrftShared.Client/Common/DDIcons.cs` — the new lava-lamp SVG (note: DDIcons lives in
  **DeepDrftShared.Client**, not DeepDrftPublic.Client — both apps consume it).
- `DeepDrftShared.Client/Components/RadialKnob.razor` — **consumed, not modified** (its API is fixed; §7e).

### 7a. Goal and scope boundary

**Goal.** Polish the Mix detail page and rework how the visualizer controls are presented: hide the four
controls behind a **popover** opened by a bespoke **lava-lamp icon button** anchored top-right of the
body (across from the `← Back` link top-left); replace the four sliders with four **RadialKnobs in a
row** inside the popover; and **widen the Mix detail body** to match the Sessions detail page.

**In scope.**
- Move the four controls out of the always-visible `TopContent` row into a popover (§7d).
- A new lava-lamp SVG icon in `DDIcons.cs` (§7f) and an icon button that triggers the popover (§7c).
- Replace each `MudSlider` with a `RadialKnob`, four in a row in the popover, carrying the existing
  icons as captions adjacent to each knob (§7e).
- Widen the Mix body container from its current `760px` to the Sessions detail width (§7g).

**Out of scope / unchanged.**
- **The WebGL2 renderer** and all four effects (Waves 1–3). This wave changes *how you reach the four
  control values*, not what they do or how the shader consumes them.
- **`MixVisualizerControlState`** — same object, same four properties, same `const` defaults, same
  `Changed` event. The knobs mutate it exactly as the sliders do today (§7e). No new state, no rename.
- **The bridge** (`MixWaveformVisualizer`) and its `setBubblyness`/`setDetach`/`setColorShiftSpeed`/
  `setZoom` pushes — untouched. It still subscribes to `MixVisualizerControlState.Changed`; it cannot
  tell whether a knob or a slider moved.
- **`MixZoomMapping`** — resolution still rides the log-space fraction↔seconds mapping (§7e, knob 1).
- **The read-only contract** (8.K §D) — no knob is a seek surface; the popover adds no playback control.
- **Persistence model** — still session-scoped via the DI-scoped `MixVisualizerControlState`: survives
  SPA nav, resets on fresh load (F5). The popover is pure presentation over the same state.

### 7b. Why a popover (the reframe)

The §3 always-visible row spends permanent vertical real estate on four controls that, in normal
listening, the user sets once and leaves. On a page whose *point* is a full-bleed living visualizer, a
persistent control strip competes with the art. Tucking the controls behind a single small affordance —
the lava-lamp button — returns the page to the visualizer and makes the controls a deliberate "I want to
tune this" gesture. This is the standard "progressive disclosure for advanced controls" move (cf.
Lightroom's collapsible panels, a video player's settings gear): the thing you adjust occasionally lives
one click away, not always on screen. The lava-lamp glyph also *names* what the controls do — it reads as
"the lava-lamp settings," which is exactly the visualizer's identity.

### 7c. The trigger: lava-lamp icon button, top-right of the body

**Placement.** Top-right of the body container, **horizontally across from the `← Back` link** (which
the scaffold renders top-left). Today `ReleaseDetailScaffold` renders, in order: the back link
(top-left), then `TopContent`, then the masthead. There is no top-right anchor today.

**Recommended scaffold change (minimal, reusable):** add an optional `TopRightAction` render-fragment
slot to `ReleaseDetailScaffold` and lay the back link + that slot as a single
`MudStack Row Justify="SpaceBetween"` at the top of the container — back link left, action right. This
keeps the back link where it is, gives a clean top-right anchor across from it, and is reusable by other
media later (it stays null for Track/Session, which don't supply it). The Mix page passes its lava-lamp
icon button into `TopRightAction`. Avoid absolute-positioning the button into the container corner — the
SpaceBetween row is the scaffold-idiomatic way and survives the width change in §7g.

The `TopContent` slot (which the §3 row used) is **emptied** by this wave — the controls no longer live
there. Leave the slot on the scaffold (other media may want it; it is generic), but `MixDetail` stops
passing the controls row into it.

**The button.** A `MudIconButton` (or `MudButton` with only the icon) rendering the lava-lamp SVG from
`DDIcons` (§7f), `Color.Secondary` to match the page's play affordance, with an `aria-label` like
"Visualizer settings". It is the popover's anchor and toggle.

### 7d. The popover

**Primitive: `MudPopover`, driven by an explicit `Open` bool toggled by the icon button.** Recommended
over `MudMenu` because the content is a custom four-knob layout with drag interaction, not a list of
menu-items — `MudMenu` is built for actionable item lists and would fight the knob drag/click model.
`MudPopover` is the right MudBlazor primitive for "anchored floating panel of arbitrary content," and it
is already in the codebase vocabulary (`SharePopover` is the precedent — follow its open/close idiom).

**Anchor & positioning.** Anchor to the lava-lamp button; open **below and right-aligned to the button**
(`AnchorOrigin=TopRight`, `TransformOrigin=TopRight` or the MudBlazor equivalent that drops the panel
down-and-left from a top-right trigger) so the panel opens *into* the page, not off the right edge. Add
modest elevation and the standard rounded surface so it reads as a floating panel over the visualizer.

**Open/close behavior.**
- Click the lava-lamp button → toggle open/closed.
- Click outside the panel → close (use MudPopover's overlay/`OutsideClickClose` idiom as `SharePopover`
  does; a transparent click-catcher overlay is acceptable if that is the established pattern).
- The panel stays open while the user drags knobs (the knob's own global mouse-capture overlay, §7e,
  must not be read as an outside-click that closes the popover — verify these don't conflict; if they do,
  gate outside-click-close off while a knob is dragging).
- No auto-close on value change — the user tunes multiple knobs in one session.
- `Esc` closes (nice-to-have; follow `SharePopover` if it does this).

**Layout inside the popover:** the four knobs **in a single row** (§7e), each with its icon caption, with
comfortable padding. On a narrow viewport the row may wrap to 2×2 — a layout call for staff-engineer, but
all four must remain reachable (mirrors the §3b "none may drop" rule). The popover is the *only* home for
these controls after this wave.

### 7e. RadialKnob integration (grounded in the actual control)

**What `RadialKnob` actually is** (read from `DeepDrftShared.Client/Components/RadialKnob.razor`, 225
lines): a self-contained SVG knob — a 270° background arc, a value arc, a center dot, and a pointer line —
that the user drags **vertically** (up = increase) to change its value. Confirmed API:

| Member | Type | Notes |
|--------|------|-------|
| `Value` | `double` | Current value. **Two-way capable** via `ValueChanged`. |
| `ValueChanged` | `EventCallback<double>` | Fires on drag (immediately, unless `HoldValue`). This is the binding seam. |
| `Min` / `Max` | `double` | Value range. Internally normalizes to `[0,1]` for the arc/pointer. Defaults `0`/`100`. |
| `Step` | `double` | Quantization. Default `1`. **Set fine (e.g. `0.001`) for continuous feel** (matches the sliders' `Step="0.001"`). |
| `Label` | `string` | Rendered as SVG `<text>` **inside** the knob (centered, bottom). **Text only — there is NO icon slot/parameter.** Default `""`. |
| `Size` | `int` | Pixel width/height (square). Default `50`. Use a larger size (e.g. `64`–`80`) so four read clearly in a row. |
| `Color` | `MudBlazor.Color` | Maps to a `--mud-palette-*` var for the arc/pointer. Use `Color.Primary` (or `Secondary`) for theme consistency. |
| `HoldValue` | `bool` | If `true`, the value display shows the live drag value as `F0` and `ValueChanged` fires only on mouse-up. If `false` (default), `ValueChanged` fires continuously during drag and the `Label` shows. |

**Two consequences for this wave, called out because they shape the implementation:**

1. **No icon slot.** The existing controls carry MudBlazor Material icons — confirmed in
   `MixVisualizerControls.razor`: `Icons.Material.Filled.ZoomIn` (resolution), `.BubbleChart`
   (bubblyness), `.Air` (detach), `.Palette` (color-shift speed). RadialKnob's `Label` is SVG text, not
   an icon. **Spec: render each icon as a `MudIcon` adjacent to its knob** (caption above or below the
   knob, in a small `MudStack` per control), and use the knob's `Label` for nothing or for a short text
   caption — **not** for the icon. One control = `{ MudIcon + RadialKnob }` stacked; four such stacks in
   a row. Carry the four existing icons over **exactly** (same four `Icons.Material.Filled.*`).

2. **`HoldValue` choice.** Leave `HoldValue=false` (the default) so the knobs are **live** — the effect
   responds continuously as the user drags, matching today's continuous-slider feel and keeping the §5
   "visibly and continuously affects its target as it is dragged" acceptance. (`HoldValue=true` would
   make the effect jump only on release — wrong for a "feel it move" tuning surface.) Trade-off: live
   mode fires `ValueChanged` on every drag delta, which raises `Changed` and pushes a uniform per delta —
   identical to the slider's current behavior, so no regression. The drag-time global mouse-capture
   overlay (the `_isDragging` full-viewport div at `z-index: 9999`) must coexist with the popover (§7d
   open/close note).

**Per-knob mapping — one knob per control, mutating `MixVisualizerControlState` exactly as the sliders
do today** (the `OnXChanged` handlers in `MixVisualizerControls.razor.cs` are reused verbatim — only the
widget that calls them changes):

| Knob | Icon (carried over) | Binds to | Min/Max/Step | Handler (unchanged) |
|------|--------------------|----------|--------------|---------------------|
| 1. Resolution | `ZoomIn` | `ControlState.VisibleSeconds` via `MixZoomMapping` | `0`/`1`/`0.001` on the **fraction** (`MixZoomMapping.SecondsToFraction` in, `FractionToSeconds` out) — identical to the slider | `OnResolutionChanged(fraction)` |
| 2. Bubblyness | `BubbleChart` | `ControlState.Bubblyness` | `0`/`1`/`0.001` | `OnBubblynessChanged(value)` |
| 3. Detach | `Air` | `ControlState.Detach` | `0`/`1`/`0.001` | `OnDetachChanged(value)` |
| 4. Color-shift speed | `Palette` | `ControlState.ColorShiftSpeed` | `0`/`1`/`0.001` | `OnColorShiftSpeedChanged(value)` |

Resolution stays special exactly as it is now: the knob's `Value` is the **fraction**
(`MixZoomMapping.SecondsToFraction(ControlState.VisibleSeconds)`), and `OnResolutionChanged` maps the
fraction back to seconds before raising `Changed`. The other three bind their normalized `[0,1]` value
directly. **The `NotifyChanged()` call after each mutation is preserved** — that is the bridge seam, and
it is what keeps this wave a pure widget swap. `aria-label` per knob carries over from the sliders.

### 7f. The lava-lamp SVG icon

**Home: `DeepDrftShared.Client/Common/DDIcons.cs`** — the same file as the hand-rolled gas-lamp
lit/unlit icons, in the same style. Match the existing convention exactly:
- A `public const string` using a C# raw-string literal.
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">` (same 24×24 viewBox as `GasLamp`).
- **`fill="currentColor"`** on the structural paths so the icon themes with its context (the gas-lamp
  icons do this; it is load-bearing for dark-mode and `Color.Secondary` tinting). Accent fills (a warmer
  blob color, à la the gas-lamp flame's `#FF9800`/`#FFCA28`) are acceptable for the lava blobs if a
  two-tone read is wanted, but the lamp silhouette must be `currentColor`.

**What it depicts.** A recognizable **lava lamp**: a tapered conical/hourglass base, a tall rounded glass
vessel rising from it, a cap on top, and **two or three rounded lava blobs** suspended at different
heights inside the vessel (one larger toward the bottom, one or two smaller rising). The silhouette must
read as "lava lamp" at icon size (≈24px) — favor a bold, simple silhouette over fine detail, exactly as
the gas-lamp icon reads as a lantern at small size. The blobs are the recognizable cue; keep them
generous and few. (Precise path data is staff-engineer's; this describes the asset and its intent.)

Expose it the same way the gas-lamp icons are consumed (raw SVG string into a MudBlazor icon-rendering
context, as `MainLayout` does for the dark-mode toggle).

### 7g. Container width — match the Sessions detail page

**Current state.** `MixDetail` composes `ReleaseDetailScaffold`, whose root is
`.deepdrft-track-detail-container` — `max-width: 760px; margin: 0 auto` (in
`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`). `SessionDetail` does **not** use the scaffold; it
wraps its content in `MudContainer MaxWidth="MaxWidth.Large"` (MudBlazor's `Large` breakpoint, ~1280px),
giving it the wider, hero-dominant feel.

**Spec the widening.** Make the Mix detail body as wide as the Sessions detail body — i.e. the
`MudContainer MaxWidth="Large"` width (~1280px), up from 760px. Because `MixDetail` shares the scaffold
container with `TrackDetail`, **do not** widen `.deepdrft-track-detail-container` globally (that would
also widen Track detail, out of scope). Two acceptable approaches, staff-engineer's call:

1. **Preferred — wrap the Mix scaffold in `MudContainer MaxWidth="Large"`** and neutralize the inner
   `max-width: 760px` for that instance (a Mix-specific class on the scaffold, or wrapping such that the
   `MudContainer` is the constraining width). This reuses the *same primitive* SessionDetail uses, so the
   two pages match by construction (memory: one source / shared idiom) rather than by a hand-copied pixel
   value.
2. Alternative — a Mix-scoped override class that sets `max-width` to the `Large` breakpoint value. Works,
   but couples the Mix page to a magic number that can drift from `Large`; prefer option 1.

Either way the body must visibly match the Sessions detail width. The full-bleed visualizer backdrop
(behind `.mix-detail-foreground`) is unaffected — it is already full-page; only the foreground content
column widens.

### 7h. Acceptance criteria (observable)

1. **Popover trigger.** A lava-lamp icon button sits at the **top-right** of the Mix detail body,
   horizontally across from the `← Back` link (top-left). Clicking it opens a popover; clicking it again,
   or clicking outside, closes it.
2. **Icon.** The button shows a recognizable lava-lamp glyph (sourced from `DDIcons`), themed via
   `currentColor` so it tints correctly in light/dark.
3. **Four knobs in a row.** The popover contains exactly four `RadialKnob`s in a row (wrap allowed on
   narrow viewports, none dropped), each captioned with its existing icon — `ZoomIn`, `BubbleChart`,
   `Air`, `Palette`, in that order.
4. **Knobs drive the effects.** Dragging each knob continuously changes its `MixVisualizerControlState`
   value and the corresponding effect responds live (post-Wave-3): resolution → visible time-span/scroll
   speed; bubblyness → straight↔liquid bulge; detach → attached↔rising blobs; color-shift speed →
   slow↔brisk field morph. Resolution still rides `MixZoomMapping`; the other three are normalized [0,1].
5. **Persistence preserved.** Knob positions survive SPA navigation to another mix within a session and
   reset to defaults on a fresh page load (F5) — unchanged `MixVisualizerControlState` behavior.
6. **Old row gone.** The always-visible four-slider row (the §3 / Wave 2 design in `TopContent`) is no
   longer rendered on the Mix detail page. The controls exist *only* in the popover.
7. **Width.** The Mix detail body container is as wide as the Sessions detail body
   (`MudContainer MaxWidth="Large"`), not the former 760px. Track detail's width is unchanged.
8. **Bridge & read-only intact.** The visualizer still couples to playback via the unchanged bridge; no
   knob and no popover element is a seek/playback surface; the renderer and effects are unchanged from
   Wave 3.

### 7i. Phasing note

- **Depends on Wave 3 merged.** The knobs drive the four effects; without Wave 3 the knobs move inert
  values (as the Wave 2 sliders do today). Wave 4 can be *built* against inert values but its acceptance
  criterion 4 ("effect responds") requires Wave 3. Sequence after Wave 3.
- **Supersedes the §3 controls-row design.** §3 (always-visible MudSlider row in `TopContent`) is the
  Wave 2 design; Wave 4 replaces it with the popover + knobs. §3 stays in this doc as the record of what
  Wave 2 shipped; this §7 is the forward design that overtakes it. When Wave 4 lands, doc-keeper archives
  the §3-vs-§7 transition per house style.
- **Pure presentation.** No renderer, state, bridge, or mapping change — the smallest-surface way to get
  the popover + knobs + width is the right way. Resist scope creep into effect or state changes here.

---

## Open items (tuning knobs, none block starting)

All have recommended defaults inline; Daniel tunes on screen:
- Exact slider ranges for bubblyness / detach / color-shift-speed (§3a).
- The precise palette-var bindings for the navy/moss field endpoints per light/dark (§4c).
- Glass intensity / specular sharpness / Fresnel falloff (§4f) — pure aesthetic tuning.
- Whether the controls row wraps or horizontally scrolls on mobile (§3b) — layout call.
- Whether control state should later persist cross-session (cookie/localStorage) — deferred upgrade to
  the one state object (§3c).
- **Wave 4:** knob `Size` (px) for the four-knob popover row, and whether each knob's `Label` carries a
  short text caption or stays blank with only the icon caption (§7e) — layout/feel call.
- **Wave 4:** popover anchor/positioning fine-tuning (drop direction, elevation, width) and whether
  outside-click-close must be gated off mid-knob-drag to avoid the drag overlay closing it (§7d).
- **Wave 4:** whether the lava-lamp icon is one-tone (`currentColor` only) or two-tone with accent blob
  fills à la the gas-lamp flame (§7f) — aesthetic call.
