feat(visualizer): Phase 15 control-deck rework

Centered tinted MudOverlay (NowPlayingCard chrome) replaces the anchored popover; eight dials become a deterministic three-row LAVA/WAVE layout; lava + waveform lamp toggles drive a genuine per-subsystem draw-skip; scroll/zoom becomes a slider; playful tooltips; green=interactive/light=static.
This commit is contained in:
daniel-c-harvey
2026-06-17 14:28:15 -04:00
parent fe481d0417
commit dd4f8ddded
8 changed files with 465 additions and 184 deletions
@@ -1,139 +1,221 @@
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftShared.Client.Common
@using DeepDrftPublic.Client.Services
@inject WaveformVisualizerControlState ControlState
@* The waveform visualizer control PANEL (Phase 12 §3d-revised). EIGHT continuous RadialKnobs — scroll
speed, gradient rotation speed, lava gravity, lava heat, fluid amount, fluid viscosity, collision
strength, waveform width — each its own dedicated control with a Material-icon caption. The single
"bubbles" knob is split into fluid-amount + fluid-viscosity (Phase 10 §5).
@* The waveform visualizer control PANEL (Phase 12 §3d-revised → Phase 15 re-layout). The control deck for
the lava-lamp visualizer: a deterministic THREE-ROW, sectioned layout that encodes what the visualizer
composes — a LAVA field and a WAVEFORM ribbon, each independently toggleable (Phase 15 §3):
This component is the PANEL CONTENT hosted inside WaveformVisualizerControlPopover. It no longer rides
an inline TopRowCenter bar; it lays out as a wrapped grid sized for a popover, styled to the NowPlaying
Hero look (§3g — dark-navy ground, green-accent knobs, light icons) from the deepdrft-* tokens.
Row 1 (MODE, always): lava lamp-toggle, waveform lamp-toggle, [collisions knob — only when BOTH on],
then the color knob pinned far-right (applies to the whole field, so always visible).
Row 2 (LAVA, only when lava on): "LAVA:" label + gravity / heat / fluid-amount / fluid-viscosity.
Row 3 (WAVE, only when waveform on): "WAVE:" label + scroll SLIDER + width knob pinned far-right.
Because MudPopover PORTALS its content out of this component's DOM subtree, Blazor CSS isolation does
not reach the rendered panel — so panel chrome lives in the GLOBAL deepdrft-styles.css
(.waveform-visualizer-control-panel*), not in the scoped .razor.css. The scoped .razor.css carries only
the inline-bar fallback (the .mix-visualizer-controls-bar reserved-height row) Mix's existing
TopRowCenter mount still uses, which is NOT portaled and so resolves under isolation.
The two lamp toggles are iconographic (lit LavaLampFilled / unlit LavaLamp glyph), green because they
are INTERACTIVE (the §5 colour principle: green = interactive, light = non-interactive). The eight
continuous dials are unchanged in tuning; the one widget-type change is scroll/zoom → a MudSlider (§8,
bound to ScrollSpeed alone). None of these is a seek surface (read-only contract §D).
Visibility: the popover host always shows the panel when open (Visible defaults true). Mix's legacy
inline mount still feeds its lava-lamp toggle into Visible (Phase 10 §4): the knobs @if-gate while the
container holds a reserved min-height so content below never pops on toggle.
This is the PANEL CONTENT hosted inside WaveformVisualizerControlPopover, now a screen-centered tinted
MudOverlay (Phase 15 §4). Because the overlay PORTALS its content out of this component's DOM subtree,
Blazor CSS isolation does not reach the rendered panel — so panel chrome AND the row/section layout live
in the GLOBAL deepdrft-styles.css (.waveform-visualizer-control-panel*), not the scoped .razor.css. The
scoped .razor.css carries only the legacy inline-bar fallback (Mix's old non-portaled mount), which may
now be dead post-Phase-12 but is left in place — flagged, not cut (out of Phase 15 scope).
COLOUR PRINCIPLE (§5): the lamp toggles + knob arcs/pointers + the slider track/thumb are green-accent
(interactive); the "LAVA:"/"WAVE:" section labels and the knob caption icons are LIGHT (static). All
colours are token-sourced (deepdrft-tokens.css) — no hardcoded hex.
It owns NO JS interop: it mutates the shared, session-scoped WaveformVisualizerControlState and raises
its Changed event. The visualizer bridge (WaveformVisualizer) subscribes to that event and pushes the
affected dial to the WebGL module. That keeps the JS module handle single-owned by the bridge and this
component purely presentational. None of these is a seek surface (read-only contract §D).
RadialKnob has no icon slot (its Label renders as SVG text) and no aria attribute-capture, so each
control's Material icon rides beside its knob as an adjacent MudIcon caption and the accessible name
rides on the wrapping group div (§7d). HoldValue stays false so the knobs are live — ValueChanged fires
continuously during drag, preserving the Changed/NotifyChanged seam. *@
its Changed event. The visualizer bridge (WaveformVisualizer) subscribes and pushes the affected dial /
subsystem-enable to the WebGL module. RadialKnob has no icon slot (its Label renders as SVG text) and no
aria capture, so each control's Material icon rides beside its knob as a caption and the accessible name
rides on the wrapping group div (§7); the playful MudTooltip rides alongside for sighted hover. *@
<div class="@($"{_panelChromeClass} mix-visualizer-controls-bar".TrimStart())">
@if (Visible)
{
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
<RadialKnob Value="@ControlState.ScrollSpeed"
ValueChanged="@OnScrollSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
@* ── Row 1 — MODE (always visible). Toggles + collisions group left; color pinned right. ── *@
<div class="wvc-row wvc-row-mode">
<div class="wvc-row-left">
<MudTooltip Text="Light the lamp — or let it go cold.">
<div class="wvc-toggle" role="group" aria-label="Lava field on or off">
<MudIconButton Icon="@(ControlState.LavaEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Color="Color.Primary"
OnClick="@ToggleLava"
aria-label="Lava field on or off"
aria-pressed="@ControlState.LavaEnabled" />
</div>
</MudTooltip>
<MudTooltip Text="Show the sound, or hide the ribbon.">
<div class="wvc-toggle" role="group" aria-label="Waveform ribbon on or off">
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Color="Color.Primary"
OnClick="@ToggleWaveform"
aria-label="Waveform ribbon on or off"
aria-pressed="@ControlState.WaveformEnabled" />
</div>
</MudTooltip>
@* Collisions are the interaction BETWEEN the two subsystems — meaningless with only one
present, so visible only when BOTH are on (§3 truth table). *@
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
{
<MudTooltip Text="How hard the blobs body-check the beat.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
}
</div>
@* Color applies to the whole field regardless of which subsystems are on, so it is pinned
far-right of row 1 and never reflows when collisions hides (§3). *@
<MudTooltip Text="How fast the lamp drifts through its colors.">
<div class="waveform-visualizer-control mix-visualizer-control wvc-row-right" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</div>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
@* ── Row 2 — LAVA section (only when lava on). ── *@
@if (ControlState.LavaEnabled)
{
<div class="wvc-row wvc-row-section">
<span class="wvc-section-label">LAVA:</span>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="How heavy the wax feels — float, or sink.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="How much goo is in the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="Runny and gooey, or tight little globes.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</div>
}
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
@* ── Row 3 — WAVE section (only when waveform on). Scroll is a SLIDER (§8); width pinned right. ── *@
@if (ControlState.WaveformEnabled)
{
<div class="wvc-row wvc-row-section">
<span class="wvc-section-label">WAVE:</span>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="How fast the sound rolls by.">
<div class="wvc-slider" role="group" aria-label="Waveform scroll speed">
<MudSlider T="double"
Value="@ControlState.ScrollSpeed"
ValueChanged="@OnScrollSpeedChanged"
Min="0" Max="1" Step="0.001"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudTooltip Text="How wide the ribbon spreads across the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control wvc-row-right" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</div>
}
}
</div>
@code {
/// <summary>
/// Whether the knob band is shown. The popover host shows the panel whenever it is open, so the
/// default is <c>true</c>. Mix's legacy inline mount still feeds its lava-lamp toggle into this — that
/// mount always renders the component, and THIS component decides knob visibility (Phase 10 §4): when
/// false the knobs are @if-gated out but the container holds its reserved height (CSS min-height), so
/// content below the inline bar never pops as the lamp toggles. Inside the popover the host owns
/// Whether the control deck is shown. The overlay host shows the panel whenever it is open, so the
/// default is <c>true</c>. Mix's legacy inline mount (if it survives) still feeds its lava-lamp toggle
/// into this — that mount always renders the component, and THIS component decides deck visibility
/// (Phase 10 §4): when false the rows are @if-gated out but the container holds its reserved height
/// (CSS min-height) so content below the inline bar never pops. Inside the overlay the host owns
/// open/closed, so the default keeps the panel populated.
/// </summary>
[Parameter] public bool Visible { get; set; } = true;
/// <summary>
/// When <c>true</c>, applies the <c>waveform-visualizer-control-panel</c> class to the root element,
/// enabling the global panel-chrome rules (dark-navy ground, border, max-width cap, pinned palette
/// tokens). Set by <see cref="WaveformVisualizerControlPopover"/>; Mix's inline mount leaves this
/// <c>false</c> so the chrome never leaks onto the inline bar.
/// enabling the global panel-chrome rules (NowPlayingCard chrome — square corners, lighter-navy
/// ground, thin light border — plus the row/section layout and pinned palette tokens). Set by
/// <see cref="WaveformVisualizerControlPopover"/>; Mix's inline mount leaves this <c>false</c> so the
/// chrome never leaks onto the inline bar.
/// </summary>
[Parameter] public bool PanelChrome { get; set; } = false;
private string _panelChromeClass => PanelChrome ? "waveform-visualizer-control-panel" : string.Empty;
// Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and
// pushes the affected dial. All values are already normalized [0,1]; the bridge maps scroll speed
// to a visible time-span and routes the rest straight to the lava/colour dials.
// Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and pushes
// the affected dial / subsystem-enable. All dial values are already normalized [0,1]; the bridge maps
// scroll speed to a visible time-span and routes the rest straight to the lava/colour dials. The two
// toggles flip a boolean (no value), driving the genuine per-subsystem draw-skip in the module (§6).
private void ToggleLava()
{
ControlState.LavaEnabled = !ControlState.LavaEnabled;
ControlState.NotifyChanged();
}
private void ToggleWaveform()
{
ControlState.WaveformEnabled = !ControlState.WaveformEnabled;
ControlState.NotifyChanged();
}
private void OnScrollSpeedChanged(double value)
{