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" />
</div>
@* The lava-lamp popover trigger lands in the panel's top-right corner (full parity, §8e). Above the
canvas and pointer-enabled so the icon is clickable even though the visualizer layer is
pointer-events:none. *@
@* The lava-lamp trigger lands in the panel's top-right corner (full parity, §8e). Above the canvas
and pointer-enabled so the icon is clickable even though the visualizer layer is
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">
<WaveformVisualizerControlPopover IconSize="Size.Small"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopLeft" />
<WaveformVisualizerControlPopover IconSize="Size.Small" />
</div>
@* Pulsing rings *@
@@ -265,9 +265,10 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
return;
}
// Seed the module with the current state now that it exists. All seven control values
// come from the shared (session-persisted) state, so a mix opened mid-session seeds the
// module with the knob positions the listener left them at.
// Seed the module with the current state now that it exists. All control values (the eight
// dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state,
// so a mix opened mid-session seeds the module with the knob/toggle positions the listener
// left them at.
await PushControlsAsync();
await PushDatumAsync();
await PushPlaybackAsync();
@@ -291,8 +292,9 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
/// <summary>
/// Push the eight control values to the module from the shared state. Used to seed on first render
/// and to re-push when the controls bar signals a change. Each value is its own dedicated dial:
/// Push the control values to the module from the shared state — the eight continuous dials plus the
/// 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">
/// <item>scroll speed [0,1] is mapped onto the useful zoom band via
/// <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 →
/// <c>setFluidViscosity</c> (cohesion / sphere-restoration) — the Phase 10 split of the
/// 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>
/// </summary>
private async Task PushControlsAsync()
@@ -319,6 +323,11 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
await _handle.InvokeVoidAsync("setFluidViscosity", ControlState.FluidViscosity);
await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength);
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>
@@ -1,56 +1,56 @@
@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.
@* The single controls affordance, placed by an icon anywhere (Phase 12 §3d-revised, re-primitived
Phase 15 §4). Closed state is just the lava-lamp icon; clicking it floats the control panel as a
SCREEN-CENTERED, tinted MODAL over the whole surface. One panel, one host, reused on every host (Mix,
Cut, Session, NowPlaying) — the SOLID seam.
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.
PRIMITIVE (Phase 15 §4): a centered MudOverlay, NOT an anchored MudPopover. The panel must read as
screen-centered regardless of where the lava-lamp icon sits (Mix corner, Cut/Session ambient,
NowPlaying corner). An anchored popover positions off the trigger's bounding rect — the wrong model
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
the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This host
only toggles open/closed and places the panel — it stays purely presentational. *@
KNOB-DRAG SAFETY (Phase 15 §4, highest-risk detail): RadialKnob mounts its own full-viewport
position:fixed; z-index:9999 mouse-capture div WHILE dragging (RadialKnob.razor lines 59). That capture
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
off so a knob drag (which can land pointer-up outside the panel's DOM subtree) does not dismiss. *@
<MudOverlay Visible="@_open" OnClick="@Close" />
The host 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 centers the panel — it stays purely presentational. *@
@* 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>
<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">
@* The tinted modal scrim that also HOLDS the panel. DarkBackground = the mild tint; OnClick on the scrim
dismisses (knob-drag-safe, see header). The panel is the overlay's centered child; it stops click
propagation so an inside click is not a dismissal. Modal so focus/scroll stay on the panel. *@
<MudOverlay Visible="@_open"
DarkBackground="true"
Modal="true"
OnClick="@Close"
Class="waveform-visualizer-control-overlay">
<div class="waveform-visualizer-control-modal" @onclick:stopPropagation="true">
<WaveformVisualizerControls PanelChrome="true" />
</MudPopover>
</div>
</div>
</MudOverlay>
@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;
@@ -1,139 +1,221 @@
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftShared.Client.Common
@using DeepDrftPublic.Client.Services
@inject WaveformVisualizerControlState ControlState
@* The waveform visualizer control PANEL (Phase 12 §3d-revised). EIGHT continuous RadialKnobs — scroll
speed, gradient rotation speed, lava gravity, lava heat, fluid amount, fluid viscosity, collision
strength, waveform width — each its own dedicated control with a Material-icon caption. The single
"bubbles" knob is split into fluid-amount + fluid-viscosity (Phase 10 §5).
@* The waveform visualizer control PANEL (Phase 12 §3d-revised → Phase 15 re-layout). The control deck for
the lava-lamp visualizer: a deterministic THREE-ROW, sectioned layout that encodes what the visualizer
composes — a LAVA field and a WAVEFORM ribbon, each independently toggleable (Phase 15 §3):
This component is the PANEL CONTENT hosted inside WaveformVisualizerControlPopover. It no longer rides
an inline TopRowCenter bar; it lays out as a wrapped grid sized for a popover, styled to the NowPlaying
Hero look (§3g — dark-navy ground, green-accent knobs, light icons) from the deepdrft-* tokens.
Row 1 (MODE, always): lava lamp-toggle, waveform lamp-toggle, [collisions knob — only when BOTH on],
then the color knob pinned far-right (applies to the whole field, so always visible).
Row 2 (LAVA, only when lava on): "LAVA:" label + gravity / heat / fluid-amount / fluid-viscosity.
Row 3 (WAVE, only when waveform on): "WAVE:" label + scroll SLIDER + width knob pinned far-right.
Because MudPopover PORTALS its content out of this component's DOM subtree, Blazor CSS isolation does
not reach the rendered panel — so panel chrome lives in the GLOBAL deepdrft-styles.css
(.waveform-visualizer-control-panel*), not in the scoped .razor.css. The scoped .razor.css carries only
the inline-bar fallback (the .mix-visualizer-controls-bar reserved-height row) Mix's existing
TopRowCenter mount still uses, which is NOT portaled and so resolves under isolation.
The two lamp toggles are iconographic (lit LavaLampFilled / unlit LavaLamp glyph), green because they
are INTERACTIVE (the §5 colour principle: green = interactive, light = non-interactive). The eight
continuous dials are unchanged in tuning; the one widget-type change is scroll/zoom → a MudSlider (§8,
bound to ScrollSpeed alone). None of these is a seek surface (read-only contract §D).
Visibility: the popover host always shows the panel when open (Visible defaults true). Mix's legacy
inline mount still feeds its lava-lamp toggle into Visible (Phase 10 §4): the knobs @if-gate while the
container holds a reserved min-height so content below never pops on toggle.
This is the PANEL CONTENT hosted inside WaveformVisualizerControlPopover, now a screen-centered tinted
MudOverlay (Phase 15 §4). Because the overlay PORTALS its content out of this component's DOM subtree,
Blazor CSS isolation does not reach the rendered panel — so panel chrome AND the row/section layout live
in the GLOBAL deepdrft-styles.css (.waveform-visualizer-control-panel*), not the scoped .razor.css. The
scoped .razor.css carries only the legacy inline-bar fallback (Mix's old non-portaled mount), which may
now be dead post-Phase-12 but is left in place — flagged, not cut (out of Phase 15 scope).
COLOUR PRINCIPLE (§5): the lamp toggles + knob arcs/pointers + the slider track/thumb are green-accent
(interactive); the "LAVA:"/"WAVE:" section labels and the knob caption icons are LIGHT (static). All
colours are token-sourced (deepdrft-tokens.css) — no hardcoded hex.
It owns NO JS interop: it mutates the shared, session-scoped WaveformVisualizerControlState and raises
its Changed event. The visualizer bridge (WaveformVisualizer) subscribes to that event and pushes the
affected dial to the WebGL module. That keeps the JS module handle single-owned by the bridge and this
component purely presentational. None of these is a seek surface (read-only contract §D).
RadialKnob has no icon slot (its Label renders as SVG text) and no aria attribute-capture, so each
control's Material icon rides beside its knob as an adjacent MudIcon caption and the accessible name
rides on the wrapping group div (§7d). HoldValue stays false so the knobs are live — ValueChanged fires
continuously during drag, preserving the Changed/NotifyChanged seam. *@
its Changed event. The visualizer bridge (WaveformVisualizer) subscribes and pushes the affected dial /
subsystem-enable to the WebGL module. RadialKnob has no icon slot (its Label renders as SVG text) and no
aria capture, so each control's Material icon rides beside its knob as a caption and the accessible name
rides on the wrapping group div (§7); the playful MudTooltip rides alongside for sighted hover. *@
<div class="@($"{_panelChromeClass} mix-visualizer-controls-bar".TrimStart())">
@if (Visible)
{
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
<RadialKnob Value="@ControlState.ScrollSpeed"
ValueChanged="@OnScrollSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
@* ── Row 1 — MODE (always visible). Toggles + collisions group left; color pinned right. ── *@
<div class="wvc-row wvc-row-mode">
<div class="wvc-row-left">
<MudTooltip Text="Light the lamp — or let it go cold.">
<div class="wvc-toggle" role="group" aria-label="Lava field on or off">
<MudIconButton Icon="@(ControlState.LavaEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Color="Color.Primary"
OnClick="@ToggleLava"
aria-label="Lava field on or off"
aria-pressed="@ControlState.LavaEnabled" />
</div>
</MudTooltip>
<MudTooltip Text="Show the sound, or hide the ribbon.">
<div class="wvc-toggle" role="group" aria-label="Waveform ribbon on or off">
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Color="Color.Primary"
OnClick="@ToggleWaveform"
aria-label="Waveform ribbon on or off"
aria-pressed="@ControlState.WaveformEnabled" />
</div>
</MudTooltip>
@* Collisions are the interaction BETWEEN the two subsystems — meaningless with only one
present, so visible only when BOTH are on (§3 truth table). *@
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
{
<MudTooltip Text="How hard the blobs body-check the beat.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
}
</div>
@* Color applies to the whole field regardless of which subsystems are on, so it is pinned
far-right of row 1 and never reflows when collisions hides (§3). *@
<MudTooltip Text="How fast the lamp drifts through its colors.">
<div class="waveform-visualizer-control mix-visualizer-control wvc-row-right" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</div>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
@* ── Row 2 — LAVA section (only when lava on). ── *@
@if (ControlState.LavaEnabled)
{
<div class="wvc-row wvc-row-section">
<span class="wvc-section-label">LAVA:</span>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="How heavy the wax feels — float, or sink.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="How much goo is in the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="Runny and gooey, or tight little globes.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</div>
}
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
@* ── Row 3 — WAVE section (only when waveform on). Scroll is a SLIDER (§8); width pinned right. ── *@
@if (ControlState.WaveformEnabled)
{
<div class="wvc-row wvc-row-section">
<span class="wvc-section-label">WAVE:</span>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="How fast the sound rolls by.">
<div class="wvc-slider" role="group" aria-label="Waveform scroll speed">
<MudSlider T="double"
Value="@ControlState.ScrollSpeed"
ValueChanged="@OnScrollSpeedChanged"
Min="0" Max="1" Step="0.001"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudTooltip Text="How wide the ribbon spreads across the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control wvc-row-right" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</div>
}
}
</div>
@code {
/// <summary>
/// Whether the knob band is shown. The popover host shows the panel whenever it is open, so the
/// default is <c>true</c>. Mix's legacy inline mount still feeds its lava-lamp toggle into this — that
/// mount always renders the component, and THIS component decides knob visibility (Phase 10 §4): when
/// false the knobs are @if-gated out but the container holds its reserved height (CSS min-height), so
/// content below the inline bar never pops as the lamp toggles. Inside the popover the host owns
/// Whether the control deck is shown. The overlay host shows the panel whenever it is open, so the
/// default is <c>true</c>. Mix's legacy inline mount (if it survives) still feeds its lava-lamp toggle
/// into this — that mount always renders the component, and THIS component decides deck visibility
/// (Phase 10 §4): when false the rows are @if-gated out but the container holds its reserved height
/// (CSS min-height) so content below the inline bar never pops. Inside the overlay the host owns
/// open/closed, so the default keeps the panel populated.
/// </summary>
[Parameter] public bool Visible { get; set; } = true;
/// <summary>
/// When <c>true</c>, applies the <c>waveform-visualizer-control-panel</c> class to the root element,
/// enabling the global panel-chrome rules (dark-navy ground, border, max-width cap, pinned palette
/// tokens). Set by <see cref="WaveformVisualizerControlPopover"/>; Mix's inline mount leaves this
/// <c>false</c> so the chrome never leaks onto the inline bar.
/// enabling the global panel-chrome rules (NowPlayingCard chrome — square corners, lighter-navy
/// ground, thin light border — plus the row/section layout and pinned palette tokens). Set by
/// <see cref="WaveformVisualizerControlPopover"/>; Mix's inline mount leaves this <c>false</c> so the
/// chrome never leaks onto the inline bar.
/// </summary>
[Parameter] public bool PanelChrome { get; set; } = false;
private string _panelChromeClass => PanelChrome ? "waveform-visualizer-control-panel" : string.Empty;
// Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and
// pushes the affected dial. All values are already normalized [0,1]; the bridge maps scroll speed
// to a visible time-span and routes the rest straight to the lava/colour dials.
// Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and pushes
// the affected dial / subsystem-enable. All dial values are already normalized [0,1]; the bridge maps
// scroll speed to a visible time-span and routes the rest straight to the lava/colour dials. The two
// toggles flip a boolean (no value), driving the genuine per-subsystem draw-skip in the module (§6).
private void ToggleLava()
{
ControlState.LavaEnabled = !ControlState.LavaEnabled;
ControlState.NotifyChanged();
}
private void ToggleWaveform()
{
ControlState.WaveformEnabled = !ControlState.WaveformEnabled;
ControlState.NotifyChanged();
}
private void OnScrollSpeedChanged(double value)
{
@@ -1,8 +1,8 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Holds the waveform visualizer's eight continuous-control positions for the lifetime of the WASM app
/// instance. Scoped in DI, so it lives across SPA navigations within one listening session — open a
/// Holds the waveform visualizer's eight continuous-control positions plus two subsystem on/off
/// 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
/// 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).
@@ -84,6 +84,19 @@ public sealed class WaveformVisualizerControlState
/// </summary>
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
/// time-span via <see cref="WaveformZoomMapping"/>; the standalone resolution/zoom control is gone.</summary>
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>
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>
/// 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.
@@ -527,6 +527,19 @@ export interface WaveformVisualizerHandle {
setCollisionStrength(value: number): void;
/** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */
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). */
refreshTheme(): 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 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 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,
// low = gooey/deformed (drives the smin blend width + wobble below)
// 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
// 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) {
// 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.
float t = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight;
float amp = sampleAt(t); // loudness 0..1 at this row
@@ -1072,6 +1091,8 @@ function noopHandle(): WaveformVisualizerHandle {
setFluidViscosity() {},
setCollisionStrength() {},
setWaveformWidth() {},
setLavaEnabled() {},
setWaveformEnabled() {},
refreshTheme() {},
dispose() {},
};
@@ -1129,6 +1150,7 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'),
visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'),
waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'),
waveformEnabled: gl.getUniformLocation(program, 'uWaveformEnabled'),
cohesion: gl.getUniformLocation(program, 'uCohesion'),
durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
colorNavy: gl.getUniformLocation(program, 'uColorNavy'),
@@ -1167,6 +1189,12 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
// LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1).
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]
* 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.
*/
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;
if (!d || timeSeconds < 0 || timeSeconds >= d.durationSeconds) return 0;
const n = d.sampleCount;
@@ -1731,6 +1762,14 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
gl.clearColor(0, 0, 0, 0);
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.bindVertexArray(vao);
@@ -1756,6 +1795,7 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
// separate dirty-tracking needed for scalars/vec3s).
gl.uniform1f(u.visibleSeconds, visibleSeconds);
gl.uniform1f(u.waveformWidth, effectiveWaveformWidth());
gl.uniform1f(u.waveformEnabled, waveformEnabled ? 1 : 0);
gl.uniform1f(u.cohesion, fluidViscosity);
gl.uniform1f(u.gradientPhase, gradientPhase);
gl.uniform3fv(u.colorNavy, theme.navy);
@@ -1769,8 +1809,15 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
const nowMs = performance.now();
const physicsDt = Math.max(0, (nowMs - lastPhysicsMs) / 1000);
lastPhysicsMs = nowMs;
stepPhysics(physicsDt);
const liveCount = packBlobs();
// Phase 15 — lava off: skip the CPU physics step AND upload zero blobs. The shader's blob loop
// (`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.uniform1i(u.blobCount, liveCount);
@@ -2156,6 +2203,22 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
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 {
theme = readTheme();
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)
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.)
WAVEFORM VISUALIZER CONTROL PANEL (Phase 12 §3d-revised / §3g → Phase 15 re-layout)
The control deck hosted inside WaveformVisualizerControlPopover, now a screen-centered
tinted MudOverlay (Phase 15 §4). MudOverlay — like the former MudPopover — PORTALS its
content out of the component's DOM subtree, so Blazor CSS isolation never reaches the
rendered panel: its chrome, the three-row/section LAYOUT, the section labels, the slider,
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
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.
PanelChrome="true" parameter is set — which the popover host does and Mix's inline mount
does NOT — so the chrome never leaks onto an inline 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.
CHROME (Phase 15 §5 — NowPlayingCard treatment): SQUARE corners, lighter-navy ground
(navy-mid), a thin LIGHT border (--deepdrft-border-light, the NowPlayingCard 0.12-alpha
light-on-dark idiom as a token). All token-sourced; no hardcoded hex.
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 {
/* 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);
border: 1px solid var(--deepdrft-border-green);
border-radius: 8px;
/* Square corners + thin light border — NowPlayingCard chrome (§5). */
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;
/* 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). */
/* Three-row sectioned deck: stack the rows top-to-bottom; conditional rows reserve no permanent
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;
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 */
max-width: 420px;
/* Pin the MudBlazor palette vars the portaled RadialKnob + slider consume. */
--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-variant: var(--deepdrft-muted); /* knob background track — muted-navy filler (§3g) */
--mud-palette-text-primary: var(--deepdrft-white); /* knob value label — light (§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 */
}
/* 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);
/* ── Row layout (§3). Each row is a horizontal band; row 1 + row 3 space-between so the right-pinned
control (color / width) hugs the right edge and never reflows when an inner control hides. ── */
.waveform-visualizer-control-panel .wvc-row {
display: flex;
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;
}
/* ── 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) {
.deepdrft-track-detail-meta {
flex-direction: column;
@@ -17,6 +17,12 @@
--deepdrft-white: #FAFAF8;
--deepdrft-border: rgba(13, 27, 42, 0.10);
--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 */
--deepdrft-font-display: "Cormorant Garamond", Georgia, serif;