diff --git a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor
index 53ed529..90e8a99 100644
--- a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor
+++ b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor
@@ -2,71 +2,71 @@
@using DeepDrftPublic.Client.Services
@inject MixVisualizerControlState ControlState
-@* The Mix visualizer controls row (Phase 10, Wave 2). Four continuous sliders — resolution,
- bubblyness, detach, color-shift speed — placed above the mix details and below the back button.
- This component owns NO JS interop: it mutates the shared, session-scoped MixVisualizerControlState
- and raises its Changed event. The backdrop bridge (MixWaveformVisualizer) subscribes to that event
- and pushes the affected uniform 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 (spec §D). *@
+@* The Mix visualizer controls (Phase 10, Wave 4). Four continuous RadialKnobs — resolution,
+ bubblyness, detach, color-shift speed — laid out in a row. This component lives inside the
+ lava-lamp popover on the Mix detail page (Wave 4 moved it out of the always-visible TopContent row).
+ It owns NO JS interop: it mutates the shared, session-scoped MixVisualizerControlState and raises its
+ Changed event. The backdrop bridge (MixWaveformVisualizer) subscribes to that event and pushes the
+ affected uniform 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 (spec §D).
+
+ RadialKnob has no icon slot (its Label renders as SVG text), so each control's Material icon rides
+ beside its knob as an adjacent MudIcon caption (spec §7e). HoldValue stays false so the knobs are
+ live — ValueChanged fires continuously during drag, exactly as the sliders fired before, preserving
+ the "visibly and continuously affects its target" feel and the Changed/NotifyChanged seam. *@
-
+ @* RadialKnob exposes no aria-label/attribute-capture and is out of scope to modify, so the
+ accessible name rides on the wrapping group div instead (a plain element accepts it). *@
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
@code {
- // Resolution rides the log mapping (slider fraction [0,1] ↔ visible seconds); the other three are
+ // Resolution rides the log mapping (knob fraction [0,1] ↔ visible seconds); the other three are
// already normalized [0,1] and bind to their state properties directly.
private double ResolutionFraction => MixZoomMapping.SecondsToFraction(ControlState.VisibleSeconds);
diff --git a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css
index 0653f51..40d7fe2 100644
--- a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css
+++ b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css
@@ -1,34 +1,25 @@
-/* The controls row sits in the mix-detail foreground, below the back button and above the masthead.
- A horizontal row of four icon+slider controls. On narrow viewports it wraps to keep all four
- present (spec §3b: wrap is the chosen mobile behaviour — none may drop). */
+/* The controls live inside the lava-lamp popover (Wave 4). Four knob+icon stacks laid out in a row.
+ On a narrow viewport the row wraps to 2×2 so all four stay reachable (spec §7d: none may drop). */
.mix-visualizer-controls {
display: flex;
flex-wrap: wrap;
- align-items: center;
- gap: 0.75rem 1.5rem;
- margin: 0.5rem 0 1.5rem;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 1rem 1.25rem;
+ padding: 0.25rem;
}
-/* One control: a compact label icon followed by the slider. The slider gets a fixed-ish track width
- so the four read as a tidy row rather than stretching unevenly. */
+/* One control: a RadialKnob with its Material icon as a caption underneath. RadialKnob has no icon
+ slot, so the icon rides adjacent (spec §7e). Center the pair so the four read as a tidy row. */
.mix-visualizer-control {
display: flex;
+ flex-direction: column;
align-items: center;
- gap: 0.5rem;
- flex: 1 1 180px;
- min-width: 160px;
- max-width: 260px;
+ gap: 0.35rem;
}
-.mix-visualizer-control-icon {
- flex: 0 0 auto;
+/* The caption icon is a MudIcon (a Razor component), so Blazor CSS isolation does not stamp the scope
+ attribute onto its element — reach it with ::deep. */
+.mix-visualizer-control ::deep .mix-visualizer-control-icon {
opacity: 0.7;
}
-
-/* MudSlider renders a Razor component, so its root is reached with ::deep (a bare scoped selector
- would not be stamped onto the child component's element). Let the slider fill the remaining width
- of its control so the icon+slider pair lays out cleanly. */
-.mix-visualizer-control ::deep .mud-slider {
- flex: 1 1 auto;
- margin: 0;
-}
diff --git a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor
index 947945d..8ac92cc 100644
--- a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor
+++ b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor
@@ -7,9 +7,15 @@
-
- ← @BackLabel
-
+ @* Back link top-left, optional medium action top-right, on one SpaceBetween row. The action slot
+ stays null for media that don't supply it (Track/Session), so they render the back link alone. *@
+
+
+ ← @BackLabel
+
+
+ @TopRightAction
+
@TopContent
diff --git a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs
index 9f90815..02defd4 100644
--- a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs
+++ b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs
@@ -31,6 +31,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
///
[Parameter] public RenderFragment? TopContent { get; set; }
+ ///
+ /// Optional action rendered at the top-right of the container, on the same SpaceBetween row as the
+ /// back link (back link left, action right). The Mix detail page uses it for the lava-lamp
+ /// visualizer-settings button; other media leave it null and render the back link alone.
+ ///
+ [Parameter] public RenderFragment? TopRightAction { get; set; }
+
/// Medium-specific hero visual (cover art, hero image, or waveform background).
[Parameter] public RenderFragment? Hero { get; set; }
diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor b/DeepDrftPublic.Client/Pages/MixDetail.razor
index e206344..532d506 100644
--- a/DeepDrftPublic.Client/Pages/MixDetail.razor
+++ b/DeepDrftPublic.Client/Pages/MixDetail.razor
@@ -40,18 +40,40 @@ else
+
-
- @* The four visualizer controls — resolution, bubblyness, detach, color-shift speed —
- in a row below the back button and above the masthead (spec §3). They mutate the
- shared MixVisualizerControlState; the backdrop bridge above pushes the uniforms. *@
-
-
+
+ @* Lava-lamp button top-right, across from the back link. Toggles a popover holding the
+ four visualizer knobs (spec §7c/§7d). The controls themselves are unchanged — they
+ mutate the shared MixVisualizerControlState; the backdrop bridge pushes the uniforms.
+ The popover only progressively-discloses them off the always-visible row. *@
+
+
+
+
+ @* Outside-click close via MudOverlay (the SharePopover idiom). A knob drag never lands
+ on this overlay — the knob's own global capture overlay is a child of the popover
+ content above it — so dragging a knob does not dismiss the popover. *@
+
+
+
+
+
+
}
@code {
protected override string PersistKey => "mix-detail";
+
+ // Lava-lamp settings popover open state. Pure presentation over MixVisualizerControlState — the
+ // popover discloses the four knobs; toggling it touches no control value or bridge push.
+ private bool _settingsOpen;
+
+ private void ToggleSettings() => _settingsOpen = !_settingsOpen;
+
+ private void CloseSettings() => _settingsOpen = false;
}
diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
index 1032225..2601f6c 100644
--- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
+++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
@@ -281,6 +281,17 @@ h2, h3, h4, h5, h6,
padding: 3rem 1.5rem 4rem;
}
+/* Mix detail widens its body to the Sessions detail width (MudContainer Large, ~1280px) by hosting the
+ scaffold inside a MudContainer Large and neutralizing the scaffold's own 760px cap for that instance.
+ Both classes are global, so a plain descendant selector reaches the scaffold div without ::deep. The
+ horizontal gutter is dropped here because the wrapping MudContainer supplies its own. Mix-scoped, so
+ Track detail (which also uses .deepdrft-track-detail-container) stays at 760px. */
+.mix-detail-container .deepdrft-track-detail-container {
+ max-width: none;
+ padding-left: 0;
+ padding-right: 0;
+}
+
.deepdrft-track-detail-back {
display: inline-flex;
align-items: center;
@@ -353,6 +364,15 @@ h2, h3, h4, h5, h6,
max-width: 360px;
}
+/* The lava-lamp visualizer-settings popover (Wave 4). Holds the four RadialKnobs in a row; sized so the
+ four read clearly with comfortable padding, wrapping to 2×2 on narrow viewports (the inner
+ .mix-visualizer-controls owns the flex-wrap). MudPopover renders into the popover-provider portal at
+ the document root, so this is a global class — not component-scoped. */
+.mix-visualizer-popover {
+ padding: 0.75rem;
+ max-width: 360px;
+}
+
/* Monospace snippet so the iframe markup stays legible inside the readonly field. */
.deepdrft-share-embed-field {
flex: 1 1 auto;
diff --git a/DeepDrftShared.Client/Common/DDIcons.cs b/DeepDrftShared.Client/Common/DDIcons.cs
index 11fecb6..38f1e02 100644
--- a/DeepDrftShared.Client/Common/DDIcons.cs
+++ b/DeepDrftShared.Client/Common/DDIcons.cs
@@ -22,4 +22,19 @@ public static class DDIcons
""";
+
+ ///
+ /// Lava lamp - the Mix visualizer settings glyph. Tapered base, tall rounded glass vessel, a cap,
+ /// and three suspended blobs at varied heights. The silhouette is currentColor so it themes with
+ /// its context (Color.Secondary tint, light/dark). The blobs carry a warm two-tone accent à la the
+ /// gas-lamp flame so the lamp reads as "lit" at icon size; the silhouette still tints with context.
+ ///
+ public const string LavaLamp = """
+
+ """;
}