@if (Visible)
{
-
@code {
///
- /// Whether the knob band is shown. The popover host shows the panel whenever it is open, so the
- /// default is true. Mix's legacy inline mount still feeds its lava-lamp toggle into this — that
- /// mount always renders the component, and THIS component decides knob visibility (Phase 10 §4): when
- /// false the knobs are @if-gated out but the container holds its reserved height (CSS min-height), so
- /// content below the 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 true. 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.
///
[Parameter] public bool Visible { get; set; } = true;
///
/// When true, applies the waveform-visualizer-control-panel class to the root element,
- /// enabling the global panel-chrome rules (dark-navy ground, border, max-width cap, pinned palette
- /// tokens). Set by ; Mix's inline mount leaves this
- /// false so the chrome never leaks onto the inline bar.
+ /// 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
+ /// ; Mix's inline mount leaves this false so the
+ /// chrome never leaks onto the inline bar.
///
[Parameter] public bool PanelChrome { get; set; } = false;
private string _panelChromeClass => PanelChrome ? "waveform-visualizer-control-panel" : string.Empty;
- // Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and
- // pushes the affected dial. All values are already normalized [0,1]; the bridge maps scroll speed
- // 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)
{
diff --git a/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs b/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs
index 65b6470..36659b9 100644
--- a/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs
+++ b/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs
@@ -1,8 +1,8 @@
namespace DeepDrftPublic.Client.Services;
///
-/// 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
///
public const double DefaultWaveformWidth = 0.5;
+ ///
+ /// Default lava-subsystem on-state. true 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.
+ ///
+ public const bool DefaultLavaEnabled = true;
+
+ ///
+ /// Default waveform-subsystem on-state. true so the waveform ribbon is on out of the box.
+ /// Backs the row-1 waveform lamp toggle (Phase 15 §6).
+ ///
+ public const bool DefaultWaveformEnabled = true;
+
///
Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible
/// time-span via ; the standalone resolution/zoom control is gone.
public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
@@ -110,6 +123,20 @@ public sealed class WaveformVisualizerControlState
///
Waveform-band horizontal extent, normalized [0,1]. Narrowing clears room for the lava.
public double WaveformWidth { get; set; } = DefaultWaveformWidth;
+ ///
+ /// Whether the lava field is drawn. When false 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).
+ ///
+ public bool LavaEnabled { get; set; } = DefaultLavaEnabled;
+
+ ///
+ /// Whether the waveform ribbon is drawn. When false 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).
+ ///
+ public bool WaveformEnabled { get; set; } = DefaultWaveformEnabled;
+
///
/// 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.
diff --git a/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts b/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts
index ad30fad..c8f978b 100644
--- a/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts
+++ b/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts
@@ -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();
diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
index c50a627..8ff76d0 100644
--- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
+++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
@@ -376,49 +376,151 @@ 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 (MODE) and row 3 (WAVE) use
+ space-between so the right-pinned control (color / width) hugs the far edge. Row 2 (LAVA) uses
+ flex-start so its label + four knobs group left rather than spreading edge-to-edge. ── */
+.waveform-visualizer-control-panel .wvc-row {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 0.85rem 1rem;
+}
+
+/* Row 1 (MODE): two direct flex children — the left toggle group and the color knob tooltip wrapper.
+ space-between pins the color knob to the far right and keeps it there when collisions hides. */
+.waveform-visualizer-control-panel .wvc-row-mode {
+ justify-content: space-between;
+}
+
+/* Row 2 (LAVA): label + four knobs group left — no right-pinned control. */
+.waveform-visualizer-control-panel .wvc-row-section {
+ justify-content: flex-start;
+}
+
+/* Row 3 (WAVE): label + scroll-slider + width-knob tooltip wrappers are direct flex children.
+ space-between pins the width knob to the far right while the label + slider sit left. */
+.waveform-visualizer-control-panel .wvc-row-wave {
+ 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;
+}
+
+/* ── 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(var(--deepdrft-scrim-rgb), 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;
diff --git a/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css b/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css
index 952ef4c..a4ee762 100644
--- a/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css
+++ b/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css
@@ -17,6 +17,16 @@
--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 base colour (RGB triple for use in rgba()) — panel dark-ground (#0D1B2A).
+ Deliberately NOT --deepdrft-navy (#112338); tokenised here so the scrim rule in
+ deepdrft-styles.css has no hardcoded literals. Change here once. */
+ --deepdrft-scrim-rgb: 13, 27, 42;
+ /* 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;