Phase 10 reframe R4: seven-knob inline visualizer controls, always-on lava loop, filled lava-lamp icon

This commit is contained in:
daniel-c-harvey
2026-06-16 17:17:14 -04:00
parent fe28573b68
commit 41ac7a5a93
9 changed files with 518 additions and 343 deletions
@@ -2,96 +2,144 @@
@using DeepDrftPublic.Client.Services
@inject MixVisualizerControlState ControlState
@* 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).
@* The Mix visualizer controls (lava reframe Wave R4). SEVEN continuous RadialKnobs — scroll speed,
gradient rotation speed, lava gravity, lava heat, blob density, collision strength, waveform width —
each its own dedicated control with a Material-icon caption (no more R2 temp-remapping: no knob
caption misrepresents its function). The bar lives INLINE in the mix-detail controls area and
ANIMATES open/closed in place via CSS transition off the @Expanded flag — it reads as the controls
collapsing/expanding, NOT a floating popover/drawer (§7b).
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).
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), 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 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="mix-visualizer-controls">
Aesthetic: the bar matches the session-hero NowPlaying overlay (§7e) — a translucent dark glass
surface with overlay-label captions and Color.Secondary accents, so it reads as of-a-piece with the
hero rather than a generic MudBlazor panel. *@
@* 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).
R2 TEMP: this knob is repurposed from resolution/zoom to WAVEFORM WIDTH for in-browser lava
testing (scroll speed isn't critical for evaluating the lava). The on-screen icon still reads
ZoomIn; R4 redraws the controls and restores the resolution mapping. *@
<div class="mix-visualizer-control" role="group" aria-label="Waveform width (R2 temp: on the resolution knob)">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ZoomIn" Size="Size.Small" Class="mix-visualizer-control-icon" />
<div class="mix-visualizer-controls-bar @(Expanded ? "is-expanded" : "")" aria-hidden="@(!Expanded)">
<div class="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.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Bubblyness">
<RadialKnob Value="@ControlState.Bubblyness"
ValueChanged="@OnBubblynessChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<div class="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.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="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.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="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.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Blob density and size">
<RadialKnob Value="@ControlState.BlobDensity"
ValueChanged="@OnBlobDensityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Detach (unleash the lava lamp)">
<RadialKnob Value="@ControlState.Detach"
ValueChanged="@OnDetachChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Air" Size="Size.Small" Class="mix-visualizer-control-icon" />
<div class="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.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Color-shift speed">
<RadialKnob Value="@ControlState.ColorShiftSpeed"
ValueChanged="@OnColorShiftSpeedChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="mix-visualizer-control-icon" />
<div class="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.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
</div>
@code {
// R2 TEMP: the resolution knob is repurposed to WAVEFORM WIDTH (already normalized [0,1], binds
// directly). R4 restores the log zoom mapping (MixZoomMapping) and gives width its own knob.
/// <summary>
/// Whether the knob bar is expanded. Owned by the host page (the lava-lamp toggle button flips it);
/// drives the CSS open/close transition. When false the bar collapses to zero size in place.
/// </summary>
[Parameter] public bool Expanded { get; set; }
// 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.
private void OnScrollSpeedChanged(double value)
{
ControlState.ScrollSpeed = value;
ControlState.NotifyChanged();
}
private void OnGradientRotationSpeedChanged(double value)
{
ControlState.GradientRotationSpeed = value;
ControlState.NotifyChanged();
}
private void OnLavaGravityChanged(double value)
{
ControlState.LavaGravity = value;
ControlState.NotifyChanged();
}
private void OnLavaHeatChanged(double value)
{
ControlState.LavaHeat = value;
ControlState.NotifyChanged();
}
private void OnBlobDensityChanged(double value)
{
ControlState.BlobDensity = value;
ControlState.NotifyChanged();
}
private void OnCollisionStrengthChanged(double value)
{
ControlState.CollisionStrength = value;
ControlState.NotifyChanged();
}
private void OnWaveformWidthChanged(double value)
{
ControlState.WaveformWidth = value;
ControlState.NotifyChanged();
}
private void OnBubblynessChanged(double value)
{
ControlState.Bubblyness = value;
ControlState.NotifyChanged();
}
private void OnDetachChanged(double value)
{
ControlState.Detach = value;
ControlState.NotifyChanged();
}
private void OnColorShiftSpeedChanged(double value)
{
ControlState.ColorShiftSpeed = value;
ControlState.NotifyChanged();
}
}
@@ -1,25 +1,65 @@
/* 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 {
/* The seven-knob bar lives INLINE in the mix-detail controls area and animates open/closed in place
(lava reframe §7b) — NOT a popover or drawer. Collapsed, it has zero size and is fully transparent;
the @Expanded flag (mirrored to the .is-expanded class) transitions it open. We animate max-width +
max-height + opacity + transform together so the bar reads as the controls growing in place rather
than a panel popping in. Closed state is pointer-events:none + visibility:hidden so collapsed knobs
are not focusable or hit-testable. */
.mix-visualizer-controls-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 1rem 1.25rem;
padding: 0.25rem;
justify-content: flex-end;
gap: 0.85rem 1rem;
/* Collapsed: zero footprint, slid up toward the toggle, transparent. */
max-width: 0;
max-height: 0;
opacity: 0;
transform: translateY(-8px);
overflow: hidden;
visibility: hidden;
pointer-events: none;
/* NowPlaying glass surface (§7e): translucent dark shim, soft blur, rounded, secondary-tinted
hairline — matches the session-hero overlay family. Padding animates in with the size. */
padding: 0;
border-radius: 10px;
background: rgba(13, 27, 42, 0.55);
border: 1px solid color-mix(in srgb, var(--mud-palette-secondary) 22%, transparent);
backdrop-filter: blur(10px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
transition:
max-width 0.32s cubic-bezier(0.22, 0.61, 0.36, 1),
max-height 0.32s cubic-bezier(0.22, 0.61, 0.36, 1),
opacity 0.24s ease,
padding 0.32s cubic-bezier(0.22, 0.61, 0.36, 1),
transform 0.32s cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* 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-controls-bar.is-expanded {
max-width: 640px;
max-height: 420px;
opacity: 1;
transform: translateY(0);
visibility: visible;
pointer-events: auto;
padding: 0.85rem 1rem;
}
/* One control: a RadialKnob with its Material icon caption underneath. RadialKnob has no icon slot, so
the icon rides adjacent (§7d). Center the pair so the seven read as a tidy bar. */
.mix-visualizer-control {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
gap: 0.3rem;
}
/* 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. */
attribute onto its element — reach it with ::deep. Tinted to the secondary accent and the
overlay-label opacity so it matches the session-hero NowPlaying captions (§7e). */
.mix-visualizer-control ::deep .mix-visualizer-control-icon {
opacity: 0.7;
color: var(--mud-palette-secondary);
opacity: 0.78;
}
@@ -58,9 +58,9 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed
// `[MixVisualizer]`, same as the JS logs so the two interleave into one timeline). These pinpoint
// which upstream link is broken when the ribbon stays blank — set false once confirmed healthy.
// ON for the Phase 10 reframe Wave R2 lava test (matches the JS-side DEBUG in
// MixVisualizer.ts). Daniel evaluates the physics in-browser; flip back to false at
// reframe close along with the JS flag.
// ON for the Phase 10 reframe Wave R4 controls test (matches the JS-side DEBUG in
// MixVisualizer.ts). Daniel evaluates the seven-knob bar + pause behavior in-browser; flip back to
// false at reframe close along with the JS flag.
private static readonly bool Debug = true;
private const string Tag = "[MixVisualizer]";
@@ -176,9 +176,9 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
return;
}
// Seed the module with the current state now that it exists. All four control values
// 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 slider positions the listener left them at.
// module with the knob positions the listener left them at.
await PushControlsAsync();
await PushDatumAsync();
await PushPlaybackAsync();
@@ -190,10 +190,10 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
await PushThemeIfChangedAsync();
}
// The controls row mutated a slider on the shared state and raised Changed. Push all four control
// values (cheap scalar interop). In the Phase 10 reframe Wave R2, three of them are re-routed to
// the lava physics inside the JS handle (setBubblyness→gravity, setDetach→heat,
// setColorShiftSpeed→collision) — see MixVisualizer.ts; the bridge contract is unchanged.
// The controls bar mutated a knob on the shared state and raised Changed. Push all seven control
// values (cheap scalar interop). Each control now drives its own dedicated dial in the JS handle
// (lava reframe Wave R4) — scroll speed → visible-time-span, plus the six lava/colour dials; see
// PushControlsAsync. The bridge stays the sole owner of the JS module handle.
private void OnControlStateChanged() => InvokeAsync(async () =>
{
await PushControlsAsync();
@@ -202,20 +202,29 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
/// <summary>
/// Push the control values to the module from the shared state. Used to seed on first render and
/// to re-push when the controls row signals a change. In the Phase 10 reframe Wave R2 the four
/// live controls are routed to the lava physics by the JS handle (see MixVisualizer.ts):
/// Bubblyness→gravity, Detach→heat, ColorShiftSpeed→collision, and the repurposed resolution knob
/// (WaveformWidth)→waveform width. VisibleSeconds is still seeded once via setZoom so the window
/// holds at its default; the controls row no longer mutates it this wave. Bridge contract unchanged.
/// Push the seven 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 (lava reframe Wave R4). Each value is its
/// own dedicated dial now — no more R2 temp-remapping:
/// <list type="bullet">
/// <item>scroll speed [0,1] is mapped to a visible time-span via <see cref="MixZoomMapping"/> and
/// pushed through <c>setScrollSpeed</c> (higher speed → tighter window → faster scroll);</item>
/// <item>gradient rotation speed → <c>setGradientRotationSpeed</c> (inert until Wave R3);</item>
/// <item>gravity / heat / blob density / collision strength → their dedicated lava-physics dials;</item>
/// <item>waveform width → the ribbon-extent uniform.</item>
/// </list>
/// </summary>
private async Task PushControlsAsync()
{
if (_handle is null) return;
await _handle.InvokeVoidAsync("setZoom", ControlState.VisibleSeconds);
await _handle.InvokeVoidAsync("setBubblyness", ControlState.Bubblyness);
await _handle.InvokeVoidAsync("setDetach", ControlState.Detach);
await _handle.InvokeVoidAsync("setColorShiftSpeed", ControlState.ColorShiftSpeed);
// Scroll speed is a normalized [0,1] axis; map it to the visible time-span the renderer scrolls
// through. The log map keeps the even-to-the-hand feel the old zoom slider had.
var visibleSeconds = MixZoomMapping.FractionToSeconds(ControlState.ScrollSpeed);
await _handle.InvokeVoidAsync("setScrollSpeed", visibleSeconds);
await _handle.InvokeVoidAsync("setGradientRotationSpeed", ControlState.GradientRotationSpeed);
await _handle.InvokeVoidAsync("setLavaGravity", ControlState.LavaGravity);
await _handle.InvokeVoidAsync("setLavaHeat", ControlState.LavaHeat);
await _handle.InvokeVoidAsync("setBlobDensity", ControlState.BlobDensity);
await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength);
await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth);
}