docs(plan): add Phase 12 Mix Visualizer Lava Reframe spec; supersede Phase 10 effects/controls

This commit is contained in:
daniel-c-harvey
2026-06-16 10:33:24 -04:00
parent 96b13af95d
commit 74b9c02722
3 changed files with 695 additions and 0 deletions
+17
View File
@@ -234,6 +234,23 @@ Sequenced as **seven waves**; the critical path is `11.A → 11.B → 11.C`, wit
---
## Phase 12 — Mix Visualizer Lava Reframe
A **major reframe of the Mix visualizer's effects, controls, and color model**, building on the landed Phase 10 WebGL2 renderer infrastructure (pipeline, datum texture, playhead interp, bridge, widened body, lava-lamp trigger) but **replacing what it paints**. Daniel tested the Phase 10 effects end-to-end and rejected the visual result: the lava read as "giant disconnected circles," the colors drifted to cyan (an HSL saturation-boost artifact), and the waveform and lava read as two unrelated things sharing a canvas. The diagnosis (staff-engineer research pass) is that the rejected look is **structural to the effect approach, not a tuning miss**.
**This supersedes the Phase 10 effects/controls/color design**`product-notes/mix-visualizer-webgl-renderer.md` §4 (effects) and §7 (popover-controls) are marked superseded with a pointer to the new spec. The renderer *infrastructure* carries forward unchanged.
**The three reframes:**
- **Lava → CPU-physics wax blobs.** Keep the single-pass WebGL2 fragment renderer; add a small CPU-side per-frame physics step modeling ~1632 Lagrangian "wax blobs" (position/velocity/temperature/radius) uploaded as uniforms and blended with `smin` SDF metaballs. The waveform and lava share **the same plane WITH real 2D elastic collision** (blob↔waveform-boundary + blob↔blob) — the waveform pushes the fluid out of its way (read-only authority preserved; the fluid never deforms the waveform). At heat 0 the wax rests at the bottom and only collision moves it (collision always on, independent of heat); at heat max many bubbles rise/morph per second. **Rejected: a full ping-pong FBO Navier-Stokes fluid sim** — a lava lamp is high-viscosity/low-turbulence, the opposite regime; large rewrite for unwanted realism. Deliberate later upgrade only.
- **Color → three-color OKLab gradient with three motions.** One source of truth (`DeepDrftPalettes`), no hardcoded hexes. Always A→B linear from the center line outward. Three combined motions: (1) anchors A/B **rotate among three theme colors X/Y/Z** at the rotation-speed control's rate — **OKLab interpolation, never through the rainbow** (the cyan fix is structural, not a tuning dial); (2) per-bar sinusoidal variation **baked at segment entry and fixed as the segment scrolls** (implies per-segment color state — cleanest realization: key the sinusoid to mix-time so it travels by construction); (3) per-bar gradient curve shifts with scroll height (mostly A at bottom → mostly B at top). The static noise/frost texture is **removed** (Daniel: makes the screen look dirty).
- **Controls → six knobs in a flyout.** Replaces the four: (1) waveform scroll speed [replaces resolution/zoom as a standalone control], (2) gradient rotation speed, (3) lava gravity, (4) lava heat, (5) blob density/size, (6) collision strength (soft→hard). **NOT a popover — an extended flyout menu bar** (MudBlazor survey: recommend a horizontal `MudPopover` styled as a knob bar, or `MudDrawer Anchor="Bottom"` if Daniel wants the edge-slide motion — the one Daniel call worth making up front). The six `RadialKnob`s live in the flyout, styled to **match the NowPlaying hero aesthetic** (the session-detail hero overlay — translucent dark glass, overlay-label typography, `Color.Secondary`). Also: overflow-clip the visualizer to the **dynamic footer height** (the player bar changes height minimized/expanded) so visuals stop cleanly above it; the clip line is also the lava rest line.
Heat→intensity and collision soft↔hard transfer functions are **staff-engineer tuning tasks** (endpoints fixed in the spec, formulas not). Full design, the wax-blob model, the collision model, the three-motion color model, the flyout survey, observable acceptance criteria, and phasing: `product-notes/phase-12-mix-visualizer-lava-reframe.md`.
**Sequenced as four waves.** `Wave 1 → Wave 2 → (Wave 3 ‖ Wave 4)`. Wave 1 (de-noise + dynamic footer clip) is a cheap unblock for a clean substrate. Wave 2 (wax-blob physics + 2D collision) is the load-bearing prerequisite — prove the lava before the color and the UI. Wave 3 (OKLab three-color gradient, the three motions) and Wave 4 (six controls + NowPlaying-styled flyout + widened state to six properties + extended bridge handle) both depend on Wave 2 but are independent of each other. **Open Daniel call:** flyout primitive (popover-flyout vs. bottom drawer — spec §7b/§10).
---
## Working with this file
- **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 15. Phase numbers are organisational, not sequencing.
@@ -214,6 +214,17 @@ contract on both sides).
## 4. The four visual effects
> **SUPERSEDED (2026-06-16) by `product-notes/phase-12-mix-visualizer-lava-reframe.md`.** 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 new 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.
@@ -422,6 +433,16 @@ hand).
## 7. Wave 4 — Detail-page polish + controls rework (presentation only)
> **PARTIALLY SUPERSEDED (2026-06-16) by `product-notes/phase-12-mix-visualizer-lava-reframe.md`.**
> **Kept:** the lava-lamp icon-button trigger top-right of the body across from the back link (§7c, §7f
> — `DDIcons.LavaLamp`, landed) and the widened Mix body (`MudContainer MaxWidth="Large"`, §7g, landed).
> **Superseded:** the four-knob **popover** becomes a six-knob **flyout** (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), and the
> popover primitive is reconsidered (`MudDrawer` vs. popover-as-flyout — see the new spec §7b). See the
> new spec §7 for the six controls, the flyout survey, 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
@@ -0,0 +1,657 @@
# Mix Visualizer — Lava Reframe (Design Spec)
Status: **design-complete, implementation-ready.** Author: product-designer. Date: 2026-06-16.
**No code has been written by this doc.**
This is a **major reframe of the Mix visualizer's effects layer, controls, and color model**. It
builds on the landed Phase 10 WebGL2 renderer (Waves 14: 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.
**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 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 six-knob
flyout, and the popover primitive is reconsidered in §7b).
What carries forward unchanged from Phase 10 (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**).
Cross-references (read these before implementing):
- `product-notes/mix-visualizer-webgl-renderer.md` — the Phase 10 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 six knobs (§7).
- `DeepDrftPublic.Client/Services/MixVisualizerControlState.cs` — the scoped four-property state holder;
widens to six 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 popover becomes the flyout (§7).
- `DeepDrftPublic.Client/Pages/SessionDetail.razor[.css]` — the **NowPlaying / hero aesthetic** the
flyout 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).
---
## 1. Goal and scope boundary
**Goal.** Replace the rejected lava/bulge/glass/color treatment with a **credible physical lava-lamp**:
~1632 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 **six-knob flyout** styled to match the hero NowPlaying
aesthetic. Remove the static noise texture that makes the screen look dirty.
**In scope.**
- A small **CPU-side per-frame physics step** modeling ~1632 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).
- **Six controls** replacing the four: scroll speed, gradient rotation speed, lava gravity, lava heat,
blob density/size, collision strength (§7).
- The **flyout** (replacing the popover) styled to the NowPlaying hero aesthetic (§7).
- **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 | Replaced by this reframe |
|-------|----------------------|--------------------------|
| 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) | — | 6 knobs in a flyout (§7) |
---
## 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 **~1632 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** (~20px100px 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
1632 blobs with `smin` merging reads as continuous wax that splits and recombines.
### 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.
The mapping from the 0..1 heat scalar to effect intensity (rise rate, how many blobs are buoyant at
once, churn frequency) 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 = rest-at-bottom,
collision-only; max = many rising/morphing per second) and a smooth, good-feeling sweep between them.
### 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 ~1632
band and/or the average radius within the ~20100px 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.** 1632 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.*
### 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 **soft → 100% hard.** It blends between two
behaviors:
- **(a) Hard obstacle:** the waveform is a rigid wall the fluid flows around — full elastic reflection,
blobs cannot enter the silhouette at all.
- **(b) Soft displacement/shove:** the waveform gently pushes the fluid aside — a soft penalty force
proportional to penetration depth, blobs squish against and partially into the boundary before being
eased out.
The knob blends (b) → (a) as it sweeps 0 → 1. At 0 it is a gentle shove (the fluid yields and slowly
recovers); at 1 it is a hard wall (crisp reflection, no penetration). 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, and the soft↔hard
blend shape are **staff-engineer tuning tasks.** This spec fixes the *model* (two elastic collision
pairs, a soft↔hard blend on a knob, collision always on) and the *endpoints*, not the constants.
---
## 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.**
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.** This implies **per-segment color state tied to scroll position** — the renderer must track, for
each visible segment, the A/B colors assigned when it entered, and carry them with the segment as it
scrolls. (Flagged as a notable implementation requirement: this is per-segment state, not a stateless
per-fragment function. Staff-engineer designs the storage — likely a ring buffer keyed to scroll
position, or baking the per-segment phase into the datum-time coordinate so the sinusoid is a pure
function of mix-time and therefore *automatically* travels with the segment. The mix-time approach is the
cleaner realization: if the per-bar sinusoid is keyed to the segment's mix-time rather than its current
screen-Y, it is fixed-per-segment *by construction* and scrolls correctly with no explicit buffer.)
**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, 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 six controls + the flyout
### 7a. The six 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 rising/morphing (§4c) | normalized 0→1 | the old **detach**, re-modeled |
| 5 | **Blob density/size** | Amount of wax — count/size within the 1632 / 20100px bands (§4e) | normalized 0→1 | new (the old **bubblyness** is gone; bulge is now physical) |
| 6 | **Collision strength** | Soft → 100% hard waveform/blob collision (§5c) | normalized 0→1 → soft→hard | new |
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.
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.5, heat ~0.3, density
~0.4, collision ~0.5. These are feel-anchors, not commitments.
### 7b. Flyout, not popover — survey of MudBlazor options
**Reframe from Phase 10 §7:** the controls live in an **extended flyout menu bar**, not a popover.
Clicking the lava-lamp icon makes the **RadialKnobs fly out** for editing.
MudBlazor options surveyed for "click an icon, knobs fly out into an editing strip":
| Option | Fit | Verdict |
|--------|-----|---------|
| **`MudDrawer`** (`Anchor="Right"`/`Bottom"`, `Variant="Temporary"` or `"Mini"`) | A real drawer that slides in from an edge; `Open`-bound, overlay-dismissable, themeable. The "Mini" variant expands/collapses in place — close to a flyout bar. | **Recommended for an edge-anchored flyout strip.** A right or bottom temporary drawer reads as "the lava-lamp panel slid in," matches the "extended menu bar" language, and gives the six knobs room in a row/column. |
| **`MudPopover` as a flyout** (the Phase 10 idiom, widened) | Anchored floating panel of arbitrary content; already in the codebase (`SharePopover`). Can be styled as a horizontal bar dropping from the icon. | **Acceptable, lighter-weight.** Closest to what Phase 10 shipped; if the flyout should *hang off the icon* rather than slide from a screen edge, a wide horizontal `MudPopover` styled as a knob bar is the smallest change. |
| **`MudMenu`** | Built for actionable item lists, not a custom drag-interaction knob row. | **Rejected** — fights the knob drag/click model (same reasoning as Phase 10 §7d). |
| **A bespoke CSS expanding panel** (no MudBlazor primitive) | Full control of the "menu bar extends" animation; an absolutely-positioned strip that animates width/opacity from the icon. | **Fallback** if neither drawer nor popover gives the exact "menu bar flies out" motion Daniel wants. More CSS to own. |
**Recommendation:** start with a **horizontal `MudPopover` styled as a flyout knob bar** anchored to the
lava-lamp icon (smallest delta from the landed §7 popover, keeps the icon-button trigger and the
outside-click/overlay idiom already wired in `MixDetail.razor`), **unless** Daniel wants the controls to
slide from a screen edge — in which case use a **`MudDrawer`** (`Anchor="Bottom"` reads most like an
"extended menu bar"). This is a genuine fork on the desired *motion*; both are cheap. Surfaced as the one
open question worth a Daniel call (§9).
Either way: the six RadialKnobs live in the flyout, the lava-lamp icon button is the trigger (kept from
§7c — `DDIcons.LavaLamp`, top-right of the body across from the back link), the flyout stays open while
dragging a knob (the knob's global mouse-capture overlay must not be read as an outside-click — verify;
gate dismiss-on-outside-click off mid-drag if needed, as Phase 10 §7d already noted), and no flyout
element is a seek surface.
### 7c. State — widen to six properties
Widen `MixVisualizerControlState` from four properties to **six**: `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`. 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
flyout component only mutates state and raises `Changed`. **This is the same architecture as today, just
six properties instead of four.**
The bridge handle gains setters for the new controls (`setScrollSpeed`, `setGradientRotationSpeed`,
`setLavaGravity`, `setLavaHeat`, `setBlobDensity`, `setCollisionStrength`) — extend, don't redesign,
mirroring how Phase 10 §2d extended the handle.
### 7d. RadialKnob — consumed unchanged, six 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). Six knobs in the flyout, 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` |
Six knobs in a row may wrap on narrow viewports (2×3 / 3×2) — a layout call, but all six must remain
reachable.
### 7e. Aesthetic target — match the NowPlaying hero
**The flyout'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 flyout 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 flyout'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 flyout feels of-a-piece with the hero.
Staff-engineer studies `SessionDetail.razor[.css]` and matches its surface color + structural rhythm.
The intent: open the flyout and it looks like it belongs to the same design family as the session hero's
now-playing overlay, not a generic MudBlazor panel. (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.)
---
## 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 pools 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 = active.** At max heat, many bubbles rise and morph per second — an actively roiling lamp.
7. **Blobs.** Blobs read as varied organic wax shapes (not N identical circles), in the ~20100px range,
merging and splitting via `smin` — no "giant disconnected circles."
8. **Gravity** changes settling/rise behavior independently of heat.
9. **Blob density/size** changes how much wax is in the system across its range.
**Collision**
10. **Two elastic pairs.** Blob↔waveform and blob↔blob both collide elastically.
11. **Collision strength** sweeps soft (fluid yields and recovers) → hard (rigid wall, no penetration)
across its range.
**Color**
12. **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.
13. **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.
14. **Per-segment bake.** 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.
15. **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.
16. **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.
**Controls / flyout**
17. **Six controls.** Exactly six RadialKnobs — scroll speed, gradient rotation speed, gravity, heat,
density/size, collision strength — each captioned with an icon; resolution/zoom as a standalone
control is gone.
18. **Flyout.** Clicking the lava-lamp icon flies the knobs out (drawer or popover-flyout per the §7b
decision); clicking outside closes; dragging a knob does not close it.
19. **NowPlaying aesthetic.** The flyout's surface color + structure match the session hero now-playing
overlay (translucent dark glass, overlay-label typography, `Color.Secondary` accents).
20. **Persistence + read-only.** All six positions survive SPA nav within a session, reset on fresh
load; no control and no flyout element is a seek/playback surface; the bridge and read-only contract
are intact.
**Performance**
21. **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
A **physics-and-collision first, color second, flyout third** sequence — the lava is the real work and
the riskiest, so prove it before the gradient and the UI rework.
### Wave 1 — De-noise + footer clip (cheap, unblocks a clean canvas)
Remove the static noise/frost layer (§3) and implement the dynamic footer-height clip + lava-rest line
(§2c). Small, independent, and gives a clean substrate to build the lava on. **Acceptance:** §8 #3, #4.
### Wave 2 — The wax-blob physics + collision (the load-bearing step)
Stand up the CPU physics step (1632 blobs: position/velocity/temperature/radius), the per-frame uniform
upload, the `smin` metaball render, the heat/gravity/density mapping, and the 2D collision model (both
pairs, the soft↔hard blend). This is where the architecture is proven; it replaces the §4-effect GLSL.
**Acceptance:** §8 #1, #5#11, #21 (at this wave's workload). Controls can be temporary sliders/debug
knobs here — the real flyout is Wave 4.
### Wave 3 — 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, per-bar curve shift), sourced from
`DeepDrftPalettes` with no hardcoded duplicates. **Acceptance:** §8 #12#16. The per-segment-bake
requirement (§6b motion 2) is the subtle part — prefer the mix-time-keyed realization so it travels with
the segment by construction.
### Wave 4 — Six controls + the NowPlaying-styled flyout
Widen `MixVisualizerControlState` to six properties; replace the four-knob popover with the six-knob
flyout (drawer or popover-flyout per §7b); style it to the session-hero aesthetic; extend the bridge
handle with the new setters. **Acceptance:** §8 #17#20.
**Dependency shape:** `Wave 1 → Wave 2 → (Wave 3 ‖ Wave 4)`. Wave 1 is a quick unblock. Wave 2 is the
prerequisite for everything visual. Waves 3 (color) and 4 (controls/flyout) both depend on Wave 2 but are
independent of each other — the gradient can land before the flyout, 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 and one genuine fork. None block starting Wave 1.
- **§7b — flyout primitive (the one Daniel call worth making up front):** horizontal `MudPopover`
styled as a knob bar (smallest delta, hangs off the icon) **vs.** `MudDrawer Anchor="Bottom"` (slides
from a screen edge, reads most like an "extended menu bar"). Both cheap; the choice is about the
*motion* Daniel wants. Recommend popover-flyout unless he wants the edge-slide.
- **§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 1632 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).
- **§6b motion 2 — per-segment color storage** (ring buffer vs. mix-time-keyed sinusoid — recommend
mix-time-keyed so it travels by construction).
- **§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.
- **Defaults** for all six controls — Daniel tunes on screen.