docs(plan): spec Phase 10 Wave 4 — Mix detail popover controls, RadialKnobs, lava-lamp icon, wider body

This commit is contained in:
daniel-c-harvey
2026-06-15 23:38:26 -04:00
parent e9f4411fdf
commit 31e00e6abd
2 changed files with 253 additions and 1 deletions
@@ -418,6 +418,250 @@ hand).
---
---
## 7. Wave 4 — Detail-page polish + controls rework (presentation only)
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 13). 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:
@@ -427,3 +671,9 @@ All have recommended defaults inline; Daniel tunes on screen:
- 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.