Merge p12-w2-t2-popover-panel into dev (12.E: popover-hosted waveform control panel)

This commit is contained in:
daniel-c-harvey
2026-06-17 11:22:36 -04:00
4 changed files with 178 additions and 49 deletions
@@ -0,0 +1,62 @@
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftShared.Client.Common
@* The single controls affordance, placed by an icon anywhere (Phase 12 §3d-revised). Closed state is
just the lava-lamp icon; clicking it floats the eight-knob WaveformVisualizerControls panel over the
surface. One panel, one popover host, reused on every host (Mix, Cut, Session, NowPlaying card) — the
SOLID seam: variance is the per-host anchor (§8e), never a forked popover.
Anchoring follows the SharePopover precedent: Fixed so the panel reads the trigger's bounding rect
rather than fighting CSS container tricks. AnchorOrigin/TransformOrigin are per-host
parameters (§8e) defaulted to bottom-right open-down — the cleanest case (Mix's TopRightAction corner);
tight hosts (the NowPlaying card) override to open away from the card body.
The popover owns NO control state and NO JS interop. The hosted WaveformVisualizerControls panel mutates
the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This host
only toggles open/closed and places the panel — it stays purely presentational. *@
@* Backdrop dismissal mirrors SharePopover: a viewport overlay closes on outside click. AutoClose stays
off so a knob drag (which can land pointer-up outside the panel's DOM subtree) does not dismiss. *@
<MudOverlay Visible="@_open" OnClick="@Close" />
@* Activator and popover share this wrapper so MudPopover anchors off the trigger's bounding rect. *@
<div>
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Size="@IconSize"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Visualizer settings"
aria-expanded="@_open" />
</MudTooltip>
<MudPopover Open="@_open"
Fixed="true"
AnchorOrigin="@AnchorOrigin"
TransformOrigin="@TransformOrigin"
Class="waveform-visualizer-control-popover">
<WaveformVisualizerControls PanelChrome="true" />
</MudPopover>
</div>
@code {
/// <summary>
/// Where the panel anchors relative to the trigger icon (§8e). Defaults to opening down-from the
/// icon's bottom-right — fits Mix's top-right corner and the ambient hosts. Tight hosts (the
/// NowPlaying card) override to open away from the card body.
/// </summary>
[Parameter] public Origin AnchorOrigin { get; set; } = Origin.BottomRight;
/// <summary>The panel's own corner that pins to <see cref="AnchorOrigin"/> (§8e).</summary>
[Parameter] public Origin TransformOrigin { get; set; } = Origin.TopRight;
/// <summary>Trigger-icon size. Defaults Large to match the Phase 10 Mix lava-lamp button.</summary>
[Parameter] public Size IconSize { get; set; } = Size.Large;
private bool _open;
private void Toggle() => _open = !_open;
private void Close() => _open = false;
}
@@ -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
@@ -1,13 +1,16 @@
/* The eight-knob band. Phase 10 §4: the host ALWAYS renders this component and the component @if-gates
the knobs on its Visible parameter. So the container is permanent and reserves its height whether or
not the knobs are present — content below the bar never pops on toggle. No collapse machinery, no
transitions, no glass surface. A plain transparent horizontal flex row of the eight knobs that wraps
to a second line only if the band is genuinely too narrow.
/* SCOPED fallback for the LEGACY inline mount only (Mix's TopRowCenter bar). The popover-hosted panel's
chrome lives in the GLOBAL deepdrft-styles.css (.waveform-visualizer-control-panel*) because MudPopover
portals its content out of this component's DOM subtree, where Blazor CSS isolation cannot reach. These
scoped rules apply only when the panel is mounted inline (not portaled) — i.e. Mix's existing bar.
min-height reserves one knob-row's worth of space (knob Size=64 + icon caption + gaps + margins) so
the empty (hidden) state occupies the same vertical box the populated single-row state does. On very
narrow viewports a populated band may wrap to a second row and exceed this floor — the no-pop
guarantee is exact for the common single-row (desktop) layout. */
Phase 10 §4: the inline host ALWAYS renders this component and the component @if-gates the knobs on its
Visible parameter. So the container is permanent and reserves its height whether or not the knobs are
present — content below the bar never pops on toggle. A plain transparent horizontal flex row of the
eight knobs that wraps to a second line only if the band is genuinely too narrow.
min-height reserves one knob-row's worth of space (knob Size=64 + icon caption + gaps + margins) so the
empty (hidden) state occupies the same vertical box the populated single-row state does. The
popover-panel rule in the global sheet overrides this min-height (a popover does not reserve height). */
.mix-visualizer-controls-bar {
display: flex;
flex-wrap: wrap;
@@ -19,7 +22,7 @@
}
/* One control: a RadialKnob with its Material icon caption underneath. RadialKnob has no icon slot, so
the icon rides adjacent (§7d). Center the pair so the seven read as a tidy bar. */
the icon rides adjacent (§7d). Center the pair so the eight read as a tidy bar. */
.mix-visualizer-control {
display: flex;
flex-direction: column;
@@ -28,8 +31,9 @@
}
/* The caption icon is a MudIcon (a Razor component), so Blazor CSS isolation does not stamp the scope
attribute onto its element — reach it with ::deep. Tinted to the secondary accent and the
overlay-label opacity so it matches the session-hero NowPlaying captions (§7e). */
attribute onto its element — reach it with ::deep. Tinted to the primary accent and the overlay-label
opacity so it matches the session-hero NowPlaying captions (§7e). The portaled popover panel tints the
same icons via the global sheet instead. */
.mix-visualizer-control ::deep .mix-visualizer-control-icon {
color: var(--mud-palette-primary);
opacity: 0.78;
@@ -375,6 +375,50 @@ h2, h3, h4, h5, h6,
word-break: break-all;
}
/* =============================================================================
WAVEFORM VISUALIZER CONTROL PANEL (Phase 12 §3d-revised / §3g)
The eight-knob panel hosted inside WaveformVisualizerControlPopover. MudPopover
PORTALS its content out of the component's DOM subtree, so Blazor CSS isolation
never reaches the rendered panel — its chrome must live here in the global sheet,
not in the scoped WaveformVisualizerControls.razor.css. (The scoped file keeps only
the inline-bar fallback Mix's legacy TopRowCenter mount uses, which is not portaled.)
The waveform-visualizer-control-panel class is applied ONLY when the component's
PanelChrome="true" parameter is set — which WaveformVisualizerControlPopover does
and Mix's inline mount does NOT. This prevents the chrome from leaking onto Mix's
inline controls bar.
The NowPlaying Hero look (§3g): dark-navy ground, green-accent knobs, light icons,
muted-navy filler — all from the deepdrft-* token source of truth, no hardcoded hex.
The RadialKnob reads --mud-palette-* for its arc/track/center/label colours; we pin
those palette vars to the Hero tokens ON THE PANEL so the panel reads the same
navy/green/off-white regardless of the page's light/dark theme.
============================================================================= */
.waveform-visualizer-control-panel.mix-visualizer-controls-bar {
/* Dark-navy elevated panel ground (§3g: navy-mid for the elevated surface). */
background: var(--deepdrft-navy-mid);
border: 1px solid var(--deepdrft-border-green);
border-radius: 8px;
padding: 1rem 1.25rem;
/* Popover panel: cap width so eight 64px knobs wrap to a tidy grid rather than one long bar.
This OVERRIDES the inline-bar min-height reserve (which only matters for Mix's non-popover mount). */
min-height: 0;
max-width: 340px;
/* Pin the MudBlazor palette vars the portaled RadialKnob consumes to the Hero tokens. */
--mud-palette-primary: var(--deepdrft-green-accent); /* knob value arc / pointer / center stroke */
--mud-palette-surface: var(--deepdrft-navy); /* knob center fill — darkest navy reads against the panel */
--mud-palette-surface-variant: var(--deepdrft-muted); /* knob background track — muted-navy filler (§3g) */
--mud-palette-text-primary: var(--deepdrft-white); /* knob value label — light (§3g) */
}
/* Green-accent caption icons (§3g: light/green icons). MudIcon is portaled here too, so this is a
plain global descendant selector — no ::deep, no scope attribute (CSS isolation does not reach
inside the popover). */
.waveform-visualizer-control-panel .waveform-visualizer-control-icon {
color: var(--deepdrft-green-accent);
opacity: 0.85;
}
@media (max-width: 419.98px) {
.deepdrft-track-detail-meta {
flex-direction: column;