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);
}
+23 -30
View File
@@ -48,31 +48,25 @@ else
BackLabel="All mixes"
ShowMeta="@(hasGenre || hasDate)">
<TopRightAction>
@* 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. *@
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@DDIcons.LavaLamp"
Size="Size.Large"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@ToggleSettings"
aria-label="Visualizer settings" />
</MudTooltip>
@* Lava-lamp button top-right, across from the back link. Toggles the INLINE seven-knob
control bar that animates open/closed in place below it (lava reframe §7b) — not a
popover or drawer. The icon swaps to its FILLED variant while the bar is expanded
(§7f / Part B). The controls bar mutates the shared MixVisualizerControlState; the
backdrop bridge pushes the dials. A knob drag does not collapse the bar — the toggle
only flips on this button's click, never on a drag landing in the bar. *@
<div class="mix-visualizer-controls-anchor">
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@(_controlsExpanded ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Size="Size.Large"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@ToggleSettings"
aria-label="Visualizer settings"
aria-expanded="@_controlsExpanded" />
</MudTooltip>
@* 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. *@
<MudOverlay Visible="@_settingsOpen" OnClick="@CloseSettings" AutoClose="true" />
<MudPopover Open="@_settingsOpen"
Fixed="false"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight"
Class="mix-visualizer-popover">
<MixVisualizerControls />
</MudPopover>
<MixVisualizerControls Expanded="@_controlsExpanded" />
</div>
</TopRightAction>
<Hero>
<div class="mix-detail-cover">
@@ -114,11 +108,10 @@ else
@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;
// Lava-lamp inline knob-bar expanded state. Pure presentation over MixVisualizerControlState — the
// bar discloses the seven knobs and animates open/closed; toggling it touches no control value or
// bridge push. The lava-lamp button's filled/outline glyph is driven off this same flag.
private bool _controlsExpanded;
private void ToggleSettings() => _settingsOpen = !_settingsOpen;
private void CloseSettings() => _settingsOpen = false;
private void ToggleSettings() => _controlsExpanded = !_controlsExpanded;
}
@@ -4,6 +4,28 @@
z-index: 1;
}
/* The lava-lamp toggle + its inline knob-bar. The anchor stacks the button over the bar and lets the
bar grow downward/leftward in place when expanded, without shoving the masthead. The bar itself is
absolutely positioned under the button (top-right of the detail body), so its open/close animation
reads as the controls growing out from the icon rather than reflowing the page (lava reframe §7b). */
.mix-visualizer-controls-anchor {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-end;
}
/* MixVisualizerControls renders the .mix-visualizer-controls-bar as its single root. It is a child
Razor component, so its scope attribute is not stamped here — reach the bar with ::deep to position
it as a floating-but-inline element anchored to the toggle's bottom-right. */
.mix-visualizer-controls-anchor ::deep .mix-visualizer-controls-bar {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
z-index: 3;
transform-origin: top right;
}
/* Medium square cover — deliberately smaller than the 360px cut cover so the
waveform backdrop keeps room. The placeholder/art MudPaper fills this frame. */
.mix-detail-cover {
@@ -1,89 +1,103 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Holds the Mix visualizer's four continuous-control positions for the lifetime of the WASM app
/// Holds the Mix visualizer's seven 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
/// second mix and the sliders keep where you left them — but a fresh page load (F5) constructs a new
/// second mix and the knobs keep where you left them — but a fresh page load (F5) constructs a new
/// instance, resetting to defaults. That matches the spec's "persist within session, reset on fresh
/// load" without any cookie/localStorage round-trip (see mix-visualizer-webgl-renderer §3c).
/// load" without any cookie/localStorage round-trip (lava reframe §7c).
///
/// One state object, four properties — not four sibling holders (Daniel's decided shape, spec §3c).
/// Each C#-side default mirrors a TS-side tuning anchor in MixVisualizer.ts; keep the two in sync, as
/// the existing <c>DefaultVisibleSeconds</c> / <c>DEFAULT_VISIBLE_SECONDS</c> pair does.
/// One state object, seven properties — not seven sibling holders, and (deliberately) NO constructor
/// parameters: this is a plain scoped value holder, so widening it from four to seven properties adds
/// fields + defaults only and never forces a consumer constructor to grow. Each C#-side default mirrors
/// a TS-side tuning anchor in MixVisualizer.ts; keep the two in sync, as the existing
/// <c>DefaultVisibleSeconds</c> / <c>DEFAULT_VISIBLE_SECONDS</c> pair does.
///
/// <para>
/// <see cref="Changed"/> is the decoupling seam between the controls row and the visualizer bridge.
/// <see cref="Changed"/> is the decoupling seam between the controls bar and the visualizer bridge.
/// The controls component (a sibling of the backdrop in the page tree) only mutates this shared state
/// and raises <see cref="Changed"/>; the bridge component (<c>MixWaveformVisualizer</c>) subscribes
/// and pushes the affected uniform to the JS module. This keeps the JS module handle single-owned by
/// the bridge — no handle sharing, no service-locator, no cross-component interop.
/// and pushes the affected uniform/dial to the JS module. This keeps the JS module handle single-owned
/// by the bridge — no handle sharing, no service-locator, no cross-component interop.
/// </para>
/// </summary>
public sealed class MixVisualizerControlState
{
/// <summary>
/// Default opening window. Mirrors <c>DEFAULT_VISIBLE_SECONDS</c> in MixVisualizer.ts; keep the
/// two in sync (the TS owns the rendering anchors, this owns the C#-side session default).
/// </summary>
public const double DefaultVisibleSeconds = 10.0;
// R2 TEMP (Phase 10 reframe Wave R2): the FOUR controls below are re-routed to the new
// lava physics for Daniel's in-browser test — the JS handle setters map them as:
// Bubblyness → lava GRAVITY, Detach → lava HEAT, ColorShiftSpeed → COLLISION STRENGTH,
// Resolution (VisibleSeconds knob) → WAVEFORM WIDTH (see MixVisualizerControls.razor).
// The defaults are tuned to Daniel's sweet spot (~20% gravity, ~100% heat). Wave R4
// replaces this with the proper seven-knob set + its own typed properties. Keep these
// mirrored to the DEFAULT_* anchors in MixVisualizer.ts, as the existing sync discipline.
// ── The seven control defaults (lava reframe §7a). Each mirrors a DEFAULT_* anchor in
// MixVisualizer.ts; keep the two in sync, as the existing default-sync discipline requires.
// Feel-anchors only — Daniel tunes on screen; the ~20% gravity / ~100% heat pair is his stated
// sweet spot (§4c).
/// <summary>
/// Default GRAVITY dial (R2 temp; was bulge). Mirrors <c>DEFAULT_BUBBLYNESS</c> in MixVisualizer.ts.
/// Normalized [0,1]; 0 = near-weightless float, 1 = wax falls + settles fast. Tuned to Daniel's
/// ~20% sweet spot so the wax is buoyant-dominated and flows.
/// Default scroll-speed dial. Mirrors <c>DEFAULT_SCROLL_SPEED</c> in MixVisualizer.ts. Normalized
/// [0,1] → mapped to the visible time-span via <see cref="MixZoomMapping"/> (0 = slow/wide window,
/// 1 = fast/tight window). Opens mid-range.
/// </summary>
public const double DefaultBubblyness = 0.2;
public const double DefaultScrollSpeed = 0.5;
/// <summary>
/// Default HEAT dial (R2 temp; was detach). Mirrors <c>DEFAULT_DETACH</c> in MixVisualizer.ts.
/// Normalized [0,1]; 0 = wax rests at the bottom (collision-only), 1 = lots of small turbulent
/// bubbles. Tuned to Daniel's ~100% sweet spot.
/// Default gradient-rotation-speed dial. Mirrors <c>DEFAULT_GRADIENT_ROTATION_SPEED</c> in
/// MixVisualizer.ts. Normalized [0,1] → slow→fast anchor-rotation. INERT until Wave R3 builds the
/// OKLab three-colour gradient that consumes it.
/// </summary>
public const double DefaultDetach = 1.0;
public const double DefaultGradientRotationSpeed = 0.3;
/// <summary>
/// Default COLLISION-STRENGTH dial (R2 temp; was color-shift). Mirrors
/// <c>DEFAULT_COLOR_SHIFT_SPEED</c> in MixVisualizer.ts. Normalized [0,1]; 0 = soft mush,
/// 1 = hard elastic throw.
/// Default lava-gravity dial. Mirrors <c>DEFAULT_LAVA_GRAVITY</c> in MixVisualizer.ts. Normalized
/// [0,1]; 0 = near-weightless float, 1 = wax falls + settles fast. Tuned to Daniel's ~20% sweet spot.
/// </summary>
public const double DefaultColorShiftSpeed = 0.5;
public const double DefaultLavaGravity = 0.2;
/// <summary>
/// Default WAVEFORM-WIDTH dial (R2 temp; routed to the resolution/zoom knob this wave). Mirrors
/// <c>DEFAULT_WAVEFORM_WIDTH</c> in MixVisualizer.ts. Normalized [0,1]; 1 = full ribbon width
/// (prior look), lower narrows the band so the lava gets more room. Opens at full width.
/// Default lava-heat dial. Mirrors <c>DEFAULT_LAVA_HEAT</c> in MixVisualizer.ts. Normalized [0,1];
/// 0 = wax rests at the bottom (collision-only), 1 = many small turbulent rising bubbles. Tuned to
/// Daniel's ~100% sweet spot.
/// </summary>
public const double DefaultWaveformWidth = 1.0;
/// <summary>Visible time-span in seconds (the resolution/zoom control). Reused as-is from 8.K.</summary>
public double VisibleSeconds { get; set; } = DefaultVisibleSeconds;
/// <summary>Bulge amount, normalized [0,1]. Inert until Wave 3 consumes the uniform.</summary>
public double Bubblyness { get; set; } = DefaultBubblyness;
/// <summary>Lava-lamp detachment, normalized [0,1]. Inert until Wave 3 consumes the uniform.</summary>
public double Detach { get; set; } = DefaultDetach;
/// <summary>Gradient-morph rate, normalized [0,1]. Inert until Wave 3 consumes the uniform.</summary>
public double ColorShiftSpeed { get; set; } = DefaultColorShiftSpeed;
public const double DefaultLavaHeat = 1.0;
/// <summary>
/// Waveform width, normalized [0,1]. R2 TEMP: routed to the resolution/zoom knob for in-browser
/// testing (Wave R4 gives it its own knob and restores the resolution knob to VisibleSeconds).
/// Default blob-density dial. Mirrors <c>DEFAULT_BLOB_DENSITY</c> in MixVisualizer.ts. Normalized
/// [0,1]; 0 = a few large lazy blobs, 1 = many smaller active blobs.
/// </summary>
public const double DefaultBlobDensity = 0.4;
/// <summary>
/// Default collision-strength dial. Mirrors <c>DEFAULT_COLLISION_STRENGTH</c> in MixVisualizer.ts.
/// Normalized [0,1]; 0 = soft mush, 1 = hard elastic up-and-out throw.
/// </summary>
public const double DefaultCollisionStrength = 0.5;
/// <summary>
/// Default waveform-width dial. Mirrors <c>DEFAULT_WAVEFORM_WIDTH</c> in MixVisualizer.ts.
/// Normalized [0,1]; 1 = full ribbon width, lower narrows the band so the lava gets more room.
/// </summary>
public const double DefaultWaveformWidth = 0.6;
/// <summary>Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible
/// time-span via <see cref="MixZoomMapping"/>; the standalone resolution/zoom control is gone.</summary>
public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
/// <summary>Gradient anchor-rotation rate, normalized [0,1]. Inert until Wave R3 consumes it.</summary>
public double GradientRotationSpeed { get; set; } = DefaultGradientRotationSpeed;
/// <summary>Downward force on the wax, normalized [0,1].</summary>
public double LavaGravity { get; set; } = DefaultLavaGravity;
/// <summary>Energy into the lava system, normalized [0,1]. 0 = rest-at-bottom, 1 = roiling.</summary>
public double LavaHeat { get; set; } = DefaultLavaHeat;
/// <summary>Amount of wax (blob count/size), normalized [0,1].</summary>
public double BlobDensity { get; set; } = DefaultBlobDensity;
/// <summary>Collision hardness, normalized [0,1]. 0 = soft mush, 1 = hard up-and-out throw.</summary>
public double CollisionStrength { get; set; } = DefaultCollisionStrength;
/// <summary>Waveform-band horizontal extent, normalized [0,1]. Narrowing clears room for the lava.</summary>
public double WaveformWidth { get; set; } = DefaultWaveformWidth;
/// <summary>
/// Raised whenever any control value changes. The visualizer bridge subscribes to push the
/// affected uniform(s). Mutators set the property then raise this; subscribers re-read the values.
/// affected dial(s). Mutators set the property then raise this; subscribers re-read the values.
/// </summary>
public event Action? Changed;