diff --git a/PLAN.md b/PLAN.md index 7f4dc0c..14a341e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 ~16–32 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 1–5. Phase numbers are organisational, not sequencing. diff --git a/product-notes/mix-visualizer-webgl-renderer.md b/product-notes/mix-visualizer-webgl-renderer.md index a66fc09..eec22c7 100644 --- a/product-notes/mix-visualizer-webgl-renderer.md +++ b/product-notes/mix-visualizer-webgl-renderer.md @@ -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 diff --git a/product-notes/phase-12-mix-visualizer-lava-reframe.md b/product-notes/phase-12-mix-visualizer-lava-reframe.md new file mode 100644 index 0000000..37958f4 --- /dev/null +++ b/product-notes/phase-12-mix-visualizer-lava-reframe.md @@ -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 1–4: 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**: +~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 **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 ~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). +- **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 **~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. + +### 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 ~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.* + +### 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 16–32 / 20–100px 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 ~20–100px 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 (16–32 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 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). +- **§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.