706 lines
50 KiB
Markdown
706 lines
50 KiB
Markdown
# Mix Visualizer — WebGL2 Fragment-Shader Renderer (Design Spec)
|
||
|
||
Status: **design-complete, implementation-ready.** Author: product-designer. Date: 2026-06-15
|
||
(interview answers captured 2026-06-15). **No code has been written by this doc.**
|
||
|
||
This is the successor to the landed Canvas 2D Mix visualizer (8.K, `COMPLETED.md`). It replaces the
|
||
renderer wholesale; it is not a new feature bolted on. A future implementation wave can be dispatched
|
||
straight from this spec.
|
||
|
||
Cross-references:
|
||
- `product-notes/phase-9-mix-visualizer-redesign.md` — the 8.K spec the Canvas 2D renderer was built
|
||
from. **The motion model (§A), zoom/Guitar-Hero coupling (§B), read-only contract (§D), and the
|
||
datum-resolution analysis (§F, the 333ms-quarter-note-at-180BPM framework) all carry forward
|
||
unchanged.** This spec does not re-derive them — it references them and builds the new renderer on
|
||
top.
|
||
- `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — the Canvas 2D module being replaced.
|
||
- `DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor[.cs/.css]` — the Blazor bridge, **largely
|
||
preserved** (see §2).
|
||
- `DeepDrftPublic.Client/Services/MixVisualizerZoomState.cs` — the session-scoped persistence pattern
|
||
the new controls mirror (see §3).
|
||
- `DeepDrftPublic.Client/Controls/MixZoomMapping.cs` — the log-space slider↔seconds mapping, reused as-is.
|
||
- `DeepDrftShared.Client/Common/DeepDrftPalettes.cs` — the source of truth for the navy/moss theme
|
||
tokens (see §4c).
|
||
|
||
---
|
||
|
||
## Why a renderer swap, not an iteration
|
||
|
||
A staff-engineer analysis concluded the Canvas 2D approach **cannot afford the planned effects**. The
|
||
current renderer already strains at 1–2 FPS; the three per-frame killers are full-viewport `shadowBlur`,
|
||
the CSS `backdrop-filter: blur()` layer, and per-frame `getBoundingClientRect()`. More decisively: the
|
||
effects Daniel wants — bulge, detach, a morphing 2D color field, glass — are **all-dynamic, per-pixel,
|
||
per-frame** effects. That is precisely the workload Canvas 2D is worst at and a fragment shader is best
|
||
at: in a shader the effects become GLSL evaluated across every pixel in parallel on the GPU, and they
|
||
get *cheaper relative to Canvas* as they get fancier.
|
||
|
||
**Decision (Daniel, explicit): rebuild as a WebGL2 fragment-shader renderer. No Canvas 2D stopgap.**
|
||
"WebGL as step 1, no pussyfooting." This supersedes the 8.K spec's §E recommendation (which defaulted to
|
||
Canvas 2D and reserved WebGL only as a fallback) — that recommendation has been overtaken by the effect
|
||
roadmap. The 8.K spec's §E "no tricks, well-commented, industry-standard" *intent* still holds; it now
|
||
means **textbook WebGL2 with a well-commented shader**, not Canvas.
|
||
|
||
---
|
||
|
||
## 1. Goal and scope boundary
|
||
|
||
**Goal.** Replace the Canvas 2D Mix visualizer with a WebGL2 fragment-shader renderer that holds a
|
||
smooth **60 FPS** while carrying four live, per-pixel visual effects — bulge/bubble shaping, lava-lamp
|
||
detachment, a morphing navy↔moss 2D color field, and an in-shader glass treatment — driven by four
|
||
persistent continuous-slider controls. The visualizer remains a **read-only, playback-coupled,
|
||
windowed, bottom-to-top scrolling** background element on the Mix detail page.
|
||
|
||
**In scope.**
|
||
- A new WebGL2 fragment-shader rendering module replacing `MixVisualizer.ts` (the rAF loop, scroll/zoom
|
||
math, and theme handling move into / alongside the GL pipeline).
|
||
- A new **controls row** above the mix details: four continuous sliders (resolution, bubblyness, detach,
|
||
color-shift speed), all persistent across page visits.
|
||
- The four visual effects, in-shader.
|
||
- Preserving the existing Blazor↔JS bridge contract as far as the new inputs allow (§2).
|
||
|
||
**Out of scope / unchanged.**
|
||
- **No playback-control changes.** The visualizer stays strictly read-only (8.K §D). One-way
|
||
`PlaybackPosition` input; no seek, no scrub, no write-back. The dropped `OnSeek` seam stays dropped.
|
||
- **No datum format change.** The duration-derived ~333 samples/sec capture (8.K §F, Wave 1, landed)
|
||
already feeds the renderer correctly and is the right resolution for the max-zoom anchor. The new
|
||
renderer consumes the *same* `WaveformProfileDto` (base64 loudness bytes + duration-from-player). The
|
||
only change is that the datum now lands in the GPU as a **texture** rather than a JS array walked on
|
||
the CPU (§4b).
|
||
- **No change to where the visualizer sits** (full-page background behind `.mix-detail-foreground`),
|
||
except that the new controls row sits *above the mix details, below the back button* (§3).
|
||
- **Control-range guards** (resolution × bubblyness combinations that look unpleasant) — deferred per
|
||
Daniel; he tunes bad ranges by hand once it is on screen.
|
||
- **Motion-speed coupling to bubblyness** — deferred. Bubblyness controls *shape/amount only* for now;
|
||
scroll speed remains a pure function of zoom (8.K §B), independent of the bubble controls.
|
||
|
||
---
|
||
|
||
## 2. Renderer architecture (spec level)
|
||
|
||
The implementation choices below the seam are staff-engineer's. This section fixes the *contract* and
|
||
the *data-flow shape*, not the GLSL.
|
||
|
||
### 2a. Pipeline shape
|
||
|
||
A single full-window WebGL2 quad (two triangles covering the canvas), rasterized once per frame, with
|
||
**all the visual work in the fragment shader**. Each fragment (pixel) computes: which mix-time it maps
|
||
to (from its screen Y, the scroll offset, and the zoom), samples the loudness datum, decides whether it
|
||
is inside the ribbon / a bubble / a detached blob, and shades itself with the morphing gradient + glass.
|
||
The vertex shader is trivial (pass-through quad); the art is entirely fragment-side. This is the
|
||
textbook "shadertoy-style" full-screen-fragment pattern — well-trodden, well-documented, no exotic
|
||
tricks. Comment the shader so Daniel can follow it (8.K §E discipline carries forward).
|
||
|
||
**This is a deliberate inversion of the Canvas model.** Canvas walked screen rows on the CPU and built a
|
||
path; the shader instead asks, per pixel in parallel on the GPU, "am I inside the shape, and what color
|
||
am I?" The scroll/zoom math from `MixVisualizer.ts` (time-at-screen-Y, sample-at-time) moves essentially
|
||
intact into GLSL — the same equations, evaluated per-fragment instead of per-row. Preserve the
|
||
well-commented derivation; it is the part Daniel reads to follow the thing.
|
||
|
||
### 2b. How the waveform datum feeds in
|
||
|
||
**As a 1-D texture (or a 2-D texture one row tall), not a uniform array.** The datum is up to ~1.8 MB
|
||
(~1.8M samples for a 90-min mix at 333/s); uniform arrays are far too small and the wrong tool. A
|
||
texture is the standard vehicle: upload the normalized loudness samples once when the datum arrives (the
|
||
`setDatum` bridge call), and let the fragment shader sample it with the GPU's built-in linear filtering —
|
||
which gives the **smooth, no-stair-stepping interpolation at every zoom** the 8.K spec wanted, for free,
|
||
in hardware. Texture coordinate = `mixTime / durationSeconds`; the GPU interpolates between samples.
|
||
|
||
Upload happens once per datum (off the animation path), exactly mirroring today's once-per-datum decode.
|
||
The per-frame path touches no large buffer — it only updates small uniforms (§2c). This preserves the
|
||
existing optimization where a per-tick playback push never re-decodes the datum.
|
||
|
||
### 2c. Uniforms — what changes per frame vs. per change
|
||
|
||
The fragment shader is parameterized entirely by uniforms. Group them by update cadence:
|
||
|
||
- **Per-frame (updated in the rAF loop):**
|
||
- `playheadSeconds` — current playback position (drives the scroll).
|
||
- `timeSeconds` — a monotonic clock for the always-shifting color field and bubble animation (so the
|
||
field morphs and blobs drift even when scroll is slow). This is the one input that makes the field
|
||
"never static."
|
||
- **Per control change (updated when a slider moves):**
|
||
- `visibleSeconds` — the zoom (8.K §B), drives window time-span and apparent scroll speed.
|
||
- `bubblyness` — bulge amount (§5b).
|
||
- `detach` — lava-lamp detachment amount (§5c).
|
||
- `colorShiftSpeed` — gradient morph rate (§5d). (Multiplies `timeSeconds` inside the shader.)
|
||
- **Per theme change:** `colorNavy`, `colorMoss` (and any derived stops) — the gradient endpoints, read
|
||
from the live palette on a dark-mode toggle (§4c).
|
||
- **Per resize:** canvas resolution / aspect.
|
||
- **Once per datum:** the datum texture + `durationSeconds` + `samplesPerSecond`.
|
||
|
||
### 2d. The Blazor bridge — preserved
|
||
|
||
The existing `MixWaveformVisualizer.razor.cs` bridge is **largely reusable** and should be preserved.
|
||
What stays:
|
||
- Self-fetch of the datum from `ReleaseId` via `IReleaseDataService.GetMixWaveform` (unchanged).
|
||
- The cascaded-player coupling: `IStreamingPlayerService.StateChanged` subscription, `IsActivePlayer`
|
||
gating on `TrackId`, `CurrentPositionSeconds` / `IsPlaying` / `PlayerDurationSeconds` derivation
|
||
(unchanged — this is the playback signal, renderer-agnostic).
|
||
- The idempotent `PushDatumAsync` guard (don't re-upload the datum every tick) — even more important now
|
||
that the datum is a GPU texture upload.
|
||
- The `refreshTheme` push on dark-mode toggle.
|
||
- `IAsyncDisposable` teardown of the module handle.
|
||
- The module-handle shape (`create(canvas)` → handle with `setDatum` / `setPlayback` / `setZoom` /
|
||
`refreshTheme` / `dispose`). **Extend** it with `setBubblyness`, `setDetach`, `setColorShiftSpeed`;
|
||
do not redesign it.
|
||
|
||
What changes: `create` initializes a WebGL2 context (`canvas.getContext('webgl2')`) instead of `'2d'`,
|
||
compiles the shader program, and uploads the datum as a texture. The no-context fallback (return a no-op
|
||
handle so the component still renders a plain backdrop) carries forward — now guarding against *no WebGL2*
|
||
rather than *no 2D context*.
|
||
|
||
### 2e. Animation lives in the rAF/shader loop — NOT in Blazor re-renders
|
||
|
||
**Load-bearing constraint.** Today `OnPlayerStateChanged` calls `StateHasChanged()` on every player tick
|
||
(~10×/sec) to keep the slider/visibility in sync. That re-render cadence must **not** become the
|
||
animation driver — 10 Blazor re-renders/sec is both far below 60 FPS and the wrong layer for animation.
|
||
The animation is entirely the GPU rAF loop's job: the shader's `timeSeconds` uniform advances every frame
|
||
and the field morphs / blobs drift continuously between player ticks. Blazor pushes *playhead position*
|
||
on its tick (cheap uniform write); the *smooth motion between ticks* is the shader interpolating on its
|
||
own clock. The rAF loop stays gated on `isPlaying` (cool when paused — 8.K §E), with the same one-shot
|
||
redraw-while-idle behavior for zoom/theme/datum/resize changes.
|
||
|
||
---
|
||
|
||
## 3. The controls row
|
||
|
||
A new horizontal controls section for the visualizer, placed **above the rest of the mix details and
|
||
below the "back" button** on the Mix detail page. It holds the four controls in a row. All are
|
||
**continuous sliders** (MudSlider, `Step` fine enough to feel continuous — the existing zoom slider uses
|
||
`Step="0.001"`, follow that), and all are **persistent across page visits** within a listening session.
|
||
|
||
### 3a. The four controls, ranges and defaults
|
||
|
||
Daniel asked for sensible default ranges with the explicit note that he will tune them on screen.
|
||
Recommendations:
|
||
|
||
| # | Control | What it does | Slider range (normalized) | Default | Notes |
|
||
|---|---------|--------------|---------------------------|---------|-------|
|
||
| 1 | **Resolution** | Visible time-span / zoom (the existing 8.K control, relocated into this row) | 0 → 1, log-mapped to 30 s → 0.333 s | ~10 s window | Reuse `MixZoomMapping` and `MixVisualizerZoomState` exactly as-is. Just move the slider's home into the new row. |
|
||
| 2 | **Bubblyness** | Bulge amount of each bar (§5b) | 0 → 1 | ~0.35 | 0 = straight rectangular scrolling bars; 1 = fully rounded liquid silhouettes that bulge from the zero-line outward. Bars stay **attached** at max. |
|
||
| 3 | **Detach** ("unleash the lava lamp") | How much bubbles detach from the bar and rise independently (§5c) | 0 → 1 | 0 | 0 = fully attached (whatever bubblyness produced); 1 = blobs separate and float upward freely. Separate axis from bubblyness. |
|
||
| 4 | **Color-shift speed** | How fast the gradient field morph cycles (§5d) | 0 → 1, mapped to a slow→quick cycle-rate range | ~0.3 | Map normalized 0→1 onto a cycle period of roughly **60 s (very slow drift) → 4 s (pretty quick)**. Default ~0.3 lands around a ~20 s cycle — alive but not busy. Never fully zero (the field is "always shifting, never static" — §4b); the slow end is "barely perceptible drift," not "frozen." |
|
||
|
||
These are starting numbers for feel. The max-zoom 0.333 s anchor for control 1 is fixed (8.K §B); the
|
||
other three ranges are Daniel's to tune.
|
||
|
||
### 3b. Continuous-slider UX
|
||
|
||
All four are bare continuous sliders (no tick marks, no snap), `Size.Small`, `Color.Primary`, each with
|
||
an `aria-label`. Lay them in a row with compact labels (icon or short text caption). On mobile the row
|
||
may wrap or scroll horizontally — a layout call for staff-engineer, but the controls must remain
|
||
present (they are the one thing that affects the visualizer, and none of them is a seek surface — 8.K
|
||
§D holds).
|
||
|
||
### 3c. Persistence — mirror MixVisualizerZoomState
|
||
|
||
The existing `MixVisualizerZoomState` is a DI-**scoped** holder: it survives SPA navigation within one
|
||
WASM app instance (open a second mix, the slider keeps its place) and resets to default on a fresh page
|
||
load (F5). That is the "persist within session, reset on fresh load" model from 8.K §B, and it is the
|
||
pattern to mirror for **all four** controls.
|
||
|
||
**Decision (Daniel): widen the single state object rather than spawning four.** Rename/widen
|
||
`MixVisualizerZoomState` into a `MixVisualizerControlState` (scoped) holding `VisibleSeconds`,
|
||
`Bubblyness`, `Detach`, `ColorShiftSpeed`, each with a `const` default mirrored to the TS tuning anchors.
|
||
One injected dependency, one persistence story, four properties. (If Daniel later wants cross-session
|
||
persistence — "this mix should remember my lava-lamp settings" — that is a cookie/localStorage upgrade on
|
||
this one object, deferred and out of scope now, same as the 8.K note.)
|
||
|
||
Keep the C#-side defaults and the TS-side tuning anchors in sync, as the existing
|
||
`MixVisualizerZoomState.DefaultVisibleSeconds` / `DEFAULT_VISIBLE_SECONDS` pair already does (comment the
|
||
contract on both sides).
|
||
|
||
---
|
||
|
||
## 4. The four visual effects
|
||
|
||
> **SUPERSEDED (2026-06-16) by `product-notes/phase-10-mix-visualizer-lava-reframe.md` (the Phase 10
|
||
> reframe, Waves R1–R4).** Daniel tested
|
||
> the landed effects end-to-end and rejected the visual result — the lava read as "giant disconnected
|
||
> circles," the color drifted to cyan (an HSL saturation-boost artifact), and the waveform and lava read
|
||
> as two unrelated things. The reframe replaces this entire effects layer: the per-bar bulge + detach
|
||
> blobs become a **CPU-physics wax-blob lava with real 2D collision** (the waveform pushes the fluid out
|
||
> of its way), the HSL navy↔moss treatment becomes a **three-color OKLab gradient with three combined
|
||
> motions**, the separate "glass" effect folds into the blob shading, and the static noise/frost layer is
|
||
> **removed**. The renderer *infrastructure* (pipeline, datum texture, playhead interp, bridge) is reused;
|
||
> the *art* below is replaced. See the reframe spec §3–§6. The four-effect text below is retained as the
|
||
> record of what the rejected Wave 3 shipped.
|
||
|
||
Described as **intended look + shader-side approach in conceptual terms**. The exact GLSL is
|
||
staff-engineer's. Each effect is a continuous function of its control value, so the whole range from
|
||
"off" to "maxed" is reachable by dragging one slider.
|
||
|
||
### 4a. Waveform geometry (the substrate all effects sit on)
|
||
|
||
Traditional **−1..+1 amplitude with 0 at the center line**; peaks extend symmetrically above and below
|
||
the zero-line. The waveform **scrolls bottom-to-top** (new audio enters at the bottom, played audio exits
|
||
the top — 8.K §A, unchanged). Per-fragment: map screen Y → mix-time (via playhead + zoom), sample the
|
||
datum texture for loudness at that time, and the loudness defines the half-width of the symmetric ribbon
|
||
about the horizontal center. The "now" line sits at a fixed screen Y (center by default — 8.K §A). This
|
||
is the same geometry the Canvas renderer drew; in the shader it becomes a signed-distance test ("is this
|
||
pixel inside the ribbon silhouette?").
|
||
|
||
### 4b. The 2D morphing navy↔moss gradient field (the headline visual)
|
||
|
||
**Replace the boring grey with a living color field built from the two theme colors — navy blue and moss
|
||
green.** Two coupled dimensions:
|
||
|
||
1. **Per-bar / along-the-bar (the vertical / amplitude axis).** Color varies along each bar's height —
|
||
the zero-line and the peak differ. This gives each bar internal structure: a gradient from center-line
|
||
out to the peak.
|
||
2. **Along the scroll/time axis (the second dimension).** The mix between navy and moss **shifts along
|
||
time** — more green at some moments, more blue at others — and **always shifts, never static**, driven
|
||
by the `timeSeconds` clock × `colorShiftSpeed` (§3a control 4). This is what makes it a *field* and not
|
||
a fixed gradient.
|
||
|
||
**The tension to capture (Daniel's words):** it should read as **a continuous 2D color field** — coherent
|
||
across the whole window, neighbors related — *yet each bar should "feel like its own living thing."**
|
||
Shader approach to hold both at once:
|
||
- A smooth, low-frequency base field (navy↔moss) over (time-axis, amplitude-axis) gives the **field-like
|
||
coherence** — sampled with a flowing noise or layered sinusoids in the time dimension so it morphs
|
||
continuously. This is the "2D color field" layer.
|
||
- A higher-frequency per-bar modulation — keyed off each bar's own index/phase and its instantaneous
|
||
loudness — perturbs the field locally so adjacent bars are *related but not identical*, and a loud bar
|
||
reads brighter/more-saturated than its quiet neighbor. This is the "own living thing" layer.
|
||
- The two compose: coherent field + per-bar liveness = the intended tension.
|
||
|
||
Pull the **exact endpoints from the theme tokens** (§4c) so a dark-mode toggle re-themes the field live.
|
||
|
||
### 4c. Theme tokens — the concrete colors
|
||
|
||
The bespoke palettes live in `DeepDrftShared.Client/Common/DeepDrftPalettes.cs` (the "Charleston in the
|
||
Day" / "Lowcountry Summer Nights" identities map onto these). The navy/moss pair:
|
||
|
||
- **Navy:** `#17283f` (Primary, light) / `#0D1B2A` (the dark ground) / `#162437` (Surface, dark, elevated).
|
||
- **Moss / green:** `#3D7A68` (Secondary light / Primary dark — the interactive green) / `#429d6a`
|
||
(Tertiary light, green-interactive) / `#2A5C4F` (PrimaryDarken dark).
|
||
|
||
As in the Canvas renderer, the shader cannot resolve `var(--mud-palette-*)` directly — read the computed
|
||
CSS custom properties off the canvas element on theme refresh and pass them as `vec3` color uniforms.
|
||
**Recommended endpoints:** navy = `--mud-palette-primary` in light / the dark ground in dark; moss =
|
||
`--mud-palette-secondary` (light) / `--mud-palette-primary` (dark, where green *is* primary). Staff-
|
||
engineer picks the precise var bindings that give the richest navy↔moss spread per palette; the design
|
||
intent is "the two theme signature colors, navy and moss, as the two poles of the field." Re-read on
|
||
`refreshTheme` so the toggle re-themes live (preserve the existing mechanism).
|
||
|
||
### 4d. Bulge / bubble shaping — "bubblyness" (control 2)
|
||
|
||
**Intent.** At **minimum**: straight rectangular bars that only scroll bottom-to-top — a clean,
|
||
hard-edged waveform. As bubblyness rises: bars **gain space and bulge into rounded, liquid silhouettes
|
||
that swell from the center zero-line outward toward the peak** — like a lava-lamp bubble growing. At
|
||
**maximum**: fully rounded liquid blobs, but still **attached** to the bar (detachment is a separate
|
||
axis — §4e). Bubblyness controls **bulge shape/amount only**, not motion speed (deferred).
|
||
|
||
**Shader-side approach.** This is a signed-distance / metaball move. Each bar contributes a field
|
||
contribution; `bubblyness` interpolates the per-bar silhouette from a **box SDF** (sharp rectangle, bulge
|
||
= 0) toward a **rounded capsule / metaball SDF** (bulge = 1) that swells outward from the zero-line. As
|
||
bubblyness rises, increase the rounding radius and let neighboring bars' fields blend (metaball
|
||
union with a smooth-min) so the silhouette reads as continuous liquid rather than discrete bars. The
|
||
"swell from the center outward" is the SDF growing its radius along the amplitude axis with loudness. The
|
||
GPU evaluates the SDF per-pixel — this is exactly the per-pixel work that was unaffordable on Canvas and
|
||
is cheap in a shader.
|
||
|
||
### 4e. Detach — "unleash the lava lamp" (control 3)
|
||
|
||
**Intent.** A **separate** control from bubblyness. At 0, bubbles stay attached (whatever shape
|
||
bubblyness produced). As detach rises, the bulging bubbles **separate from the bar and rise independently**
|
||
— blobs pinch off and float upward on their own, the true lava-lamp experience. At max, freely floating
|
||
detached blobs drifting up through the window.
|
||
|
||
**Shader-side approach.** Building on the metaball field from §4d: as `detach` rises, displace a fraction
|
||
of each bar's bubble mass **upward** by an amount that grows over the bubble's life (the `timeSeconds`
|
||
clock gives the rise animation), and **weaken the smooth-min link** between the detaching blob and its
|
||
parent bar so the metaball connection thins and pinches off (classic metaball separation — two metaballs
|
||
pulling apart show the liquid "neck" thinning then breaking). Detached blobs become independent metaball
|
||
centers with their own upward velocity and slight horizontal drift, fading as they near the top. Keep the
|
||
count and motion bounded so it stays a hypnotic drift, not a particle storm — detach amount scales *how
|
||
much* detaches and *how far it rises*, not an unbounded spawn rate.
|
||
|
||
This effect is the clearest argument for the WebGL move: independent floating metaballs with smooth-min
|
||
merging, evaluated per-pixel every frame, is the canonical fragment-shader lava-lamp and is essentially
|
||
free on the GPU while being impossible at 60 FPS on Canvas 2D.
|
||
|
||
### 4f. Glass treatment (delegated to design judgment, in-shader)
|
||
|
||
Daniel named refraction/distortion of what's behind, specular highlights/sheen on the bars, frosted
|
||
translucency, and a wet glossy surface — and explicitly delegated the aesthetic: "use your terrific
|
||
claude design judgment for maximum style." **Hard constraint: achieve glass entirely in the shader. Do
|
||
NOT reintroduce a per-frame CPU `backdrop-filter: blur()`** — that was a confirmed perf killer in the
|
||
Canvas renderer and is exactly the cost the GPU move exists to eliminate.
|
||
|
||
**Recommended concrete, shippable glass treatment (all fragment-shader, all 60-FPS-affordable):**
|
||
|
||
1. **Refraction of what's behind.** The visualizer is a full-page background behind the mix-detail
|
||
content. True refraction of arbitrary DOM behind a WebGL canvas isn't directly available, so achieve
|
||
the *read* of refraction cheaply: (a) where a blob/ribbon's surface curves (high SDF gradient), warp
|
||
the gradient-field sampling coordinates by the surface normal — so the color field appears bent
|
||
through the glass at the edges, the hallmark of looking through a curved lens; and (b) keep the page
|
||
background showing through via translucency (below). This gives "refraction" as an internal distortion
|
||
of the field, which reads correctly without needing the actual DOM pixels.
|
||
2. **Specular highlights / sheen.** Treat each blob's surface as lit by a fixed virtual light: compute a
|
||
surface normal from the SDF gradient and add a sharp specular hotspot (Blinn-Phong-style highlight)
|
||
plus a soft broad sheen along the upper edge of each bulge. Highlights drift as blobs move — this is
|
||
what sells "wet glossy surface." Cheap: a couple of dot products per fragment.
|
||
3. **Frosted translucency.** The ribbon/blobs render at partial alpha over the page (carry forward the
|
||
RIBBON_OPACITY-style backdrop intent — it stays a backdrop, not a chart) with a subtle internal
|
||
softening (a small in-shader blur of the field, or a frosted noise modulation of alpha) so edges read
|
||
soft and lit rather than hard. This is the "frosted glass" read, done in-shader with no CSS filter.
|
||
4. **Fresnel edge glow.** Add a Fresnel term (brighter at grazing angles / silhouette edges) so the rims
|
||
of blobs catch light — the single most effective "this is glass / lit volume" cue, and one cheap
|
||
`pow(1 - dot(n, viewDir), k)` per fragment.
|
||
|
||
Net look: **lit, wet, frosted glass blobs of navy-and-moss light drifting upward** — hypnotic, ambient,
|
||
unmistakably "lava lamp made of theme-colored glass." The glass is layered *on top of* the color field
|
||
and geometry, so all four effects compose into one coherent surface.
|
||
|
||
---
|
||
|
||
## 5. Acceptance criteria (observable)
|
||
|
||
1. **Frame rate.** The visualizer holds **a smooth 60 FPS** on a mid-range desktop while playing, with
|
||
bubblyness, detach, and color-shift-speed all at non-trivial values simultaneously. (The whole point
|
||
of the swap — measure it, don't assume it.) Graceful degrade on weaker/mobile devices: drop internal
|
||
resolution or effect cost before dropping frames (8.K §E carries forward).
|
||
2. **Renderer.** Rendering is WebGL2 fragment-shader; there is no Canvas 2D path and no CSS
|
||
`backdrop-filter` on the visualizer. (The old per-frame killers — `shadowBlur`, `backdrop-filter`,
|
||
per-frame `getBoundingClientRect`-driven layout — are gone.)
|
||
3. **Motion.** A windowed slice scrolls bottom-to-top, coupled to playback; scroll holds when paused; no
|
||
scroll when nothing is playing; scrolls in-from-empty at the start and out-to-empty at the end (8.K §A
|
||
preserved). The rAF loop is gated on `isPlaying` and burns no frames while paused.
|
||
4. **Animation independence.** The color field visibly morphs and detached blobs visibly drift **between**
|
||
Blazor player ticks — i.e. motion is smooth at 60 FPS, not stepping at the ~10 Hz `StateHasChanged`
|
||
cadence. Animation is driven by the shader clock, not Blazor re-renders.
|
||
5. **Controls.** Four continuous sliders sit in a row above the mix details / below the back button. Each
|
||
visibly and continuously affects its target as it is dragged: resolution changes the visible
|
||
time-span and apparent scroll speed; bubblyness sweeps straight bars → attached liquid bulges; detach
|
||
sweeps attached → free-floating rising blobs; color-shift-speed sweeps barely-drifting → briskly
|
||
morphing field.
|
||
6. **Persistence.** All four control positions survive SPA navigation to another mix within a session and
|
||
reset to defaults on a fresh page load (mirrors `MixVisualizerZoomState`).
|
||
7. **Gradient.** Bars are filled with a navy↔moss gradient that (a) varies along each bar's height and
|
||
(b) shifts along the time axis and never sits fully static; the field reads as coherent across the
|
||
window while individual bars read as distinct/alive. Toggling dark mode re-themes the field live with
|
||
no reload.
|
||
8. **Geometry.** Amplitude is symmetric about a center zero-line (−1..+1), peaks extending both ways.
|
||
9. **Glass.** Blobs/ribbon read as lit, frosted, glossy glass — visible specular highlights, soft frosted
|
||
edges, edge/Fresnel glow, field distortion at curved surfaces — with no CPU backdrop-filter.
|
||
10. **Read-only.** No seek, no scrub, no write-back; the only inputs that affect the visualizer are
|
||
playback position (one-way) and the four sliders. No control is a seek surface.
|
||
11. **Bridge intact.** The component still self-fetches its datum from `ReleaseId`, couples to the
|
||
cascaded player via `TrackId` gating, and tears down its GL resources on dispose without leaking.
|
||
|
||
---
|
||
|
||
## 6. Suggested phasing / waves
|
||
|
||
The work decomposes naturally into a **parity swap first, effects second** sequence. This de-risks the
|
||
hardest architectural step (getting WebGL2 on screen at all, holding 60 FPS, bridge intact) before any
|
||
art is layered on, and gives Daniel a working renderer to tune effects against.
|
||
|
||
### Wave 1 — Renderer swap at parity (the load-bearing step)
|
||
Stand up the WebGL2 pipeline reproducing **today's visual** (the scrolling navy/moss-ish ribbon) but on
|
||
the GPU: full-window quad, datum-as-texture, scroll/zoom math ported into the fragment shader, rAF loop
|
||
gated on `isPlaying`, bridge contract preserved (`setDatum`/`setPlayback`/`setZoom`/`refreshTheme`/
|
||
`dispose`), no-WebGL2 fallback to a plain backdrop. **Acceptance:** §5 criteria 1 (at parity workload),
|
||
2, 3, 4, 10, 11 — a 60-FPS GPU renderer at visual parity with the Canvas version. No new effects yet.
|
||
This is where the architecture is proven; everything after is shading.
|
||
|
||
### Wave 2 — Controls row + state
|
||
Add the four-control row above the mix details, widen `MixVisualizerZoomState` →
|
||
`MixVisualizerControlState` (the three new properties + persistence), wire the three new
|
||
uniforms (`bubblyness`, `detach`, `colorShiftSpeed`) through the bridge as no-op-until-shader-uses-them
|
||
inputs. Resolution relocates here and keeps working. **Acceptance:** §5 criterion 6 and the slider half
|
||
of 5 (sliders present, persistent, wired) — even before the shader consumes them visibly.
|
||
(Wave 2 can begin once Wave 1's bridge is stable; the controls are inert visually until Wave 3 but the
|
||
plumbing is independent.)
|
||
|
||
### Wave 3 — The four effects in the shader
|
||
Layer the effects onto the proven pipeline, in increasing order of risk:
|
||
1. **Gradient field** (§4b) — replace the parity fill with the morphing 2D navy↔moss field. (§5.7)
|
||
2. **Bubblyness** (§4d) — box→metaball SDF interpolation. (§5.5 bubblyness)
|
||
3. **Detach** (§4e) — metaball pinch-off + rising blobs. (§5.5 detach)
|
||
4. **Glass** (§4f) — specular + Fresnel + frosted + field distortion, composed on top. (§5.9)
|
||
**Acceptance:** §5 criteria 5 (visual half), 7, 8, 9 — all four effects live and slider-driven at 60 FPS.
|
||
|
||
**Dependency shape:** `Wave 1 → (Wave 2 ‖ Wave 3 plumbing) → Wave 3 art`. Wave 1 is the prerequisite for
|
||
everything. Wave 2 (controls/state) and Wave 3 (effects) both depend on Wave 1's bridge but the four
|
||
effects within Wave 3 are independently shippable and independently tunable — land and tune one slider's
|
||
effect at a time. Color-range and bubblyness-range guards stay deferred throughout (Daniel tunes by
|
||
hand).
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 7. Wave 4 — Detail-page polish + controls rework (presentation only)
|
||
|
||
> **PARTIALLY SUPERSEDED (2026-06-16) by `product-notes/phase-10-mix-visualizer-lava-reframe.md` (the
|
||
> Phase 10 reframe, Waves R1–R4).**
|
||
> **Kept:** the lava-lamp icon-button trigger top-right of the body across from the back link (§7c, §7f
|
||
> — `DDIcons.LavaLamp`, landed; the **glyph itself is redrawn** by the reframe §7f).
|
||
> **Kept:** the widened Mix body (`MudContainer MaxWidth="Large"`, §7g, landed).
|
||
> **Superseded:** the four-knob **popover** becomes a six-knob **inline collapse/expand knob-bar** — NOT a
|
||
> popover or drawer (the reframe adds two controls —
|
||
> lava gravity, blob density/size, collision strength — and drops/recasts others: resolution/zoom is
|
||
> removed in favor of scroll speed; bubblyness/detach are replaced by the physical lava model). The
|
||
> controls become an `@if`-guarded animated flex row of six RadialKnobs inline in the controls area,
|
||
> toggled by the lava-lamp icon — see the reframe spec §7b. See the reframe spec §7 for the six controls,
|
||
> the inline knob-bar, and the NowPlaying-hero aesthetic target. The §7
|
||
> text below is retained as the record of what the four-knob popover shipped.
|
||
|
||
Status: **design-complete, implementation-ready.** Added 2026-06-15. **Depends on Wave 3 being merged**
|
||
(the knobs in this wave drive the four effects that Wave 3 makes real). **This wave supersedes the §3
|
||
always-visible controls-row design** — the row moves into a popover and the four MudSliders become four
|
||
`RadialKnob`s. The renderer, the control *values*, the `MixVisualizerControlState`, and the
|
||
`Changed`-event bridge seam are all **unchanged**; this is a widget/placement/width rework, not a
|
||
behavior change.
|
||
|
||
Files this wave touches (for orientation; staff-engineer's to implement):
|
||
- `DeepDrftPublic.Client/Controls/MixVisualizerControls.razor[.cs/.css]` — the four controls; sliders → knobs.
|
||
- `DeepDrftPublic.Client/Pages/MixDetail.razor[.css]` — container width; the new icon-button + popover placement.
|
||
- `DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor[.cs]` — a new top-right action slot (see §7c).
|
||
- `DeepDrftShared.Client/Common/DDIcons.cs` — the new lava-lamp SVG (note: DDIcons lives in
|
||
**DeepDrftShared.Client**, not DeepDrftPublic.Client — both apps consume it).
|
||
- `DeepDrftShared.Client/Components/RadialKnob.razor` — **consumed, not modified** (its API is fixed; §7e).
|
||
|
||
### 7a. Goal and scope boundary
|
||
|
||
**Goal.** Polish the Mix detail page and rework how the visualizer controls are presented: hide the four
|
||
controls behind a **popover** opened by a bespoke **lava-lamp icon button** anchored top-right of the
|
||
body (across from the `← Back` link top-left); replace the four sliders with four **RadialKnobs in a
|
||
row** inside the popover; and **widen the Mix detail body** to match the Sessions detail page.
|
||
|
||
**In scope.**
|
||
- Move the four controls out of the always-visible `TopContent` row into a popover (§7d).
|
||
- A new lava-lamp SVG icon in `DDIcons.cs` (§7f) and an icon button that triggers the popover (§7c).
|
||
- Replace each `MudSlider` with a `RadialKnob`, four in a row in the popover, carrying the existing
|
||
icons as captions adjacent to each knob (§7e).
|
||
- Widen the Mix body container from its current `760px` to the Sessions detail width (§7g).
|
||
|
||
**Out of scope / unchanged.**
|
||
- **The WebGL2 renderer** and all four effects (Waves 1–3). This wave changes *how you reach the four
|
||
control values*, not what they do or how the shader consumes them.
|
||
- **`MixVisualizerControlState`** — same object, same four properties, same `const` defaults, same
|
||
`Changed` event. The knobs mutate it exactly as the sliders do today (§7e). No new state, no rename.
|
||
- **The bridge** (`MixWaveformVisualizer`) and its `setBubblyness`/`setDetach`/`setColorShiftSpeed`/
|
||
`setZoom` pushes — untouched. It still subscribes to `MixVisualizerControlState.Changed`; it cannot
|
||
tell whether a knob or a slider moved.
|
||
- **`MixZoomMapping`** — resolution still rides the log-space fraction↔seconds mapping (§7e, knob 1).
|
||
- **The read-only contract** (8.K §D) — no knob is a seek surface; the popover adds no playback control.
|
||
- **Persistence model** — still session-scoped via the DI-scoped `MixVisualizerControlState`: survives
|
||
SPA nav, resets on fresh load (F5). The popover is pure presentation over the same state.
|
||
|
||
### 7b. Why a popover (the reframe)
|
||
|
||
The §3 always-visible row spends permanent vertical real estate on four controls that, in normal
|
||
listening, the user sets once and leaves. On a page whose *point* is a full-bleed living visualizer, a
|
||
persistent control strip competes with the art. Tucking the controls behind a single small affordance —
|
||
the lava-lamp button — returns the page to the visualizer and makes the controls a deliberate "I want to
|
||
tune this" gesture. This is the standard "progressive disclosure for advanced controls" move (cf.
|
||
Lightroom's collapsible panels, a video player's settings gear): the thing you adjust occasionally lives
|
||
one click away, not always on screen. The lava-lamp glyph also *names* what the controls do — it reads as
|
||
"the lava-lamp settings," which is exactly the visualizer's identity.
|
||
|
||
### 7c. The trigger: lava-lamp icon button, top-right of the body
|
||
|
||
**Placement.** Top-right of the body container, **horizontally across from the `← Back` link** (which
|
||
the scaffold renders top-left). Today `ReleaseDetailScaffold` renders, in order: the back link
|
||
(top-left), then `TopContent`, then the masthead. There is no top-right anchor today.
|
||
|
||
**Recommended scaffold change (minimal, reusable):** add an optional `TopRightAction` render-fragment
|
||
slot to `ReleaseDetailScaffold` and lay the back link + that slot as a single
|
||
`MudStack Row Justify="SpaceBetween"` at the top of the container — back link left, action right. This
|
||
keeps the back link where it is, gives a clean top-right anchor across from it, and is reusable by other
|
||
media later (it stays null for Track/Session, which don't supply it). The Mix page passes its lava-lamp
|
||
icon button into `TopRightAction`. Avoid absolute-positioning the button into the container corner — the
|
||
SpaceBetween row is the scaffold-idiomatic way and survives the width change in §7g.
|
||
|
||
The `TopContent` slot (which the §3 row used) is **emptied** by this wave — the controls no longer live
|
||
there. Leave the slot on the scaffold (other media may want it; it is generic), but `MixDetail` stops
|
||
passing the controls row into it.
|
||
|
||
**The button.** A `MudIconButton` (or `MudButton` with only the icon) rendering the lava-lamp SVG from
|
||
`DDIcons` (§7f), `Color.Secondary` to match the page's play affordance, with an `aria-label` like
|
||
"Visualizer settings". It is the popover's anchor and toggle.
|
||
|
||
### 7d. The popover
|
||
|
||
**Primitive: `MudPopover`, driven by an explicit `Open` bool toggled by the icon button.** Recommended
|
||
over `MudMenu` because the content is a custom four-knob layout with drag interaction, not a list of
|
||
menu-items — `MudMenu` is built for actionable item lists and would fight the knob drag/click model.
|
||
`MudPopover` is the right MudBlazor primitive for "anchored floating panel of arbitrary content," and it
|
||
is already in the codebase vocabulary (`SharePopover` is the precedent — follow its open/close idiom).
|
||
|
||
**Anchor & positioning.** Anchor to the lava-lamp button; open **below and right-aligned to the button**
|
||
(`AnchorOrigin=TopRight`, `TransformOrigin=TopRight` or the MudBlazor equivalent that drops the panel
|
||
down-and-left from a top-right trigger) so the panel opens *into* the page, not off the right edge. Add
|
||
modest elevation and the standard rounded surface so it reads as a floating panel over the visualizer.
|
||
|
||
**Open/close behavior.**
|
||
- Click the lava-lamp button → toggle open/closed.
|
||
- Click outside the panel → close (use MudPopover's overlay/`OutsideClickClose` idiom as `SharePopover`
|
||
does; a transparent click-catcher overlay is acceptable if that is the established pattern).
|
||
- The panel stays open while the user drags knobs (the knob's own global mouse-capture overlay, §7e,
|
||
must not be read as an outside-click that closes the popover — verify these don't conflict; if they do,
|
||
gate outside-click-close off while a knob is dragging).
|
||
- No auto-close on value change — the user tunes multiple knobs in one session.
|
||
- `Esc` closes (nice-to-have; follow `SharePopover` if it does this).
|
||
|
||
**Layout inside the popover:** the four knobs **in a single row** (§7e), each with its icon caption, with
|
||
comfortable padding. On a narrow viewport the row may wrap to 2×2 — a layout call for staff-engineer, but
|
||
all four must remain reachable (mirrors the §3b "none may drop" rule). The popover is the *only* home for
|
||
these controls after this wave.
|
||
|
||
### 7e. RadialKnob integration (grounded in the actual control)
|
||
|
||
**What `RadialKnob` actually is** (read from `DeepDrftShared.Client/Components/RadialKnob.razor`, 225
|
||
lines): a self-contained SVG knob — a 270° background arc, a value arc, a center dot, and a pointer line —
|
||
that the user drags **vertically** (up = increase) to change its value. Confirmed API:
|
||
|
||
| Member | Type | Notes |
|
||
|--------|------|-------|
|
||
| `Value` | `double` | Current value. **Two-way capable** via `ValueChanged`. |
|
||
| `ValueChanged` | `EventCallback<double>` | Fires on drag (immediately, unless `HoldValue`). This is the binding seam. |
|
||
| `Min` / `Max` | `double` | Value range. Internally normalizes to `[0,1]` for the arc/pointer. Defaults `0`/`100`. |
|
||
| `Step` | `double` | Quantization. Default `1`. **Set fine (e.g. `0.001`) for continuous feel** (matches the sliders' `Step="0.001"`). |
|
||
| `Label` | `string` | Rendered as SVG `<text>` **inside** the knob (centered, bottom). **Text only — there is NO icon slot/parameter.** Default `""`. |
|
||
| `Size` | `int` | Pixel width/height (square). Default `50`. Use a larger size (e.g. `64`–`80`) so four read clearly in a row. |
|
||
| `Color` | `MudBlazor.Color` | Maps to a `--mud-palette-*` var for the arc/pointer. Use `Color.Primary` (or `Secondary`) for theme consistency. |
|
||
| `HoldValue` | `bool` | If `true`, the value display shows the live drag value as `F0` and `ValueChanged` fires only on mouse-up. If `false` (default), `ValueChanged` fires continuously during drag and the `Label` shows. |
|
||
|
||
**Two consequences for this wave, called out because they shape the implementation:**
|
||
|
||
1. **No icon slot.** The existing controls carry MudBlazor Material icons — confirmed in
|
||
`MixVisualizerControls.razor`: `Icons.Material.Filled.ZoomIn` (resolution), `.BubbleChart`
|
||
(bubblyness), `.Air` (detach), `.Palette` (color-shift speed). RadialKnob's `Label` is SVG text, not
|
||
an icon. **Spec: render each icon as a `MudIcon` adjacent to its knob** (caption above or below the
|
||
knob, in a small `MudStack` per control), and use the knob's `Label` for nothing or for a short text
|
||
caption — **not** for the icon. One control = `{ MudIcon + RadialKnob }` stacked; four such stacks in
|
||
a row. Carry the four existing icons over **exactly** (same four `Icons.Material.Filled.*`).
|
||
|
||
2. **`HoldValue` choice.** Leave `HoldValue=false` (the default) so the knobs are **live** — the effect
|
||
responds continuously as the user drags, matching today's continuous-slider feel and keeping the §5
|
||
"visibly and continuously affects its target as it is dragged" acceptance. (`HoldValue=true` would
|
||
make the effect jump only on release — wrong for a "feel it move" tuning surface.) Trade-off: live
|
||
mode fires `ValueChanged` on every drag delta, which raises `Changed` and pushes a uniform per delta —
|
||
identical to the slider's current behavior, so no regression. The drag-time global mouse-capture
|
||
overlay (the `_isDragging` full-viewport div at `z-index: 9999`) must coexist with the popover (§7d
|
||
open/close note).
|
||
|
||
**Per-knob mapping — one knob per control, mutating `MixVisualizerControlState` exactly as the sliders
|
||
do today** (the `OnXChanged` handlers in `MixVisualizerControls.razor.cs` are reused verbatim — only the
|
||
widget that calls them changes):
|
||
|
||
| Knob | Icon (carried over) | Binds to | Min/Max/Step | Handler (unchanged) |
|
||
|------|--------------------|----------|--------------|---------------------|
|
||
| 1. Resolution | `ZoomIn` | `ControlState.VisibleSeconds` via `MixZoomMapping` | `0`/`1`/`0.001` on the **fraction** (`MixZoomMapping.SecondsToFraction` in, `FractionToSeconds` out) — identical to the slider | `OnResolutionChanged(fraction)` |
|
||
| 2. Bubblyness | `BubbleChart` | `ControlState.Bubblyness` | `0`/`1`/`0.001` | `OnBubblynessChanged(value)` |
|
||
| 3. Detach | `Air` | `ControlState.Detach` | `0`/`1`/`0.001` | `OnDetachChanged(value)` |
|
||
| 4. Color-shift speed | `Palette` | `ControlState.ColorShiftSpeed` | `0`/`1`/`0.001` | `OnColorShiftSpeedChanged(value)` |
|
||
|
||
Resolution stays special exactly as it is now: the knob's `Value` is the **fraction**
|
||
(`MixZoomMapping.SecondsToFraction(ControlState.VisibleSeconds)`), and `OnResolutionChanged` maps the
|
||
fraction back to seconds before raising `Changed`. The other three bind their normalized `[0,1]` value
|
||
directly. **The `NotifyChanged()` call after each mutation is preserved** — that is the bridge seam, and
|
||
it is what keeps this wave a pure widget swap. `aria-label` per knob carries over from the sliders.
|
||
|
||
### 7f. The lava-lamp SVG icon
|
||
|
||
**Home: `DeepDrftShared.Client/Common/DDIcons.cs`** — the same file as the hand-rolled gas-lamp
|
||
lit/unlit icons, in the same style. Match the existing convention exactly:
|
||
- A `public const string` using a C# raw-string literal.
|
||
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">` (same 24×24 viewBox as `GasLamp`).
|
||
- **`fill="currentColor"`** on the structural paths so the icon themes with its context (the gas-lamp
|
||
icons do this; it is load-bearing for dark-mode and `Color.Secondary` tinting). Accent fills (a warmer
|
||
blob color, à la the gas-lamp flame's `#FF9800`/`#FFCA28`) are acceptable for the lava blobs if a
|
||
two-tone read is wanted, but the lamp silhouette must be `currentColor`.
|
||
|
||
**What it depicts.** A recognizable **lava lamp**: a tapered conical/hourglass base, a tall rounded glass
|
||
vessel rising from it, a cap on top, and **two or three rounded lava blobs** suspended at different
|
||
heights inside the vessel (one larger toward the bottom, one or two smaller rising). The silhouette must
|
||
read as "lava lamp" at icon size (≈24px) — favor a bold, simple silhouette over fine detail, exactly as
|
||
the gas-lamp icon reads as a lantern at small size. The blobs are the recognizable cue; keep them
|
||
generous and few. (Precise path data is staff-engineer's; this describes the asset and its intent.)
|
||
|
||
Expose it the same way the gas-lamp icons are consumed (raw SVG string into a MudBlazor icon-rendering
|
||
context, as `MainLayout` does for the dark-mode toggle).
|
||
|
||
### 7g. Container width — match the Sessions detail page
|
||
|
||
**Current state.** `MixDetail` composes `ReleaseDetailScaffold`, whose root is
|
||
`.deepdrft-track-detail-container` — `max-width: 760px; margin: 0 auto` (in
|
||
`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`). `SessionDetail` does **not** use the scaffold; it
|
||
wraps its content in `MudContainer MaxWidth="MaxWidth.Large"` (MudBlazor's `Large` breakpoint, ~1280px),
|
||
giving it the wider, hero-dominant feel.
|
||
|
||
**Spec the widening.** Make the Mix detail body as wide as the Sessions detail body — i.e. the
|
||
`MudContainer MaxWidth="Large"` width (~1280px), up from 760px. Because `MixDetail` shares the scaffold
|
||
container with `TrackDetail`, **do not** widen `.deepdrft-track-detail-container` globally (that would
|
||
also widen Track detail, out of scope). Two acceptable approaches, staff-engineer's call:
|
||
|
||
1. **Preferred — wrap the Mix scaffold in `MudContainer MaxWidth="Large"`** and neutralize the inner
|
||
`max-width: 760px` for that instance (a Mix-specific class on the scaffold, or wrapping such that the
|
||
`MudContainer` is the constraining width). This reuses the *same primitive* SessionDetail uses, so the
|
||
two pages match by construction (memory: one source / shared idiom) rather than by a hand-copied pixel
|
||
value.
|
||
2. Alternative — a Mix-scoped override class that sets `max-width` to the `Large` breakpoint value. Works,
|
||
but couples the Mix page to a magic number that can drift from `Large`; prefer option 1.
|
||
|
||
Either way the body must visibly match the Sessions detail width. The full-bleed visualizer backdrop
|
||
(behind `.mix-detail-foreground`) is unaffected — it is already full-page; only the foreground content
|
||
column widens.
|
||
|
||
### 7h. Acceptance criteria (observable)
|
||
|
||
1. **Popover trigger.** A lava-lamp icon button sits at the **top-right** of the Mix detail body,
|
||
horizontally across from the `← Back` link (top-left). Clicking it opens a popover; clicking it again,
|
||
or clicking outside, closes it.
|
||
2. **Icon.** The button shows a recognizable lava-lamp glyph (sourced from `DDIcons`), themed via
|
||
`currentColor` so it tints correctly in light/dark.
|
||
3. **Four knobs in a row.** The popover contains exactly four `RadialKnob`s in a row (wrap allowed on
|
||
narrow viewports, none dropped), each captioned with its existing icon — `ZoomIn`, `BubbleChart`,
|
||
`Air`, `Palette`, in that order.
|
||
4. **Knobs drive the effects.** Dragging each knob continuously changes its `MixVisualizerControlState`
|
||
value and the corresponding effect responds live (post-Wave-3): resolution → visible time-span/scroll
|
||
speed; bubblyness → straight↔liquid bulge; detach → attached↔rising blobs; color-shift speed →
|
||
slow↔brisk field morph. Resolution still rides `MixZoomMapping`; the other three are normalized [0,1].
|
||
5. **Persistence preserved.** Knob positions survive SPA navigation to another mix within a session and
|
||
reset to defaults on a fresh page load (F5) — unchanged `MixVisualizerControlState` behavior.
|
||
6. **Old row gone.** The always-visible four-slider row (the §3 / Wave 2 design in `TopContent`) is no
|
||
longer rendered on the Mix detail page. The controls exist *only* in the popover.
|
||
7. **Width.** The Mix detail body container is as wide as the Sessions detail body
|
||
(`MudContainer MaxWidth="Large"`), not the former 760px. Track detail's width is unchanged.
|
||
8. **Bridge & read-only intact.** The visualizer still couples to playback via the unchanged bridge; no
|
||
knob and no popover element is a seek/playback surface; the renderer and effects are unchanged from
|
||
Wave 3.
|
||
|
||
### 7i. Phasing note
|
||
|
||
- **Depends on Wave 3 merged.** The knobs drive the four effects; without Wave 3 the knobs move inert
|
||
values (as the Wave 2 sliders do today). Wave 4 can be *built* against inert values but its acceptance
|
||
criterion 4 ("effect responds") requires Wave 3. Sequence after Wave 3.
|
||
- **Supersedes the §3 controls-row design.** §3 (always-visible MudSlider row in `TopContent`) is the
|
||
Wave 2 design; Wave 4 replaces it with the popover + knobs. §3 stays in this doc as the record of what
|
||
Wave 2 shipped; this §7 is the forward design that overtakes it. When Wave 4 lands, doc-keeper archives
|
||
the §3-vs-§7 transition per house style.
|
||
- **Pure presentation.** No renderer, state, bridge, or mapping change — the smallest-surface way to get
|
||
the popover + knobs + width is the right way. Resist scope creep into effect or state changes here.
|
||
|
||
---
|
||
|
||
## Open items (tuning knobs, none block starting)
|
||
|
||
All have recommended defaults inline; Daniel tunes on screen:
|
||
- Exact slider ranges for bubblyness / detach / color-shift-speed (§3a).
|
||
- The precise palette-var bindings for the navy/moss field endpoints per light/dark (§4c).
|
||
- Glass intensity / specular sharpness / Fresnel falloff (§4f) — pure aesthetic tuning.
|
||
- Whether the controls row wraps or horizontally scrolls on mobile (§3b) — layout call.
|
||
- Whether control state should later persist cross-session (cookie/localStorage) — deferred upgrade to
|
||
the one state object (§3c).
|
||
- **Wave 4:** knob `Size` (px) for the four-knob popover row, and whether each knob's `Label` carries a
|
||
short text caption or stays blank with only the icon caption (§7e) — layout/feel call.
|
||
- **Wave 4:** popover anchor/positioning fine-tuning (drop direction, elevation, width) and whether
|
||
outside-click-close must be gated off mid-knob-drag to avoid the drag overlay closing it (§7d).
|
||
- **Wave 4:** whether the lava-lamp icon is one-tone (`currentColor` only) or two-tone with accent blob
|
||
fills à la the gas-lamp flame (§7f) — aesthetic call.
|