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
@@ -15,13 +15,12 @@
TrackEntryKey="@Player?.CurrentTrack?.EntryKey" /> TrackEntryKey="@Player?.CurrentTrack?.EntryKey" />
</div> </div>
@* The lava-lamp popover trigger lands in the panel's top-right corner (full parity, §8e). Above the @* The lava-lamp trigger lands in the panel's top-right corner (full parity, §8e). Above the canvas
canvas and pointer-enabled so the icon is clickable even though the visualizer layer is and pointer-enabled so the icon is clickable even though the visualizer layer is
pointer-events:none. *@ pointer-events:none. The panel itself opens screen-centered (Phase 15 §4 — no per-host anchor),
so only the icon size is host-specific now. *@
<div class="np-visualizer-controls"> <div class="np-visualizer-controls">
<WaveformVisualizerControlPopover IconSize="Size.Small" <WaveformVisualizerControlPopover IconSize="Size.Small" />
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopLeft" />
</div> </div>
@* Pulsing rings *@ @* Pulsing rings *@
@@ -265,9 +265,10 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
return; return;
} }
// Seed the module with the current state now that it exists. All seven control values // Seed the module with the current state now that it exists. All control values (the eight
// come from the shared (session-persisted) state, so a mix opened mid-session seeds the // dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state,
// module with the knob positions the listener left them at. // so a mix opened mid-session seeds the module with the knob/toggle positions the listener
// left them at.
await PushControlsAsync(); await PushControlsAsync();
await PushDatumAsync(); await PushDatumAsync();
await PushPlaybackAsync(); await PushPlaybackAsync();
@@ -291,8 +292,9 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
// ── Bridge pushes. Each is a no-op until the module handle exists. ─────────────────────────── // ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
/// <summary> /// <summary>
/// Push the eight control values to the module from the shared state. Used to seed on first render /// Push the control values to the module from the shared state — the eight continuous dials plus the
/// and to re-push when the controls bar signals a change. Each value is its own dedicated dial: /// two Phase 15 subsystem enables. Used to seed on first render and to re-push when the controls
/// panel signals a change. Each value is its own dedicated dial / enable:
/// <list type="bullet"> /// <list type="bullet">
/// <item>scroll speed [0,1] is mapped onto the useful zoom band via /// <item>scroll speed [0,1] is mapped onto the useful zoom band via
/// <see cref="WaveformZoomMapping.ScrollKnobToSeconds"/> and pushed through <c>setScrollSpeed</c> /// <see cref="WaveformZoomMapping.ScrollKnobToSeconds"/> and pushed through <c>setScrollSpeed</c>
@@ -302,7 +304,9 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
/// <item>fluid amount → <c>setFluidAmount</c> (blob count + volume); fluid viscosity → /// <item>fluid amount → <c>setFluidAmount</c> (blob count + volume); fluid viscosity →
/// <c>setFluidViscosity</c> (cohesion / sphere-restoration) — the Phase 10 split of the /// <c>setFluidViscosity</c> (cohesion / sphere-restoration) — the Phase 10 split of the
/// former single density knob;</item> /// former single density knob;</item>
/// <item>waveform width → the ribbon-extent uniform.</item> /// <item>waveform width → the ribbon-extent uniform;</item>
/// <item>lava / waveform enabled → <c>setLavaEnabled</c> / <c>setWaveformEnabled</c>, the genuine
/// per-subsystem draw-skip (no physics / no blob upload, ribbon SDF skipped — §10.1).</item>
/// </list> /// </list>
/// </summary> /// </summary>
private async Task PushControlsAsync() private async Task PushControlsAsync()
@@ -319,6 +323,11 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
await _handle.InvokeVoidAsync("setFluidViscosity", ControlState.FluidViscosity); await _handle.InvokeVoidAsync("setFluidViscosity", ControlState.FluidViscosity);
await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength); await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength);
await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth); await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth);
// Phase 15 — the two subsystem enables. "Off" is a genuine draw-skip in the module (no physics,
// no blob upload / ribbon SDF skipped), not a dim. Pushed through the same Changed seam as the
// dials, so a toggle re-reads here exactly as a knob does.
await _handle.InvokeVoidAsync("setLavaEnabled", ControlState.LavaEnabled);
await _handle.InvokeVoidAsync("setWaveformEnabled", ControlState.WaveformEnabled);
} }
/// <summary> /// <summary>
@@ -1,56 +1,56 @@
@namespace DeepDrftPublic.Client.Controls @namespace DeepDrftPublic.Client.Controls
@using DeepDrftShared.Client.Common @using DeepDrftShared.Client.Common
@* The single controls affordance, placed by an icon anywhere (Phase 12 §3d-revised). Closed state is @* The single controls affordance, placed by an icon anywhere (Phase 12 §3d-revised, re-primitived
just the lava-lamp icon; clicking it floats the eight-knob WaveformVisualizerControls panel over the Phase 15 §4). Closed state is just the lava-lamp icon; clicking it floats the control panel as a
surface. One panel, one popover host, reused on every host (Mix, Cut, Session, NowPlaying card) — the SCREEN-CENTERED, tinted MODAL over the whole surface. One panel, one host, reused on every host (Mix,
SOLID seam: variance is the per-host anchor (§8e), never a forked popover. Cut, Session, NowPlaying) — the SOLID seam.
Anchoring follows the SharePopover precedent: Fixed so the panel reads the trigger's bounding rect PRIMITIVE (Phase 15 §4): a centered MudOverlay, NOT an anchored MudPopover. The panel must read as
rather than fighting CSS container tricks. AnchorOrigin/TransformOrigin are per-host screen-centered regardless of where the lava-lamp icon sits (Mix corner, Cut/Session ambient,
parameters (§8e) defaulted to bottom-right open-down — the cleanest case (Mix's TopRightAction corner); NowPlaying corner). An anchored popover positions off the trigger's bounding rect — the wrong model
tight hosts (the NowPlaying card) override to open away from the card body. for "centered on the screen." So the icon is just an opener; the overlay hosts the panel in its centre
(the overlay is a full-viewport flex container — its content is centered by .waveform-visualizer-control-
overlay in the GLOBAL sheet, since the overlay portals out of this subtree). DarkBackground gives the
mild modal tint (alpha from the single --deepdrft-modal-scrim-alpha token, §10.5). There is therefore
no AnchorOrigin/TransformOrigin: a centered modal has no anchor (Phase 15 drops those parameters).
The popover owns NO control state and NO JS interop. The hosted WaveformVisualizerControls panel mutates KNOB-DRAG SAFETY (Phase 15 §4, highest-risk detail): RadialKnob mounts its own full-viewport
the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This host position:fixed; z-index:9999 mouse-capture div WHILE dragging (RadialKnob.razor lines 59). That capture
only toggles open/closed and places the panel — it stays purely presentational. *@ div sits ABOVE the overlay scrim, so a knob drag's pointer-up lands on the capture div, never the scrim
— the overlay's OnClick does not fire mid-drag, so releasing the mouse outside the panel does NOT close
the modal. AutoClose is left OFF (the default) and dismissal is via the explicit scrim OnClick only,
carrying the SharePopover idiom forward under the new primitive. The panel stops click propagation so a
click INSIDE it never bubbles to the scrim's close handler.
@* Backdrop dismissal mirrors SharePopover: a viewport overlay closes on outside click. AutoClose stays The host owns NO control state and NO JS interop. The hosted WaveformVisualizerControls panel mutates
off so a knob drag (which can land pointer-up outside the panel's DOM subtree) does not dismiss. *@ the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This
<MudOverlay Visible="@_open" OnClick="@Close" /> host only toggles open/closed and centers the panel — it stays purely presentational. *@
@* Activator and popover share this wrapper so MudPopover anchors off the trigger's bounding rect. *@ <MudTooltip Text="Visualizer settings">
<div> <MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
<MudTooltip Text="Visualizer settings"> Size="@IconSize"
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)" Color="Color.Secondary"
Size="@IconSize" Disabled="@(!RendererInfo.IsInteractive)"
Color="Color.Secondary" OnClick="@Toggle"
Disabled="@(!RendererInfo.IsInteractive)" aria-label="Visualizer settings"
OnClick="@Toggle" aria-expanded="@_open" />
aria-label="Visualizer settings" </MudTooltip>
aria-expanded="@_open" />
</MudTooltip>
<MudPopover Open="@_open" @* The tinted modal scrim that also HOLDS the panel. DarkBackground = the mild tint; OnClick on the scrim
Fixed="true" dismisses (knob-drag-safe, see header). The panel is the overlay's centered child; it stops click
AnchorOrigin="@AnchorOrigin" propagation so an inside click is not a dismissal. Modal so focus/scroll stay on the panel. *@
TransformOrigin="@TransformOrigin" <MudOverlay Visible="@_open"
Class="waveform-visualizer-control-popover"> DarkBackground="true"
Modal="true"
OnClick="@Close"
Class="waveform-visualizer-control-overlay">
<div class="waveform-visualizer-control-modal" @onclick:stopPropagation="true">
<WaveformVisualizerControls PanelChrome="true" /> <WaveformVisualizerControls PanelChrome="true" />
</MudPopover> </div>
</div> </MudOverlay>
@code { @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> /// <summary>Trigger-icon size. Defaults Large to match the Phase 10 Mix lava-lamp button.</summary>
[Parameter] public Size IconSize { get; set; } = Size.Large; [Parameter] public Size IconSize { get; set; } = Size.Large;
@@ -1,139 +1,221 @@
@namespace DeepDrftPublic.Client.Controls @namespace DeepDrftPublic.Client.Controls
@using DeepDrftShared.Client.Common
@using DeepDrftPublic.Client.Services @using DeepDrftPublic.Client.Services
@inject WaveformVisualizerControlState ControlState @inject WaveformVisualizerControlState ControlState
@* The waveform visualizer control PANEL (Phase 12 §3d-revised). EIGHT continuous RadialKnobs — scroll @* The waveform visualizer control PANEL (Phase 12 §3d-revised → Phase 15 re-layout). The control deck for
speed, gradient rotation speed, lava gravity, lava heat, fluid amount, fluid viscosity, collision the lava-lamp visualizer: a deterministic THREE-ROW, sectioned layout that encodes what the visualizer
strength, waveform width — each its own dedicated control with a Material-icon caption. The single composes — a LAVA field and a WAVEFORM ribbon, each independently toggleable (Phase 15 §3):
"bubbles" knob is split into fluid-amount + fluid-viscosity (Phase 10 §5).
This component is the PANEL CONTENT hosted inside WaveformVisualizerControlPopover. It no longer rides Row 1 (MODE, always): lava lamp-toggle, waveform lamp-toggle, [collisions knob — only when BOTH on],
an inline TopRowCenter bar; it lays out as a wrapped grid sized for a popover, styled to the NowPlaying then the color knob pinned far-right (applies to the whole field, so always visible).
Hero look (§3g — dark-navy ground, green-accent knobs, light icons) from the deepdrft-* tokens. 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 The two lamp toggles are iconographic (lit LavaLampFilled / unlit LavaLamp glyph), green because they
not reach the rendered panel — so panel chrome lives in the GLOBAL deepdrft-styles.css are INTERACTIVE (the §5 colour principle: green = interactive, light = non-interactive). The eight
(.waveform-visualizer-control-panel*), not in the scoped .razor.css. The scoped .razor.css carries only continuous dials are unchanged in tuning; the one widget-type change is scroll/zoom → a MudSlider (§8,
the inline-bar fallback (the .mix-visualizer-controls-bar reserved-height row) Mix's existing bound to ScrollSpeed alone). None of these is a seek surface (read-only contract §D).
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 This is the PANEL CONTENT hosted inside WaveformVisualizerControlPopover, now a screen-centered tinted
inline mount still feeds its lava-lamp toggle into Visible (Phase 10 §4): the knobs @if-gate while the MudOverlay (Phase 15 §4). Because the overlay PORTALS its content out of this component's DOM subtree,
container holds a reserved min-height so content below never pops on toggle. 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 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 its Changed event. The visualizer bridge (WaveformVisualizer) subscribes and pushes the affected dial /
affected dial to the WebGL module. That keeps the JS module handle single-owned by the bridge and this subsystem-enable to the WebGL module. RadialKnob has no icon slot (its Label renders as SVG text) and no
component purely presentational. None of these is a seek surface (read-only contract §D). 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. *@
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. *@
<div class="@($"{_panelChromeClass} mix-visualizer-controls-bar".TrimStart())"> <div class="@($"{_panelChromeClass} mix-visualizer-controls-bar".TrimStart())">
@if (Visible) @if (Visible)
{ {
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed"> @* ── Row 1 — MODE (always visible). Toggles + collisions group left; color pinned right. ── *@
<RadialKnob Value="@ControlState.ScrollSpeed" <div class="wvc-row wvc-row-mode">
ValueChanged="@OnScrollSpeedChanged" <div class="wvc-row-left">
Min="0" Max="1" Step="0.001"
Size="64" <MudTooltip Text="Light the lamp — or let it go cold.">
Color="Color.Primary" /> <div class="wvc-toggle" role="group" aria-label="Lava field on or off">
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" /> <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>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed"> @* ── Row 2 — LAVA section (only when lava on). ── *@
<RadialKnob Value="@ControlState.GradientRotationSpeed" @if (ControlState.LavaEnabled)
ValueChanged="@OnGradientRotationSpeedChanged" {
Min="0" Max="1" Step="0.001" <div class="wvc-row wvc-row-section">
Size="64" <span class="wvc-section-label">LAVA:</span>
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity"> <MudTooltip Text="How heavy the wax feels — float, or sink.">
<RadialKnob Value="@ControlState.LavaGravity" <div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
ValueChanged="@OnLavaGravityChanged" <RadialKnob Value="@ControlState.LavaGravity"
Min="0" Max="1" Step="0.001" ValueChanged="@OnLavaGravityChanged"
Size="64" Min="0" Max="1" Step="0.001"
Color="Color.Primary" /> Size="64"
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" /> Color="Color.Primary" />
</div> <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"> <MudTooltip Text="Crank the burner. More heat, more rolling boil.">
<RadialKnob Value="@ControlState.LavaHeat" <div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
ValueChanged="@OnLavaHeatChanged" <RadialKnob Value="@ControlState.LavaHeat"
Min="0" Max="1" Step="0.001" ValueChanged="@OnLavaHeatChanged"
Size="64" Min="0" Max="1" Step="0.001"
Color="Color.Primary" /> Size="64"
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" /> Color="Color.Primary" />
</div> <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"> <MudTooltip Text="How much goo is in the lamp.">
<RadialKnob Value="@ControlState.FluidAmount" <div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
ValueChanged="@OnFluidAmountChanged" <RadialKnob Value="@ControlState.FluidAmount"
Min="0" Max="1" Step="0.001" ValueChanged="@OnFluidAmountChanged"
Size="64" Min="0" Max="1" Step="0.001"
Color="Color.Primary" /> Size="64"
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" /> Color="Color.Primary" />
</div> <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"> <MudTooltip Text="Runny and gooey, or tight little globes.">
<RadialKnob Value="@ControlState.FluidViscosity" <div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
ValueChanged="@OnFluidViscosityChanged" <RadialKnob Value="@ControlState.FluidViscosity"
Min="0" Max="1" Step="0.001" ValueChanged="@OnFluidViscosityChanged"
Size="64" Min="0" Max="1" Step="0.001"
Color="Color.Primary" /> Size="64"
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" /> Color="Color.Primary" />
</div> <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"> @* ── Row 3 — WAVE section (only when waveform on). Scroll is a SLIDER (§8); width pinned right. ── *@
<RadialKnob Value="@ControlState.CollisionStrength" @if (ControlState.WaveformEnabled)
ValueChanged="@OnCollisionStrengthChanged" {
Min="0" Max="1" Step="0.001" <div class="wvc-row wvc-row-section">
Size="64" <span class="wvc-section-label">WAVE:</span>
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform width"> <MudTooltip Text="How fast the sound rolls by.">
<RadialKnob Value="@ControlState.WaveformWidth" <div class="wvc-slider" role="group" aria-label="Waveform scroll speed">
ValueChanged="@OnWaveformWidthChanged" <MudSlider T="double"
Min="0" Max="1" Step="0.001" Value="@ControlState.ScrollSpeed"
Size="64" ValueChanged="@OnScrollSpeedChanged"
Color="Color.Primary" /> Min="0" Max="1" Step="0.001"
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" /> Color="Color.Primary" />
</div> <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> </div>
@code { @code {
/// <summary> /// <summary>
/// Whether the knob band is shown. The popover host shows the panel whenever it is open, so the /// 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 still feeds its lava-lamp toggle into this — that /// default is <c>true</c>. Mix's legacy inline mount (if it survives) still feeds its lava-lamp toggle
/// mount always renders the component, and THIS component decides knob visibility (Phase 10 §4): when /// into this — that mount always renders the component, and THIS component decides deck visibility
/// false the knobs are @if-gated out but the container holds its reserved height (CSS min-height), so /// (Phase 10 §4): when false the rows are @if-gated out but the container holds its reserved height
/// content below the inline bar never pops as the lamp toggles. Inside the popover the host owns /// (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. /// open/closed, so the default keeps the panel populated.
/// </summary> /// </summary>
[Parameter] public bool Visible { get; set; } = true; [Parameter] public bool Visible { get; set; } = true;
/// <summary> /// <summary>
/// When <c>true</c>, applies the <c>waveform-visualizer-control-panel</c> class to the root element, /// 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 /// enabling the global panel-chrome rules (NowPlayingCard chrome — square corners, lighter-navy
/// tokens). Set by <see cref="WaveformVisualizerControlPopover"/>; Mix's inline mount leaves this /// ground, thin light border — plus the row/section layout and pinned palette tokens). Set by
/// <c>false</c> so the chrome never leaks onto the inline bar. /// <see cref="WaveformVisualizerControlPopover"/>; Mix's inline mount leaves this <c>false</c> so the
/// chrome never leaks onto the inline bar.
/// </summary> /// </summary>
[Parameter] public bool PanelChrome { get; set; } = false; [Parameter] public bool PanelChrome { get; set; } = false;
private string _panelChromeClass => PanelChrome ? "waveform-visualizer-control-panel" : string.Empty; 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 // Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and pushes
// pushes the affected dial. All values are already normalized [0,1]; the bridge maps scroll speed // the affected dial / subsystem-enable. All dial values are already normalized [0,1]; the bridge maps
// to a visible time-span and routes the rest straight to the lava/colour dials. // 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) private void OnScrollSpeedChanged(double value)
{ {
@@ -1,8 +1,8 @@
namespace DeepDrftPublic.Client.Services; namespace DeepDrftPublic.Client.Services;
/// <summary> /// <summary>
/// Holds the waveform visualizer's eight continuous-control positions for the lifetime of the WASM app /// Holds the waveform visualizer's eight continuous-control positions plus two subsystem on/off
/// instance. Scoped in DI, so it lives across SPA navigations within one listening session — open a /// toggles for the lifetime of the WASM app instance. Scoped in DI, so it lives across SPA navigations within one listening session — open a
/// second mix and the knobs keep where you left them — but a fresh page load (F5) constructs a new /// second mix and the knobs keep where you left them — but a fresh page load (F5) constructs a new
/// instance, resetting to defaults. That matches the spec's "persist within session, reset on fresh /// instance, resetting to defaults. That matches the spec's "persist within session, reset on fresh
/// load" without any cookie/localStorage round-trip (lava reframe §7c). /// load" without any cookie/localStorage round-trip (lava reframe §7c).
@@ -84,6 +84,19 @@ public sealed class WaveformVisualizerControlState
/// </summary> /// </summary>
public const double DefaultWaveformWidth = 0.5; public const double DefaultWaveformWidth = 0.5;
/// <summary>
/// Default lava-subsystem on-state. <c>true</c> so the lava field is on out of the box — the
/// current behavior. Backs the row-1 lava lamp toggle (Phase 15 §6). Has no TS-side anchor: the
/// bridge pushes it as an enable/disable, not a tuning dial.
/// </summary>
public const bool DefaultLavaEnabled = true;
/// <summary>
/// Default waveform-subsystem on-state. <c>true</c> so the waveform ribbon is on out of the box.
/// Backs the row-1 waveform lamp toggle (Phase 15 §6).
/// </summary>
public const bool DefaultWaveformEnabled = true;
/// <summary>Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible /// <summary>Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible
/// time-span via <see cref="WaveformZoomMapping"/>; the standalone resolution/zoom control is gone.</summary> /// time-span via <see cref="WaveformZoomMapping"/>; the standalone resolution/zoom control is gone.</summary>
public double ScrollSpeed { get; set; } = DefaultScrollSpeed; public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
@@ -110,6 +123,20 @@ public sealed class WaveformVisualizerControlState
/// <summary>Waveform-band horizontal extent, normalized [0,1]. Narrowing clears room for the lava.</summary> /// <summary>Waveform-band horizontal extent, normalized [0,1]. Narrowing clears room for the lava.</summary>
public double WaveformWidth { get; set; } = DefaultWaveformWidth; public double WaveformWidth { get; set; } = DefaultWaveformWidth;
/// <summary>
/// Whether the lava field is drawn. When <c>false</c> the lava subsystem is genuinely not rendered
/// (the bridge skips its physics + uploads no blobs — no render cost, Phase 15 §6/§10.1), not dimmed.
/// Also gates the row-1/row-2 control visibility (§3).
/// </summary>
public bool LavaEnabled { get; set; } = DefaultLavaEnabled;
/// <summary>
/// Whether the waveform ribbon is drawn. When <c>false</c> the ribbon subsystem is genuinely not
/// rendered (the bridge disables the ribbon SDF + drops its collision boundary — no render cost,
/// Phase 15 §6/§10.1), not dimmed. Also gates the row-1/row-3 control visibility (§3).
/// </summary>
public bool WaveformEnabled { get; set; } = DefaultWaveformEnabled;
/// <summary> /// <summary>
/// Raised whenever any control value changes. The visualizer bridge subscribes to push the /// Raised whenever any control value changes. The visualizer bridge subscribes to push the
/// affected dial(s). Mutators set the property then raise this; subscribers re-read the values. /// affected dial(s). Mutators set the property then raise this; subscribers re-read the values.
@@ -527,6 +527,19 @@ export interface WaveformVisualizerHandle {
setCollisionStrength(value: number): void; setCollisionStrength(value: number): void;
/** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */ /** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */
setWaveformWidth(value: number): void; setWaveformWidth(value: number): void;
/**
* Enable/disable the LAVA subsystem (Phase 15). When disabled the wax is genuinely NOT rendered:
* the per-frame physics step is skipped and zero blobs are uploaded (uBlobCount = 0), so the
* shader's blob loop unions nothing — no render cost, not a dimmed/visible=false uniform (§10.1).
*/
setLavaEnabled(enabled: boolean): void;
/**
* Enable/disable the WAVEFORM-ribbon subsystem (Phase 15). When disabled the ribbon SDF is skipped
* in the shader (uWaveformEnabled = 0 makes waveformSdf return "fully outside") and its CPU
* collision boundary is dropped (sampleLoudnessAt reads 0), so the ribbon contributes nothing to
* the surface and the wax stops bouncing off an invisible wall — a genuine skip, not a dim (§10.1).
*/
setWaveformEnabled(enabled: boolean): void;
/** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */ /** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
refreshTheme(): void; refreshTheme(): void;
dispose(): void; dispose(): void;
@@ -613,6 +626,8 @@ uniform float uPlayheadSeconds; // current playback position (per-frame)
uniform float uTimeSeconds; // monotonic clock (per-frame) — drives field morph uniform float uTimeSeconds; // monotonic clock (per-frame) — drives field morph
uniform float uVisibleSeconds; // zoom: window time-span (per change) uniform float uVisibleSeconds; // zoom: window time-span (per change)
uniform float uWaveformWidth; // [0,1] R2: scales the ribbon half-width (narrow the band for lava room) uniform float uWaveformWidth; // [0,1] R2: scales the ribbon half-width (narrow the band for lava room)
uniform float uWaveformEnabled; // [0,1] Phase 15: 1 = ribbon drawn, 0 = ribbon subsystem skipped (no
// contribution to the surface — see waveformSdf's early-out)
uniform float uCohesion; // [0,1] Phase 10: fluid viscosity/cohesion — high = crisp spheres, uniform float uCohesion; // [0,1] Phase 10: fluid viscosity/cohesion — high = crisp spheres,
// low = gooey/deformed (drives the smin blend width + wobble below) // low = gooey/deformed (drives the smin blend width + wobble below)
// NOTE: the lava physics params (gravity/heat/collision/density) are NOT shader uniforms // NOTE: the lava physics params (gravity/heat/collision/density) are NOT shader uniforms
@@ -877,6 +892,10 @@ vec3 anchorAtPhase(float phase) {
// distance to that vertical ribbon band. Loudness at neighbour rows is NOT re-stacked // distance to that vertical ribbon band. Loudness at neighbour rows is NOT re-stacked
// here (the per-row geometry from Wave 1 is already smooth); the band is the ribbon. // here (the per-row geometry from Wave 1 is already smooth); the band is the ribbon.
float waveformSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight) { float waveformSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight) {
// Phase 15: ribbon subsystem off → return "fully outside" so the smin union ignores it entirely
// (a far positive distance never pulls the surface toward the centre line). This is the genuine
// skip — the ribbon contributes nothing, rather than being drawn-then-hidden.
if (uWaveformEnabled < 0.5) return 1e9;
// Mix-time at this row: rows below the now-line are future audio, above are past. // Mix-time at this row: rows below the now-line are future audio, above are past.
float t = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight; float t = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight;
float amp = sampleAt(t); // loudness 0..1 at this row float amp = sampleAt(t); // loudness 0..1 at this row
@@ -1072,6 +1091,8 @@ function noopHandle(): WaveformVisualizerHandle {
setFluidViscosity() {}, setFluidViscosity() {},
setCollisionStrength() {}, setCollisionStrength() {},
setWaveformWidth() {}, setWaveformWidth() {},
setLavaEnabled() {},
setWaveformEnabled() {},
refreshTheme() {}, refreshTheme() {},
dispose() {}, dispose() {},
}; };
@@ -1129,6 +1150,7 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'), timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'),
visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'), visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'),
waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'), waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'),
waveformEnabled: gl.getUniformLocation(program, 'uWaveformEnabled'),
cohesion: gl.getUniformLocation(program, 'uCohesion'), cohesion: gl.getUniformLocation(program, 'uCohesion'),
durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'), durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
colorNavy: gl.getUniformLocation(program, 'uColorNavy'), colorNavy: gl.getUniformLocation(program, 'uColorNavy'),
@@ -1167,6 +1189,12 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
let waveformWidth = DEFAULT_WAVEFORM_WIDTH; let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
// LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1). // LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1).
let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED; let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED;
// Phase 15 — subsystem on/off. Default ON (mirrors C# DefaultLavaEnabled / DefaultWaveformEnabled),
// so out of the box both subsystems run exactly as before. "Off" is a genuine draw-skip: lava off
// skips stepPhysics + uploads zero blobs; waveform off skips the ribbon SDF (uWaveformEnabled) and
// its CPU collision boundary. With both off, draw() short-circuits to a clear — no SDF eval at all.
let lavaEnabled = true;
let waveformEnabled = true;
/** Effective ribbon-width fraction for the current width knob (Phase 10 §3.7): the knob's [0,1] /** Effective ribbon-width fraction for the current width knob (Phase 10 §3.7): the knob's [0,1]
* travel maps onto the useful 10%95% band (full-width 100% read too wide; sub-10% vanished). * travel maps onto the useful 10%95% band (full-width 100% read too wide; sub-10% vanished).
@@ -1365,6 +1393,9 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
* boundary matches the rendered waveform exactly. Reads the retained datum.samples. * boundary matches the rendered waveform exactly. Reads the retained datum.samples.
*/ */
function sampleLoudnessAt(timeSeconds: number): number { function sampleLoudnessAt(timeSeconds: number): number {
// Phase 15: waveform off → no ribbon boundary. Reporting zero loudness collapses the collision
// half-width to 0, so wax never bounces off an invisible wall (matches the skipped ribbon draw).
if (!waveformEnabled) return 0;
const d = datum; const d = datum;
if (!d || timeSeconds < 0 || timeSeconds >= d.durationSeconds) return 0; if (!d || timeSeconds < 0 || timeSeconds >= d.durationSeconds) return 0;
const n = d.sampleCount; const n = d.sampleCount;
@@ -1731,6 +1762,14 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
gl.clearColor(0, 0, 0, 0); gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT); gl.clear(gl.COLOR_BUFFER_BIT);
// Phase 15 — both subsystems off: there is nothing to draw. Short-circuit past the physics
// step, the blob upload, and the full-screen SDF evaluation entirely — a genuine no-render-cost
// empty field (§10.1), not a shader that runs and outputs transparent. The cleared (transparent)
// buffer above is the result. The gradient/playhead clocks are not advanced while fully off;
// they resume from their held value when a subsystem is turned back on (no visible snap, since
// an off field shows nothing to snap).
if (!lavaEnabled && !waveformEnabled) return;
gl.useProgram(program); gl.useProgram(program);
gl.bindVertexArray(vao); gl.bindVertexArray(vao);
@@ -1756,6 +1795,7 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
// separate dirty-tracking needed for scalars/vec3s). // separate dirty-tracking needed for scalars/vec3s).
gl.uniform1f(u.visibleSeconds, visibleSeconds); gl.uniform1f(u.visibleSeconds, visibleSeconds);
gl.uniform1f(u.waveformWidth, effectiveWaveformWidth()); gl.uniform1f(u.waveformWidth, effectiveWaveformWidth());
gl.uniform1f(u.waveformEnabled, waveformEnabled ? 1 : 0);
gl.uniform1f(u.cohesion, fluidViscosity); gl.uniform1f(u.cohesion, fluidViscosity);
gl.uniform1f(u.gradientPhase, gradientPhase); gl.uniform1f(u.gradientPhase, gradientPhase);
gl.uniform3fv(u.colorNavy, theme.navy); gl.uniform3fv(u.colorNavy, theme.navy);
@@ -1769,8 +1809,15 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
const nowMs = performance.now(); const nowMs = performance.now();
const physicsDt = Math.max(0, (nowMs - lastPhysicsMs) / 1000); const physicsDt = Math.max(0, (nowMs - lastPhysicsMs) / 1000);
lastPhysicsMs = nowMs; lastPhysicsMs = nowMs;
stepPhysics(physicsDt); // Phase 15 — lava off: skip the CPU physics step AND upload zero blobs. The shader's blob loop
const liveCount = packBlobs(); // (`for … if (i >= uBlobCount) break;`) then unions nothing, so no wax is drawn and no physics
// runs — a genuine subsystem skip (§10.1), not a hidden-but-simulated field. The wax keeps its
// last positions for free (we just stop integrating); turning lava back on resumes from there.
let liveCount = 0;
if (lavaEnabled) {
stepPhysics(physicsDt);
liveCount = packBlobs();
}
gl.uniform4fv(u.blobs, blobUpload); gl.uniform4fv(u.blobs, blobUpload);
gl.uniform1i(u.blobCount, liveCount); gl.uniform1i(u.blobCount, liveCount);
@@ -2156,6 +2203,22 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
if (rafId === null) redrawOnce(); if (rafId === null) redrawOnce();
}, },
// Phase 15 — subsystem enables. "Off" is a genuine draw-skip (§10.1): lava off stops the physics
// step + uploads zero blobs (handled in draw()); waveform off skips the ribbon SDF + collision
// boundary. redrawOnce guards the fully-stopped (tab-hidden) case so the toggle lands a still
// frame when the loop resumes — including the both-off → cleared empty field.
setLavaEnabled(enabled: boolean): void {
lavaEnabled = enabled;
debugLog(`setLavaEnabled → ${enabled}.`);
if (rafId === null) redrawOnce();
},
setWaveformEnabled(enabled: boolean): void {
waveformEnabled = enabled;
debugLog(`setWaveformEnabled → ${enabled}.`);
if (rafId === null) redrawOnce();
},
refreshTheme(): void { refreshTheme(): void {
theme = readTheme(); theme = readTheme();
if (rafId === null) redrawOnce(); if (rafId === null) redrawOnce();
+124 -29
View File
@@ -376,49 +376,144 @@ h2, h3, h4, h5, h6,
} }
/* ============================================================================= /* =============================================================================
WAVEFORM VISUALIZER CONTROL PANEL (Phase 12 §3d-revised / §3g) WAVEFORM VISUALIZER CONTROL PANEL (Phase 12 §3d-revised / §3g → Phase 15 re-layout)
The eight-knob panel hosted inside WaveformVisualizerControlPopover. MudPopover The control deck hosted inside WaveformVisualizerControlPopover, now a screen-centered
PORTALS its content out of the component's DOM subtree, so Blazor CSS isolation tinted MudOverlay (Phase 15 §4). MudOverlay — like the former MudPopover — PORTALS its
never reaches the rendered panel — its chrome must live here in the global sheet, content out of the component's DOM subtree, so Blazor CSS isolation never reaches the
not in the scoped WaveformVisualizerControls.razor.css. (The scoped file keeps only rendered panel: its chrome, the three-row/section LAYOUT, the section labels, the slider,
the inline-bar fallback Mix's legacy TopRowCenter mount uses, which is not portaled.) and the toggles all live here in the global sheet, not in the scoped
WaveformVisualizerControls.razor.css. (The scoped file keeps only the legacy inline-bar
fallback Mix's old TopRowCenter mount used, which is not portaled.)
The waveform-visualizer-control-panel class is applied ONLY when the component's The waveform-visualizer-control-panel class is applied ONLY when the component's
PanelChrome="true" parameter is set — which WaveformVisualizerControlPopover does PanelChrome="true" parameter is set — which the popover host does and Mix's inline mount
and Mix's inline mount does NOT. This prevents the chrome from leaking onto Mix's does NOT — so the chrome never leaks onto an inline bar.
inline controls bar.
The NowPlaying Hero look (§3g): dark-navy ground, green-accent knobs, light icons, CHROME (Phase 15 §5 — NowPlayingCard treatment): SQUARE corners, lighter-navy ground
muted-navy filler — all from the deepdrft-* token source of truth, no hardcoded hex. (navy-mid), a thin LIGHT border (--deepdrft-border-light, the NowPlayingCard 0.12-alpha
The RadialKnob reads --mud-palette-* for its arc/track/center/label colours; we pin light-on-dark idiom as a token). All token-sourced; no hardcoded hex.
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. COLOUR PRINCIPLE (§5 — green = interactive, light = non-interactive): the RadialKnob reads
--mud-palette-* for its arc/pointer/center/label; we pin --mud-palette-primary to the green
accent (interactive arcs/pointers) and --mud-palette-text-primary to light. Caption icons and
section labels are LIGHT (static). The slider track/thumb and the lamp toggles are green.
============================================================================= */ ============================================================================= */
.waveform-visualizer-control-panel.mix-visualizer-controls-bar { .waveform-visualizer-control-panel.mix-visualizer-controls-bar {
/* Dark-navy elevated panel ground (§3g: navy-mid for the elevated surface). */ /* Lighter-navy elevated panel ground (§5: navy-mid). */
background: var(--deepdrft-navy-mid); background: var(--deepdrft-navy-mid);
border: 1px solid var(--deepdrft-border-green); /* Square corners + thin light border — NowPlayingCard chrome (§5). */
border-radius: 8px; border: 1px solid var(--deepdrft-border-light);
border-radius: 0;
/* Optional backdrop blur — cheap on a small modal panel, nice over the visualizer (§5). */
backdrop-filter: blur(8px);
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
/* Popover panel: cap width so eight 64px knobs wrap to a tidy grid rather than one long bar. /* Three-row sectioned deck: stack the rows top-to-bottom; conditional rows reserve no permanent
This OVERRIDES the inline-bar min-height reserve (which only matters for Mix's non-popover mount). */ height (§3 reflow discipline). This OVERRIDES the inline-bar min-height + flex-wrap (which only
matter for Mix's non-portaled legacy mount). */
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0; min-height: 0;
max-width: 340px; max-width: 420px;
/* Pin the MudBlazor palette vars the portaled RadialKnob consumes to the Hero tokens. */ /* Pin the MudBlazor palette vars the portaled RadialKnob + slider consume. */
--mud-palette-primary: var(--deepdrft-green-accent); /* knob value arc / pointer / center stroke */ --mud-palette-primary: var(--deepdrft-green-accent); /* knob arc/pointer + slider track/thumb (interactive) */
--mud-palette-surface: var(--deepdrft-navy); /* knob center fill — darkest navy reads against the panel */ --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-surface-variant: var(--deepdrft-muted); /* knob background track — muted-navy filler */
--mud-palette-text-primary: var(--deepdrft-white); /* knob value label — light (§3g) */ --mud-palette-text-primary: var(--deepdrft-white); /* knob value label — light */
} }
/* Green-accent caption icons (§3g: light/green icons). MudIcon is portaled here too, so this is a /* ── Row layout (§3). Each row is a horizontal band; row 1 + row 3 space-between so the right-pinned
plain global descendant selector — no ::deep, no scope attribute (CSS isolation does not reach control (color / width) hugs the right edge and never reflows when an inner control hides. ── */
inside the popover). */ .waveform-visualizer-control-panel .wvc-row {
.waveform-visualizer-control-panel .waveform-visualizer-control-icon { display: flex;
color: var(--deepdrft-green-accent); flex-direction: row;
align-items: flex-start;
gap: 0.85rem 1rem;
}
.waveform-visualizer-control-panel .wvc-row-mode,
.waveform-visualizer-control-panel .wvc-row-section {
/* space-between pushes the right-pinned control to the far edge; the left group holds the rest. */
justify-content: space-between;
}
/* The left group of row 1 (toggles + conditional collisions) flows left; the color knob is the
space-between right sibling, so it stays put when collisions hides (§3). */
.waveform-visualizer-control-panel .wvc-row-left {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 0.85rem 1rem;
}
/* The right-pinned control (color in row 1, width in row 3) — sits as the space-between right sibling. */
.waveform-visualizer-control-panel .wvc-row-right {
margin-left: auto;
}
/* ── Section label "LAVA:" / "WAVE:" (§3, §5). NowPlayingCard .np-label TYPOGRAPHY (mono, uppercase,
tracked), recoloured LIGHT — labels are static, so light by the colour principle (§5, §10.3). ── */
.waveform-visualizer-control-panel .wvc-section-label {
font-family: var(--deepdrft-font-mono);
font-size: 0.6rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--deepdrft-white);
align-self: center;
flex: 0 0 auto;
opacity: 0.85; opacity: 0.85;
} }
/* ── The lamp toggles (§3 row 1). Iconographic lit/unlit lamp glyph, GREEN because interactive (§5).
Color="Color.Primary" already drives the glyph currentColor to the pinned green --mud-palette-primary;
this just sizes the hit-target to read as a row-1 peer of the knobs. ── */
.waveform-visualizer-control-panel .wvc-toggle {
display: flex;
align-items: center;
justify-content: center;
}
/* ── The scroll SLIDER (§8). Track/thumb green (the pinned --mud-palette-primary, interactive). Give it
a sensible width so it reads as "position along a continuum" next to the rotary width knob. ── */
.waveform-visualizer-control-panel .wvc-slider {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
min-width: 160px;
flex: 1 1 auto;
align-self: center;
}
.waveform-visualizer-control-panel .wvc-slider .mud-slider {
width: 100%;
}
/* Caption icons + section labels render LIGHT (§5/§9 colour principle: static/decorative = light). 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 overlay). The knob arcs/pointers + slider stay green (interactive). */
.waveform-visualizer-control-panel .waveform-visualizer-control-icon {
color: var(--deepdrft-white);
opacity: 0.85;
}
/* ── The modal overlay (Phase 15 §4). MudOverlay is already a full-viewport flex scrim that centers its
content (.mud-overlay { display:flex; align-items:center; justify-content:center }), which gives the
screen-centered panel on every host for free — we do NOT fight that positioning. We only (a) set the
mild modal tint from the SINGLE --deepdrft-modal-scrim-alpha token (§10.5, one point of change) and
(b) cap the centered content's height so a tall both-on deck scrolls inside the modal rather than
overflowing the viewport. The overlay portals to the body, so these are plain global rules (no scope
attribute). The doubled .mud-overlay-scrim.mud-overlay-dark selector (0,2,0) outranks MudBlazor's own
.mud-overlay-dark (0,1,0), so the tint wins regardless of stylesheet load order. ── */
.waveform-visualizer-control-overlay .mud-overlay-scrim.mud-overlay-dark {
background-color: rgba(13, 27, 42, var(--deepdrft-modal-scrim-alpha));
}
.waveform-visualizer-control-overlay .mud-overlay-content {
max-height: 90vh;
overflow-y: auto;
}
@media (max-width: 419.98px) { @media (max-width: 419.98px) {
.deepdrft-track-detail-meta { .deepdrft-track-detail-meta {
flex-direction: column; flex-direction: column;
@@ -17,6 +17,12 @@
--deepdrft-white: #FAFAF8; --deepdrft-white: #FAFAF8;
--deepdrft-border: rgba(13, 27, 42, 0.10); --deepdrft-border: rgba(13, 27, 42, 0.10);
--deepdrft-border-green: rgba(26, 60, 52, 0.20); --deepdrft-border-green: rgba(26, 60, 52, 0.20);
/* Thin light-on-dark border, NowPlayingCard spirit (Phase 15 §5). One token instead of scattering
the rgba(250,250,248,0.12) literal NowPlayingCard uses inline. */
--deepdrft-border-light: rgba(250, 250, 248, 0.12);
/* Modal scrim opacity the SINGLE point of truth for the visualizer-controls overlay tint
(Phase 15 §4/§10.5). Mild so the panel reads as modal without a blackout. Change here once. */
--deepdrft-modal-scrim-alpha: 0.3;
/* Wireframe font stack */ /* Wireframe font stack */
--deepdrft-font-display: "Cormorant Garamond", Georgia, serif; --deepdrft-font-display: "Cormorant Garamond", Georgia, serif;