From 31e00e6abd97c5503cd01c94f09b1fc909a26c7b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 15 Jun 2026 23:38:26 -0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20spec=20Phase=2010=20Wave=204=20?= =?UTF-8?q?=E2=80=94=20Mix=20detail=20popover=20controls,=20RadialKnobs,?= =?UTF-8?q?=20lava-lamp=20icon,=20wider=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.md | 4 +- .../mix-visualizer-webgl-renderer.md | 250 ++++++++++++++++++ 2 files changed, 253 insertions(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index d633fd9..775cbe1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -196,7 +196,9 @@ Adds a **controls row** above the mix details / below the back button: four cont Full design, renderer architecture, the four effects, acceptance criteria, and phasing: `product-notes/mix-visualizer-webgl-renderer.md`. -**Sequenced as three waves.** Wave 1 (renderer swap at parity — prove WebGL2 on screen at 60 FPS, bridge intact, no new effects) is the load-bearing prerequisite. Wave 2 (controls row + widened state) and Wave 3 (the four effects in the shader) both follow Wave 1; the four effects within Wave 3 are independently shippable and tunable. **Deferred (Daniel):** control-range guards and motion-speed coupling to bubblyness — he tunes bad ranges by hand once on screen. **Landed:** Wave 1 (2026-06-15). Wave 2 (2026-06-15). +**Sequenced as four waves.** Wave 1 (renderer swap at parity — prove WebGL2 on screen at 60 FPS, bridge intact, no new effects) is the load-bearing prerequisite. Wave 2 (controls row + widened state) and Wave 3 (the four effects in the shader) both follow Wave 1; the four effects within Wave 3 are independently shippable and tunable. **Deferred (Daniel):** control-range guards and motion-speed coupling to bubblyness — he tunes bad ranges by hand once on screen. **Landed:** Wave 1 (2026-06-15). Wave 2 (2026-06-15). + +**Wave 4 — detail-page polish + controls rework (presentation only; the final wave).** A UI/placement pass over the Mix detail page — **no renderer, state, bridge, or mapping change.** (1) The four controls move out of the always-visible row into a **popover** (`MudPopover`, `SharePopover`-idiom) opened by a new bespoke **lava-lamp icon button** anchored **top-right of the body, across from the `← Back` link** (recommend a new `TopRightAction` slot on `ReleaseDetailScaffold`, laid as a SpaceBetween row with the back link). (2) The lava-lamp SVG lives in `DeepDrftShared.Client/Common/DDIcons.cs` in the hand-rolled gas-lamp style (`currentColor`, 24×24 viewBox, raw-string const) — a recognizable lamp with two-three suspended blobs. (3) The four `MudSlider`s become four **`RadialKnob`s** (`DeepDrftShared.Client/Components/RadialKnob.razor`) **in a row in the popover**, each carrying its existing Material icon (`ZoomIn`/`BubbleChart`/`Air`/`Palette`) **as an adjacent `MudIcon` caption** — RadialKnob has **no icon slot** (its `Label` is SVG text), so icons sit beside each knob. Knobs bind `Value`/`ValueChanged` to the **unchanged** `MixVisualizerControlState` via the **same `OnXChanged` handlers + `NotifyChanged()` seam** the sliders use today (resolution via `MixZoomMapping` fraction; other three normalized [0,1]; `HoldValue=false` for live feel). (4) **Widen the Mix body** to match the Sessions detail page — `MudContainer MaxWidth="Large"` (~1280px, up from the scaffold's 760px), Mix-scoped so Track detail is unaffected. **Depends on Wave 3 merged** (the knobs drive the Wave 3 effects) and **supersedes the controls-row design** (`product-notes/mix-visualizer-webgl-renderer.md` §3 → §7). Read-only contract intact; no knob is a seek surface. Full design + acceptance: that spec's **§7**. --- diff --git a/product-notes/mix-visualizer-webgl-renderer.md b/product-notes/mix-visualizer-webgl-renderer.md index 9d69b53..a66fc09 100644 --- a/product-notes/mix-visualizer-webgl-renderer.md +++ b/product-notes/mix-visualizer-webgl-renderer.md @@ -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 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` | 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 `` **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. +- `` (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.