docs(phase-15): spec visualizer controls enhancements (modal popover, sectioned layout, lava/waveform toggles)

This commit is contained in:
daniel-c-harvey
2026-06-17 13:44:00 -04:00
parent 43bbc8172b
commit 6f00c6fa54
2 changed files with 459 additions and 0 deletions
@@ -0,0 +1,439 @@
# Phase 15 — Visualizer Controls Enhancements (Design Spec)
Status: **design-complete, implementation-ready** (with open questions flagged at §10). Author:
product-designer. Date: 2026-06-17. **No code has been written by this doc.**
This is a presentation + interaction rework of the **waveform visualizer control surface** — the eight
RadialKnob panel introduced in Phase 12 and hosted by `WaveformVisualizerControlPopover`. It does **not**
touch the WebGL2 renderer, the `WaveformVisualizerControlState` value model, the `Changed`-event bridge
seam, or any playback path. It is a widget/layout/primitive rework of how the controls are *presented and
reached*, and it adds **two new on/off toggles** (lava, waveform) plus **deterministic conditional
visibility** of the existing knobs. The visualizer stays read-only — no control added here is a seek
surface (the standing read-only contract, Phase 10 §D / Phase 12).
## Phase numbering
This is **Phase 15**, not 14. Phase 13 (CMS Public Landing) is the highest landed phase in
`COMPLETED.md`. Phase **14** is in active use by the `p14-w1-releases-consolidation` worktree (another
concurrent session). A second worktree, `nowplaying-card-reactivity`, is also live and touches the
NowPlaying surface this phase is adjacent to — so 14 is taken and 15 is the next genuinely-free number.
Cross-references (read these before implementing):
- `DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor[.css]` — the eight-knob panel being
re-laid-out. Today: a `flex-wrap` grid of eight knobs in `.mix-visualizer-controls-bar`, gated only by a
single `Visible` bool.
- `DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor` — the `MudPopover`-based host
being re-primitived (§4). Today: `MudPopover Fixed` anchored to the lava-lamp trigger + a transparent
`MudOverlay` for dismissal (no tint).
- `DeepDrftPublic.Client/Controls/NowPlayingCard.razor[.css]` — the **look-and-feel reference** for the
panel chrome (§5). Square corners, faint light border, light type on a dark ground.
- `DeepDrftPublic.Client/Controls/SharePopover.razor` — the established overlay/dismissal idiom
(`MudOverlay Visible OnClick`, `AutoClose` off so a knob drag does not dismiss). Carry it forward.
- `DeepDrftShared.Client/Components/RadialKnob.razor`**consumed, not modified.** Fixed API. Note: it
emits a single `<style>` block and its `Label` renders as SVG text — it has no icon slot and no aria
attributes, which is why icons ride beside knobs (§7) and accessible names ride on a wrapping group div.
- `DeepDrftShared.Client/Common/DDIcons.cs` — the lava-lamp trigger glyph (`LavaLamp`/`LavaLampFilled`),
consumed unchanged. (Note: DDIcons lives in **DeepDrftShared.Client**, not DeepDrftPublic.Client — the
root `CLAUDE.md` line that places it in `DeepDrftPublic.Client.Common` is stale; flag for doc-keeper.)
- `DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs` — the eight continuous dials. This
phase **adds two booleans** (`LavaEnabled`, `WaveformEnabled`) to it (§6).
- `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` — the **global** panel-chrome block
(`.waveform-visualizer-control-panel*`). Because MudBlazor portals popover/overlay content out of the
component's DOM subtree, Blazor CSS isolation cannot reach it; panel chrome lives in the global sheet,
not the scoped `.razor.css`. **This constraint persists** under the new primitive (§4, §5).
- `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` — token source of truth. All chrome colours
resolve from `--deepdrft-*`; **no hardcoded hex** (the RadialKnob's two filled-icon literals in DDIcons
are the documented named exception and are untouched here).
- `product-notes/mix-visualizer-webgl-renderer.md §7` and
`product-notes/phase-12-waveform-visualizer-generalization.md` — the popover idiom this evolves from.
---
## 1. Goal
Make the visualizer control surface (a) read as a deliberate, **screen-centered modal panel** rather than
a corner-anchored dropdown, (b) match the **NowPlayingCard** chrome, (c) lay the controls out in a
**deterministic, sectioned structure** that mirrors the two things the visualizer actually composes — the
**lava** field and the **waveform** ribbon — each independently toggleable, and (d) make every control
self-describing via a **playful tooltip**. This turns an undifferentiated grid of eight identical knobs
into a legible "lava lamp control deck."
**The reframe worth naming.** Today the eight knobs are presented as a flat, equal grid — the user cannot
tell at a glance which knobs affect the lava and which affect the waveform, and there is no way to turn
either subsystem off. The deterministic re-layout is not just cosmetic: it **encodes the visualizer's
composition** (lava + waveform, optionally overlaid) into the control surface, and the two new toggles
make "I only want the lava" or "I only want the waveform" a first-class choice. That is the load-bearing
change; the centering and chrome are the polish around it.
---
## 2. Scope boundary
**In scope.**
- Two new on/off toggle controls (lava, waveform) and the `WaveformVisualizerControlState` booleans that
back them (§6).
- A deterministic three-row layout with conditional visibility (§3).
- Re-primitiving the popover host to a **screen-centered, tinted modal** overlay (§4).
- NowPlayingCard-matched panel chrome (§5).
- One control changes widget type: **scroll/zoom becomes a slider, not a knob** (§3, §8).
- Per-control playful tooltips (§7) and the icon-colour change to light (§9).
**Out of scope / unchanged.**
- The **WebGL2 renderer** and all lava/waveform effects. This phase changes how control values are reached
and which are visible, not what they do in the shader.
- The eight **continuous dial values, their `Default*` consts, and the TS-side anchors.** The two new
booleans are additive; no existing dial is renamed or retuned.
- The **`Changed`-event bridge seam.** Controls still mutate `WaveformVisualizerControlState` + raise
`Changed`; `WaveformVisualizer` still subscribes and pushes the affected dial. The bridge cannot tell a
knob from a slider from a toggle — it re-reads state on `Changed`. **One bridge addition is required**:
the bridge must learn to act on the two new booleans (enable/disable the lava and waveform subsystems in
the WebGL module). That is a real renderer-side touch and is the one place this phase reaches past pure
presentation — see §6 and the open question §10.1.
- The lava-lamp **trigger glyph** (`DDIcons.LavaLamp`/`LavaLampFilled`) and its placement on each host
(Mix `TopRightAction`, Cut/Session ambient, NowPlaying corner). Unchanged.
- **Persistence model** — still DI-scoped `WaveformVisualizerControlState`: survives SPA nav, resets on
fresh load. The two new booleans inherit this for free.
- The **read-only contract** — no toggle, slider, or knob added here is a seek surface.
---
## 3. The layout contract (deterministic rows + conditional visibility)
The panel lays out as **up to three rows, top to bottom, in this exact order.** "Conditional" rows reserve
no permanent height — they appear/disappear with their subsystem. The contract is deterministic: given the
two toggle states, the visible controls and their order are fully determined.
### Row 1 — Mode row (always visible)
Left to right:
1. **Lava toggle** — on/off toggle button. Turns the lava field on/off.
2. **Waveform toggle** — on/off toggle button. Turns the waveform ribbon on/off.
3. **Collisions knob** — visible **only if BOTH lava AND waveform are on** (collisions are the interaction
between the two subsystems; with only one present there is nothing to collide). Backed by
`CollisionStrength`.
4. **Color knob** — pinned to the **far right** of row 1, always visible. Backed by `GradientRotationSpeed`
(the gradient/colour dial). Colour applies to the whole field regardless of which subsystems are on, so
it lives in the always-visible mode row, not in either conditional section.
Layout note: items 13 group left; item 4 is right-pinned (a `space-between` row, or a left group + a
right-aligned color knob). When collisions is hidden the color knob stays put on the right — the row must
not reflow the color knob leftward.
### Row 2 — LAVA section (visible only if lava is on)
A label **"LAVA:"** (mono, uppercase, light — the NowPlaying `.np-label` idiom) then, left to right:
- **Gravity** knob — `LavaGravity`.
- **Heat** knob — `LavaHeat`.
- **Fluid amount** knob — `FluidAmount`.
- **Fluid viscosity** knob — `FluidViscosity`.
(The "two Fluid knobs" in Daniel's brief = the Phase 10 split of the single density knob into
`FluidAmount` + `FluidViscosity`. Both live here.)
### Row 3 — WAVE section (visible only if waveform is on)
A label **"WAVE:"** (same idiom as LAVA:) then, left to right:
- **Scroll/zoom slider** — a **slider, not a knob** (the one widget-type change this phase makes; §8).
Backed by `ScrollSpeed`. Rationale: scroll/zoom is a "position along a continuum" feel — a horizontal
slider reads that more naturally than a rotary knob, and it visually distinguishes the one
spatial/temporal control from the eight physical-property knobs. (Open question §10.2: is it
`ScrollSpeed` alone, or does "scroll/zoom" want the zoom mapping too? `ScrollSpeed` is the only matching
dial that exists; treated as `ScrollSpeed` here.)
- **Width** knob — pinned to the **far right** of row 3. `WaveformWidth`.
### Visibility truth table
| Lava | Waveform | Row 1 controls | Row 2 (LAVA) | Row 3 (WAVE) |
|------|----------|-----------------------------------------|---------------------|-------------------------|
| off | off | Lava tgl, Wave tgl, Color (right) | hidden | hidden |
| on | off | Lava tgl, Wave tgl, Color (right) | gravity/heat/fluids | hidden |
| off | on | Lava tgl, Wave tgl, Color (right) | hidden | scroll slider, width |
| on | on | Lava tgl, Wave tgl, **Collisions**, Color (right) | gravity/heat/fluids | scroll slider, width |
Note: the **both-off** state leaves only the two toggles + color knob — the panel never collapses to
empty, so chrome (§5) always has content to frame.
### Layout reflow discipline
The panel re-lays-out as rows appear/disappear; this is fine in a centered modal (no surrounding page
content to push). But the modal should not "jump" jarringly on every toggle — recommend the panel be
top-anchored within the centered overlay (grows downward as rows appear) and animate height/opacity on the
conditional rows if cheap. A layout call for staff-engineer; the **contract above is the requirement**, the
animation is taste.
---
## 4. Primitive choice: screen-centered, tinted modal (the recommendation)
**Recommendation: replace `MudPopover` with a centered `MudOverlay` (tinted/dark-background, `Modal`) that
hosts the control panel in its center.** Do **not** keep `MudPopover`.
### Why not `MudPopover`
`MudPopover` is, by design, an **anchored** floating panel — it positions itself relative to a trigger's
bounding rect (that is its whole purpose; `Fixed="true"` + `AnchorOrigin`/`TransformOrigin` only choose
*which corner* of the trigger it hangs off). Daniel's requirement is the panel **centered on the screen,
independent of where the lava-lamp icon sits** (and the icon sits in four different places across hosts —
Mix corner, Cut/Session ambient, NowPlaying corner). Forcing a screen-centered position out of an
anchored popover means fighting its positioning model (transform overrides, `!important` CSS against
portaled inline styles) — exactly the "do not fight MudBlazor" Daniel called out. So we change primitive.
### Why `MudOverlay` over `MudDialog`
Two viable centered-modal primitives:
- **`MudOverlay`** (recommended): a full-viewport scrim we already use for dismissal (`SharePopover`,
the current popover host). Set `DarkBackground="true"` for the tint, `Modal` semantics via the overlay
itself, and center the panel as the overlay's child (the overlay is a flex container; center its
content). This is the **smallest delta from today's idiom** — we already host a `MudOverlay` for
dismissal; here it graduates from a transparent click-catcher to a tinted modal scrim that also *holds*
the panel. No `IDialogService`, no dialog registration, no `<MudDialogProvider>` dependency surprises.
- **`MudDialog`**: purpose-built for centered modals with built-in tint and focus management. Heavier:
needs `IDialogService` + a `<MudDialogProvider>` in the layout, turns the panel into a dialog component
invoked imperatively, and brings dialog chrome (title bar, close button) we would have to suppress to
keep the NowPlayingCard look. More machinery than this needs.
**Verdict: `MudOverlay` with `DarkBackground` + centered child panel.** It gives screen-centering and the
modal tint with the least new machinery and stays closest to the `SharePopover`/current-host idiom. If
staff-engineer finds `MudOverlay` centering or focus-trapping fights the knob-drag overlay, `MudDialog` is
the documented fallback — note it and escalate rather than hand-rolling a fixed-position div.
### Behavior contract
- Lava-lamp icon click → open the overlay (the icon trigger and its glyph are unchanged from Phase 12).
- Overlay visible → a **slight page tint** behind the panel so the panel reads as modal (Daniel:
"slight tint … reads as modal"). Use `MudOverlay`'s dark background at a **low opacity** — slight, not a
blackout. Recommend ~0.30.4 scrim alpha; final value is Daniel's to eyeball on screen.
- Click the tint (outside the panel) → close. Mirror `SharePopover`: `OnClick` on the overlay closes.
- **`AutoClose` stays off / outside-click must not fire during a knob drag.** RadialKnob mounts its own
full-viewport `position:fixed; z-index:9999` mouse-capture div while dragging (see `RadialKnob.razor`
lines 59). That capture div sits **above** the overlay scrim — verify the knob drag's pointer-up does
not register as an outside-click that closes the modal. The current host already guards this (`AutoClose`
off, dismissal via explicit `OnClick`); preserve it. If the new z-order conflicts, gate close-on-tint off
while any knob is dragging. **This is the highest-risk interaction detail in the phase — call it out in
acceptance (§11).**
- `Esc` closes (nice-to-have; follow `SharePopover` if it does this).
- No auto-close on value change — the user tunes multiple controls per session.
### CSS isolation note (unchanged constraint)
`MudOverlay` portals its content to the document body just as `MudPopover` does, so Blazor CSS isolation
still cannot reach the panel. Panel chrome **stays in the global `deepdrft-styles.css`**
(`.waveform-visualizer-control-panel*`), and the icon-colour rules stay global descendant selectors (no
`::deep`). The `PanelChrome` parameter on `WaveformVisualizerControls` keeps doing its job. The scoped
`.razor.css` keeps only the legacy inline-bar fallback (if Mix still mounts inline anywhere — verify; Phase
12 moved everyone to the popover, so the inline fallback may now be dead and removable, but that cleanup is
**not** in this phase's scope — flag, don't cut).
---
## 5. Look and feel — NowPlayingCard tokens
The panel chrome follows `NowPlayingCard` (`.now-playing` in `NowPlayingCard.razor.css`): **square
corners, a lighter-navy ground, a thin light border.**
| Aspect | NowPlayingCard today | Panel target |
|-------------------|---------------------------------------------------|-------------------------------------------------------------------|
| Corners | square (no `border-radius`) | **square** — drop the current `border-radius: 8px` |
| Ground | `rgba(250,250,248,0.06)` over a dark surface | **lighter navy**`--deepdrft-navy-mid` (current) is acceptable; Daniel says "lighter navy," so favour navy-mid over the darkest navy. Confirm on screen. |
| Border | `1px solid rgba(250,250,248,0.12)` (thin, light) | **thin light border** — replace the current `--deepdrft-border-green` with a faint light border in the NowPlayingCard spirit (light-on-dark, ~0.12 alpha). |
| Backdrop | `backdrop-filter: blur(8px)` | optional — nice over the visualizer; cheap on a small modal panel. Taste call. |
| Label type | `--deepdrft-font-mono`, 0.6rem, 0.25em tracking, uppercase, `--deepdrft-green-accent` | reuse this idiom for the **"LAVA:" / "WAVE:" section labels** (§3). Daniel wants light icons (§9) but the *text labels* matching the NowPlaying green-accent label is consistent — confirm whether labels are green-accent (matching `.np-label`) or light (matching the icon change). **Open question §10.3.** |
| Knob palette pins | n/a | keep the existing pinned `--mud-palette-*` → Hero tokens block (green-accent arc, navy center, light label). **Except**: icon colour changes to light (§9). |
The existing global block `.waveform-visualizer-control-panel.mix-visualizer-controls-bar` is where most of
this lands: drop `border-radius`, swap the border token, confirm the ground token. **All colours stay
token-sourced** — no hardcoded hex (deepdrft-tokens.css §). If a "thin light border on dark" token does not
exist, the NowPlayingCard uses a literal `rgba(250,250,248,0.12)`; prefer adding/▮reusing a
`--deepdrft-border-light` token over scattering the literal (a small token-hygiene win — flag to
staff-engineer, optional).
---
## 6. State model additions
`WaveformVisualizerControlState` gains **two booleans**:
- `LavaEnabled` (default **true**) — backs the row-1 lava toggle.
- `WaveformEnabled` (default **true**) — backs the row-1 waveform toggle.
Each gets a matching `DefaultLavaEnabled` / `DefaultWaveformEnabled` const, mirroring the existing
eight-dial default convention. Defaults **true/true** so the current behavior (both subsystems on) is the
out-of-the-box state — no visible change on first open.
The two toggles mutate their boolean + call `NotifyChanged()`, exactly as the knobs do. **The bridge
(`WaveformVisualizer`) must learn to act on these two booleans** — on `Changed`, read `LavaEnabled` /
`WaveformEnabled` and enable/disable the corresponding subsystem in the WebGL module (push an `enable`
uniform/flag, or skip the subsystem's draw). This is the one renderer-adjacent touch in the phase. The
exact JS-module surface (a `setLavaEnabled(bool)` / `setWaveformEnabled(bool)` interop pair, or folding
into existing uniform pushes) is **staff-engineer's call against the live `WaveformVisualizer.ts`** — see
open question §10.1. The C# side of the seam is fully specified here; the TS side is a small additive
uniform/branch.
Conditional-visibility logic (§3) reads these same booleans in `WaveformVisualizerControls.razor` `@if`
guards — single source of truth, no duplicated flag.
---
## 7. Per-control tooltip copy (useful-but-fun)
Every control gets a tooltip. This is a **lava-lamp visualizer** — copy should be playful and describe the
*felt effect*, not the technical parameter. Wrap each control (or its group div) in a `MudTooltip Text=...`
(the trigger already uses `MudTooltip`, so the idiom is in the file). Draft copy below — Daniel's to
approve/punch-up; these are starting points, not final.
| Control | State field | Suggested tooltip copy |
|------------------|-----------------------|-------------------------------------------------------------------------------|
| Lava toggle | `LavaEnabled` | "Light the lamp — or let it go cold." |
| Waveform toggle | `WaveformEnabled` | "Show the sound, or hide the ribbon." |
| Collisions | `CollisionStrength` | "How hard the blobs body-check the beat." |
| Color | `GradientRotationSpeed`| "How fast the lamp drifts through its colors." |
| Gravity (LAVA) | `LavaGravity` | "How heavy the wax feels — float, or sink." |
| Heat (LAVA) | `LavaHeat` | "Crank the burner. More heat, more rolling boil." |
| Fluid amount | `FluidAmount` | "How much goo is in the lamp." |
| Fluid viscosity | `FluidViscosity` | "Runny and gooey, or tight little globes." |
| Scroll/zoom slider | `ScrollSpeed` | "How fast the sound rolls by." |
| Width (WAVE) | `WaveformWidth` | "How wide the ribbon spreads across the lamp." |
Accessibility: RadialKnob has no aria capture, so the accessible name still rides on the wrapping group
`div` (`role="group" aria-label=...`) as it does today. Keep the group `aria-label` (plain/technical for
screen readers) **and** add the playful `MudTooltip` (visual hover) — they serve different audiences;
don't collapse one into the other.
---
## 8. The one widget-type change: scroll/zoom → slider
Today all eight controls are `RadialKnob`s. Daniel wants the WAVE-row scroll/zoom to be a **slider, not a
knob.** Use `MudSlider` (already in the codebase — `VolumeZone`, `WaveformSeeker` use it). Bind
`Value`/`ValueChanged` to `ScrollSpeed` through the same `OnScrollSpeedChanged``NotifyChanged()` handler
the knob uses today — the bridge does not care that the widget changed. Range `01`, step matching the
knobs' `0.001` for live feel. Style it to the panel: track/thumb in green-accent (the pinned
`--mud-palette-primary`), consistent with the knob arcs. A horizontal slider in the WAVE row reads as
"position along the scroll continuum," visually distinct from the rotary physical-property knobs — that
distinction is the point, not just Daniel's preference.
This is the **only** control that changes widget type; the other nine controls (two toggles + seven knobs)
keep their types.
---
## 9. Icon colour — light, not accent green
The knob caption icons today are tinted `--deepdrft-green-accent` (global rule
`.waveform-visualizer-control-panel .waveform-visualizer-control-icon { color: var(--deepdrft-green-accent); }`).
Daniel wants them **light**. Change that rule's `color` to `--deepdrft-white` (the panel's light token),
keeping the `opacity: 0.85`. This is a one-line token swap in the global sheet. The knob **arcs/pointers
stay green-accent** (the `--mud-palette-primary` pin) — only the Material caption *icons* go light. (This
is why §5's section-label colour is an open question §10.3 — with icons now light, a green-accent text
label may look inconsistent; Daniel decides.)
The scoped fallback rule (`.mix-visualizer-control ::deep .mix-visualizer-control-icon` in the
`.razor.css`) tints to `--mud-palette-primary` for the legacy inline mount — if that mount is dead post-
Phase-12, this is moot; if it survives, align it to light too for consistency. Flag, don't assume.
---
## 10. Open questions for Daniel
1. **Bridge action on the new toggles (§6).** Disabling a subsystem in the renderer — does the WebGL module
already have an enable/disable path per subsystem (lava draw, waveform draw), or does this need new
interop (`setLavaEnabled` / `setWaveformEnabled`)? This is the one renderer-side touch; staff-engineer
confirms against `WaveformVisualizer.ts`. **Does Daniel want "off" to mean fully not-drawn, or
faded/dimmed?** (Recommend: fully not-drawn — a toggle should read as on/off, not a dimmer.)
2. **Scroll/zoom binding (§3, §8).** "Scroll/zoom" — is this `ScrollSpeed` alone (the only matching dial),
or does Daniel also want a zoom/resolution dimension folded in? The standalone resolution control was
removed in Phase 10 (folded into scroll speed via `WaveformZoomMapping`). Treated here as `ScrollSpeed`;
confirm.
3. **Section-label colour (§5, §9).** With caption icons going light, should the "LAVA:" / "WAVE:" section
labels be green-accent (matching NowPlayingCard's `.np-label`) or light (matching the new icon colour)?
Recommend **green-accent** — it preserves the NowPlayingCard label idiom and gives the rows a colour
anchor against the otherwise-light controls. Daniel's eye.
4. **Toggle widget (§3 row 1).** Daniel said "toggle buttons." Recommend `MudToggleIconButton` or a
`MudButton` pair styled to the panel (lit/unlit), so each toggle reads as on/off at a glance — possibly
reusing the lava-lamp lit/unlit glyph spirit for the lava toggle. Exact widget is staff-engineer's;
confirm whether Daniel wants iconographic toggles (lamp lit/unlit, waveform shown/hidden) or plain
text/switch toggles.
5. **Tint opacity (§4).** "Slight" tint — recommend ~0.30.4 scrim alpha. Daniel eyeballs final.
---
## 11. Acceptance criteria
1. **Centering:** opening the controls from the lava-lamp icon on **every** host (Mix, Cut, Session,
NowPlaying) lands the panel **screen-centered**, not anchored to the icon — regardless of where the icon
sits on that host.
2. **Modal tint:** while the panel is open, the page behind shows a **slight** tint; the panel reads as
modal. Clicking the tint (outside the panel) closes it.
3. **Knob-drag safety:** dragging any knob (or the new slider) and releasing the mouse **outside** the
panel does **not** close the modal (the §4 RadialKnob capture-div / outside-click guard holds).
4. **Layout contract (§3):** the visibility truth table holds exactly — collisions appears iff both lava
and waveform are on; LAVA row appears iff lava on; WAVE row appears iff waveform on; color knob is always
far-right of row 1 and does not reflow when collisions hides; both-off leaves toggles + color only.
5. **Chrome:** panel has square corners, lighter-navy ground, thin light border — visibly matching
NowPlayingCard's treatment. All colours token-sourced; no new hardcoded hex.
6. **Widget types:** scroll/zoom is a slider; the other seven continuous controls are knobs; lava/waveform
are toggle buttons.
7. **Icons light:** knob caption icons render light (not green-accent); knob arcs/pointers stay green-accent.
8. **Tooltips:** every control has a hover tooltip with the playful copy (§7, as approved); group
`aria-label`s remain for screen readers.
9. **Toggles persist + drive the renderer:** flipping lava/waveform off visibly removes that subsystem from
the visualizer; the state survives SPA nav and resets on fresh load (DI-scoped state). Both default on.
10. **No regression:** the read-only contract holds (no control seeks); the eight existing dials behave
exactly as before when their subsystem is on; the bridge `Changed` seam is intact.
---
## 12. Wave / track decomposition
Sequenced as **one wave with four tracks.** 15.A is the load-bearing state + bridge change everything else
reads; 15.B (primitive/chrome) and 15.C (layout/widgets) both depend on 15.A's booleans existing; 15.D
(tooltips + icon colour) is cosmetic polish that can land last or fold into 15.C. The phase is small enough
to ship as a single PR if Daniel prefers (recommend one bundled PR — the tracks are tightly coupled around
one component pair and splitting would be churn); the decomposition below is for orientation and parallel
reasoning, not a mandate to split.
### 15.A — State booleans + bridge wiring (load-bearing prerequisite)
- Add `LavaEnabled` / `WaveformEnabled` (+ `Default*` consts) to `WaveformVisualizerControlState`.
- Teach `WaveformVisualizer` (the bridge) to push the two booleans to the WebGL module on `Changed`
(resolves OQ §10.1 with staff-engineer).
- **Touches:** `Services/WaveformVisualizerControlState.cs`, `Controls/WaveformVisualizer.razor`(.cs),
and the TS module `DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts` (the one renderer touch).
- **Gates:** 15.B, 15.C.
### 15.B — Screen-centered tinted modal primitive + NowPlayingCard chrome
- Re-primitive `WaveformVisualizerControlPopover` from `MudPopover` (anchored) to `MudOverlay`
(centered, `DarkBackground` tint, modal), preserving the `AutoClose`-off / knob-drag-safe dismissal idiom.
- Update the global panel-chrome block to NowPlayingCard treatment (square corners, lighter-navy ground,
thin light border) — `deepdrft-styles.css`.
- **Touches:** `Controls/WaveformVisualizerControlPopover.razor`,
`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`.
- **Depends on:** 15.A (panel content references the new toggles; primitive itself does not, but ship
together to avoid a half-state). **Coordinate with:** 15.C (both edit the panel/chrome).
### 15.C — Deterministic re-layout + toggles + scroll slider
- Rewrite `WaveformVisualizerControls.razor`'s layout into the three-row contract (§3) with `@if`
visibility off the two booleans.
- Add the two toggle-button controls (row 1) and the collisions conditional.
- Change scroll/zoom from `RadialKnob` to `MudSlider` (§8); keep the other seven knobs.
- Layout/section-label CSS for the three rows (global sheet, portaled).
- **Touches:** `Controls/WaveformVisualizerControls.razor`(.css),
`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`.
- **Depends on:** 15.A. **Coordinate with:** 15.B.
### 15.D — Tooltips + light icon colour (cosmetic polish)
- Add `MudTooltip` playful copy to each control (§7, as Daniel approves).
- Swap the caption-icon colour rule from green-accent to light (§9) — one line in the global sheet.
- **Touches:** `Controls/WaveformVisualizerControls.razor`,
`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`.
- **Depends on:** 15.C (tooltips wrap the final control layout). Can fold into 15.C.
**Dependency shape:** `15.A → {15.B, 15.C} → 15.D`. 15.B and 15.C are parallel but both edit the global
chrome sheet and the panel — if shipped as one PR (recommended), the coordination is free.
### Post-implementation doc-keeper note (not for this phase to execute)
- The root `CLAUDE.md` places `DDIcons.cs` in `DeepDrftPublic.Client.Common`; it actually lives in
`DeepDrftShared.Client/Common`. doc-keeper should correct this when the phase lands.
- The `DeepDrftPublic.Client/CLAUDE.md` `WaveformVisualizerControls` / `WaveformVisualizerControlPopover`
descriptions will need updating to the eight-knob-becomes-two-toggles-plus-seven-knobs-plus-slider
layout, the `MudOverlay` (not `MudPopover`) primitive, and the two new state booleans — doc-keeper's
task on landing, **not** product-designer's and **not** part of this spec.