Note the five round-2 changes in COMPLETED.md; mark §8 + the §2/§11 slider references superseded (scroll reverted to RadialKnob).
34 KiB
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: aflex-wrapgrid of eight knobs in.mix-visualizer-controls-bar, gated only by a singleVisiblebool.DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor— theMudPopover-based host being re-primitived (§4). Today:MudPopover Fixedanchored to the lava-lamp trigger + a transparentMudOverlayfor 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,AutoCloseoff 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 itsLabelrenders 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 rootCLAUDE.mdline that places it inDeepDrftPublic.Client.Commonis 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 §7andproduct-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
WaveformVisualizerControlStatebooleans 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 mutateWaveformVisualizerControlState+ raiseChanged;WaveformVisualizerstill subscribes and pushes the affected dial. The bridge cannot tell a knob from a slider from a toggle — it re-reads state onChanged. 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 inWaveformVisualizer.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 (MixTopRightAction, 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:
- 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.)
- 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.
- 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. - 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 toScrollSpeedalone (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). SetDarkBackground="true"for the tint,Modalsemantics 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 aMudOverlayfor dismissal; here it graduates from a transparent click-catcher to a tinted modal scrim that also holds the panel. NoIDialogService, no dialog registration, no<MudDialogProvider>dependency surprises.MudDialog: purpose-built for centered modals with built-in tint and focus management. Heavier: needsIDialogService+ 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-alphatoken indeepdrft-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:OnClickon the overlay closes. AutoClosestays off / outside-click must not fire during a knob drag. RadialKnob mounts its own full-viewportposition:fixed; z-index:9999mouse-capture div while dragging (seeRadialKnob.razorlines 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 (AutoCloseoff, dismissal via explicitOnClick); 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).Esccloses (nice-to-have; followSharePopoverif 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
MudSliderback to aRadialKnob. The scroll control is now a knob like the other dials. The rationale below is the historical design record.
Today all eight controls are RadialKnobs. 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.)
- 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.tsfor 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-opvisibleuniform) 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.) - Scroll/zoom binding (§3, §8). — RESOLVED 2026-06-17: The scroll/zoom slider binds to
ScrollSpeedalone. No separate zoom/resolution dimension is folded in. - 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.)
- 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.
- 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
- 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.
- 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.
- 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).
- 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.
- Chrome: panel has square corners, lighter-navy ground, thin light border — visibly matching NowPlayingCard's treatment. All colours token-sourced; no new hardcoded hex.
- Widget types: scroll/zoom is a slider bound to
ScrollSpeedalone (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). - 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.
- Tooltips: every control has a hover tooltip with the playful copy (§7, as approved); group
aria-labels remain for screen readers. - 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.
- 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
Changedseam 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) toWaveformVisualizerControlState. - Teach
WaveformVisualizer(the bridge) to push the two booleans to the WebGL module onChanged. - Build the per-subsystem enable/disable seam in
WaveformVisualizer.tsso an "off" subsystem is genuinely not drawn (no render cost — skip the draw path, not avisible=falseuniform 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 moduleDeepDrftPublic/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
WaveformVisualizerControlPopoverfromMudPopover(anchored) toMudOverlay(centered,DarkBackgroundtint, modal), preserving theAutoClose-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@ifvisibility off the two booleans. - Add the two toggle-button controls (row 1) and the collisions conditional.
- Change scroll/zoom from
RadialKnobtoMudSlider(§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
MudTooltipplayful 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.mdplacesDDIcons.csinDeepDrftPublic.Client.Common; it actually lives inDeepDrftShared.Client/Common. doc-keeper should correct this when the phase lands. - The
DeepDrftPublic.Client/CLAUDE.mdWaveformVisualizerControls/WaveformVisualizerControlPopoverdescriptions will need updating to the eight-knob-becomes-two-toggles-plus-seven-knobs-plus-slider layout, theMudOverlay(notMudPopover) primitive, and the two new state booleans — doc-keeper's task on landing, not product-designer's and not part of this spec.