# Phase 15 — Visualizer Controls Enhancements (Design Spec)

Status: **design-complete, implementation-ready** (all five open questions resolved by Daniel 2026-06-17 —
see §10). Author: product-designer. Date: 2026-06-17 (resolutions folded 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). *(superseded 2026-06-17 — reverted to a RadialKnob; see §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). "Off" means the subsystem is **genuinely not drawn** (no render cost), which means
  building a real per-subsystem draw-skip path in `WaveformVisualizer.ts` — the implementer should expect
  this enable seam **does not yet exist** and is part of the work, not a one-line flag. That is the one
  place this phase reaches past pure presentation — see §6 and resolved OQ §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** — an **iconographic lamp toggle** (lit/unlit lamp glyph), **green** (interactive, per
   the §5 colour principle). Turns the lava field on/off. (Resolved 2026-06-17 — OQ §10.4.)
2. **Waveform toggle** — an **iconographic lamp toggle** (lit/unlit), **green** (interactive). Turns the
   waveform ribbon on/off. Same lamp-toggle treatment as the lava toggle for a consistent row-1 pair.
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 1–3 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** — NowPlaying `.np-label` *typography*, recoloured light per
the §5 colour principle: labels are static, so light) 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. **Binds to `ScrollSpeed` alone**
  (confirmed 2026-06-17 — OQ §10.2); no separate zoom dimension is folded in.
- **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 **mild** opacity — slight, not a
  blackout (resolved 2026-06-17 — OQ §10.5: go mild). **The tint opacity must have one single point of
  truth** — a single token/constant (e.g. a `--deepdrft-modal-scrim-alpha` token in `deepdrft-tokens.css`,
  or one named const), **not a magic number repeated at call sites.** Mild ≈ 0.3 alpha as a starting eyeball
  value, set once in that single token so a future change is one edit.
- 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 5–9). 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

### Color principle (governs every control's colour, resolved 2026-06-17)

**green = interactive, light = non-interactive.** This single rule decides the colour of every element on
the panel:
- **Interactive** elements — the two lamp **toggles** (§3 row 1, §10.4), the knob **arcs/pointers**, the
  scroll **slider** track/thumb, and any other control the user can grab — are **green-accent**
  (`--deepdrft-green-accent` / the pinned `--mud-palette-primary`).
- **Static / decorative** elements — the **"LAVA:" / "WAVE:" section labels** (§10.3), the knob **caption
  icons** (§9, requirement 5), and any other label or ornament the user cannot act on — are **light**
  (`--deepdrft-white`).

This is one rule, not three instructions: Daniel's original requirement 5 (caption icons light), OQ3
(section labels light), and OQ4 (toggles green because interactive) are all the same principle. Apply it
uniformly; do not colour controls case-by-case. The affected sections below reference back to this rule
rather than restating the rationale.

### Chrome

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 **typographic** idiom (mono/tracking/uppercase) for the **"LAVA:" / "WAVE:" section labels** (§3), but recolour to **light** (`--deepdrft-white`), not green-accent. Labels are static, so by the colour principle above they are light; green is reserved for interactive elements. (Resolved 2026-06-17 — OQ §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.

**"Off" means fully absent (resolved 2026-06-17 — OQ §10.1).** When a subsystem is off it is **not drawn,
contributes no collisions, and incurs no render cost.** It is *not* dimmed, *not* drawn-then-hidden, *not*
faded — the subsystem's draw path is genuinely skipped. This sharpens the renderer touch beyond a flag
toggle: **the implementer should check `WaveformVisualizer.ts` for an existing per-subsystem enable seam
but should expect it does not yet exist.** Building that enable/disable seam — a real "don't render this
subsystem" path (skip the draw call / early-out, not a `visible=false` uniform on a subsystem that still
runs) — is **part of the work**, not a found primitive to flip. This is the one place the phase reaches
into the renderer, and it is a small build, not a one-line uniform push. The C# side of the seam is fully
specified here; the TS side is the additive draw-skip path staff-engineer builds against the live module.

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

> **SUPERSEDED (2026-06-17) — polish round 2.** Daniel reversed this decision after the initial landing:
> the WAVE-row scroll/zoom control was reverted from `MudSlider` back to a `RadialKnob`. The scroll
> control is now a knob like the other dials. The rationale below is the historical design record.

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 `0–1`, 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** — this is the §5 colour principle applied: caption icons are static/decorative,
so 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) — they are the interactive part of the control, so green
by the same principle. Only the Material caption *icons* go light. The "LAVA:" / "WAVE:" section labels go
light by the same rule (§5, §10.3 resolved).

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 — all RESOLVED 2026-06-17

(Kept visible per the project convention: resolved OQs are marked, not deleted.)

1. **Bridge action on the new toggles (§6). — RESOLVED 2026-06-17:** "Off" means **fully absent** — the
   subsystem is not drawn, contributes no collisions, and incurs **no render cost** (not dimmed, not
   drawn-then-hidden). The implementer should check `WaveformVisualizer.ts` for an existing per-subsystem
   enable seam but should **expect it does not yet exist**; building that genuine "don't render this
   subsystem" path (skip the draw, not a no-op `visible` uniform) is **part of the work**. This is a real
   renderer-side build, not a one-line flag toggle. (Reflected in §6 and the 15.A track scope.)
2. **Scroll/zoom binding (§3, §8). — RESOLVED 2026-06-17:** The scroll/zoom slider binds to **`ScrollSpeed`
   alone**. No separate zoom/resolution dimension is folded in.
3. **Section-label colour (§5, §9). — RESOLVED 2026-06-17:** "LAVA:" / "WAVE:" labels are **LIGHT**, not
   green. Rationale: green is reserved for interactive elements; labels are static, so light. (This is the
   §5 colour principle — green = interactive, light = non-interactive.)
4. **Toggle widget (§3 row 1). — RESOLVED 2026-06-17:** The toggles **ARE iconographic lamp toggles**
   (lit/unlit lamp glyph), and they are **green because they are interactive** (the §5 colour principle).
   Not plain text/switch toggles.
5. **Tint opacity (§4). — RESOLVED 2026-06-17:** Go **mild**. There must be **one single point of
   truth/change** for the tint opacity — a single token/constant, not a magic number repeated at call
   sites. (≈ 0.3 alpha as a starting eyeball value, set once in that token.)

---

## 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 bound to `ScrollSpeed` alone *(superseded 2026-06-17 — reverted to a RadialKnob; see §8)*; the other seven continuous
   controls are knobs; lava/waveform are **iconographic lamp toggles** (lit/unlit).
7. **Colour principle (green = interactive, light = non-interactive):** knob caption icons render **light**;
   "LAVA:" / "WAVE:" section labels render **light**; knob arcs/pointers, the scroll slider, and the lamp
   toggles render **green-accent**. No control is coloured against this rule. The tint scrim alpha resolves
   from a single token/constant (one point of change), set to a mild value.
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 **fully removes** that subsystem
   from the visualizer — not drawn, no collisions, no render cost (not dimmed). 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 + per-subsystem draw-skip seam (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`.
- **Build the per-subsystem enable/disable seam in `WaveformVisualizer.ts`** so an "off" subsystem is
  **genuinely not drawn** (no render cost — skip the draw path, not a `visible=false` uniform on a
  still-running subsystem). Per OQ §10.1, expect this seam **does not yet exist** — building it is part of
  this track, not flipping a found flag. This is the one real renderer-side build in the phase.
- **Touches:** `Services/WaveformVisualizerControlState.cs`, `Controls/WaveformVisualizer.razor`(.cs),
  and the TS module `DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts` (the one renderer touch —
  now a build, not a uniform push; budget for it accordingly).
- **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.
