From 7aeced6a8d827ab17d5839996384314f8d5078a8 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 17 Jun 2026 11:12:27 -0400 Subject: [PATCH] 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. --- .../WaveformVisualizerControlPopover.razor | 62 +++++++++++++ .../Controls/WaveformVisualizerControls.razor | 93 +++++++++++-------- .../WaveformVisualizerControls.razor.css | 28 +++--- .../wwwroot/styles/deepdrft-styles.css | 44 +++++++++ 4 files changed, 178 insertions(+), 49 deletions(-) create mode 100644 DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor diff --git a/DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor b/DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor new file mode 100644 index 0000000..14e9c59 --- /dev/null +++ b/DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor @@ -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. *@ + + +@* Activator and popover share this wrapper so MudPopover anchors off the trigger's bounding rect. *@ +
+ + + + + + + +
+ +@code { + /// + /// 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. + /// + [Parameter] public Origin AnchorOrigin { get; set; } = Origin.BottomRight; + + /// The panel's own corner that pins to (§8e). + [Parameter] public Origin TransformOrigin { get; set; } = Origin.TopRight; + + /// Trigger-icon size. Defaults Large to match the Phase 10 Mix lava-lamp button. + [Parameter] public Size IconSize { get; set; } = Size.Large; + + private bool _open; + + private void Toggle() => _open = !_open; + + private void Close() => _open = false; +} diff --git a/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor b/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor index 3207b8b..8325e04 100644 --- a/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor +++ b/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor @@ -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. *@ -
+
@if (Visible) { -
+
- +
-
+
- +
-
+
- +
-
+
- +
-
+
- +
-
+
- +
-
+
- +
-
+
- +
} @@ -105,12 +112,24 @@ @code { /// - /// 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 true. 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. /// - [Parameter] public bool Visible { get; set; } + [Parameter] public bool Visible { get; set; } = true; + + /// + /// When true, applies the waveform-visualizer-control-panel class to the root element, + /// enabling the global panel-chrome rules (dark-navy ground, border, max-width cap, pinned palette + /// tokens). Set by ; Mix's inline mount leaves this + /// false so the chrome never leaks onto the inline bar. + /// + [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 diff --git a/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor.css b/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor.css index 9fe3da1..9f10fb2 100644 --- a/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor.css +++ b/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor.css @@ -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; diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css index 5767715..3ee6e94 100644 --- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css +++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css @@ -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;