# Mix Visualizer — Phase 10 Reframe (Lava) — Design Spec

Status: **shipped** (Waves R1–R4 merged to dev, 2026-06-17). Author: product-designer. Date: 2026-06-16.
See `COMPLETED.md` for the completion record.

This is a **major reframe of the Mix visualizer's effects layer, controls, and color model**, folded
**under the still-open Phase 10** (the WebGL2 renderer) as a **reframe wave-set (Waves R1–R4)** —
distinct from Phase 10's original Waves 1–4 (renderer swap, controls row, the four effects, the
popover/knob polish) which are **already merged**. It builds on that landed Phase 10 infrastructure (the
single-pass fragment-shader pipeline, the loudness datum texture, the wall-clock playhead interpolation,
the controls UI, the widened Mix body) and **replaces what that pipeline paints** — the per-bar bulge,
the analytic-metaball "lava," the glass treatment, and the navy↔moss color treatment. The renderer
*infrastructure* is reused; the *art and the controls* are rebuilt.

> **Numbering note.** This reframe lives **under Phase 10**, not as a separate phase. Its waves are
> labelled **R1–R4** to stay unambiguous against Phase 10's landed Waves 1–4. The filename
> `phase-10-mix-visualizer-lava-reframe.md` reflects that; an earlier draft of this doc was numbered
> "Phase 12" — that numbering is retired.

**Why a reframe, not an iteration.** Daniel tested the landed Phase 10 effects end-to-end and rejected
the visual result: the lava reads as "giant disconnected circles," the colors drifted to cyan (an HSL
saturation-boost artifact — see §6), and the waveform and the lava read as two unrelated things sharing
a canvas rather than one coherent fluid surface. The diagnosis (from a staff-engineer research pass) is
that the rejected look is *structural to the current effect approach*, not a tuning miss: too few
scripted blobs with no physics produce disconnected circles, and HSL interpolation between two
low-saturation theme tokens passes through hue regions that read as cyan. Both are fixed by changing the
*model*, not the dials.

This spec **supersedes** the original Phase 10 effects/controls/color design:

- `product-notes/mix-visualizer-webgl-renderer.md` **§4 (the four visual effects)** — superseded by
  §3–§6 here.
- `product-notes/mix-visualizer-webgl-renderer.md` **§7 (Wave 4 popover-controls rework)** — superseded
  by §7 here (the trigger and the widened body are kept; the four-knob popover becomes a seven-knob
  inline collapse/expand knob-bar — see §7).

What carries forward unchanged from Phase 10's landed waves (do **not** re-derive — reference it):

- The single-pass WebGL2 fragment renderer, the full-window quad, the trivial pass-through vertex
  shader (`mix-visualizer-webgl-renderer.md` §2a).
- The loudness datum as a GPU texture, sampled per-fragment (§2b; the 2-D grid + `texelFetch` manual
  interpolation that the landed `MixVisualizer.ts` already implements).
- The wall-clock playhead interpolation + the netcode-style correction-offset smoothing (the landed
  `PLAYHEAD_CORRECTION_*` machinery).
- The Blazor↔JS bridge contract (`MixWaveformVisualizer.razor.cs`) — `create` → handle with
  `setDatum` / `setPlayback` / `setZoom` / `refreshTheme` / `dispose`, the idempotent datum-push guard,
  the `IsActivePlayer` gating, the rAF loop gated on `isPlaying` (§2d, §2e).
- The `MIN_VISIBLE_SECONDS = 0.333 s` max-zoom anchor (one quarter note at 180 BPM) and the
  `MixZoomMapping` log-space fraction↔seconds mapping.
- The read-only contract (8.K §D): one-way playback input, no seek, no scrub, no write-back.
- The widened Mix body (`MudContainer MaxWidth="Large"`) and the lava-lamp `DDIcons.LavaLamp`
  icon-button trigger top-right of the body, across from the back link (Phase 10 §7c, §7g — **kept**;
  the icon glyph itself is **redrawn** by this reframe, see §7f).

Cross-references (read these before implementing):

- `product-notes/mix-visualizer-webgl-renderer.md` — the Phase 10 renderer spec this reframes. §1/§2
  (scope, renderer architecture, bridge) carry forward; §4/§7 are superseded.
- `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — the landed renderer. The Wave 1 scroll/zoom
  geometry, the datum texture, and the playhead machinery are reused; the §4-effect GLSL (the bubble
  SDF, the detach blobs, the HSL `mixHsl`/`vivify` color, the glass) is the part being replaced.
- `DeepDrftPublic.Client/Controls/MixVisualizerControls.razor[.cs]` — the four-knob control component;
  becomes seven knobs in an inline collapse/expand bar (§7).
- `DeepDrftPublic.Client/Services/MixVisualizerControlState.cs` — the scoped four-property state holder;
  widens to seven properties (§7c).
- `DeepDrftPublic.Client/Controls/MixZoomMapping.cs` — reused unchanged for the scroll-speed control.
- `DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor[.cs/.css]` — the bridge. Extend the handle
  with the new control setters; the `.css` gains the overflow-clip work (§2).
- `DeepDrftPublic.Client/Pages/MixDetail.razor[.css]` — the page; the controls move into an **in-flow
  container between the back link and the lava-lamp** on the detail top row (§7b). The old
  `.mix-visualizer-controls-anchor` + `position:absolute` floating structure is **removed**.
- `DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor[.cs]` — the detail chrome; gains a new
  optional **`TopRowCenter`** slot so the top row hosts `back | center-controls | action` (§7b). The slot
  stays null for Track/Cut/Session, which render the back link alone — reusability preserved.
- `DeepDrftPublic.Client/Pages/SessionDetail.razor[.css]` — the **NowPlaying / hero aesthetic** the
  knob-bar must match (§7e).
- `DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor[.css]` — the footer/player bar
  whose **dynamic height** the clip line must follow (§2c).
- `DeepDrftShared.Client/Common/DeepDrftPalettes.cs` — the **single source of truth** for theme colors
  (§6a). `DeepDrftShared.Client/Components/RadialKnob.razor` — the knob, consumed unchanged (§7d).
- `DeepDrftShared.Client/Common/DDIcons.cs` — the lava-lamp glyph, **redrawn** by this reframe (§7f).

---

## 1. Goal and scope boundary

**Goal.** Replace the rejected lava/bulge/glass/color treatment with a **credible physical lava-lamp**:
~16–32 simulated wax blobs whose motion is integrated on the CPU each frame and rendered as smooth
metaballs in the existing fragment shader, sharing the *same plane* as the waveform **with real 2D
collision** — the waveform pushes the fluid out of its way. Replace the navy↔moss treatment with a
**three-color, OKLab-interpolated, per-segment-baked gradient** that animates along three combined
motions. Replace the four-knob popover with a **seven-knob in-flow controls container that sits between
the back link and the lava-lamp toggle** on the detail top row (reflowing the layout in place — not a
floating/absolutely-positioned bar), styled to match the hero NowPlaying aesthetic. Remove the static noise texture that makes the screen look dirty.
Redraw the lava-lamp trigger glyph to the classic 1970s silhouette (§7f).

**In scope.**

- A small **CPU-side per-frame physics step** modeling ~16–32 Lagrangian wax blobs (position, velocity,
  temperature, radius), uploaded to the shader as a uniform array / tiny data texture each frame (§4).
- A **2D collision model**: blob↔waveform-boundary and blob↔blob, elastic, with a tunable hardness knob
  that blends soft-displacement → hard-obstacle (§5).
- The **three-color OKLab gradient model** with three combined motions: anchor rotation among X/Y/Z,
  per-segment sinusoidal variation baked at segment entry, and the per-bar curve shift with scroll
  height (§6).
- **Seven controls** replacing the four: scroll speed, gradient rotation speed, lava gravity, lava heat,
  blob density/size, collision strength, waveform width (§7).
- The **in-flow controls container between the back link and the lava-lamp** (replacing the popover, and
  superseding the rejected `position:absolute` floating bar) styled to the NowPlaying hero aesthetic (§7b).
- A **redrawn `DDIcons.LavaLamp`** glyph — classic 1970s lava-lamp silhouette (§7f).
- **Overflow clipping** to the dynamic footer height (§2).
- **Removing the static noise texture** (§3).

**Out of scope / unchanged.**

- **No playback-control changes.** Read-only contract holds (8.K §D). One-way playback input; no seek,
  no scrub, no write-back. No control is a seek surface.
- **No datum format change.** The duration-derived ~333 samples/sec loudness datum (8.K §F) and the
  GPU-texture upload path are reused as-is. The waveform geometry (symmetric ±amplitude about a center
  line, rising from the bottom) is reused; this spec changes only the *clip*, the *collision role*, and
  what the fluid does around it.
- **No bridge redesign.** Extend the handle with the new control setters; preserve the single-owner
  bridge, the idempotent datum guard, the `IsActivePlayer` gating, and the `isPlaying`-gated rAF loop.
- **No renderer-tech change.** Stay single-pass WebGL2 fragment shader + a CPU physics step. **Do NOT
  build a ping-pong FBO Navier-Stokes fluid sim** (§4, "rejected alternative").

**Reused vs. replaced — at a glance.**

| Layer | Reused from Phase 10 (Waves 1–4) | Replaced by this reframe (R1–R4) |
|-------|----------------------------------|----------------------------------|
| WebGL2 pipeline / quad / vertex shader | ✅ as-is | — |
| Datum texture + sampling | ✅ as-is | — |
| Playhead interp + smoothing | ✅ as-is | — |
| Bridge contract + lifecycle | ✅ (extend setters) | — |
| Scroll/zoom geometry | ✅ as-is (now scroll-speed-driven, §7) | — |
| Waveform silhouette | ✅ (now also a collision boundary, §5) | — |
| Lava effect (bulge + detach blobs) | — | CPU-physics wax blobs + metaballs (§4) |
| Color model (HSL navy↔moss) | — | OKLab three-color gradient, 3 motions (§6) |
| Glass treatment | — | folded into the blob shading (§4f); no separate glass dials |
| Static noise/frost texture | — | **removed** (§3) |
| Controls (4 knobs, popover) | — | 7 knobs in an in-flow container between back & lamp (§7) |
| Lava-lamp trigger glyph | trigger placement kept | glyph **redrawn** (§7f) |

---

## 2. Spatial model & layout

### 2a. Same plane, with collision

The waveform and the lava **occupy the same plane**. The waveform is not a backdrop the lava floats over
— it is a **physical collision boundary** the fluid is pushed out of by. As the waveform's silhouette
rises and falls (its half-width is the loudness at each scroll row), wax blobs that drift into that
silhouette are pushed away from it. The interaction is two-way in *appearance* (the fluid visibly
parts around the waveform) but one-way in *authority* (the waveform shape is driven by the datum +
playback, never by the fluid — the read-only contract holds; the fluid never deforms the waveform).

This is the headline fix for "the waveform and lava read as two unrelated things": they now share one
SDF-composited surface and one collision space, so the fluid demonstrably flows around the waveform.

### 2b. Bottom-anchored waveform (kept)

The waveform already rises from the bottom and scrolls bottom-to-top (8.K §A). **That stays.** New audio
enters at the bottom, played audio exits the top, the "now" line sits at a fixed screen Y. This reframe
does not touch the scroll geometry except to drive it from the new scroll-speed control instead of the
zoom control (§7).

### 2c. Overflow clip to the dynamic footer height

**The problem.** The visualizer canvas is `position: fixed; inset: 0` (full viewport). Visuals currently
bleed *past the footer* — the lava and waveform paint behind/over the player bar at the bottom of the
screen instead of stopping cleanly above it.

**The fix.** The visualizer must be **clipped so its visuals stop at the top edge of the footer bar**,
and the clip line is also the lava "rest" line (§5). The clip line must **follow the actual current
height of the footer**, which changes between states:

- **Player minimized:** the footer is a small floating FAB at `bottom: 30px` (`.minimized-dock` in
  `AudioPlayerBar.razor.css`) — effectively no full-width bar; the clip line sits near the viewport
  bottom.
- **Player expanded:** the footer is a full-width `MudContainer` + `MudPaper` surface
  (`.player-surface`, `margin-bottom: 1rem`) whose height depends on the responsive grid layout
  (transport/meta/seek/volume zones reflow across breakpoints — see the `AudioPlayerBar.razor.css`
  grid-area media queries) and on whether an error `MudAlert` is showing.

So a **fixed inset is wrong** — the footer height is dynamic. The clip line must be measured from the
live footer, not hard-coded.

**Implementation requirement (flagged for staff-engineer).** The clip height is a *layout* value the
WebGL canvas needs as a *render* value. The recommended approach: the bridge observes the footer
element's height (a `ResizeObserver` on the player-bar root, or a CSS custom property the player bar
publishes that the visualizer reads) and pushes a `setFooterClip(heightPx)` uniform/scissor value to the
module; the shader (or a GL scissor/viewport) clips the bottom `heightPx` of the canvas to transparent,
and the lava rest line is computed from it. The player bar already routes all minimize/expand mutations
through one `SetMinimized` mutator and fires `OnMinimized` — that is a natural signal to recompute, but
a `ResizeObserver` is more robust because it also catches breakpoint reflow and the error-alert case.
Wrap the visualizer container in `overflow: hidden` regardless (the `.mix-waveform-bg` wrapper already
has it) so nothing bleeds during the measurement settle.

**Acceptance (§8):** with the player expanded, no waveform or lava pixel paints over or under the player
bar; minimizing the player drops the clip line and the lava rest line down to match; resizing the
window (changing the player-bar breakpoint/height) re-clips without a reload.

---

## 3. Remove the static noise texture

The current renderer applies a value-noise "frost" modulation across the whole ribbon
(`MixVisualizer.ts` `frost = 0.85 + 0.15 * valueNoise(...)` and the frosted-translucency glass layer).
Daniel: it makes the screen look dirty. **Remove it.** The waveform itself is otherwise fine once
de-noised — the de-noised waveform + the new lava + the new gradient are the visual.

This does **not** forbid noise *inside the physics* (e.g. a little organic jitter on blob shape or a
slow temperature field) — it forbids the *static screen-space dirt layer* that sits over everything. Any
remaining noise must be (a) tied to the moving fluid, not the screen, and (b) not read as a dirty
overlay. When in doubt, leave it out; the lava is the texture.

---

## 4. The lava system — CPU-physics wax blobs

### 4a. The model (from the research pass — the load-bearing recommendation)

Model the lava as **~16–32 physical "wax blobs" (Lagrangian metaballs)**. Each blob carries:

- **position** (2D, in the shared waveform/lava plane),
- **velocity** (2D),
- **temperature** (scalar — hot wax rises, cool wax sinks; drives the buoyancy that makes the lamp
  "go"),
- **radius** (~20px–100px on screen; varied per blob).

Each frame, a **small CPU-side physics step** in JS integrates the blobs: buoyancy from temperature,
gravity (the gravity control), heat exchange (the heat control sets how much energy enters the system),
collisions (§5), and a little damping/viscosity so it reads as high-viscosity wax, not water. The blob
state is uploaded to the fragment shader each frame as a **uniform array** (`vec4 blobs[N]`-style:
`xy` = position, `z` = radius, `w` = a packed temperature/identity) or a **tiny data texture** if the
array bumps a uniform-count limit. The shader blends the blobs with a **smooth-min (`smin`) SDF
metaball** union — the same `smin` machinery the landed shader already has — plus the waveform SDF, and
shades the result.

This fixes "giant disconnected circles": the prior approach had too few scripted blobs (`DETACH_BLOB_COUNT
= 6`) with hash-driven pseudo-motion and no physics, so they read as detached discs. Real physics on
16–32 blobs with `smin` merging reads as continuous wax that splits and recombines.

**The fluid must read FLAT, and the blobs must melt into one unified fluid (from Daniel's Wave R2 eval).**
Two refinements to the rendered result, both load-bearing:

- **Flat fluid surface, not bright pointed centers.** The metaball/fluid surface must read **flat** —
  an evenly-lit fluid body. It must **NOT** read as blobs with bright pointed centers / cone-like radial
  gradients (a per-blob radial falloff peaking at each center reads as a field of glowing cones, which
  Daniel rejected). Shade the *composited* surface, not each blob's center: the brightness should be a
  property of the unified fluid's surface (normal/Fresnel from the SDF, §4f), flat across the body, not a
  per-blob hotspot.
- **Melt into one fluid — low viscosity, strong coalescence.** The blobs must **coalesce into a single
  unified fluid** rather than reading as distinct stiff globs that merely touch. Bias the `smin` blend
  radius wide and the inter-blob cohesion high so overlapping/near blobs **melt together** into one
  continuous body — "behave more like a fluid," not like rigid beads in contact. This trades against the
  earlier "high-viscosity wax, not water" damping note (§4a integration): keep the *motion* viscous and
  slow, but make the *surface* coalesce freely. The damping is on velocity (slow, wax-like movement); the
  coalescence is on the surface field (fluid, unified). They are independent.

### 4b. Blob shape — varied, organic, not always circular

Blobs range **~20px to ~100px** across and are **not always circular** — they should read as varied,
organic wax shapes. The `smin` union of overlapping circles already produces non-circular composite
shapes (two merging blobs form a peanut/neck); on top of that, a per-blob slight anisotropy or a
low-frequency radius modulation (tied to the blob, not the screen — see §3) gives each blob its own
organic silhouette. Staff-engineer's call on the exact shape primitive; the *intent* is "varied organic
wax," not "N identical circles."

### 4c. Heat = 0 → rest at the bottom; collision always on

- At **heat = 0**, the lava **rests at the bottom** (pooled at the clip/rest line, §2c). No bubbles rise
  on their own. The **only** thing that moves the wax at heat 0 is **collision from the waveform** —
  i.e. the waveform pushing through the resting pool displaces it. The waveform↔lava collision is
  **always on, independent of heat** (§5).
- At **heat = max**, **many bubbles rise and morph per second** — a busy, actively roiling lamp.

**Energy-coupled dynamics (from Daniel's Wave R2 eval) — heat changes the *character* of the wax, not
just the rise rate.** Higher heat/energy → **smaller bubbles + more turbulence**; lower heat → **fewer,
larger, calmer wax**. Concretely:

- **High heat:** lots of **small, lively, turbulent bubbles** — the wax fragments into many small active
  blobs with visible churn/turbulence. Energetic and busy.
- **Low heat:** **fewer, larger, calmer** masses — big lazy wax that mostly rests and drifts.

So heat couples to *both* buoyancy (rise) *and* the effective blob size/count distribution and the
turbulence in the velocity field. (This interacts with the blob density/size control, §4e — heat shifts
the wax toward the small-and-many end of whatever the density control sets.) **The visualizer should feel
dynamic and fun** — heat is the "liveliness" axis, and the whole point is that turning it up makes the
lamp visibly come alive, not merely rise faster.

The mapping from the 0..1 heat scalar to effect intensity (rise rate, bubble size/count split, churn
frequency, turbulence) is a **well-tuned transfer function that staff-engineer owns** — this spec does
not fix a formula. Note it as a tuning task: the requirement is the *endpoints* (0 = wax rests on the
floor, collision-only; max = many small turbulent rising bubbles) and a smooth, good-feeling, dynamic
sweep between them.

**Tuning reference — Daniel's sweet spot is ~20% gravity / ~100% heat.** Calibrate the defaults and the
transfer-function ranges so that this combination lands in a great-feeling, lively state (it is the
target the small-turbulent-bubble behavior above is described against). Treat ~20% gravity / ~100% heat
as the calibration anchor for where "dynamic and fun" should sit; the ranges should make that reachable
and good, not extreme.

### 4d. Gravity control

A separate **lava gravity** control sets the downward force on the wax. Higher gravity = wax falls back
faster, blobs are flatter at rest, rising requires more heat; lower gravity = wax floats more freely,
slower settling. Interacts with heat (buoyancy vs. gravity is the lamp's core tension) — staff-engineer
tunes the interplay; the control is an independent axis the user can dial.

### 4e. Blob density/size control (the fifth control)

A **blob density/size** control sets how much wax is in the system — the blob count within the ~16–32
band and/or the average radius within the ~20–100px band. Low = a few large lazy blobs; high = many
smaller active blobs. This is the realism/cost dial from the research pass (blob count is the
performance lever — see §4g).

### 4f. Shading — glass folded in, no separate dials

The Phase 10 spec had a separate four-part "glass" effect with its own tuning constants. In this reframe
the wax is shaded as **lit, translucent, glossy wax** as part of rendering the blobs — a surface normal
from the SDF gradient, a specular highlight, a soft Fresnel rim, and translucency over the page — but
this is **a property of how blobs are drawn, not a separate user control.** There is no glass knob. Keep
it tasteful and physical (wax is glossy-translucent, lit from a fixed virtual light); do **not**
reintroduce a screen-space frost/noise layer (§3) or any CPU `backdrop-filter`. Staff-engineer owns the
shading constants; the intent is "lit glassy wax," subordinate to the gradient color (§6), not competing
with it.

### 4g. Performance levers

- **Blob count is the realism/cost dial.** 16–32 is cheap; the per-fragment SDF loop over blobs is the
  cost. Bound the loop (a hard `MAX_BLOBS` constant the shader loops to).
- **Keep the existing `MAX_DPR = 2` cap** as the graceful-degrade lever (drop internal resolution before
  dropping frames — 8.K §E, carried forward).
- The CPU physics step is O(blobs²) for blob↔blob collision at worst (32² = 1024 pair checks/frame —
  trivial) and O(blobs) for waveform collision; neither is a concern at this count.

### Rejected alternative (do not build): full fluid simulation

A ping-pong FBO Stable-Fluids / Navier-Stokes simulation was **considered and rejected.** A lava lamp is
high-viscosity / low-turbulence — the *opposite* regime from what a fluid solver buys you. It would be a
large rewrite (multi-pass FBO, advection/pressure-solve plumbing) for realism we explicitly do not want.
The Lagrangian-blob approach is both cheaper and a better match for the wax aesthetic. Keep the FBO fluid
sim as a *deliberate later upgrade only* if the blob approach ever reads too kinematic — and even then it
is a separate phase, not a tuning step.

---

## 5. The 2D collision model

### 5a. Two collision pairs, elastic

1. **Blob ↔ waveform boundary.** The waveform silhouette (the symmetric ±loudness ribbon about the
   center line) is a collision boundary. A blob overlapping the silhouette is pushed out along the
   waveform's surface normal — the fluid parts around the waveform. **2D elastic collision** against the
   boundary (the blob's velocity component into the boundary reflects, modulated by the hardness knob —
   §5c). The waveform is unaffected (read-only authority, §2a).
2. **Blob ↔ blob.** Blobs collide with each other — **2D elastic collision** (the standard
   equal-or-unequal-mass elastic response along the line connecting centers), again modulated by
   hardness. This keeps the wax from interpenetrating into one mush and gives the lamp its jostling
   liveliness.

Mass can be derived from radius (bigger blob = more mass) so large blobs shove small ones convincingly —
staff-engineer's call; the requirement is *elastic 2D collision on both pairs.*

**High elasticity, and the throw is UP AND OUT, not just sideways (from Daniel's Wave R2 eval).** The
collision is **highly elastic** at the hard end. Critically, when the waveform collides with the wax at
high collision strength it must **throw the bubbles UPWARD AND OUTWARD** — the waveform surface impulse
has a vertical (lift) component, not merely horizontal displacement. The earlier "pushed out along the
waveform's surface normal" framing is too flat: the desired read is the waveform *kicking* the fluid up
and away, like a paddle slapping liquid, so the lava springs off it. Staff-engineer composes the impulse
so a hard collision imparts both outward (normal) and upward (lift) velocity to the struck blobs.
**Collision must be smooth — no jitter** (no per-frame popping/vibration at the boundary; the response
must integrate stably even at high elasticity).

### 5b. Collision always on, independent of heat

Per §4c: the waveform↔lava collision runs **at all heat levels, including heat = 0.** At rest the
waveform still pushes through the pooled wax and displaces it. This is what keeps the waveform and the
lava feeling like one physical system rather than two layers.

### 5c. Collision strength — soft → hard (the sixth control)

A **collision strength** control sweeps the interaction from **genuinely soft → 100% hard** across a
**wide soft↔hard range** (from Daniel's Wave R2 eval — the soft end must be genuinely soft, not a faint
version of hard, and the spread between the two ends should be large and expressive). It blends between
two behaviors:

- **(a) Hard / high-elasticity throw:** the waveform is a near-rigid surface that **throws the wax UP AND
  OUT** — high restitution, the struck blobs spring off the boundary with both outward (normal) and
  upward (lift) velocity (§5a). At max it reads as the waveform energetically kicking the lava off it.
- **(b) Soft mush:** the waveform gently **mushes** the lava around — a soft penalty force proportional to
  penetration depth, the wax squishes against and partially into the boundary and slowly eases back, with
  little or no springy throw. At/near min it must be **genuinely soft** — the fluid yields and gently
  deforms, no kick.

The knob blends (b) → (a) as it sweeps 0 → 1: at 0, gentle mushing of the lava (soft, yielding, slow
recovery); at 1, a high-elasticity up-and-out throw. The range must be **wide** so the difference between
ends is dramatic, and the sweep must stay **smooth with no jitter** at any setting (§5a). The same blend
factor can scale the blob↔blob restitution for consistency (softer overall world at low strength),
staff-engineer's call.

> **Dropped:** the earlier Phase 10 "bubbles spawn from peaks" idea is **not relevant** to this model —
> blobs are a persistent physical population, not spawned-from-the-waveform particles. Do not carry it
> forward.

### 5d. Transfer functions left to staff-engineer

As with heat (§4c), the exact penetration-penalty curve, restitution coefficients, the lift/normal
impulse split, and the soft↔hard blend shape are **staff-engineer tuning tasks.** This spec fixes the
*model* (two elastic collision pairs, a wide soft↔hard blend on a knob, collision always on, smooth/no
jitter) and the *endpoints* (soft = gentle mush; hard = high-elasticity up-and-out throw), not the
constants. The heat transfer-function endpoints are likewise fixed (heat 0 = wax rests on the floor;
heat max = many small turbulent rising bubbles) with the ~20% gravity / ~100% heat sweet spot as the
calibration anchor (§4c).

---

## 6. The color / gradient model

### 6a. One source of truth — injected, not hardcoded

**Do NOT duplicate or hardcode theme hexes** anywhere in the visualizer. The canonical palette lives in
**`DeepDrftShared.Client/Common/DeepDrftPalettes.cs`** (the `Light` / `Dark` `PaletteLight`/`PaletteDark`
objects). That is the single source of truth.

The component must receive the theme color(s) as a **single injected value/parameter** derived from that
source — not a second copy of the hexes living in the TS or the component. Today the renderer reads
computed `--mud-palette-*` CSS custom properties off the canvas element (because a GLSL uniform cannot
resolve `var()`), which is *a* form of single-source consumption (the vars are emitted from
`DeepDrftPalettes`). **Keep that discipline:** the colors reach the shader by reading the live palette
(via the CSS vars the palette emits, re-read on `refreshTheme`), so a palette edit in `DeepDrftPalettes`
or a dark-mode toggle re-themes the field with no duplicate to keep in sync. If staff-engineer prefers a
more explicit injection (the page passing the three resolved colors into the component as a typed
parameter sourced from `DeepDrftPalettes`), that is acceptable and arguably cleaner — the **hard
requirement is one source, zero hardcoded duplicates.**

**Identify the three colors X, Y, Z (§6b motion 1).** The palette's signature triad for the field is the
navy / moss / off-white identity the palette is built on. Recommended bindings (staff-engineer picks the
exact `--mud-palette-*` vars per mode for the richest spread; the palette is the source either way):

- In **light**: navy = `--mud-palette-primary` (`#17283f`), moss = `--mud-palette-secondary` (`#3D7A68`)
  or tertiary (`#429d6a`), and a third anchor for variety — the off-white ground or a deeper navy.
- In **dark**: green is primary (`#3D7A68`), navy is the ground (`#0D1B2A`), off-white is secondary
  (`#FAFAF8`).

The exact triad per mode is a tuning call; the requirement is **three theme-sourced colors, no
hardcoded hexes.**

### 6b. The gradient: A → B, linear, center → outer, with three combined motions

The gradient is **always color A → color B, linear, running from the 0 center line outward along the
waveform** — A at the center/root, B at the outer/extended edge. On top of that static structure, three
motions combine:

**Motion 1 — anchor rotation among X, Y, Z (the gradient rotation speed control).**
Three theme colors X, Y, Z are in play. Over time, the gradient's two anchors **A and B rotate smoothly
*among* X, Y, Z** — both the root color A and the extended color B cycle through the three colors. The
**gradient-rotation-speed control** drives this rotation rate. **The blend must NOT travel through the
rainbow** — interpolate in **OKLab** (§6c) so the blend stays faithful to the three theme colors with no
hue drift and no cyan excursion.

**Motion 2 — per-bar sinusoidal variation, baked at segment entry (chosen realization: mix-time-keyed).**
Each bar's A and B vary slightly by a sinusoidal transfer, so the colors change in **"waves" across the
waveform** rather than one uniform gradient. **Critical implementation requirement:** a segment's colors
are **chosen when it enters (incoming, at the bottom) and stay FIXED for that segment as the waveform
scrolls up, until it scrolls out of view.** Colors are **baked per-segment at entry, not recomputed per
frame.**

**Decided realization (Daniel, 2026-06-16): key the per-bar sinusoid to the segment's mix-time, not its
screen-Y.** Daniel approved this approach explicitly ("whatever works and isn't a clusterfuck to
maintain"). Because mix-time is fixed for a given segment, the sinusoid becomes a **pure function of
mix-time** and is therefore **fixed-per-segment by construction** — it travels with the segment as it
scrolls, with **no explicit per-segment ring buffer to maintain.** This is the chosen approach.
**Rejected for maintainability:** an explicit ring buffer keyed to scroll position that tracks each
visible segment's baked A/B colors — it is more moving parts and more state to keep coherent for the
same visual result. Do not build it; use the mix-time-keyed sinusoid.

**Motion 3 — per-bar gradient curve shifts with scroll height.**
Each bar's gradient *curve* (the A→B mix profile along its own height) shifts as it scrolls up. At the
bottom a bar is mostly A (e.g. `linear-gradient A 90% → B`), and by the top it is mostly B (`A 10% → B`)
— so color appears to move outward/inward as the bar scrolls up. This is a function of the segment's
scroll height (its screen-Y / scroll position), composed on top of the A/B colors that Motion 2 baked
for that segment.

The three motions compose: Motion 1 sets *which* two theme colors A and B are right now (rotating among
X/Y/Z), Motion 2 perturbs A and B slightly per-segment in fixed-at-entry waves (keyed to mix-time), and
Motion 3 shifts the A→B blend curve along each bar as it climbs.

### 6c. OKLab, not HSL — and why

The prior cyan bug came from **HSL** interpolation: the renderer's `mixHsl` blended hue/sat/lum
independently and `vivify` boosted saturation, which dragged the navy→moss path through saturated
cyan/teal hue regions (navy's hue sits near blue, moss near green; the short-way hue arc between them
passes through cyan, and the saturation boost made that excursion vivid). **Interpolate in OKLab
instead.** OKLab is a perceptually-uniform color space where a straight line between two colors stays
perceptually faithful — no hue drift, no saturation pumping, no rainbow excursion between the theme
anchors. Implement OKLab↔linear-sRGB conversion in the shader (well-documented matrices) and `mix()` in
OKLab. Drop the `mixHsl` / `vivify` / `VIVID_*` machinery entirely.

> **Why this is a model fix, not a tuning fix:** no amount of tuning the HSL saturation floor avoids the
> cyan arc — it is inherent to interpolating hue between blue and green. OKLab removes the failure mode
> structurally. (Reference: Björn Ottosson's OKLab — the now-standard recommendation for perceptual
> color interpolation in shaders.)

---

## 7. The seven controls + the inline collapse/expand knob-bar

### 7a. The seven controls (replacing the four)

| # | Control | What it drives | Range | Replaces |
|---|---------|----------------|-------|----------|
| 1 | **Waveform scroll speed** | Apparent bottom-to-top scroll rate (decouples scroll from zoom) | normalized, mapped via `MixZoomMapping` or a new scroll-rate map | the old **resolution/zoom** control — *resolution as a standalone control is gone* |
| 2 | **Color gradient rotation speed** | Motion 1 anchor-rotation rate among X/Y/Z (§6b) | normalized 0→1 → slow→fast cycle | the old **color-shift speed** |
| 3 | **Lava gravity** | Downward force on the wax (§4d) | normalized 0→1 | new |
| 4 | **Lava heat** | Energy into the system; 0 = rest-at-bottom, max = many small turbulent rising bubbles (§4c) | normalized 0→1 | the old **detach**, re-modeled |
| 5 | **Blob density/size** | Amount of wax — count/size within the 16–32 / 20–100px bands (§4e) | normalized 0→1 | new (the old **bubblyness** is gone; bulge is now physical) |
| 6 | **Collision strength** | Soft mush → 100% hard up-and-out throw, waveform/blob collision (§5c) | normalized 0→1 → soft→hard | new |
| 7 | **Waveform width** | Width of the waveform portion of the visualizer — narrows the waveform band so the lava fluid has more room to move (§7a, below) | normalized 0→1 → narrow→wide | new (added in the Wave R2 eval) |

Note the swap: **resolution/zoom as a standalone control is removed — scroll speed replaces it.** The
`MIN_VISIBLE_SECONDS` anchor still exists internally for the datum-density framing, but the user-facing
control is "how fast does it scroll," not "how much do I see." Staff-engineer decides whether scroll
speed reuses `MixZoomMapping` (treating it as a scroll-rate map) or gets a fresh linear map; either way
keep the log-feel continuity that made the zoom slider feel good.

**Control 7 — waveform width (added in the Wave R2 eval).** Adjusts the **width of the waveform portion**
of the visualizer — i.e. how much horizontal band the symmetric ±amplitude waveform silhouette occupies.
**The point of the control:** on loud/busy songs the waveform is wide and crowds the canvas; narrowing it
gives the **lava fluid more room to move**. Low = a narrow waveform band (more room for lava); high = a
wide waveform band. Mechanically this scales the waveform's amplitude→screen-width mapping (the half-width
the silhouette extends from the center line for a given loudness), which also resizes the collision
boundary the fluid parts around (§5) — narrowing the waveform literally clears space for the wax. The
datum and scroll geometry are unchanged; only the horizontal extent of the waveform band scales.

Defaults are Daniel's to tune on screen (his standing preference — he tunes ranges by hand once it is
live). Recommended starting points: scroll speed ~mid, rotation ~0.3, gravity ~0.2, heat ~1.0, density
~0.4, collision ~0.5, width ~0.6. The **~20% gravity / ~100% heat** anchor reflects Daniel's stated sweet
spot (§4c). These are feel-anchors, not commitments.

### 7b. The in-flow controls container — between the back link and the lava-lamp (redesigned 2026-06-16)

**Decision (Daniel, 2026-06-16): the controls are an in-flow container that lives BETWEEN the back link
(left) and the lava-lamp toggle (right), on the detail top row. It is NOT a floating popover, NOT a
drawer, and NOT a `position:absolute` element anchored under the lamp.** Expanding it **reflows the
layout in place** — the container grows in the row's flow between back and lamp — it never overlays the
page, never clips, and never overlaps the masthead/hero.

> **This redesign supersedes the first realization of §7b.** The first implementation read the
> "inline collapse/expand" intent as an *absolutely-positioned floating bar anchored under the lamp*
> (`.mix-visualizer-controls-anchor` stacked the lamp over the bar; the bar was
> `position:absolute; top:calc(100% + 0.5rem); right:0; z-index:3`). That is a "floating-but-inline
> popover" — Daniel rejects it: it clipped to a vertical sliver (only ~4 of 7 knobs visible) and read as
> a detached window hanging off the icon. **Remove the `position:absolute` rule and the
> `.mix-visualizer-controls-anchor` floating structure entirely.** The container must take real layout
> space in the row, not float over it.

**Placement — restructure the scaffold's top row into three zones (recommended).**

`ReleaseDetailScaffold`'s top row is today a two-zone `MudStack Row Justify="SpaceBetween"`:
`back-link (left) | @TopRightAction (right)`. Restructure it into **three zones**:

```
back-link (left)  |  @TopRowCenter (the controls container)  |  @TopRightAction (the lamp)
```

- **Add a new optional `RenderFragment? TopRowCenter` slot** to the scaffold, rendered between the back
  link and `@TopRightAction` on the same row. The row becomes a three-child flex row: back link pinned
  left, lamp pinned right, the center slot occupying the space between (and growing/shrinking with the
  container's expand/collapse — see motion below).
- **Mix** supplies the seven-knob `MixVisualizerControls` to `TopRowCenter` and keeps the lava-lamp
  `MudIconButton` in `TopRightAction`. The lamp toggles the container; the container reveals in the
  center zone.
- **Reusability holds.** Track / Cut / Session supply neither `TopRowCenter` nor `TopRightAction`, so the
  row degrades to **back-link-alone**, exactly as today. The center slot is a generic "affordance between
  back and action" seam — it follows the scaffold's existing "variance rides a slot, never a flag"
  convention (Phase 9 §5.3), the same way `TopRightAction` and `TopContent` already do. With
  `Justify="SpaceBetween"` an absent center slot collapses to nothing and back/action sit at the two
  edges unchanged.

**Why a scaffold center slot over a MixDetail-composed row.** Composing the whole `back | controls | lamp`
row inside MixDetail (and suppressing the scaffold's own back row) would duplicate the back-link markup,
break the scaffold's "owns the back link" invariant, and fork the one place every medium's back
navigation lives. The center slot keeps the scaffold the single owner of the row and adds Mix's piece as
data, not as a structural fork. **Recommended: the scaffold gains the `TopRowCenter` slot.**
(`TopContent` — the existing below-the-row band — is the *responsive fallback target*, not the primary
home; see the responsive note in §7b-responsive below.)

**The expand/collapse mechanism — in-flow, no float, no overlay (no MudPopover, no MudDrawer):**

- A **bound `bool`** (`_controlsExpanded`) gates the container. The **lava-lamp icon button toggles it** —
  the same `DDIcons.LavaLamp` trigger kept from Phase 10 §7c, now redrawn (§7f), now an in-flow
  expand/collapse toggle. The icon swaps to its FILLED variant while expanded (§7f / Part B); a knob drag
  never collapses the container (the toggle flips only on the lamp's click).
- **Collapsed:** the container occupies **no or minimal space** in the center zone — `max-width: 0` (or
  `width: 0`) + `opacity: 0` + `overflow: hidden`, and `visibility: hidden` + `pointer-events: none` so
  the collapsed knobs are not focusable or hit-testable. Back and lamp sit at the row's two edges with the
  center collapsed between them.
- **Expanded:** the container **grows horizontally in place** between back and lamp — a `max-width` (or
  `width`) + `opacity` (+ optional slight `transform`) CSS transition — so the seven knobs reveal **in the
  row's flow**, pushing nothing over the page. **No `position: absolute`, no `float`, no `z-index` stacking
  hack, no anchored panel.** The row reflows to accommodate the container; when the container is wider than
  the available center space it wraps in-flow (see responsive, below) — it never clips to a sliver.
- **Motion intent:** a smooth horizontal grow/shrink of a real in-flow element, reading as **the controls
  area opening between the back link and the lamp** — not a panel appearing over the page. Reuse the
  existing transition vocabulary (the `cubic-bezier(0.22, 0.61, 0.36, 1)` easing already in
  `MixVisualizerControls.razor.css`) so the motion feels native. The `max-height` collapse can stay as a
  secondary axis for the wrap case, but the **primary** animated axis is horizontal width in the row.

**Must not overlap the masthead/hero.** Because the container lives in the top row (above the masthead,
which the scaffold renders below this row) and reflows in-flow, an expanded container **pushes the row's
own height** — it must never paint over the masthead, the hero/cover, or the waveform backdrop. If the
expanded container is tall (wrapped to multiple knob rows on narrow widths), the top row grows and the
masthead moves down with normal document flow; no overlap, no clipping.

**Why this over the rejected floating bar.** The floating `position:absolute` bar read as a detached
window hanging off the lamp and clipped because its width was constrained by the lamp's narrow column.
An in-flow container between back and lamp *is* the controls sitting in the layout — the open/close is a
property of the row reflowing, the seven knobs get the row's full horizontal space, and there is zero
overlay machinery. (Prior-art touchstone: a toolbar that grows a secondary in-flow region of controls in
place between two pinned end-affordances — e.g. a browser address bar revealing inline controls between
the back button and the menu — not a flyout anchored to an icon.)

### 7b-responsive. Narrow-width behavior — stay in-flow, never clip

The seven knobs are wide; on a narrow row the center zone cannot hold them on one line. **Recommended:
the container wraps to a second in-flow line under the back/lamp row** rather than clipping or scrolling
off-edge. Two acceptable realizations (staff-engineer's call, both in-flow):

1. **Flex-wrap in place.** The seven-knob flex row `flex-wrap: wrap`s within the center zone; when the
   center zone is too narrow the whole top row grows taller and the knobs reflow to 4×3 / 3×4 / two rows.
   The row's height grows in-flow; the masthead moves down. All seven stay reachable.
2. **Drop to the `TopContent` band when narrow.** Below a breakpoint, the container renders in the
   scaffold's existing **`TopContent` slot** (the full-width in-flow band below the back/lamp row, above
   the masthead) instead of the center zone — giving it the full container width to lay the seven knobs
   out without crowding the back/lamp. This is still fully in-flow (it is literally the `TopContent`
   position) and never floats. Use the center zone on wide rows, the `TopContent` band on narrow rows.

**Either way: in-flow, no overlay, no edge-clipping, no horizontal-scroll-that-hides-knobs.** A
horizontally-scrolling inline strip is a *last-resort* fallback only if wrapping proves visually worse —
and even then it must be a real in-flow scroll region with a visible affordance, never a clipped sliver.
All seven knobs must always be reachable. The wrap/drop must still read as part of the controls
container opening, not as a separate surface.

### 7c. State — widen to seven properties

Widen `MixVisualizerControlState` from four properties to **seven**: `ScrollSpeed` (replacing
`VisibleSeconds` as the user-facing axis, or keep `VisibleSeconds` internally and add a `ScrollSpeed`
that maps to it — staff-engineer's call), `GradientRotationSpeed` (rename of `ColorShiftSpeed`),
`LavaGravity`, `LavaHeat` (re-modeled from `Detach`), `BlobDensity`, `CollisionStrength`, and
`WaveformWidth` (the seventh, added in the Wave R2 eval — drives the waveform-band horizontal extent,
§7a). Each with a `const` default mirrored to the TS tuning anchors (keep the C#↔TS default-sync
discipline the existing `Default*` consts have). Same scoped-DI persistence model: survives SPA nav
within a session, resets on fresh load. Same `Changed` event seam — the bridge subscribes and pushes the
affected uniform; the knob-bar component only mutates state and raises `Changed`. **This is the same
architecture as today, just seven properties instead of four.**

The bridge handle gains setters for the new controls (`setScrollSpeed`, `setGradientRotationSpeed`,
`setLavaGravity`, `setLavaHeat`, `setBlobDensity`, `setCollisionStrength`, `setWaveformWidth`) — extend,
don't redesign, mirroring how Phase 10 §2d extended the handle.

### 7d. RadialKnob — consumed unchanged, seven instead of four

`RadialKnob` (`DeepDrftShared.Client/Components/RadialKnob.razor`) is consumed as-is (its API is fixed —
`Value`/`ValueChanged`/`Min`/`Max`/`Step`/`Label`/`Size`/`Color`/`HoldValue`; no icon slot; `Label` is
SVG text). Seven knobs in the inline bar, each with an adjacent `MudIcon` caption (the no-icon-slot
constraint from Phase 10 §7e still holds). `HoldValue=false` so they are live. `Step="0.001"` for
continuous feel. Suggested Material icons per control (staff-engineer picks final glyphs):

| Knob | Suggested icon |
|------|----------------|
| 1. Scroll speed | `FastForward` / `Speed` |
| 2. Gradient rotation speed | `Palette` / `Rotate90DegreesCcw` |
| 3. Lava gravity | `ArrowDownward` / `FilterDrama` |
| 4. Lava heat | `LocalFireDepartment` / `Whatshot` |
| 5. Blob density/size | `BubbleChart` / `Grain` |
| 6. Collision strength | `Adjust` / `Compress` |
| 7. Waveform width | `WidthNormal` / `SwapHoriz` / `SettingsEthernet` (a horizontal-extent glyph) |

Seven knobs in a row may wrap on narrow viewports (4×3 / 3×4 / two rows) — a layout call, but all seven
must remain reachable.

### 7e. Aesthetic target — match the NowPlaying hero

**The knob-bar's color and structure must match the "NowPlaying" section in the hero.** The closest
existing component to that aesthetic in the codebase is the **Session detail hero**
(`DeepDrftPublic.Client/Pages/SessionDetail.razor` + `.css`) — the hero-dominant overlay composition: a
large background image with a darkening gradient shim (`.session-hero-shim`), and overlay rows
(`.session-hero-top` / `.session-hero-bottom`) carrying title/artist, genre/date chips, and the play
affordance with translucent surfaces over the image. The structural cues to borrow:

- A **translucent dark surface** (the shim aesthetic) so the knob-bar reads as floating glass over the
  visualizer, not an opaque panel.
- The **overlay-chip / overlay-label typography** (`.session-overlay-label` / `.session-overlay-value`)
  for the knob captions, so the bar's labels match the hero's.
- `Color.Secondary` accents (the play affordance and the lava-lamp trigger both use `Color.Secondary`) —
  keep the knobs/captions on the same accent so the bar feels of-a-piece with the hero.

Staff-engineer studies `SessionDetail.razor[.css]` and matches its surface color + structural rhythm.
The intent: expand the knob-bar and it looks like it belongs to the same design family as the session
hero's now-playing overlay, not a generic MudBlazor panel — even though it is an inline collapse/expand
element, not a floating one (§7b). (If Daniel has a *specific* NowPlaying component in mind other than
the session hero overlay, confirm — but the session hero is the strongest match in the current tree.)

### 7f. Redraw the lava-lamp glyph — classic 1970s silhouette

**Decision (Daniel, 2026-06-16): the current `DDIcons.LavaLamp` is rejected ("form is shit, colors are
shit") and must be redrawn** to the classic 1970s "Lava" lamp silhouette (Daniel supplied a reference
image). The trigger placement (top-right, across from the back link) is unchanged; only the glyph art
changes.

**Home & authoring convention.** Authored in `DeepDrftShared.Client/Common/DDIcons.cs` as **inner SVG
markup only — no outer `<svg>` wrapper** (MudBlazor supplies the wrapper). A **24×24 viewBox** coordinate
space (matches the MudBlazor `viewBox="0 0 24 24"` wrapper and the existing `DDIcons` convention). A
`public const string` raw-string literal, same as the other icons in the file.

**Silhouette (bottom → top) — "offset cones":**

1. A **WIDE truncated-cone metal BASE** — widest at the very bottom, tapering *up* to a narrow waist.
2. A tall tapered **GLASS VESSEL** sitting on the base — **bulbous/rounded at the bottom**, tapering
   **upward** to a **roundedly-POINTED top** (an elongated teardrop / bullet shape — pointy-ish but
   **NOT sharp**).
3. A small **truncated-cone metal CAP** on top, **mirroring the base**.

The read is: cone base + tapered teardrop body + small cone cap. This is the iconic 1970s Lava lamp
profile — distinct from the current narrow straight-sided vessel, which is rejected.

**Colors (on-theme AND faithful to the reference).** The reference shows a silver metal base/cap, blue
fluid, and green blobs. Map to the app theme:

- **Fluid: navy. Blobs: moss.** Navy and moss **are** the theme's blue + green, so this is both faithful
  to the reference and on-theme by construction.
- **Base + cap: neutral / metallic** (the silver of the reference).
- The **body silhouette (glass vessel outline) can be `currentColor`** so it tints with context
  (`Color.Secondary`, light/dark) exactly as the existing icons do. The **fluid and blobs carry the
  navy/moss accents** as the icon's color identity.

**Keeping the icon theme-aligned (the one source-of-truth wrinkle).** The single source of truth for
theme colors is `DeepDrftPalettes` (§6a), but an inline SVG `const string` in `DDIcons.cs` **cannot
resolve `var(--mud-palette-*)`** — a fill on an SVG path inside a raw-string literal must be a literal
value (or `currentColor`). So the navy/moss fluid/blob stops will need to be **literal hex stops** in the
SVG. To minimize the duplication-of-truth this creates:

- Prefer **`currentColor` for everything that can be** (the body silhouette, ideally the metal base/cap
  via a neutral-tinted variant) so the bulk of the icon themes for free.
- For the two accent fills that genuinely must be literal (navy fluid, moss blobs), **use the exact
  `DeepDrftPalettes` navy/moss hexes** (e.g. navy `#17283f`, moss `#3D7A68`) and **add a code comment in
  `DDIcons.cs` naming the `DeepDrftPalettes` member each literal mirrors**, so the two literals are
  explicitly traceable to the source and a future palette change has a documented sync point. This is the
  same `currentColor`-where-possible + commented-literal-where-not discipline the existing gas-lamp flame
  icon already uses (its `#FF9800`/`#FFCA28` flame stops are literals on an otherwise-`currentColor`
  lamp). The hard requirement: **no silent second copy of the palette** — `currentColor` first, commented
  literal only where SVG forces it.

(The precise path data is staff-engineer's; this fixes the silhouette, the color mapping, the authoring
convention, and the theme-alignment discipline — not the exact coordinates.)

---

## 8. Acceptance criteria (observable)

**Spatial / layout**

1. **Same-plane collision.** The waveform visibly pushes the wax out of its way — blobs part around the
   waveform silhouette rather than overlapping it indiscriminately. The waveform shape is never deformed
   by the fluid (read-only authority preserved).
2. **Bottom-anchored waveform.** The waveform still rises from the bottom and scrolls bottom-to-top,
   unchanged from 8.K.
3. **Footer clip.** With the player expanded, no waveform or lava pixel paints over or under the player
   bar. Minimizing the player drops the clip + lava-rest line to match the FAB footer. Changing the
   window size (player-bar breakpoint/height change) re-clips with no reload.
4. **Noise removed.** No static screen-space noise/frost/dirt layer remains; the screen reads clean.

**Lava behavior**

5. **Heat 0 = rest + collision-only.** At heat 0 the wax rests on the floor (pooled at the rest line) and
   does not rise on its own; the waveform pushing through it still displaces it (collision always on).
6. **Heat max = small turbulent bubbles.** At max heat, the wax breaks into **many small, lively,
   turbulent bubbles** that rise and morph per second — an actively roiling, fragmented lamp (not a few
   big masses rising faster). Energy couples to bubble size/count and turbulence, not just rise rate.
7. **Flat, unified fluid.** The fluid reads **flat** — an evenly-lit fluid body, **not** blobs with bright
   pointed centers / cone-like radial gradients. Blobs **melt into one unified fluid** (strong
   coalescence) rather than distinct stiff globs in contact.
8. **Blobs.** Blobs read as varied organic wax shapes (not N identical circles), in the ~20–100px range,
   merging and splitting via `smin` — no "giant disconnected circles."
9. **Gravity** changes settling/rise behavior independently of heat. At the **~20% gravity / ~100% heat**
   sweet spot the lamp reads dynamic, lively, and fun (the calibration anchor, §4c).
10. **Blob density/size** changes how much wax is in the system across its range.

**Collision**

11. **Two elastic pairs.** Blob↔waveform and blob↔blob both collide elastically.
12. **Collision strength — wide soft↔hard sweep.** Sweeps **genuinely soft mush** (the waveform gently
    pushes the lava around; fluid yields and slowly recovers, no springy kick) → **hard up-and-out throw**
    (high elasticity; the waveform throws the bubbles **upward and outward**, not just sideways). The
    range is wide/expressive and the response is **smooth — no jitter** at any setting.

**Color**

13. **Three-color OKLab gradient.** A→B linear from center outward; no cyan, no rainbow excursion — the
    blend stays faithful to the theme colors at all rotation phases.
14. **Anchor rotation.** A and B rotate among X/Y/Z over time at the gradient-rotation-speed rate;
    dragging the control visibly changes the rotation rate, never frozen at the slow end.
15. **Per-segment bake (mix-time-keyed).** A segment's colors are fixed when it enters at the bottom and
    travel with it unchanged as it scrolls up and out — colors do not recompute under a stationary
    segment. (Realized via the mix-time-keyed sinusoid, §6b motion 2.)
16. **Per-bar curve shift.** A bar is mostly A at the bottom and mostly B by the top — color appears to
    move outward as the bar climbs.
17. **One source of truth.** No hardcoded theme hexes in the visualizer; a `DeepDrftPalettes` edit or a
    dark-mode toggle re-themes the field live with no duplicate to maintain. (The icon's two accent
    literals are the documented, commented exception — §7f.)

**Controls / knob-bar**

18. **Seven controls.** Exactly seven RadialKnobs — scroll speed, gradient rotation speed, gravity, heat,
    density/size, collision strength, **waveform width** — each captioned with an icon; resolution/zoom as
    a standalone control is gone.
19. **Waveform width.** Dragging the waveform-width control narrows/widens the waveform band across its
    range; narrowing it visibly clears horizontal space for the lava (and shrinks the collision boundary
    the fluid parts around). The datum/scroll geometry is otherwise unchanged.
20. **In-flow container between back and lamp.** The controls container sits **inline on the detail top
    row, between the back link (left) and the lava-lamp toggle (right)**. Clicking the lava-lamp icon
    expands it **in the layout flow** (animated open/closed via CSS width/opacity transition), reflowing
    the row — **not** a floating popover, drawer, or `position:absolute` bar, and **not** clipped to a
    sliver. Collapsed it takes no/minimal space (back and lamp at the row's two edges). All seven knobs are
    visible and horizontal when expanded; the expansion never overlaps the masthead/hero. Clicking again
    collapses it; dragging a knob does not collapse it.
21. **NowPlaying aesthetic.** The knob-bar's surface color + structure match the session hero now-playing
    overlay (translucent dark glass, overlay-label typography, `Color.Secondary` accents).
22. **Persistence + read-only.** All seven positions survive SPA nav within a session, reset on fresh
    load; no control and no knob-bar element is a seek/playback surface; the bridge and read-only contract
    are intact.

**Icon**

23. **Redrawn lava-lamp glyph.** The trigger shows the classic 1970s silhouette — wide truncated-cone
    base, bulbous→roundedly-pointed teardrop glass body, small cone cap — with navy fluid + moss blobs
    and a neutral/metallic base+cap; body tints via `currentColor`; renders cleanly at ~24px.

**Performance**

24. **60 FPS** on a mid-range desktop with heat, density, and collision at non-trivial values
    simultaneously; graceful degrade (drop internal resolution before frames) on weaker/mobile devices.

---

## 9. Suggested phasing / waves (R1–R4)

A **physics-and-collision first, color second, knob-bar third** sequence — the lava is the real work and
the riskiest, so prove it before the gradient and the UI rework. **These are reframe waves R1–R4, folded
under the open Phase 10 and distinct from its landed Waves 1–4.**

### Wave R1 — De-noise + footer clip + icon redraw (cheap, unblocks a clean canvas)
Remove the static noise/frost layer (§3), implement the dynamic footer-height clip + lava-rest line
(§2c), and **redraw the `DDIcons.LavaLamp` glyph** (§7f — independent, can land anytime, grouped here as
cheap polish). Small, independent, and gives a clean substrate to build the lava on. **Acceptance:** §8
#3, #4, #23.

### Wave R2 — The wax-blob physics + collision (the load-bearing step)
Stand up the CPU physics step (16–32 blobs: position/velocity/temperature/radius), the per-frame uniform
upload, the `smin` metaball render (**flat, coalescing fluid** — §4a refinement), the heat/gravity/density
mapping (**energy-coupled: high heat → small turbulent bubbles** — §4c), and the 2D collision model (both
pairs, the wide soft-mush ↔ hard up-and-out-throw blend, smooth/no jitter — §5a/§5c). This is where the
architecture is proven; it replaces the §4-effect GLSL. **Acceptance:** §8 #1, #5–#12, #24 (at this wave's
workload). Controls can be temporary sliders/debug knobs here — the real inline knob-bar is Wave R4.

### Wave R3 — The OKLab three-color gradient (the three motions)
Replace the HSL `mixHsl`/`vivify` color with OKLab interpolation; implement the three motions (anchor
rotation among X/Y/Z, per-segment baked sinusoidal variation **keyed to mix-time**, per-bar curve shift),
sourced from `DeepDrftPalettes` with no hardcoded duplicates. **Acceptance:** §8 #13–#17. The
per-segment-bake requirement (§6b motion 2) uses the mix-time-keyed realization so it travels with the
segment by construction (decided — §6b).

### Wave R4 — Seven controls + the NowPlaying-styled in-flow controls container
Widen `MixVisualizerControlState` to **seven** properties (including the new **waveform width** control,
§7a); add the scaffold's **`TopRowCenter`** slot and move the seven-knob `MixVisualizerControls` into an
**in-flow container between the back link and the lava-lamp** (§7b — animated horizontal grow/shrink in
the row's flow, lava-lamp icon toggles it, **no popover/drawer, no `position:absolute`**; **remove** the
old `.mix-visualizer-controls-anchor` + `position:absolute` floating structure); handle the narrow-width
in-flow wrap/drop (§7b-responsive); wire the waveform-width control to scale the waveform-band extent and
its collision boundary (§7a); style it to the session-hero aesthetic; extend the bridge handle with the
new setters (including `setWaveformWidth`). **Acceptance:** §8 #18–#22.

**Dependency shape:** `Wave R1 → Wave R2 → (Wave R3 ‖ Wave R4)`. Wave R1 is a quick unblock (and folds in
the independent icon redraw). Wave R2 is the prerequisite for everything visual. Waves R3 (color) and R4
(controls/knob-bar) both depend on Wave R2 but are independent of each other — the gradient can land
before the knob-bar, or vice versa, and each control's effect is independently tunable. Daniel tunes
ranges/transfer-functions by hand once on screen throughout (his standing preference).

---

## 10. Open items

Tuning knobs, one undecided behavior call, and one deferred future idea — the controls-UI fork and the
per-segment-storage fork are both now **decided** (§7b in-flow controls container between back & lamp —
redesigned 2026-06-16, superseding the rejected `position:absolute` floating bar; §6b mix-time-keyed).
None of the items below block starting Wave R1.

- **§4c, §5d — transfer functions:** heat 0..1 → rise/morph intensity; collision 0..1 → soft↔hard blend
  shape; restitution coefficients; penetration-penalty curve. All staff-engineer tuning tasks with the
  endpoints fixed here.
- **§4g — blob count band** within 16–32 and the density-control mapping to count vs. radius.
- **§6a — exact X/Y/Z theme-var bindings** per light/dark for the richest spread (the palette is the
  source either way).
- **§7a — scroll-speed mapping:** reuse `MixZoomMapping` as a scroll-rate map vs. a fresh linear map.
- **§7e — NowPlaying source:** confirm the session hero overlay is the intended "NowPlaying" aesthetic
  (strongest match in the tree) or point to a different component.
- **§7f — icon accent literals:** the two literal navy/moss stops the SVG forces (commented to their
  `DeepDrftPalettes` source) — exact hexes are staff-engineer's to pull from the palette.
- **§7d — waveform-width icon glyph:** `WidthNormal` / `SwapHoriz` / `SettingsEthernet` suggested;
  staff-engineer picks the final horizontal-extent glyph.
- **Defaults** for all seven controls — Daniel tunes on screen (the ~20% gravity / ~100% heat sweet spot,
  §4c, anchors the heat/gravity pair).

### Undecided — Daniel calls (not blocking, but unresolved)

- **Pause behavior — does the lava keep convecting while audio is paused?** Today the visualizer freezes
  on pause (the rAF loop is gated on `isPlaying` — Phase 10 §2e, carried forward). The reframe's wax
  physics raises the question of whether the lava should **keep convecting/bubbling while paused** (the
  lamp is "always on," the way a real lava lamp keeps going regardless of the music) or **freeze with the
  scroll** as it does now. **Undecided — Daniel's call.** It affects the rAF-gating rule and whether the
  physics step runs decoupled from playback. Flagged here; not blocking Wave R1/R2 (default to the current
  freeze-on-pause behavior until Daniel decides; switching to always-convect is a localized change to the
  gating, not the model).

### Future enhancements (explicitly out of the current reframe scope)

- **Per-control slow LFO auto-modulation.** Each knob could carry an optional **"auto-modulate" checkbox**
  that, when enabled, gently oscillates that control's value over time via a **low-frequency oscillator**
  (a slow sinusoid around the knob's set point), so the visualizer drifts and breathes on its own without
  the user touching it. Daniel flagged this as a "cool future enhancement" (Wave R2 eval). **Deferred — not
  in the R1–R4 scope.** When picked up it is additive: a per-control bool + amplitude/rate in
  `MixVisualizerControlState`, an LFO applied at the bridge before the uniform push, and a small checkbox
  affordance per knob in the inline knob-bar. Captured so the knob-bar's per-control layout (§7b) leaves
  room for a future toggle, but nothing in R1–R4 builds it.
