937 lines
64 KiB
Markdown
937 lines
64 KiB
Markdown
# Mix Visualizer — Phase 10 Reframe (Lava) — Design Spec
|
||
|
||
Status: **shipped** (Waves R1–R4 merged to dev, 2026-06-17). Author: product-designer. Date: 2026-06-16.
|
||
See `COMPLETED.md` for the completion record.
|
||
|
||
This is a **major reframe of the Mix visualizer's effects layer, controls, and color model**, folded
|
||
**under the still-open Phase 10** (the WebGL2 renderer) as a **reframe wave-set (Waves R1–R4)** —
|
||
distinct from Phase 10's original Waves 1–4 (renderer swap, controls row, the four effects, the
|
||
popover/knob polish) which are **already merged**. It builds on that landed Phase 10 infrastructure (the
|
||
single-pass fragment-shader pipeline, the loudness datum texture, the wall-clock playhead interpolation,
|
||
the controls UI, the widened Mix body) and **replaces what that pipeline paints** — the per-bar bulge,
|
||
the analytic-metaball "lava," the glass treatment, and the navy↔moss color treatment. The renderer
|
||
*infrastructure* is reused; the *art and the controls* are rebuilt.
|
||
|
||
> **Numbering note.** This reframe lives **under Phase 10**, not as a separate phase. Its waves are
|
||
> labelled **R1–R4** to stay unambiguous against Phase 10's landed Waves 1–4. The filename
|
||
> `phase-10-mix-visualizer-lava-reframe.md` reflects that; an earlier draft of this doc was numbered
|
||
> "Phase 12" — that numbering is retired.
|
||
|
||
**Why a reframe, not an iteration.** Daniel tested the landed Phase 10 effects end-to-end and rejected
|
||
the visual result: the lava reads as "giant disconnected circles," the colors drifted to cyan (an HSL
|
||
saturation-boost artifact — see §6), and the waveform and the lava read as two unrelated things sharing
|
||
a canvas rather than one coherent fluid surface. The diagnosis (from a staff-engineer research pass) is
|
||
that the rejected look is *structural to the current effect approach*, not a tuning miss: too few
|
||
scripted blobs with no physics produce disconnected circles, and HSL interpolation between two
|
||
low-saturation theme tokens passes through hue regions that read as cyan. Both are fixed by changing the
|
||
*model*, not the dials.
|
||
|
||
This spec **supersedes** the original Phase 10 effects/controls/color design:
|
||
|
||
- `product-notes/mix-visualizer-webgl-renderer.md` **§4 (the four visual effects)** — superseded by
|
||
§3–§6 here.
|
||
- `product-notes/mix-visualizer-webgl-renderer.md` **§7 (Wave 4 popover-controls rework)** — superseded
|
||
by §7 here (the trigger and the widened body are kept; the four-knob popover becomes a seven-knob
|
||
inline collapse/expand knob-bar — see §7).
|
||
|
||
What carries forward unchanged from Phase 10's landed waves (do **not** re-derive — reference it):
|
||
|
||
- The single-pass WebGL2 fragment renderer, the full-window quad, the trivial pass-through vertex
|
||
shader (`mix-visualizer-webgl-renderer.md` §2a).
|
||
- The loudness datum as a GPU texture, sampled per-fragment (§2b; the 2-D grid + `texelFetch` manual
|
||
interpolation that the landed `MixVisualizer.ts` already implements).
|
||
- The wall-clock playhead interpolation + the netcode-style correction-offset smoothing (the landed
|
||
`PLAYHEAD_CORRECTION_*` machinery).
|
||
- The Blazor↔JS bridge contract (`MixWaveformVisualizer.razor.cs`) — `create` → handle with
|
||
`setDatum` / `setPlayback` / `setZoom` / `refreshTheme` / `dispose`, the idempotent datum-push guard,
|
||
the `IsActivePlayer` gating, the rAF loop gated on `isPlaying` (§2d, §2e).
|
||
- The `MIN_VISIBLE_SECONDS = 0.333 s` max-zoom anchor (one quarter note at 180 BPM) and the
|
||
`MixZoomMapping` log-space fraction↔seconds mapping.
|
||
- The read-only contract (8.K §D): one-way playback input, no seek, no scrub, no write-back.
|
||
- The widened Mix body (`MudContainer MaxWidth="Large"`) and the lava-lamp `DDIcons.LavaLamp`
|
||
icon-button trigger top-right of the body, across from the back link (Phase 10 §7c, §7g — **kept**;
|
||
the icon glyph itself is **redrawn** by this reframe, see §7f).
|
||
|
||
Cross-references (read these before implementing):
|
||
|
||
- `product-notes/mix-visualizer-webgl-renderer.md` — the Phase 10 renderer spec this reframes. §1/§2
|
||
(scope, renderer architecture, bridge) carry forward; §4/§7 are superseded.
|
||
- `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — the landed renderer. The Wave 1 scroll/zoom
|
||
geometry, the datum texture, and the playhead machinery are reused; the §4-effect GLSL (the bubble
|
||
SDF, the detach blobs, the HSL `mixHsl`/`vivify` color, the glass) is the part being replaced.
|
||
- `DeepDrftPublic.Client/Controls/MixVisualizerControls.razor[.cs]` — the four-knob control component;
|
||
becomes seven knobs in an inline collapse/expand bar (§7).
|
||
- `DeepDrftPublic.Client/Services/MixVisualizerControlState.cs` — the scoped four-property state holder;
|
||
widens to seven properties (§7c).
|
||
- `DeepDrftPublic.Client/Controls/MixZoomMapping.cs` — reused unchanged for the scroll-speed control.
|
||
- `DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor[.cs/.css]` — the bridge. Extend the handle
|
||
with the new control setters; the `.css` gains the overflow-clip work (§2).
|
||
- `DeepDrftPublic.Client/Pages/MixDetail.razor[.css]` — the page; the controls move into an **in-flow
|
||
container between the back link and the lava-lamp** on the detail top row (§7b). The old
|
||
`.mix-visualizer-controls-anchor` + `position:absolute` floating structure is **removed**.
|
||
- `DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor[.cs]` — the detail chrome; gains a new
|
||
optional **`TopRowCenter`** slot so the top row hosts `back | center-controls | action` (§7b). The slot
|
||
stays null for Track/Cut/Session, which render the back link alone — reusability preserved.
|
||
- `DeepDrftPublic.Client/Pages/SessionDetail.razor[.css]` — the **NowPlaying / hero aesthetic** the
|
||
knob-bar must match (§7e).
|
||
- `DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor[.css]` — the footer/player bar
|
||
whose **dynamic height** the clip line must follow (§2c).
|
||
- `DeepDrftShared.Client/Common/DeepDrftPalettes.cs` — the **single source of truth** for theme colors
|
||
(§6a). `DeepDrftShared.Client/Components/RadialKnob.razor` — the knob, consumed unchanged (§7d).
|
||
- `DeepDrftShared.Client/Common/DDIcons.cs` — the lava-lamp glyph, **redrawn** by this reframe (§7f).
|
||
|
||
---
|
||
|
||
## 1. Goal and scope boundary
|
||
|
||
**Goal.** Replace the rejected lava/bulge/glass/color treatment with a **credible physical lava-lamp**:
|
||
~16–32 simulated wax blobs whose motion is integrated on the CPU each frame and rendered as smooth
|
||
metaballs in the existing fragment shader, sharing the *same plane* as the waveform **with real 2D
|
||
collision** — the waveform pushes the fluid out of its way. Replace the navy↔moss treatment with a
|
||
**three-color, OKLab-interpolated, per-segment-baked gradient** that animates along three combined
|
||
motions. Replace the four-knob popover with a **seven-knob in-flow controls container that sits between
|
||
the back link and the lava-lamp toggle** on the detail top row (reflowing the layout in place — not a
|
||
floating/absolutely-positioned bar), styled to match the hero NowPlaying aesthetic. Remove the static noise texture that makes the screen look dirty.
|
||
Redraw the lava-lamp trigger glyph to the classic 1970s silhouette (§7f).
|
||
|
||
**In scope.**
|
||
|
||
- A small **CPU-side per-frame physics step** modeling ~16–32 Lagrangian wax blobs (position, velocity,
|
||
temperature, radius), uploaded to the shader as a uniform array / tiny data texture each frame (§4).
|
||
- A **2D collision model**: blob↔waveform-boundary and blob↔blob, elastic, with a tunable hardness knob
|
||
that blends soft-displacement → hard-obstacle (§5).
|
||
- The **three-color OKLab gradient model** with three combined motions: anchor rotation among X/Y/Z,
|
||
per-segment sinusoidal variation baked at segment entry, and the per-bar curve shift with scroll
|
||
height (§6).
|
||
- **Seven controls** replacing the four: scroll speed, gradient rotation speed, lava gravity, lava heat,
|
||
blob density/size, collision strength, waveform width (§7).
|
||
- The **in-flow controls container between the back link and the lava-lamp** (replacing the popover, and
|
||
superseding the rejected `position:absolute` floating bar) styled to the NowPlaying hero aesthetic (§7b).
|
||
- A **redrawn `DDIcons.LavaLamp`** glyph — classic 1970s lava-lamp silhouette (§7f).
|
||
- **Overflow clipping** to the dynamic footer height (§2).
|
||
- **Removing the static noise texture** (§3).
|
||
|
||
**Out of scope / unchanged.**
|
||
|
||
- **No playback-control changes.** Read-only contract holds (8.K §D). One-way playback input; no seek,
|
||
no scrub, no write-back. No control is a seek surface.
|
||
- **No datum format change.** The duration-derived ~333 samples/sec loudness datum (8.K §F) and the
|
||
GPU-texture upload path are reused as-is. The waveform geometry (symmetric ±amplitude about a center
|
||
line, rising from the bottom) is reused; this spec changes only the *clip*, the *collision role*, and
|
||
what the fluid does around it.
|
||
- **No bridge redesign.** Extend the handle with the new control setters; preserve the single-owner
|
||
bridge, the idempotent datum guard, the `IsActivePlayer` gating, and the `isPlaying`-gated rAF loop.
|
||
- **No renderer-tech change.** Stay single-pass WebGL2 fragment shader + a CPU physics step. **Do NOT
|
||
build a ping-pong FBO Navier-Stokes fluid sim** (§4, "rejected alternative").
|
||
|
||
**Reused vs. replaced — at a glance.**
|
||
|
||
| Layer | Reused from Phase 10 (Waves 1–4) | Replaced by this reframe (R1–R4) |
|
||
|-------|----------------------------------|----------------------------------|
|
||
| WebGL2 pipeline / quad / vertex shader | ✅ as-is | — |
|
||
| Datum texture + sampling | ✅ as-is | — |
|
||
| Playhead interp + smoothing | ✅ as-is | — |
|
||
| Bridge contract + lifecycle | ✅ (extend setters) | — |
|
||
| Scroll/zoom geometry | ✅ as-is (now scroll-speed-driven, §7) | — |
|
||
| Waveform silhouette | ✅ (now also a collision boundary, §5) | — |
|
||
| Lava effect (bulge + detach blobs) | — | CPU-physics wax blobs + metaballs (§4) |
|
||
| Color model (HSL navy↔moss) | — | OKLab three-color gradient, 3 motions (§6) |
|
||
| Glass treatment | — | folded into the blob shading (§4f); no separate glass dials |
|
||
| Static noise/frost texture | — | **removed** (§3) |
|
||
| Controls (4 knobs, popover) | — | 7 knobs in an in-flow container between back & lamp (§7) |
|
||
| Lava-lamp trigger glyph | trigger placement kept | glyph **redrawn** (§7f) |
|
||
|
||
---
|
||
|
||
## 2. Spatial model & layout
|
||
|
||
### 2a. Same plane, with collision
|
||
|
||
The waveform and the lava **occupy the same plane**. The waveform is not a backdrop the lava floats over
|
||
— it is a **physical collision boundary** the fluid is pushed out of by. As the waveform's silhouette
|
||
rises and falls (its half-width is the loudness at each scroll row), wax blobs that drift into that
|
||
silhouette are pushed away from it. The interaction is two-way in *appearance* (the fluid visibly
|
||
parts around the waveform) but one-way in *authority* (the waveform shape is driven by the datum +
|
||
playback, never by the fluid — the read-only contract holds; the fluid never deforms the waveform).
|
||
|
||
This is the headline fix for "the waveform and lava read as two unrelated things": they now share one
|
||
SDF-composited surface and one collision space, so the fluid demonstrably flows around the waveform.
|
||
|
||
### 2b. Bottom-anchored waveform (kept)
|
||
|
||
The waveform already rises from the bottom and scrolls bottom-to-top (8.K §A). **That stays.** New audio
|
||
enters at the bottom, played audio exits the top, the "now" line sits at a fixed screen Y. This reframe
|
||
does not touch the scroll geometry except to drive it from the new scroll-speed control instead of the
|
||
zoom control (§7).
|
||
|
||
### 2c. Overflow clip to the dynamic footer height
|
||
|
||
**The problem.** The visualizer canvas is `position: fixed; inset: 0` (full viewport). Visuals currently
|
||
bleed *past the footer* — the lava and waveform paint behind/over the player bar at the bottom of the
|
||
screen instead of stopping cleanly above it.
|
||
|
||
**The fix.** The visualizer must be **clipped so its visuals stop at the top edge of the footer bar**,
|
||
and the clip line is also the lava "rest" line (§5). The clip line must **follow the actual current
|
||
height of the footer**, which changes between states:
|
||
|
||
- **Player minimized:** the footer is a small floating FAB at `bottom: 30px` (`.minimized-dock` in
|
||
`AudioPlayerBar.razor.css`) — effectively no full-width bar; the clip line sits near the viewport
|
||
bottom.
|
||
- **Player expanded:** the footer is a full-width `MudContainer` + `MudPaper` surface
|
||
(`.player-surface`, `margin-bottom: 1rem`) whose height depends on the responsive grid layout
|
||
(transport/meta/seek/volume zones reflow across breakpoints — see the `AudioPlayerBar.razor.css`
|
||
grid-area media queries) and on whether an error `MudAlert` is showing.
|
||
|
||
So a **fixed inset is wrong** — the footer height is dynamic. The clip line must be measured from the
|
||
live footer, not hard-coded.
|
||
|
||
**Implementation requirement (flagged for staff-engineer).** The clip height is a *layout* value the
|
||
WebGL canvas needs as a *render* value. The recommended approach: the bridge observes the footer
|
||
element's height (a `ResizeObserver` on the player-bar root, or a CSS custom property the player bar
|
||
publishes that the visualizer reads) and pushes a `setFooterClip(heightPx)` uniform/scissor value to the
|
||
module; the shader (or a GL scissor/viewport) clips the bottom `heightPx` of the canvas to transparent,
|
||
and the lava rest line is computed from it. The player bar already routes all minimize/expand mutations
|
||
through one `SetMinimized` mutator and fires `OnMinimized` — that is a natural signal to recompute, but
|
||
a `ResizeObserver` is more robust because it also catches breakpoint reflow and the error-alert case.
|
||
Wrap the visualizer container in `overflow: hidden` regardless (the `.mix-waveform-bg` wrapper already
|
||
has it) so nothing bleeds during the measurement settle.
|
||
|
||
**Acceptance (§8):** with the player expanded, no waveform or lava pixel paints over or under the player
|
||
bar; minimizing the player drops the clip line and the lava rest line down to match; resizing the
|
||
window (changing the player-bar breakpoint/height) re-clips without a reload.
|
||
|
||
---
|
||
|
||
## 3. Remove the static noise texture
|
||
|
||
The current renderer applies a value-noise "frost" modulation across the whole ribbon
|
||
(`MixVisualizer.ts` `frost = 0.85 + 0.15 * valueNoise(...)` and the frosted-translucency glass layer).
|
||
Daniel: it makes the screen look dirty. **Remove it.** The waveform itself is otherwise fine once
|
||
de-noised — the de-noised waveform + the new lava + the new gradient are the visual.
|
||
|
||
This does **not** forbid noise *inside the physics* (e.g. a little organic jitter on blob shape or a
|
||
slow temperature field) — it forbids the *static screen-space dirt layer* that sits over everything. Any
|
||
remaining noise must be (a) tied to the moving fluid, not the screen, and (b) not read as a dirty
|
||
overlay. When in doubt, leave it out; the lava is the texture.
|
||
|
||
---
|
||
|
||
## 4. The lava system — CPU-physics wax blobs
|
||
|
||
### 4a. The model (from the research pass — the load-bearing recommendation)
|
||
|
||
Model the lava as **~16–32 physical "wax blobs" (Lagrangian metaballs)**. Each blob carries:
|
||
|
||
- **position** (2D, in the shared waveform/lava plane),
|
||
- **velocity** (2D),
|
||
- **temperature** (scalar — hot wax rises, cool wax sinks; drives the buoyancy that makes the lamp
|
||
"go"),
|
||
- **radius** (~20px–100px on screen; varied per blob).
|
||
|
||
Each frame, a **small CPU-side physics step** in JS integrates the blobs: buoyancy from temperature,
|
||
gravity (the gravity control), heat exchange (the heat control sets how much energy enters the system),
|
||
collisions (§5), and a little damping/viscosity so it reads as high-viscosity wax, not water. The blob
|
||
state is uploaded to the fragment shader each frame as a **uniform array** (`vec4 blobs[N]`-style:
|
||
`xy` = position, `z` = radius, `w` = a packed temperature/identity) or a **tiny data texture** if the
|
||
array bumps a uniform-count limit. The shader blends the blobs with a **smooth-min (`smin`) SDF
|
||
metaball** union — the same `smin` machinery the landed shader already has — plus the waveform SDF, and
|
||
shades the result.
|
||
|
||
This fixes "giant disconnected circles": the prior approach had too few scripted blobs (`DETACH_BLOB_COUNT
|
||
= 6`) with hash-driven pseudo-motion and no physics, so they read as detached discs. Real physics on
|
||
16–32 blobs with `smin` merging reads as continuous wax that splits and recombines.
|
||
|
||
**The fluid must read FLAT, and the blobs must melt into one unified fluid (from Daniel's Wave R2 eval).**
|
||
Two refinements to the rendered result, both load-bearing:
|
||
|
||
- **Flat fluid surface, not bright pointed centers.** The metaball/fluid surface must read **flat** —
|
||
an evenly-lit fluid body. It must **NOT** read as blobs with bright pointed centers / cone-like radial
|
||
gradients (a per-blob radial falloff peaking at each center reads as a field of glowing cones, which
|
||
Daniel rejected). Shade the *composited* surface, not each blob's center: the brightness should be a
|
||
property of the unified fluid's surface (normal/Fresnel from the SDF, §4f), flat across the body, not a
|
||
per-blob hotspot.
|
||
- **Melt into one fluid — low viscosity, strong coalescence.** The blobs must **coalesce into a single
|
||
unified fluid** rather than reading as distinct stiff globs that merely touch. Bias the `smin` blend
|
||
radius wide and the inter-blob cohesion high so overlapping/near blobs **melt together** into one
|
||
continuous body — "behave more like a fluid," not like rigid beads in contact. This trades against the
|
||
earlier "high-viscosity wax, not water" damping note (§4a integration): keep the *motion* viscous and
|
||
slow, but make the *surface* coalesce freely. The damping is on velocity (slow, wax-like movement); the
|
||
coalescence is on the surface field (fluid, unified). They are independent.
|
||
|
||
### 4b. Blob shape — varied, organic, not always circular
|
||
|
||
Blobs range **~20px to ~100px** across and are **not always circular** — they should read as varied,
|
||
organic wax shapes. The `smin` union of overlapping circles already produces non-circular composite
|
||
shapes (two merging blobs form a peanut/neck); on top of that, a per-blob slight anisotropy or a
|
||
low-frequency radius modulation (tied to the blob, not the screen — see §3) gives each blob its own
|
||
organic silhouette. Staff-engineer's call on the exact shape primitive; the *intent* is "varied organic
|
||
wax," not "N identical circles."
|
||
|
||
### 4c. Heat = 0 → rest at the bottom; collision always on
|
||
|
||
- At **heat = 0**, the lava **rests at the bottom** (pooled at the clip/rest line, §2c). No bubbles rise
|
||
on their own. The **only** thing that moves the wax at heat 0 is **collision from the waveform** —
|
||
i.e. the waveform pushing through the resting pool displaces it. The waveform↔lava collision is
|
||
**always on, independent of heat** (§5).
|
||
- At **heat = max**, **many bubbles rise and morph per second** — a busy, actively roiling lamp.
|
||
|
||
**Energy-coupled dynamics (from Daniel's Wave R2 eval) — heat changes the *character* of the wax, not
|
||
just the rise rate.** Higher heat/energy → **smaller bubbles + more turbulence**; lower heat → **fewer,
|
||
larger, calmer wax**. Concretely:
|
||
|
||
- **High heat:** lots of **small, lively, turbulent bubbles** — the wax fragments into many small active
|
||
blobs with visible churn/turbulence. Energetic and busy.
|
||
- **Low heat:** **fewer, larger, calmer** masses — big lazy wax that mostly rests and drifts.
|
||
|
||
So heat couples to *both* buoyancy (rise) *and* the effective blob size/count distribution and the
|
||
turbulence in the velocity field. (This interacts with the blob density/size control, §4e — heat shifts
|
||
the wax toward the small-and-many end of whatever the density control sets.) **The visualizer should feel
|
||
dynamic and fun** — heat is the "liveliness" axis, and the whole point is that turning it up makes the
|
||
lamp visibly come alive, not merely rise faster.
|
||
|
||
The mapping from the 0..1 heat scalar to effect intensity (rise rate, bubble size/count split, churn
|
||
frequency, turbulence) is a **well-tuned transfer function that staff-engineer owns** — this spec does
|
||
not fix a formula. Note it as a tuning task: the requirement is the *endpoints* (0 = wax rests on the
|
||
floor, collision-only; max = many small turbulent rising bubbles) and a smooth, good-feeling, dynamic
|
||
sweep between them.
|
||
|
||
**Tuning reference — Daniel's sweet spot is ~20% gravity / ~100% heat.** Calibrate the defaults and the
|
||
transfer-function ranges so that this combination lands in a great-feeling, lively state (it is the
|
||
target the small-turbulent-bubble behavior above is described against). Treat ~20% gravity / ~100% heat
|
||
as the calibration anchor for where "dynamic and fun" should sit; the ranges should make that reachable
|
||
and good, not extreme.
|
||
|
||
### 4d. Gravity control
|
||
|
||
A separate **lava gravity** control sets the downward force on the wax. Higher gravity = wax falls back
|
||
faster, blobs are flatter at rest, rising requires more heat; lower gravity = wax floats more freely,
|
||
slower settling. Interacts with heat (buoyancy vs. gravity is the lamp's core tension) — staff-engineer
|
||
tunes the interplay; the control is an independent axis the user can dial.
|
||
|
||
### 4e. Blob density/size control (the fifth control)
|
||
|
||
A **blob density/size** control sets how much wax is in the system — the blob count within the ~16–32
|
||
band and/or the average radius within the ~20–100px band. Low = a few large lazy blobs; high = many
|
||
smaller active blobs. This is the realism/cost dial from the research pass (blob count is the
|
||
performance lever — see §4g).
|
||
|
||
### 4f. Shading — glass folded in, no separate dials
|
||
|
||
The Phase 10 spec had a separate four-part "glass" effect with its own tuning constants. In this reframe
|
||
the wax is shaded as **lit, translucent, glossy wax** as part of rendering the blobs — a surface normal
|
||
from the SDF gradient, a specular highlight, a soft Fresnel rim, and translucency over the page — but
|
||
this is **a property of how blobs are drawn, not a separate user control.** There is no glass knob. Keep
|
||
it tasteful and physical (wax is glossy-translucent, lit from a fixed virtual light); do **not**
|
||
reintroduce a screen-space frost/noise layer (§3) or any CPU `backdrop-filter`. Staff-engineer owns the
|
||
shading constants; the intent is "lit glassy wax," subordinate to the gradient color (§6), not competing
|
||
with it.
|
||
|
||
### 4g. Performance levers
|
||
|
||
- **Blob count is the realism/cost dial.** 16–32 is cheap; the per-fragment SDF loop over blobs is the
|
||
cost. Bound the loop (a hard `MAX_BLOBS` constant the shader loops to).
|
||
- **Keep the existing `MAX_DPR = 2` cap** as the graceful-degrade lever (drop internal resolution before
|
||
dropping frames — 8.K §E, carried forward).
|
||
- The CPU physics step is O(blobs²) for blob↔blob collision at worst (32² = 1024 pair checks/frame —
|
||
trivial) and O(blobs) for waveform collision; neither is a concern at this count.
|
||
|
||
### Rejected alternative (do not build): full fluid simulation
|
||
|
||
A ping-pong FBO Stable-Fluids / Navier-Stokes simulation was **considered and rejected.** A lava lamp is
|
||
high-viscosity / low-turbulence — the *opposite* regime from what a fluid solver buys you. It would be a
|
||
large rewrite (multi-pass FBO, advection/pressure-solve plumbing) for realism we explicitly do not want.
|
||
The Lagrangian-blob approach is both cheaper and a better match for the wax aesthetic. Keep the FBO fluid
|
||
sim as a *deliberate later upgrade only* if the blob approach ever reads too kinematic — and even then it
|
||
is a separate phase, not a tuning step.
|
||
|
||
---
|
||
|
||
## 5. The 2D collision model
|
||
|
||
### 5a. Two collision pairs, elastic
|
||
|
||
1. **Blob ↔ waveform boundary.** The waveform silhouette (the symmetric ±loudness ribbon about the
|
||
center line) is a collision boundary. A blob overlapping the silhouette is pushed out along the
|
||
waveform's surface normal — the fluid parts around the waveform. **2D elastic collision** against the
|
||
boundary (the blob's velocity component into the boundary reflects, modulated by the hardness knob —
|
||
§5c). The waveform is unaffected (read-only authority, §2a).
|
||
2. **Blob ↔ blob.** Blobs collide with each other — **2D elastic collision** (the standard
|
||
equal-or-unequal-mass elastic response along the line connecting centers), again modulated by
|
||
hardness. This keeps the wax from interpenetrating into one mush and gives the lamp its jostling
|
||
liveliness.
|
||
|
||
Mass can be derived from radius (bigger blob = more mass) so large blobs shove small ones convincingly —
|
||
staff-engineer's call; the requirement is *elastic 2D collision on both pairs.*
|
||
|
||
**High elasticity, and the throw is UP AND OUT, not just sideways (from Daniel's Wave R2 eval).** The
|
||
collision is **highly elastic** at the hard end. Critically, when the waveform collides with the wax at
|
||
high collision strength it must **throw the bubbles UPWARD AND OUTWARD** — the waveform surface impulse
|
||
has a vertical (lift) component, not merely horizontal displacement. The earlier "pushed out along the
|
||
waveform's surface normal" framing is too flat: the desired read is the waveform *kicking* the fluid up
|
||
and away, like a paddle slapping liquid, so the lava springs off it. Staff-engineer composes the impulse
|
||
so a hard collision imparts both outward (normal) and upward (lift) velocity to the struck blobs.
|
||
**Collision must be smooth — no jitter** (no per-frame popping/vibration at the boundary; the response
|
||
must integrate stably even at high elasticity).
|
||
|
||
### 5b. Collision always on, independent of heat
|
||
|
||
Per §4c: the waveform↔lava collision runs **at all heat levels, including heat = 0.** At rest the
|
||
waveform still pushes through the pooled wax and displaces it. This is what keeps the waveform and the
|
||
lava feeling like one physical system rather than two layers.
|
||
|
||
### 5c. Collision strength — soft → hard (the sixth control)
|
||
|
||
A **collision strength** control sweeps the interaction from **genuinely soft → 100% hard** across a
|
||
**wide soft↔hard range** (from Daniel's Wave R2 eval — the soft end must be genuinely soft, not a faint
|
||
version of hard, and the spread between the two ends should be large and expressive). It blends between
|
||
two behaviors:
|
||
|
||
- **(a) Hard / high-elasticity throw:** the waveform is a near-rigid surface that **throws the wax UP AND
|
||
OUT** — high restitution, the struck blobs spring off the boundary with both outward (normal) and
|
||
upward (lift) velocity (§5a). At max it reads as the waveform energetically kicking the lava off it.
|
||
- **(b) Soft mush:** the waveform gently **mushes** the lava around — a soft penalty force proportional to
|
||
penetration depth, the wax squishes against and partially into the boundary and slowly eases back, with
|
||
little or no springy throw. At/near min it must be **genuinely soft** — the fluid yields and gently
|
||
deforms, no kick.
|
||
|
||
The knob blends (b) → (a) as it sweeps 0 → 1: at 0, gentle mushing of the lava (soft, yielding, slow
|
||
recovery); at 1, a high-elasticity up-and-out throw. The range must be **wide** so the difference between
|
||
ends is dramatic, and the sweep must stay **smooth with no jitter** at any setting (§5a). The same blend
|
||
factor can scale the blob↔blob restitution for consistency (softer overall world at low strength),
|
||
staff-engineer's call.
|
||
|
||
> **Dropped:** the earlier Phase 10 "bubbles spawn from peaks" idea is **not relevant** to this model —
|
||
> blobs are a persistent physical population, not spawned-from-the-waveform particles. Do not carry it
|
||
> forward.
|
||
|
||
### 5d. Transfer functions left to staff-engineer
|
||
|
||
As with heat (§4c), the exact penetration-penalty curve, restitution coefficients, the lift/normal
|
||
impulse split, and the soft↔hard blend shape are **staff-engineer tuning tasks.** This spec fixes the
|
||
*model* (two elastic collision pairs, a wide soft↔hard blend on a knob, collision always on, smooth/no
|
||
jitter) and the *endpoints* (soft = gentle mush; hard = high-elasticity up-and-out throw), not the
|
||
constants. The heat transfer-function endpoints are likewise fixed (heat 0 = wax rests on the floor;
|
||
heat max = many small turbulent rising bubbles) with the ~20% gravity / ~100% heat sweet spot as the
|
||
calibration anchor (§4c).
|
||
|
||
---
|
||
|
||
## 6. The color / gradient model
|
||
|
||
### 6a. One source of truth — injected, not hardcoded
|
||
|
||
**Do NOT duplicate or hardcode theme hexes** anywhere in the visualizer. The canonical palette lives in
|
||
**`DeepDrftShared.Client/Common/DeepDrftPalettes.cs`** (the `Light` / `Dark` `PaletteLight`/`PaletteDark`
|
||
objects). That is the single source of truth.
|
||
|
||
The component must receive the theme color(s) as a **single injected value/parameter** derived from that
|
||
source — not a second copy of the hexes living in the TS or the component. Today the renderer reads
|
||
computed `--mud-palette-*` CSS custom properties off the canvas element (because a GLSL uniform cannot
|
||
resolve `var()`), which is *a* form of single-source consumption (the vars are emitted from
|
||
`DeepDrftPalettes`). **Keep that discipline:** the colors reach the shader by reading the live palette
|
||
(via the CSS vars the palette emits, re-read on `refreshTheme`), so a palette edit in `DeepDrftPalettes`
|
||
or a dark-mode toggle re-themes the field with no duplicate to keep in sync. If staff-engineer prefers a
|
||
more explicit injection (the page passing the three resolved colors into the component as a typed
|
||
parameter sourced from `DeepDrftPalettes`), that is acceptable and arguably cleaner — the **hard
|
||
requirement is one source, zero hardcoded duplicates.**
|
||
|
||
**Identify the three colors X, Y, Z (§6b motion 1).** The palette's signature triad for the field is the
|
||
navy / moss / off-white identity the palette is built on. Recommended bindings (staff-engineer picks the
|
||
exact `--mud-palette-*` vars per mode for the richest spread; the palette is the source either way):
|
||
|
||
- In **light**: navy = `--mud-palette-primary` (`#17283f`), moss = `--mud-palette-secondary` (`#3D7A68`)
|
||
or tertiary (`#429d6a`), and a third anchor for variety — the off-white ground or a deeper navy.
|
||
- In **dark**: green is primary (`#3D7A68`), navy is the ground (`#0D1B2A`), off-white is secondary
|
||
(`#FAFAF8`).
|
||
|
||
The exact triad per mode is a tuning call; the requirement is **three theme-sourced colors, no
|
||
hardcoded hexes.**
|
||
|
||
### 6b. The gradient: A → B, linear, center → outer, with three combined motions
|
||
|
||
The gradient is **always color A → color B, linear, running from the 0 center line outward along the
|
||
waveform** — A at the center/root, B at the outer/extended edge. On top of that static structure, three
|
||
motions combine:
|
||
|
||
**Motion 1 — anchor rotation among X, Y, Z (the gradient rotation speed control).**
|
||
Three theme colors X, Y, Z are in play. Over time, the gradient's two anchors **A and B rotate smoothly
|
||
*among* X, Y, Z** — both the root color A and the extended color B cycle through the three colors. The
|
||
**gradient-rotation-speed control** drives this rotation rate. **The blend must NOT travel through the
|
||
rainbow** — interpolate in **OKLab** (§6c) so the blend stays faithful to the three theme colors with no
|
||
hue drift and no cyan excursion.
|
||
|
||
**Motion 2 — per-bar sinusoidal variation, baked at segment entry (chosen realization: mix-time-keyed).**
|
||
Each bar's A and B vary slightly by a sinusoidal transfer, so the colors change in **"waves" across the
|
||
waveform** rather than one uniform gradient. **Critical implementation requirement:** a segment's colors
|
||
are **chosen when it enters (incoming, at the bottom) and stay FIXED for that segment as the waveform
|
||
scrolls up, until it scrolls out of view.** Colors are **baked per-segment at entry, not recomputed per
|
||
frame.**
|
||
|
||
**Decided realization (Daniel, 2026-06-16): key the per-bar sinusoid to the segment's mix-time, not its
|
||
screen-Y.** Daniel approved this approach explicitly ("whatever works and isn't a clusterfuck to
|
||
maintain"). Because mix-time is fixed for a given segment, the sinusoid becomes a **pure function of
|
||
mix-time** and is therefore **fixed-per-segment by construction** — it travels with the segment as it
|
||
scrolls, with **no explicit per-segment ring buffer to maintain.** This is the chosen approach.
|
||
**Rejected for maintainability:** an explicit ring buffer keyed to scroll position that tracks each
|
||
visible segment's baked A/B colors — it is more moving parts and more state to keep coherent for the
|
||
same visual result. Do not build it; use the mix-time-keyed sinusoid.
|
||
|
||
**Motion 3 — per-bar gradient curve shifts with scroll height.**
|
||
Each bar's gradient *curve* (the A→B mix profile along its own height) shifts as it scrolls up. At the
|
||
bottom a bar is mostly A (e.g. `linear-gradient A 90% → B`), and by the top it is mostly B (`A 10% → B`)
|
||
— so color appears to move outward/inward as the bar scrolls up. This is a function of the segment's
|
||
scroll height (its screen-Y / scroll position), composed on top of the A/B colors that Motion 2 baked
|
||
for that segment.
|
||
|
||
The three motions compose: Motion 1 sets *which* two theme colors A and B are right now (rotating among
|
||
X/Y/Z), Motion 2 perturbs A and B slightly per-segment in fixed-at-entry waves (keyed to mix-time), and
|
||
Motion 3 shifts the A→B blend curve along each bar as it climbs.
|
||
|
||
### 6c. OKLab, not HSL — and why
|
||
|
||
The prior cyan bug came from **HSL** interpolation: the renderer's `mixHsl` blended hue/sat/lum
|
||
independently and `vivify` boosted saturation, which dragged the navy→moss path through saturated
|
||
cyan/teal hue regions (navy's hue sits near blue, moss near green; the short-way hue arc between them
|
||
passes through cyan, and the saturation boost made that excursion vivid). **Interpolate in OKLab
|
||
instead.** OKLab is a perceptually-uniform color space where a straight line between two colors stays
|
||
perceptually faithful — no hue drift, no saturation pumping, no rainbow excursion between the theme
|
||
anchors. Implement OKLab↔linear-sRGB conversion in the shader (well-documented matrices) and `mix()` in
|
||
OKLab. Drop the `mixHsl` / `vivify` / `VIVID_*` machinery entirely.
|
||
|
||
> **Why this is a model fix, not a tuning fix:** no amount of tuning the HSL saturation floor avoids the
|
||
> cyan arc — it is inherent to interpolating hue between blue and green. OKLab removes the failure mode
|
||
> structurally. (Reference: Björn Ottosson's OKLab — the now-standard recommendation for perceptual
|
||
> color interpolation in shaders.)
|
||
|
||
---
|
||
|
||
## 7. The seven controls + the inline collapse/expand knob-bar
|
||
|
||
### 7a. The seven controls (replacing the four)
|
||
|
||
| # | Control | What it drives | Range | Replaces |
|
||
|---|---------|----------------|-------|----------|
|
||
| 1 | **Waveform scroll speed** | Apparent bottom-to-top scroll rate (decouples scroll from zoom) | normalized, mapped via `MixZoomMapping` or a new scroll-rate map | the old **resolution/zoom** control — *resolution as a standalone control is gone* |
|
||
| 2 | **Color gradient rotation speed** | Motion 1 anchor-rotation rate among X/Y/Z (§6b) | normalized 0→1 → slow→fast cycle | the old **color-shift speed** |
|
||
| 3 | **Lava gravity** | Downward force on the wax (§4d) | normalized 0→1 | new |
|
||
| 4 | **Lava heat** | Energy into the system; 0 = rest-at-bottom, max = many small turbulent rising bubbles (§4c) | normalized 0→1 | the old **detach**, re-modeled |
|
||
| 5 | **Blob density/size** | Amount of wax — count/size within the 16–32 / 20–100px bands (§4e) | normalized 0→1 | new (the old **bubblyness** is gone; bulge is now physical) |
|
||
| 6 | **Collision strength** | Soft mush → 100% hard up-and-out throw, waveform/blob collision (§5c) | normalized 0→1 → soft→hard | new |
|
||
| 7 | **Waveform width** | Width of the waveform portion of the visualizer — narrows the waveform band so the lava fluid has more room to move (§7a, below) | normalized 0→1 → narrow→wide | new (added in the Wave R2 eval) |
|
||
|
||
Note the swap: **resolution/zoom as a standalone control is removed — scroll speed replaces it.** The
|
||
`MIN_VISIBLE_SECONDS` anchor still exists internally for the datum-density framing, but the user-facing
|
||
control is "how fast does it scroll," not "how much do I see." Staff-engineer decides whether scroll
|
||
speed reuses `MixZoomMapping` (treating it as a scroll-rate map) or gets a fresh linear map; either way
|
||
keep the log-feel continuity that made the zoom slider feel good.
|
||
|
||
**Control 7 — waveform width (added in the Wave R2 eval).** Adjusts the **width of the waveform portion**
|
||
of the visualizer — i.e. how much horizontal band the symmetric ±amplitude waveform silhouette occupies.
|
||
**The point of the control:** on loud/busy songs the waveform is wide and crowds the canvas; narrowing it
|
||
gives the **lava fluid more room to move**. Low = a narrow waveform band (more room for lava); high = a
|
||
wide waveform band. Mechanically this scales the waveform's amplitude→screen-width mapping (the half-width
|
||
the silhouette extends from the center line for a given loudness), which also resizes the collision
|
||
boundary the fluid parts around (§5) — narrowing the waveform literally clears space for the wax. The
|
||
datum and scroll geometry are unchanged; only the horizontal extent of the waveform band scales.
|
||
|
||
Defaults are Daniel's to tune on screen (his standing preference — he tunes ranges by hand once it is
|
||
live). Recommended starting points: scroll speed ~mid, rotation ~0.3, gravity ~0.2, heat ~1.0, density
|
||
~0.4, collision ~0.5, width ~0.6. The **~20% gravity / ~100% heat** anchor reflects Daniel's stated sweet
|
||
spot (§4c). These are feel-anchors, not commitments.
|
||
|
||
### 7b. The in-flow controls container — between the back link and the lava-lamp (redesigned 2026-06-16)
|
||
|
||
**Decision (Daniel, 2026-06-16): the controls are an in-flow container that lives BETWEEN the back link
|
||
(left) and the lava-lamp toggle (right), on the detail top row. It is NOT a floating popover, NOT a
|
||
drawer, and NOT a `position:absolute` element anchored under the lamp.** Expanding it **reflows the
|
||
layout in place** — the container grows in the row's flow between back and lamp — it never overlays the
|
||
page, never clips, and never overlaps the masthead/hero.
|
||
|
||
> **This redesign supersedes the first realization of §7b.** The first implementation read the
|
||
> "inline collapse/expand" intent as an *absolutely-positioned floating bar anchored under the lamp*
|
||
> (`.mix-visualizer-controls-anchor` stacked the lamp over the bar; the bar was
|
||
> `position:absolute; top:calc(100% + 0.5rem); right:0; z-index:3`). That is a "floating-but-inline
|
||
> popover" — Daniel rejects it: it clipped to a vertical sliver (only ~4 of 7 knobs visible) and read as
|
||
> a detached window hanging off the icon. **Remove the `position:absolute` rule and the
|
||
> `.mix-visualizer-controls-anchor` floating structure entirely.** The container must take real layout
|
||
> space in the row, not float over it.
|
||
|
||
**Placement — restructure the scaffold's top row into three zones (recommended).**
|
||
|
||
`ReleaseDetailScaffold`'s top row is today a two-zone `MudStack Row Justify="SpaceBetween"`:
|
||
`back-link (left) | @TopRightAction (right)`. Restructure it into **three zones**:
|
||
|
||
```
|
||
back-link (left) | @TopRowCenter (the controls container) | @TopRightAction (the lamp)
|
||
```
|
||
|
||
- **Add a new optional `RenderFragment? TopRowCenter` slot** to the scaffold, rendered between the back
|
||
link and `@TopRightAction` on the same row. The row becomes a three-child flex row: back link pinned
|
||
left, lamp pinned right, the center slot occupying the space between (and growing/shrinking with the
|
||
container's expand/collapse — see motion below).
|
||
- **Mix** supplies the seven-knob `MixVisualizerControls` to `TopRowCenter` and keeps the lava-lamp
|
||
`MudIconButton` in `TopRightAction`. The lamp toggles the container; the container reveals in the
|
||
center zone.
|
||
- **Reusability holds.** Track / Cut / Session supply neither `TopRowCenter` nor `TopRightAction`, so the
|
||
row degrades to **back-link-alone**, exactly as today. The center slot is a generic "affordance between
|
||
back and action" seam — it follows the scaffold's existing "variance rides a slot, never a flag"
|
||
convention (Phase 9 §5.3), the same way `TopRightAction` and `TopContent` already do. With
|
||
`Justify="SpaceBetween"` an absent center slot collapses to nothing and back/action sit at the two
|
||
edges unchanged.
|
||
|
||
**Why a scaffold center slot over a MixDetail-composed row.** Composing the whole `back | controls | lamp`
|
||
row inside MixDetail (and suppressing the scaffold's own back row) would duplicate the back-link markup,
|
||
break the scaffold's "owns the back link" invariant, and fork the one place every medium's back
|
||
navigation lives. The center slot keeps the scaffold the single owner of the row and adds Mix's piece as
|
||
data, not as a structural fork. **Recommended: the scaffold gains the `TopRowCenter` slot.**
|
||
(`TopContent` — the existing below-the-row band — is the *responsive fallback target*, not the primary
|
||
home; see the responsive note in §7b-responsive below.)
|
||
|
||
**The expand/collapse mechanism — in-flow, no float, no overlay (no MudPopover, no MudDrawer):**
|
||
|
||
- A **bound `bool`** (`_controlsExpanded`) gates the container. The **lava-lamp icon button toggles it** —
|
||
the same `DDIcons.LavaLamp` trigger kept from Phase 10 §7c, now redrawn (§7f), now an in-flow
|
||
expand/collapse toggle. The icon swaps to its FILLED variant while expanded (§7f / Part B); a knob drag
|
||
never collapses the container (the toggle flips only on the lamp's click).
|
||
- **Collapsed:** the container occupies **no or minimal space** in the center zone — `max-width: 0` (or
|
||
`width: 0`) + `opacity: 0` + `overflow: hidden`, and `visibility: hidden` + `pointer-events: none` so
|
||
the collapsed knobs are not focusable or hit-testable. Back and lamp sit at the row's two edges with the
|
||
center collapsed between them.
|
||
- **Expanded:** the container **grows horizontally in place** between back and lamp — a `max-width` (or
|
||
`width`) + `opacity` (+ optional slight `transform`) CSS transition — so the seven knobs reveal **in the
|
||
row's flow**, pushing nothing over the page. **No `position: absolute`, no `float`, no `z-index` stacking
|
||
hack, no anchored panel.** The row reflows to accommodate the container; when the container is wider than
|
||
the available center space it wraps in-flow (see responsive, below) — it never clips to a sliver.
|
||
- **Motion intent:** a smooth horizontal grow/shrink of a real in-flow element, reading as **the controls
|
||
area opening between the back link and the lamp** — not a panel appearing over the page. Reuse the
|
||
existing transition vocabulary (the `cubic-bezier(0.22, 0.61, 0.36, 1)` easing already in
|
||
`MixVisualizerControls.razor.css`) so the motion feels native. The `max-height` collapse can stay as a
|
||
secondary axis for the wrap case, but the **primary** animated axis is horizontal width in the row.
|
||
|
||
**Must not overlap the masthead/hero.** Because the container lives in the top row (above the masthead,
|
||
which the scaffold renders below this row) and reflows in-flow, an expanded container **pushes the row's
|
||
own height** — it must never paint over the masthead, the hero/cover, or the waveform backdrop. If the
|
||
expanded container is tall (wrapped to multiple knob rows on narrow widths), the top row grows and the
|
||
masthead moves down with normal document flow; no overlap, no clipping.
|
||
|
||
**Why this over the rejected floating bar.** The floating `position:absolute` bar read as a detached
|
||
window hanging off the lamp and clipped because its width was constrained by the lamp's narrow column.
|
||
An in-flow container between back and lamp *is* the controls sitting in the layout — the open/close is a
|
||
property of the row reflowing, the seven knobs get the row's full horizontal space, and there is zero
|
||
overlay machinery. (Prior-art touchstone: a toolbar that grows a secondary in-flow region of controls in
|
||
place between two pinned end-affordances — e.g. a browser address bar revealing inline controls between
|
||
the back button and the menu — not a flyout anchored to an icon.)
|
||
|
||
### 7b-responsive. Narrow-width behavior — stay in-flow, never clip
|
||
|
||
The seven knobs are wide; on a narrow row the center zone cannot hold them on one line. **Recommended:
|
||
the container wraps to a second in-flow line under the back/lamp row** rather than clipping or scrolling
|
||
off-edge. Two acceptable realizations (staff-engineer's call, both in-flow):
|
||
|
||
1. **Flex-wrap in place.** The seven-knob flex row `flex-wrap: wrap`s within the center zone; when the
|
||
center zone is too narrow the whole top row grows taller and the knobs reflow to 4×3 / 3×4 / two rows.
|
||
The row's height grows in-flow; the masthead moves down. All seven stay reachable.
|
||
2. **Drop to the `TopContent` band when narrow.** Below a breakpoint, the container renders in the
|
||
scaffold's existing **`TopContent` slot** (the full-width in-flow band below the back/lamp row, above
|
||
the masthead) instead of the center zone — giving it the full container width to lay the seven knobs
|
||
out without crowding the back/lamp. This is still fully in-flow (it is literally the `TopContent`
|
||
position) and never floats. Use the center zone on wide rows, the `TopContent` band on narrow rows.
|
||
|
||
**Either way: in-flow, no overlay, no edge-clipping, no horizontal-scroll-that-hides-knobs.** A
|
||
horizontally-scrolling inline strip is a *last-resort* fallback only if wrapping proves visually worse —
|
||
and even then it must be a real in-flow scroll region with a visible affordance, never a clipped sliver.
|
||
All seven knobs must always be reachable. The wrap/drop must still read as part of the controls
|
||
container opening, not as a separate surface.
|
||
|
||
### 7c. State — widen to seven properties
|
||
|
||
Widen `MixVisualizerControlState` from four properties to **seven**: `ScrollSpeed` (replacing
|
||
`VisibleSeconds` as the user-facing axis, or keep `VisibleSeconds` internally and add a `ScrollSpeed`
|
||
that maps to it — staff-engineer's call), `GradientRotationSpeed` (rename of `ColorShiftSpeed`),
|
||
`LavaGravity`, `LavaHeat` (re-modeled from `Detach`), `BlobDensity`, `CollisionStrength`, and
|
||
`WaveformWidth` (the seventh, added in the Wave R2 eval — drives the waveform-band horizontal extent,
|
||
§7a). Each with a `const` default mirrored to the TS tuning anchors (keep the C#↔TS default-sync
|
||
discipline the existing `Default*` consts have). Same scoped-DI persistence model: survives SPA nav
|
||
within a session, resets on fresh load. Same `Changed` event seam — the bridge subscribes and pushes the
|
||
affected uniform; the knob-bar component only mutates state and raises `Changed`. **This is the same
|
||
architecture as today, just seven properties instead of four.**
|
||
|
||
The bridge handle gains setters for the new controls (`setScrollSpeed`, `setGradientRotationSpeed`,
|
||
`setLavaGravity`, `setLavaHeat`, `setBlobDensity`, `setCollisionStrength`, `setWaveformWidth`) — extend,
|
||
don't redesign, mirroring how Phase 10 §2d extended the handle.
|
||
|
||
### 7d. RadialKnob — consumed unchanged, seven instead of four
|
||
|
||
`RadialKnob` (`DeepDrftShared.Client/Components/RadialKnob.razor`) is consumed as-is (its API is fixed —
|
||
`Value`/`ValueChanged`/`Min`/`Max`/`Step`/`Label`/`Size`/`Color`/`HoldValue`; no icon slot; `Label` is
|
||
SVG text). Seven knobs in the inline bar, each with an adjacent `MudIcon` caption (the no-icon-slot
|
||
constraint from Phase 10 §7e still holds). `HoldValue=false` so they are live. `Step="0.001"` for
|
||
continuous feel. Suggested Material icons per control (staff-engineer picks final glyphs):
|
||
|
||
| Knob | Suggested icon |
|
||
|------|----------------|
|
||
| 1. Scroll speed | `FastForward` / `Speed` |
|
||
| 2. Gradient rotation speed | `Palette` / `Rotate90DegreesCcw` |
|
||
| 3. Lava gravity | `ArrowDownward` / `FilterDrama` |
|
||
| 4. Lava heat | `LocalFireDepartment` / `Whatshot` |
|
||
| 5. Blob density/size | `BubbleChart` / `Grain` |
|
||
| 6. Collision strength | `Adjust` / `Compress` |
|
||
| 7. Waveform width | `WidthNormal` / `SwapHoriz` / `SettingsEthernet` (a horizontal-extent glyph) |
|
||
|
||
Seven knobs in a row may wrap on narrow viewports (4×3 / 3×4 / two rows) — a layout call, but all seven
|
||
must remain reachable.
|
||
|
||
### 7e. Aesthetic target — match the NowPlaying hero
|
||
|
||
**The knob-bar's color and structure must match the "NowPlaying" section in the hero.** The closest
|
||
existing component to that aesthetic in the codebase is the **Session detail hero**
|
||
(`DeepDrftPublic.Client/Pages/SessionDetail.razor` + `.css`) — the hero-dominant overlay composition: a
|
||
large background image with a darkening gradient shim (`.session-hero-shim`), and overlay rows
|
||
(`.session-hero-top` / `.session-hero-bottom`) carrying title/artist, genre/date chips, and the play
|
||
affordance with translucent surfaces over the image. The structural cues to borrow:
|
||
|
||
- A **translucent dark surface** (the shim aesthetic) so the knob-bar reads as floating glass over the
|
||
visualizer, not an opaque panel.
|
||
- The **overlay-chip / overlay-label typography** (`.session-overlay-label` / `.session-overlay-value`)
|
||
for the knob captions, so the bar's labels match the hero's.
|
||
- `Color.Secondary` accents (the play affordance and the lava-lamp trigger both use `Color.Secondary`) —
|
||
keep the knobs/captions on the same accent so the bar feels of-a-piece with the hero.
|
||
|
||
Staff-engineer studies `SessionDetail.razor[.css]` and matches its surface color + structural rhythm.
|
||
The intent: expand the knob-bar and it looks like it belongs to the same design family as the session
|
||
hero's now-playing overlay, not a generic MudBlazor panel — even though it is an inline collapse/expand
|
||
element, not a floating one (§7b). (If Daniel has a *specific* NowPlaying component in mind other than
|
||
the session hero overlay, confirm — but the session hero is the strongest match in the current tree.)
|
||
|
||
### 7f. Redraw the lava-lamp glyph — classic 1970s silhouette
|
||
|
||
**Decision (Daniel, 2026-06-16): the current `DDIcons.LavaLamp` is rejected ("form is shit, colors are
|
||
shit") and must be redrawn** to the classic 1970s "Lava" lamp silhouette (Daniel supplied a reference
|
||
image). The trigger placement (top-right, across from the back link) is unchanged; only the glyph art
|
||
changes.
|
||
|
||
**Home & authoring convention.** Authored in `DeepDrftShared.Client/Common/DDIcons.cs` as **inner SVG
|
||
markup only — no outer `<svg>` wrapper** (MudBlazor supplies the wrapper). A **24×24 viewBox** coordinate
|
||
space (matches the MudBlazor `viewBox="0 0 24 24"` wrapper and the existing `DDIcons` convention). A
|
||
`public const string` raw-string literal, same as the other icons in the file.
|
||
|
||
**Silhouette (bottom → top) — "offset cones":**
|
||
|
||
1. A **WIDE truncated-cone metal BASE** — widest at the very bottom, tapering *up* to a narrow waist.
|
||
2. A tall tapered **GLASS VESSEL** sitting on the base — **bulbous/rounded at the bottom**, tapering
|
||
**upward** to a **roundedly-POINTED top** (an elongated teardrop / bullet shape — pointy-ish but
|
||
**NOT sharp**).
|
||
3. A small **truncated-cone metal CAP** on top, **mirroring the base**.
|
||
|
||
The read is: cone base + tapered teardrop body + small cone cap. This is the iconic 1970s Lava lamp
|
||
profile — distinct from the current narrow straight-sided vessel, which is rejected.
|
||
|
||
**Colors (on-theme AND faithful to the reference).** The reference shows a silver metal base/cap, blue
|
||
fluid, and green blobs. Map to the app theme:
|
||
|
||
- **Fluid: navy. Blobs: moss.** Navy and moss **are** the theme's blue + green, so this is both faithful
|
||
to the reference and on-theme by construction.
|
||
- **Base + cap: neutral / metallic** (the silver of the reference).
|
||
- The **body silhouette (glass vessel outline) can be `currentColor`** so it tints with context
|
||
(`Color.Secondary`, light/dark) exactly as the existing icons do. The **fluid and blobs carry the
|
||
navy/moss accents** as the icon's color identity.
|
||
|
||
**Keeping the icon theme-aligned (the one source-of-truth wrinkle).** The single source of truth for
|
||
theme colors is `DeepDrftPalettes` (§6a), but an inline SVG `const string` in `DDIcons.cs` **cannot
|
||
resolve `var(--mud-palette-*)`** — a fill on an SVG path inside a raw-string literal must be a literal
|
||
value (or `currentColor`). So the navy/moss fluid/blob stops will need to be **literal hex stops** in the
|
||
SVG. To minimize the duplication-of-truth this creates:
|
||
|
||
- Prefer **`currentColor` for everything that can be** (the body silhouette, ideally the metal base/cap
|
||
via a neutral-tinted variant) so the bulk of the icon themes for free.
|
||
- For the two accent fills that genuinely must be literal (navy fluid, moss blobs), **use the exact
|
||
`DeepDrftPalettes` navy/moss hexes** (e.g. navy `#17283f`, moss `#3D7A68`) and **add a code comment in
|
||
`DDIcons.cs` naming the `DeepDrftPalettes` member each literal mirrors**, so the two literals are
|
||
explicitly traceable to the source and a future palette change has a documented sync point. This is the
|
||
same `currentColor`-where-possible + commented-literal-where-not discipline the existing gas-lamp flame
|
||
icon already uses (its `#FF9800`/`#FFCA28` flame stops are literals on an otherwise-`currentColor`
|
||
lamp). The hard requirement: **no silent second copy of the palette** — `currentColor` first, commented
|
||
literal only where SVG forces it.
|
||
|
||
(The precise path data is staff-engineer's; this fixes the silhouette, the color mapping, the authoring
|
||
convention, and the theme-alignment discipline — not the exact coordinates.)
|
||
|
||
---
|
||
|
||
## 8. Acceptance criteria (observable)
|
||
|
||
**Spatial / layout**
|
||
|
||
1. **Same-plane collision.** The waveform visibly pushes the wax out of its way — blobs part around the
|
||
waveform silhouette rather than overlapping it indiscriminately. The waveform shape is never deformed
|
||
by the fluid (read-only authority preserved).
|
||
2. **Bottom-anchored waveform.** The waveform still rises from the bottom and scrolls bottom-to-top,
|
||
unchanged from 8.K.
|
||
3. **Footer clip.** With the player expanded, no waveform or lava pixel paints over or under the player
|
||
bar. Minimizing the player drops the clip + lava-rest line to match the FAB footer. Changing the
|
||
window size (player-bar breakpoint/height change) re-clips with no reload.
|
||
4. **Noise removed.** No static screen-space noise/frost/dirt layer remains; the screen reads clean.
|
||
|
||
**Lava behavior**
|
||
|
||
5. **Heat 0 = rest + collision-only.** At heat 0 the wax rests on the floor (pooled at the rest line) and
|
||
does not rise on its own; the waveform pushing through it still displaces it (collision always on).
|
||
6. **Heat max = small turbulent bubbles.** At max heat, the wax breaks into **many small, lively,
|
||
turbulent bubbles** that rise and morph per second — an actively roiling, fragmented lamp (not a few
|
||
big masses rising faster). Energy couples to bubble size/count and turbulence, not just rise rate.
|
||
7. **Flat, unified fluid.** The fluid reads **flat** — an evenly-lit fluid body, **not** blobs with bright
|
||
pointed centers / cone-like radial gradients. Blobs **melt into one unified fluid** (strong
|
||
coalescence) rather than distinct stiff globs in contact.
|
||
8. **Blobs.** Blobs read as varied organic wax shapes (not N identical circles), in the ~20–100px range,
|
||
merging and splitting via `smin` — no "giant disconnected circles."
|
||
9. **Gravity** changes settling/rise behavior independently of heat. At the **~20% gravity / ~100% heat**
|
||
sweet spot the lamp reads dynamic, lively, and fun (the calibration anchor, §4c).
|
||
10. **Blob density/size** changes how much wax is in the system across its range.
|
||
|
||
**Collision**
|
||
|
||
11. **Two elastic pairs.** Blob↔waveform and blob↔blob both collide elastically.
|
||
12. **Collision strength — wide soft↔hard sweep.** Sweeps **genuinely soft mush** (the waveform gently
|
||
pushes the lava around; fluid yields and slowly recovers, no springy kick) → **hard up-and-out throw**
|
||
(high elasticity; the waveform throws the bubbles **upward and outward**, not just sideways). The
|
||
range is wide/expressive and the response is **smooth — no jitter** at any setting.
|
||
|
||
**Color**
|
||
|
||
13. **Three-color OKLab gradient.** A→B linear from center outward; no cyan, no rainbow excursion — the
|
||
blend stays faithful to the theme colors at all rotation phases.
|
||
14. **Anchor rotation.** A and B rotate among X/Y/Z over time at the gradient-rotation-speed rate;
|
||
dragging the control visibly changes the rotation rate, never frozen at the slow end.
|
||
15. **Per-segment bake (mix-time-keyed).** A segment's colors are fixed when it enters at the bottom and
|
||
travel with it unchanged as it scrolls up and out — colors do not recompute under a stationary
|
||
segment. (Realized via the mix-time-keyed sinusoid, §6b motion 2.)
|
||
16. **Per-bar curve shift.** A bar is mostly A at the bottom and mostly B by the top — color appears to
|
||
move outward as the bar climbs.
|
||
17. **One source of truth.** No hardcoded theme hexes in the visualizer; a `DeepDrftPalettes` edit or a
|
||
dark-mode toggle re-themes the field live with no duplicate to maintain. (The icon's two accent
|
||
literals are the documented, commented exception — §7f.)
|
||
|
||
**Controls / knob-bar**
|
||
|
||
18. **Seven controls.** Exactly seven RadialKnobs — scroll speed, gradient rotation speed, gravity, heat,
|
||
density/size, collision strength, **waveform width** — each captioned with an icon; resolution/zoom as
|
||
a standalone control is gone.
|
||
19. **Waveform width.** Dragging the waveform-width control narrows/widens the waveform band across its
|
||
range; narrowing it visibly clears horizontal space for the lava (and shrinks the collision boundary
|
||
the fluid parts around). The datum/scroll geometry is otherwise unchanged.
|
||
20. **In-flow container between back and lamp.** The controls container sits **inline on the detail top
|
||
row, between the back link (left) and the lava-lamp toggle (right)**. Clicking the lava-lamp icon
|
||
expands it **in the layout flow** (animated open/closed via CSS width/opacity transition), reflowing
|
||
the row — **not** a floating popover, drawer, or `position:absolute` bar, and **not** clipped to a
|
||
sliver. Collapsed it takes no/minimal space (back and lamp at the row's two edges). All seven knobs are
|
||
visible and horizontal when expanded; the expansion never overlaps the masthead/hero. Clicking again
|
||
collapses it; dragging a knob does not collapse it.
|
||
21. **NowPlaying aesthetic.** The knob-bar's surface color + structure match the session hero now-playing
|
||
overlay (translucent dark glass, overlay-label typography, `Color.Secondary` accents).
|
||
22. **Persistence + read-only.** All seven positions survive SPA nav within a session, reset on fresh
|
||
load; no control and no knob-bar element is a seek/playback surface; the bridge and read-only contract
|
||
are intact.
|
||
|
||
**Icon**
|
||
|
||
23. **Redrawn lava-lamp glyph.** The trigger shows the classic 1970s silhouette — wide truncated-cone
|
||
base, bulbous→roundedly-pointed teardrop glass body, small cone cap — with navy fluid + moss blobs
|
||
and a neutral/metallic base+cap; body tints via `currentColor`; renders cleanly at ~24px.
|
||
|
||
**Performance**
|
||
|
||
24. **60 FPS** on a mid-range desktop with heat, density, and collision at non-trivial values
|
||
simultaneously; graceful degrade (drop internal resolution before frames) on weaker/mobile devices.
|
||
|
||
---
|
||
|
||
## 9. Suggested phasing / waves (R1–R4)
|
||
|
||
A **physics-and-collision first, color second, knob-bar third** sequence — the lava is the real work and
|
||
the riskiest, so prove it before the gradient and the UI rework. **These are reframe waves R1–R4, folded
|
||
under the open Phase 10 and distinct from its landed Waves 1–4.**
|
||
|
||
### Wave R1 — De-noise + footer clip + icon redraw (cheap, unblocks a clean canvas)
|
||
Remove the static noise/frost layer (§3), implement the dynamic footer-height clip + lava-rest line
|
||
(§2c), and **redraw the `DDIcons.LavaLamp` glyph** (§7f — independent, can land anytime, grouped here as
|
||
cheap polish). Small, independent, and gives a clean substrate to build the lava on. **Acceptance:** §8
|
||
#3, #4, #23.
|
||
|
||
### Wave R2 — The wax-blob physics + collision (the load-bearing step)
|
||
Stand up the CPU physics step (16–32 blobs: position/velocity/temperature/radius), the per-frame uniform
|
||
upload, the `smin` metaball render (**flat, coalescing fluid** — §4a refinement), the heat/gravity/density
|
||
mapping (**energy-coupled: high heat → small turbulent bubbles** — §4c), and the 2D collision model (both
|
||
pairs, the wide soft-mush ↔ hard up-and-out-throw blend, smooth/no jitter — §5a/§5c). This is where the
|
||
architecture is proven; it replaces the §4-effect GLSL. **Acceptance:** §8 #1, #5–#12, #24 (at this wave's
|
||
workload). Controls can be temporary sliders/debug knobs here — the real inline knob-bar is Wave R4.
|
||
|
||
### Wave R3 — The OKLab three-color gradient (the three motions)
|
||
Replace the HSL `mixHsl`/`vivify` color with OKLab interpolation; implement the three motions (anchor
|
||
rotation among X/Y/Z, per-segment baked sinusoidal variation **keyed to mix-time**, per-bar curve shift),
|
||
sourced from `DeepDrftPalettes` with no hardcoded duplicates. **Acceptance:** §8 #13–#17. The
|
||
per-segment-bake requirement (§6b motion 2) uses the mix-time-keyed realization so it travels with the
|
||
segment by construction (decided — §6b).
|
||
|
||
### Wave R4 — Seven controls + the NowPlaying-styled in-flow controls container
|
||
Widen `MixVisualizerControlState` to **seven** properties (including the new **waveform width** control,
|
||
§7a); add the scaffold's **`TopRowCenter`** slot and move the seven-knob `MixVisualizerControls` into an
|
||
**in-flow container between the back link and the lava-lamp** (§7b — animated horizontal grow/shrink in
|
||
the row's flow, lava-lamp icon toggles it, **no popover/drawer, no `position:absolute`**; **remove** the
|
||
old `.mix-visualizer-controls-anchor` + `position:absolute` floating structure); handle the narrow-width
|
||
in-flow wrap/drop (§7b-responsive); wire the waveform-width control to scale the waveform-band extent and
|
||
its collision boundary (§7a); style it to the session-hero aesthetic; extend the bridge handle with the
|
||
new setters (including `setWaveformWidth`). **Acceptance:** §8 #18–#22.
|
||
|
||
**Dependency shape:** `Wave R1 → Wave R2 → (Wave R3 ‖ Wave R4)`. Wave R1 is a quick unblock (and folds in
|
||
the independent icon redraw). Wave R2 is the prerequisite for everything visual. Waves R3 (color) and R4
|
||
(controls/knob-bar) both depend on Wave R2 but are independent of each other — the gradient can land
|
||
before the knob-bar, or vice versa, and each control's effect is independently tunable. Daniel tunes
|
||
ranges/transfer-functions by hand once on screen throughout (his standing preference).
|
||
|
||
---
|
||
|
||
## 10. Open items
|
||
|
||
Tuning knobs, one undecided behavior call, and one deferred future idea — the controls-UI fork and the
|
||
per-segment-storage fork are both now **decided** (§7b in-flow controls container between back & lamp —
|
||
redesigned 2026-06-16, superseding the rejected `position:absolute` floating bar; §6b mix-time-keyed).
|
||
None of the items below block starting Wave R1.
|
||
|
||
- **§4c, §5d — transfer functions:** heat 0..1 → rise/morph intensity; collision 0..1 → soft↔hard blend
|
||
shape; restitution coefficients; penetration-penalty curve. All staff-engineer tuning tasks with the
|
||
endpoints fixed here.
|
||
- **§4g — blob count band** within 16–32 and the density-control mapping to count vs. radius.
|
||
- **§6a — exact X/Y/Z theme-var bindings** per light/dark for the richest spread (the palette is the
|
||
source either way).
|
||
- **§7a — scroll-speed mapping:** reuse `MixZoomMapping` as a scroll-rate map vs. a fresh linear map.
|
||
- **§7e — NowPlaying source:** confirm the session hero overlay is the intended "NowPlaying" aesthetic
|
||
(strongest match in the tree) or point to a different component.
|
||
- **§7f — icon accent literals:** the two literal navy/moss stops the SVG forces (commented to their
|
||
`DeepDrftPalettes` source) — exact hexes are staff-engineer's to pull from the palette.
|
||
- **§7d — waveform-width icon glyph:** `WidthNormal` / `SwapHoriz` / `SettingsEthernet` suggested;
|
||
staff-engineer picks the final horizontal-extent glyph.
|
||
- **Defaults** for all seven controls — Daniel tunes on screen (the ~20% gravity / ~100% heat sweet spot,
|
||
§4c, anchors the heat/gravity pair).
|
||
|
||
### Undecided — Daniel calls (not blocking, but unresolved)
|
||
|
||
- **Pause behavior — does the lava keep convecting while audio is paused?** Today the visualizer freezes
|
||
on pause (the rAF loop is gated on `isPlaying` — Phase 10 §2e, carried forward). The reframe's wax
|
||
physics raises the question of whether the lava should **keep convecting/bubbling while paused** (the
|
||
lamp is "always on," the way a real lava lamp keeps going regardless of the music) or **freeze with the
|
||
scroll** as it does now. **Undecided — Daniel's call.** It affects the rAF-gating rule and whether the
|
||
physics step runs decoupled from playback. Flagged here; not blocking Wave R1/R2 (default to the current
|
||
freeze-on-pause behavior until Daniel decides; switching to always-convect is a localized change to the
|
||
gating, not the model).
|
||
|
||
### Future enhancements (explicitly out of the current reframe scope)
|
||
|
||
- **Per-control slow LFO auto-modulation.** Each knob could carry an optional **"auto-modulate" checkbox**
|
||
that, when enabled, gently oscillates that control's value over time via a **low-frequency oscillator**
|
||
(a slow sinusoid around the knob's set point), so the visualizer drifts and breathes on its own without
|
||
the user touching it. Daniel flagged this as a "cool future enhancement" (Wave R2 eval). **Deferred — not
|
||
in the R1–R4 scope.** When picked up it is additive: a per-control bool + amplitude/rate in
|
||
`MixVisualizerControlState`, an LFO applied at the bridge before the uniform push, and a small checkbox
|
||
affordance per knob in the inline knob-bar. Captured so the knob-bar's per-control layout (§7b) leaves
|
||
room for a future toggle, but nothing in R1–R4 builds it.
|