feat(visualizer): popover-hosted control panel (Phase 12.E)

Build WaveformVisualizerControlPopover pairing the lava-lamp trigger with the eight-knob WaveformVisualizerControls panel; style to the NowPlaying Hero look from tokens. Panel chrome scoped to the popover mount via a PanelChrome flag; Mix's inline mount unchanged.
This commit is contained in:
daniel-c-harvey
2026-06-17 11:12:27 -04:00
parent ec3989c354
commit 7aeced6a8d
4 changed files with 178 additions and 49 deletions
@@ -2,102 +2,109 @@
@using DeepDrftPublic.Client.Services
@inject WaveformVisualizerControlState ControlState
@* The waveform visualizer controls. 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). 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).
Visibility (Phase 10 §4): the host ALWAYS renders this component now and feeds the lava-lamp toggle
into the @Visible parameter. THIS component decides knob visibility — it @if-gates the knobs but keeps
the container's reserved size, so the content below the controls bar never pops when the lamp toggles.
The gating is Blazor @if (matching the established "@if-gated knob band, no CSS hide/glass/animation"
convention) — the knobs are simply not rendered when hidden, while a min-height container holds the
layout. No collapse animation, no glass surface, no CSS visibility-hiding of populated knobs.
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.
It owns NO JS interop: it mutates the shared, session-scoped WaveformVisualizerControlState and raises its
Changed event. The backdrop 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).
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.
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.
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. *@
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. *@
<div class="mix-visualizer-controls-bar">
<div class="@($"{_panelChromeClass} mix-visualizer-controls-bar".TrimStart())">
@if (Visible)
{
<div class="mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
<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="mix-visualizer-control-icon" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
<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="mix-visualizer-control-icon" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Lava gravity">
<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="mix-visualizer-control-icon" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Lava heat">
<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="mix-visualizer-control-icon" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Fluid amount">
<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="mix-visualizer-control-icon" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<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="mix-visualizer-control-icon" />
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Collision strength">
<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="mix-visualizer-control-icon" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Waveform width">
<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="mix-visualizer-control-icon" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
}
@@ -105,12 +112,24 @@
@code {
/// <summary>
/// Whether the knob band is shown. The host wires its lava-lamp toggle straight into this — the host
/// always renders this component, and THIS component decides knob visibility (Phase 10 §4). When
/// 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 bar never pops as the lamp toggles.
/// content below the inline bar never pops as the lamp toggles. Inside the popover the host owns
/// open/closed, so the default keeps the panel populated.
/// </summary>
[Parameter] public bool Visible { get; set; }
[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.
/// </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