feat(manifold): verdict-cluster + dock polish

- thumbs-up → green; down/explore → orange (dice icon in explore mode,
  thumb-down in dislike mode); undo button shrunk
- add nudge | randomise two-segment pill under the verdict cluster
- dock drawers closed by default; collapse to two depths (condensed
  side-panel / expanded ~80% centred modal) with a left-edge expand tab
- Mode button → 'M' (white border, orange letter)
- smoke test asserts rail button title (drawers now closed by default)
This commit is contained in:
monkey-w1n5t0n 2026-06-28 21:39:54 +02:00
parent 453047612a
commit cb28facc65
7 changed files with 329 additions and 172 deletions

View file

@ -106,8 +106,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
const [tame, setTame] = useState(0.85); const [tame, setTame] = useState(0.85);
const [health, setHealth] = useState(0.8); const [health, setHealth] = useState(0.8);
const [rev, setRev] = useState(1); const [rev, setRev] = useState(1);
const [active, setActive] = useState<DrawerKey | null>('learn'); const [active, setActive] = useState<DrawerKey | null>(null);
const [depth, setDepth] = useState<DrawerDepth>('peek'); const [depth, setDepth] = useState<DrawerDepth>('condensed');
// Sandwich (parameter-landscape) centre-stage toggle — dock-bottom layers icon. // Sandwich (parameter-landscape) centre-stage toggle — dock-bottom layers icon.
const [sandwich, setSandwich] = useState(false); const [sandwich, setSandwich] = useState(false);
@ -222,8 +222,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
setFollow(false); setFollow(false);
setPins([]); setPins([]);
setMarkers([]); setMarkers([]);
setActive('learn'); setActive(null);
setDepth('peek'); setDepth('condensed');
if (engine) engine.setInput(0.5, 0.5); if (engine) engine.setInput(0.5, 0.5);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [modeId]); }, [modeId]);
@ -411,6 +411,23 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
setRev((r) => r + 1); setRev((r) => r + 1);
}; };
/**
* Nudge a small bounded weight perturbation of the current net (the left
* half of the secondary pill). Routes to the scratchpad while exploring,
* otherwise nudges the real net directly.
*/
const nudgeNet = () => {
const c = controllerRef.current;
if (feedbackMode === 'explore-and-place' && c?.getState().exploring) {
c.nudge();
} else {
engine?.feedback.nudge(0.05);
engine?.process();
}
syncController();
setRev((r) => r + 1);
};
/** /**
* Undo. While exploring (Mode 2) this pops the scratchpad undo ring (reroll / * Undo. While exploring (Mode 2) this pops the scratchpad undo ring (reroll /
* nudge). Otherwise it falls back to the UI snapshot stack (visual A/B seed). * nudge). Otherwise it falls back to the UI snapshot stack (visual A/B seed).
@ -526,8 +543,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
}; };
if (map[e.key]) { if (map[e.key]) {
setActive((a) => (a === map[e.key] ? null : map[e.key])); setActive((a) => (a === map[e.key] ? null : map[e.key]));
setDepth('peek'); setDepth('condensed');
} else if (e.key === '\\') setDepth((d) => (d === 'full' ? 'peek' : 'full')); } else if (e.key === '\\') setDepth((d) => (d === 'expanded' ? 'condensed' : 'expanded'));
else if (focus === 'composite' && e.key === '[') { else if (focus === 'composite' && e.key === '[') {
e.preventDefault(); e.preventDefault();
setSplit((s) => Math.max(0, s - 0.04)); setSplit((s) => Math.max(0, s - 0.04));
@ -903,6 +920,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
onUndo={undo} onUndo={undo}
onCommit={commit} onCommit={commit}
onReroll={reroll} onReroll={reroll}
onNudge={nudgeNet}
onRandomise={reroll}
canUndo={feedbackMode === 'explore-and-place' && exploring ? undoDepth > 0 : snapshots.length > 0} canUndo={feedbackMode === 'explore-and-place' && exploring ? undoDepth > 0 : snapshots.length > 0}
ab={ab} ab={ab}
onToggleAB={toggleAB} onToggleAB={toggleAB}

View file

@ -1,6 +1,8 @@
/** /**
* The Console dock right-edge 48px rail + one mutually-exclusive drawer with * The Console dock right-edge 48px rail + one mutually-exclusive drawer with
* three depth states (Peek 320 / Expand 520 / Full modal). * two depth states: condensed (360px side panel) and expanded (centred modal,
* ~80% of the viewport). A vertically-centred tab on the panel's left edge
* toggles between them; drawers are closed by default.
* *
* RESTRUCTURED (operator dock restructure): * RESTRUCTURED (operator dock restructure):
* - TOP, pinned: the **Mode** selector the active OUTPUT BACKEND/target * - TOP, pinned: the **Mode** selector the active OUTPUT BACKEND/target
@ -22,7 +24,6 @@ import type { ConsoleCtx, DrawerDepth, DrawerKey, OutputMode } from './types';
import { OUTPUT_MODES } from './output-mode'; import { OUTPUT_MODES } from './output-mode';
import { useSettings, unfocusedIconCss } from '../settings/settings-store'; import { useSettings, unfocusedIconCss } from '../settings/settings-store';
import { import {
ModeIcon,
ParticleIcon, ParticleIcon,
MidiIcon, MidiIcon,
OscIcon, OscIcon,
@ -80,17 +81,20 @@ function ModeSelector({
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 'var(--r-2)', borderRadius: 'var(--r-2)',
border: '1px solid var(--accent)', border: '1px solid #ffffff',
background: 'rgba(255,106,0,0.12)', background: 'rgba(255,106,0,0.12)',
color: 'var(--accent)', color: 'var(--accent)',
cursor: 'pointer', cursor: 'pointer',
fontSize: 18, fontFamily: 'var(--font-mono)',
fontSize: 22,
fontWeight: 700,
lineHeight: 1,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
{mono ? <ModeIcon /> : '⊞'} M
</button> </button>
{open && ( {open && (
<div <div
@ -196,7 +200,7 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan
title={s.label} title={s.label}
onClick={() => { onClick={() => {
setActive(on ? null : key); setActive(on ? null : key);
setDepth('peek'); setDepth('condensed');
}} }}
style={{ style={{
position: 'relative', position: 'relative',
@ -234,107 +238,146 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan
); );
}; };
const width = depth === 'expand' ? 520 : 320; const expanded = depth === 'expanded';
const full = depth === 'full';
const section = active ? DRAWERS[active] : null; const section = active ? DRAWERS[active] : null;
return ( return (
<> <>
{/* Dim backdrop behind the centred modal; click to collapse back. */}
{section && expanded && (
<div
onClick={() => setDepth('condensed')}
style={{
position: 'fixed',
inset: 0,
zIndex: 85,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(2px)',
WebkitBackdropFilter: 'blur(2px)',
}}
/>
)}
{section && ( {section && (
// Shell: positions/sizes the drawer and is the anchor for the left-edge
// tab. overflow:visible so the tab (at left:-22) is NOT clipped; the
// inner wrapper handles scrolling.
<aside <aside
style={ style={
full expanded
? { ? {
position: 'fixed', position: 'fixed',
inset: 0, top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '80vw',
height: '80vh',
zIndex: 90, zIndex: 90,
background: 'var(--glass)', background: 'var(--glass)',
backdropFilter: 'blur(16px)', backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
padding: 'var(--sp-6)', border: '1px solid var(--glass-line)',
display: 'flex', borderRadius: 'var(--r-3)',
flexDirection: 'column', boxShadow: 'var(--shadow-2)',
gap: 'var(--sp-3)', overflow: 'visible',
} }
: { : {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
right: 48, right: 48,
bottom: 0, bottom: 0,
width, width: 360,
zIndex: 35, zIndex: 35,
background: 'var(--glass)', background: 'var(--glass)',
backdropFilter: 'blur(16px)', backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
borderLeft: '1px solid var(--glass-line)', borderLeft: '1px solid var(--glass-line)',
boxShadow: '-8px 0 24px rgba(0,0,0,0.4)', boxShadow: '-8px 0 24px rgba(0,0,0,0.4)',
padding: 'var(--sp-4)', overflow: 'visible',
display: 'flex',
flexDirection: 'column',
gap: 'var(--sp-2)',
overflow: 'auto',
animation: 'mfDrawerIn var(--dur-med) var(--ease-console)', animation: 'mfDrawerIn var(--dur-med) var(--ease-console)',
} }
} }
> >
<header {/* Expand/collapse tab — vertically centred on the panel's left edge. */}
<button
type="button"
title={expanded ? 'Condense' : 'Expand'}
onClick={() => setDepth(expanded ? 'condensed' : 'expanded')}
style={{ style={{
position: 'absolute',
left: -22,
top: '50%',
transform: 'translateY(-50%)',
width: 22,
height: 56,
borderRadius: 'var(--r-2) 0 0 var(--r-2)',
border: '1px solid var(--glass-line)',
borderRight: 'none',
background: 'var(--glass)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
color: 'var(--fg-mute)',
cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 'var(--sp-2)', justifyContent: 'center',
borderBottom: '1px solid var(--glass-line)', zIndex: 1,
paddingBottom: 'var(--sp-2)',
}} }}
> >
<span style={{ color: 'var(--accent)', display: 'inline-flex' }}> <ExpandIcon size={12} />
{mono ? section.icon : <span style={{ fontSize: 'var(--fs-md)' }}>{section.glyph}</span>} </button>
</span> {/* Inner scroll wrapper — holds header + content, scrolls independently. */}
<h3 style={{ margin: 0, fontSize: 'var(--fs-md)', color: 'var(--fg)' }}>{section.label}</h3>
<span
style={{
fontSize: 10,
color: 'var(--fg-dim)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginLeft: 4,
}}
>
{depth}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
{!full && (
<button
type="button"
title={depth === 'peek' ? 'More' : 'Peek'}
onClick={() => setDepth(depth === 'peek' ? 'expand' : 'peek')}
style={dockMini}
>
<ExpandIcon size={12} />
</button>
)}
<button
type="button"
title="Open full"
onClick={() => setDepth(full ? 'peek' : 'full')}
style={dockMini}
>
<ExpandIcon size={12} />
</button>
<button type="button" title="Close" onClick={() => setActive(null)} style={dockMini}>
<CloseIcon size={12} />
</button>
</div>
</header>
<div <div
style={{ style={{
height: '100%',
boxSizing: 'border-box',
overflow: 'auto',
padding: expanded ? 'var(--sp-6)' : 'var(--sp-4)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 'var(--sp-2)', gap: expanded ? 'var(--sp-3)' : 'var(--sp-2)',
flex: 1, borderRadius: 'inherit',
...(full ? { maxWidth: 720 } : {}),
}} }}
> >
{section.render(ctx, depth)} <header
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--sp-2)',
borderBottom: '1px solid var(--glass-line)',
paddingBottom: 'var(--sp-2)',
}}
>
<span style={{ color: 'var(--accent)', display: 'inline-flex' }}>
{mono ? section.icon : <span style={{ fontSize: 'var(--fs-md)' }}>{section.glyph}</span>}
</span>
<h3 style={{ margin: 0, fontSize: 'var(--fs-md)', color: 'var(--fg)' }}>{section.label}</h3>
<span
style={{
fontSize: 10,
color: 'var(--fg-dim)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginLeft: 4,
}}
>
{depth}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<button type="button" title="Close" onClick={() => setActive(null)} style={dockMini}>
<CloseIcon size={12} />
</button>
</div>
</header>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--sp-2)',
flex: 1,
}}
>
{section.render(ctx, depth)}
</div>
</div> </div>
</aside> </aside>
)} )}

View file

@ -1,6 +1,6 @@
/** /**
* Console the FIVE real dock drawers (operator dock restructure). Each renderer * Console the FIVE real dock drawers (operator dock restructure). Each renderer
* takes (ctx, depth); what shows is gated by depth (peek | expand | full): * takes (ctx, depth); what shows is gated by depth (condensed | expanded):
* *
* learn Learning : feedback-mode selector, solo/arm chooser, live params * learn Learning : feedback-mode selector, solo/arm chooser, live params
* inputs Inputs : input source * inputs Inputs : input source
@ -162,13 +162,13 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
<SectionLabel>Down action · feedback mode</SectionLabel> <SectionLabel>Down action · feedback mode</SectionLabel>
<Segmented value={ctx.feedbackMode} onChange={ctx.setFeedbackMode} options={FEEDBACK_OPTS} /> <Segmented value={ctx.feedbackMode} onChange={ctx.setFeedbackMode} options={FEEDBACK_OPTS} />
{depth !== 'peek' && ( {depth === 'expanded' && (
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}> <p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
{FEEDBACK_DESC[ctx.feedbackMode]} {FEEDBACK_DESC[ctx.feedbackMode]}
</p> </p>
)} )}
{depth !== 'peek' && ( {depth === 'expanded' && (
<> <>
<SectionLabel>Solo / arm scope</SectionLabel> <SectionLabel>Solo / arm scope</SectionLabel>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
@ -204,7 +204,7 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
</> </>
)} )}
{depth === 'full' && ( {depth === 'expanded' && (
<> <>
<SectionLabel>Feedback lab</SectionLabel> <SectionLabel>Feedback lab</SectionLabel>
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}> <p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
@ -339,21 +339,21 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
<SectionLabel>Input source</SectionLabel> <SectionLabel>Input source</SectionLabel>
<Segmented value={inp.inputMode} onChange={inp.setInputMode} options={INPUT_MODE_OPTS} /> <Segmented value={inp.inputMode} onChange={inp.setInputMode} options={INPUT_MODE_OPTS} />
{active && depth !== 'peek' && ( {active && depth === 'expanded' && (
<span style={{ fontSize: 9, color: STATUS_TONE[active.status.state] ?? 'var(--fg-dim)' }}> <span style={{ fontSize: 9, color: STATUS_TONE[active.status.state] ?? 'var(--fg-dim)' }}>
{active.status.message} {active.status.message}
</span> </span>
)} )}
{/* ---- Internal (XY pad / manifold) ---- */} {/* ---- Internal (XY pad / manifold) ---- */}
{inp.inputMode === 'internal' && depth !== 'peek' && ( {inp.inputMode === 'internal' && depth === 'expanded' && (
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}> <p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
Drag the on-screen manifold / XY pad. Two axes feed the net directly this is the default. Drag the on-screen manifold / XY pad. Two axes feed the net directly this is the default.
</p> </p>
)} )}
{/* ---- Game Controller ---- */} {/* ---- Game Controller ---- */}
{inp.inputMode === 'gamepad' && depth !== 'peek' && ( {inp.inputMode === 'gamepad' && depth === 'expanded' && (
<> <>
<SectionLabel>Sticks</SectionLabel> <SectionLabel>Sticks</SectionLabel>
<Segmented <Segmented
@ -384,7 +384,7 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
)} )}
{/* ---- MIDI ---- */} {/* ---- MIDI ---- */}
{inp.inputMode === 'midi' && depth !== 'peek' && ( {inp.inputMode === 'midi' && depth === 'expanded' && (
<> <>
<SectionLabel>Device</SectionLabel> <SectionLabel>Device</SectionLabel>
{inp.midiInputs.length === 0 ? ( {inp.midiInputs.length === 0 ? (
@ -466,7 +466,7 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
)} )}
{/* ---- Reshape note (only when >2 axes feed the fixed WASM head) ---- */} {/* ---- Reshape note (only when >2 axes feed the fixed WASM head) ---- */}
{reshaping && depth !== 'peek' && ( {reshaping && depth === 'expanded' && (
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}> <p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
{/* TODO(workstream F, docs/redesign/inputs-spec.md "multiple WASM modules + {/* TODO(workstream F, docs/redesign/inputs-spec.md "multiple WASM modules +
warm-start"): give every axis its own genuine input dimension by (re)loading a warm-start"): give every axis its own genuine input dimension by (re)loading a
@ -509,7 +509,7 @@ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) {
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>audio starts on the play gesture</span> <span style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>audio starts on the play gesture</span>
</div> </div>
<Slider label="master volume" value={ctx.volume} min={0} max={1} step={0.01} onChange={ctx.setVolume} /> <Slider label="master volume" value={ctx.volume} min={0} max={1} step={0.01} onChange={ctx.setVolume} />
{depth !== 'peek' && ( {depth === 'expanded' && (
<> <>
<SectionLabel>Tempo</SectionLabel> <SectionLabel>Tempo</SectionLabel>
<Slider label="bpm" value={ctx.bpm} min={40} max={220} step={1} onChange={ctx.setBpm} /> <Slider label="bpm" value={ctx.bpm} min={40} max={220} step={1} onChange={ctx.setBpm} />
@ -530,7 +530,7 @@ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) {
</> </>
); );
case 'particles': case 'particles':
return depth !== 'peek' ? ( return depth === 'expanded' ? (
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}> <p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
Flow-field visualiser driven by the first {Math.min(20, ctx.params.length)} outputs (no audio). Flow-field visualiser driven by the first {Math.min(20, ctx.params.length)} outputs (no audio).
{/* TODO(backends-spec §4): port FlowFieldVisualizer + visual preset chips. */} {/* TODO(backends-spec §4): port FlowFieldVisualizer + visual preset chips. */}
@ -539,13 +539,13 @@ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) {
case 'midi': case 'midi':
// The full MIDI config (port picker, CC count, per-output CC/channel/name) // The full MIDI config (port picker, CC count, per-output CC/channel/name)
// + preset bar render via OutputsBackendConfig in RoutingDrawer below. // + preset bar render via OutputsBackendConfig in RoutingDrawer below.
return depth !== 'peek' ? ( return depth === 'expanded' ? (
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}> <p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
Each output sends a real Web MIDI CC. Pick a port and set CC# / channel per output. Each output sends a real Web MIDI CC. Pick a port and set CC# / channel per output.
</p> </p>
) : null; ) : null;
case 'osc': case 'osc':
return depth !== 'peek' ? ( return depth === 'expanded' ? (
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}> <p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
Each output sends to an OSC path with a physical range, over the WebSocket bridge. Each output sends to an OSC path with a physical range, over the WebSocket bridge.
</p> </p>
@ -568,23 +568,8 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
const nameFor = (idx: number, fallback: string) => const nameFor = (idx: number, fallback: string) =>
ctx.outputMode === 'particles' ? VISUAL_NAMES[idx] ?? fallback : fallback; ctx.outputMode === 'particles' ? VISUAL_NAMES[idx] ?? fallback : fallback;
if (depth === 'full') { const expanded = depth === 'expanded';
return ( const rows = expanded ? ctx.params : ctx.params.slice(0, 6);
<>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip tone="var(--accent)">{modeDesc.label}</Chip>
<BackendStatusChip ctx={ctx} />
</div>
{ModeConfig(ctx, depth)}
{/* Specialised, editable per-backend config (MIDI/OSC) + named-preset bar. */}
<OutputsBackendConfig ctx={ctx} backend={modeDesc.backend} />
<SectionLabel>Advanced · {modeDesc.label}</SectionLabel>
<BackendAdvanced backend={modeDesc.backend} params={ctx.params} setParam={ctx.setParam} />
</>
);
}
const rows = depth === 'peek' ? ctx.params.slice(0, 6) : ctx.params;
return ( return (
<> <>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
@ -596,15 +581,15 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
<BackendStatusChip ctx={ctx} /> <BackendStatusChip ctx={ctx} />
</div> </div>
{ModeConfig(ctx, depth)} {ModeConfig(ctx, depth)}
{/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden at peek. */} {/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden when condensed. */}
{depth !== 'peek' && <OutputsBackendConfig ctx={ctx} backend={modeDesc.backend} />} {expanded && <OutputsBackendConfig ctx={ctx} backend={modeDesc.backend} />}
<SectionLabel>Outputs · M mute · S arm · off/fixed/live</SectionLabel> <SectionLabel>Outputs · M mute · S arm · off/fixed/live</SectionLabel>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 4, gap: 4,
maxHeight: depth === 'peek' ? 220 : 460, maxHeight: expanded ? 460 : 220,
overflow: 'auto', overflow: 'auto',
}} }}
> >
@ -617,14 +602,20 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
param={labelled} param={labelled}
value={values[i] ?? 0} value={values[i] ?? 0}
onChange={(patch) => ctx.setParam(i, patch)} onChange={(patch) => ctx.setParam(i, patch)}
showCurve={depth !== 'peek'} showCurve={expanded}
/> />
); );
})} })}
</div> </div>
{depth === 'peek' && ctx.params.length > 6 && ( {!expanded && ctx.params.length > 6 && (
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{ctx.params.length - 6} more expand to edit</span> <span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{ctx.params.length - 6} more expand to edit</span>
)} )}
{expanded && (
<>
<SectionLabel>Advanced · {modeDesc.label}</SectionLabel>
<BackendAdvanced backend={modeDesc.backend} params={ctx.params} setParam={ctx.setParam} />
</>
)}
</> </>
); );
} }
@ -654,7 +645,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
onChange={(v) => set('monochromeIcons', v)} onChange={(v) => set('monochromeIcons', v)}
label="Monochrome icons" label="Monochrome icons"
/> />
{depth !== 'peek' && ( {depth === 'expanded' && (
<> <>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>Unfocused icon colour</div> <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>Unfocused icon colour</div>
<PillToggle <PillToggle
@ -683,7 +674,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
onChange={(v) => set('inputMap', v as InputMapMode)} onChange={(v) => set('inputMap', v as InputMapMode)}
options={INPUT_MAP_OPTS} options={INPUT_MAP_OPTS}
/> />
{depth !== 'peek' && ( {depth === 'expanded' && (
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}> <p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
The 2D input surface: a rectangular XY map or a circular joystick-style disc. "Follow mode" The 2D input surface: a rectangular XY map or a circular joystick-style disc. "Follow mode"
uses the active mode's declared input (joystick circular, else rectangular). uses the active mode's declared input (joystick circular, else rectangular).
@ -700,7 +691,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
unit="px" unit="px"
onChange={(v) => set('cornerRadius', Math.round(v))} onChange={(v) => set('cornerRadius', Math.round(v))}
/> />
{depth !== 'peek' && ( {depth === 'expanded' && (
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}> <p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
Roundness of buttons, control rows, dock icons and panels. Pills and the circular verdict Roundness of buttons, control rows, dock icons and panels. Pills and the circular verdict
buttons are intentionally exempt. Default 2px. buttons are intentionally exempt. Default 2px.
@ -716,7 +707,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
const KEYS: [string, string][] = [ const KEYS: [string, string][] = [
['15', 'open drawers'], ['15', 'open drawers'],
['\\', 'full depth'], ['\\', 'expand drawer'],
['space / ↑', 'commit +'], ['space / ↑', 'commit +'],
['↓', 'perturb / down '], ['↓', 'perturb / down '],
['z', 'undo'], ['z', 'undo'],

View file

@ -18,6 +18,7 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import type { FeedbackModeUI } from './types'; import type { FeedbackModeUI } from './types';
import { DiceIcon } from './icons';
function ThumbIcon({ size = 24, down = false }: { size?: number; down?: boolean }) { function ThumbIcon({ size = 24, down = false }: { size?: number; down?: boolean }) {
return ( return (
@ -44,6 +45,10 @@ export interface VerdictClusterProps {
onUndo: () => void; onUndo: () => void;
onCommit: () => void; onCommit: () => void;
onReroll: () => void; onReroll: () => void;
/** Small bounded weight perturbation of the current net (left half of the pill). */
onNudge: () => void;
/** Full re-roll of the current net (right half of the pill). */
onRandomise: () => void;
canUndo: boolean; canUndo: boolean;
ab: 'A' | 'B'; ab: 'A' | 'B';
onToggleAB: () => void; onToggleAB: () => void;
@ -62,6 +67,8 @@ export function VerdictCluster({
onUndo, onUndo,
onCommit, onCommit,
onReroll, onReroll,
onNudge,
onRandomise,
canUndo, canUndo,
ab, ab,
onToggleAB, onToggleAB,
@ -116,6 +123,22 @@ export function VerdictCluster({
...extra, ...extra,
}); });
// Two halves of the secondary pill (nudge | randomise).
const pillHalf = (extra: CSSProperties): CSSProperties => ({
flex: 1,
height: 30,
border: 0,
background: 'transparent',
color: 'var(--fg-mute)',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
letterSpacing: '0.04em',
textTransform: 'uppercase',
transition: 'background var(--dur-fast), color var(--dur-fast)',
...extra,
});
return ( return (
<div <div
onPointerEnter={() => setHover(true)} onPointerEnter={() => setHover(true)}
@ -126,75 +149,138 @@ export function VerdictCluster({
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: 'var(--sp-3)', gap: 'var(--sp-2)',
padding: 'var(--sp-2) var(--sp-3)',
background: 'var(--glass)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
border: '1px solid var(--glass-line)',
borderRadius: 'var(--r-pill)',
boxShadow: 'var(--shadow-2)',
opacity: hover || firstSession ? 1 : 0.55, opacity: hover || firstSession ? 1 : 0.55,
transition: 'opacity var(--dur-med) var(--ease-console)', transition: 'opacity var(--dur-med) var(--ease-console)',
zIndex: 40, zIndex: 40,
}} }}
> >
<button <div
type="button" style={{
title={downTitle} display: 'flex',
onPointerDown={perturbDown} alignItems: 'center',
onPointerUp={perturbUp} gap: 'var(--sp-3)',
onPointerLeave={() => { padding: 'var(--sp-2) var(--sp-3)',
if (lp.current) clearTimeout(lp.current); background: 'var(--glass)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
border: '1px solid var(--glass-line)',
borderRadius: 'var(--r-pill)',
boxShadow: 'var(--shadow-2)',
}} }}
style={big(
explore
? exploring
? { background: 'rgba(0,204,255,0.16)', color: 'var(--accent-2)' }
: { background: 'rgba(0,204,255,0.10)', color: 'var(--accent-2)' }
: { background: 'rgba(255,68,102,0.16)', color: 'var(--danger)' },
)}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.08)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
> >
{/* Explore-mode down is a re-roll/explore (↻), not a dislike thumb. */} <button
{explore ? <span style={{ fontSize: 24, lineHeight: 1 }}></span> : <ThumbIcon down />} type="button"
</button> title={downTitle}
onPointerDown={perturbDown}
onPointerUp={perturbUp}
onPointerLeave={() => {
if (lp.current) clearTimeout(lp.current);
}}
style={big({
background: explore && exploring ? 'rgba(255,106,0,0.22)' : 'rgba(255,106,0,0.16)',
color: 'var(--accent)',
})}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.08)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
>
{/* Explore mode = dice (re-roll into a scratchpad); dislike mode = thumb-down. */}
{explore ? <DiceIcon size={24} /> : <ThumbIcon down />}
</button>
<button <button
type="button" type="button"
title="Undo (z)" title="Undo (z)"
onClick={onUndo} onClick={onUndo}
disabled={!canUndo} disabled={!canUndo}
style={big({ style={big({
width: 48, width: 36,
height: 48, height: 36,
fontSize: 20, fontSize: 16,
background: 'var(--bg-2)', background: 'var(--bg-2)',
color: 'var(--fg-mute)', color: 'var(--fg-mute)',
opacity: canUndo ? 1 : 0.4, opacity: canUndo ? 1 : 0.4,
cursor: canUndo ? 'pointer' : 'not-allowed', cursor: canUndo ? 'pointer' : 'not-allowed',
})} })}
> >
</button> </button>
<button <button
type="button" type="button"
title={upTitle} title={upTitle}
onClick={onCommit} onClick={onCommit}
style={big({ style={big(
background: picking ? 'rgba(0,204,255,0.22)' : 'rgba(255,106,0,0.18)', picking
color: picking ? 'var(--accent-2)' : 'var(--accent)', ? {
boxShadow: picking ? '0 0 16px var(--accent-2)' : '0 0 16px var(--glow-accent)', background: 'rgba(0,204,255,0.22)',
})} color: 'var(--accent-2)',
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.08)')} boxShadow: '0 0 16px var(--accent-2)',
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')} }
: {
background: 'rgba(107,194,107,0.20)',
color: 'var(--good)',
boxShadow: '0 0 16px rgba(107,194,107,0.45)',
},
)}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.08)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
>
{/* Explore-mode up is "place" (a pin glyph) once exploring. */}
{explore && exploring ? <span style={{ fontSize: 22, lineHeight: 1 }}></span> : <ThumbIcon />}
</button>
</div>
{/* Secondary pill — two halves: nudge (small perturbation) | randomise (full re-roll). */}
<div
style={{
display: 'flex',
alignItems: 'stretch',
width: 220,
background: 'var(--glass)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
border: '1px solid var(--glass-line)',
borderRadius: 'var(--r-pill)',
boxShadow: 'var(--shadow-2)',
overflow: 'hidden',
}}
> >
{/* Explore-mode up is "place" (a pin glyph) once exploring. */} <button
{explore && exploring ? <span style={{ fontSize: 22, lineHeight: 1 }}></span> : <ThumbIcon />} type="button"
</button> title="Nudge — small bounded perturbation of the current net"
onClick={onNudge}
style={pillHalf({ borderRight: '1px solid var(--glass-line)' })}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,106,0,0.12)';
e.currentTarget.style.color = 'var(--accent)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--fg-mute)';
}}
>
nudge
</button>
<button
type="button"
title="Randomise — full re-roll of the current net"
onClick={onRandomise}
style={pillHalf({})}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,106,0,0.12)';
e.currentTarget.style.color = 'var(--accent)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--fg-mute)';
}}
>
randomise
</button>
</div>
</div> </div>
); );
} }

View file

@ -130,6 +130,22 @@ export function HelpIcon({ size = 18, style }: IconProps) {
); );
} }
/** Dice — explore / re-roll into a scratchpad (a die showing five pips). */
export function DiceIcon({ size = 22, style }: IconProps) {
return svg(
size,
style,
<>
<rect x="4" y="4" width="16" height="16" rx="3" />
<circle cx="8.5" cy="8.5" r="1.1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="8.5" r="1.1" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.1" fill="currentColor" stroke="none" />
<circle cx="8.5" cy="15.5" r="1.1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="15.5" r="1.1" fill="currentColor" stroke="none" />
</>,
);
}
/** Close (✕). */ /** Close (✕). */
export function CloseIcon({ size = 14, style }: IconProps) { export function CloseIcon({ size = 14, style }: IconProps) {
return svg(size, style, <path d="M6 6l12 12M18 6 6 18" />); return svg(size, style, <path d="M6 6l12 12M18 6 6 18" />);

View file

@ -43,7 +43,7 @@ export interface Snapshot {
} }
export type DrawerKey = 'learn' | 'inputs' | 'route' | 'settings' | 'help'; export type DrawerKey = 'learn' | 'inputs' | 'route' | 'settings' | 'help';
export type DrawerDepth = 'peek' | 'expand' | 'full'; export type DrawerDepth = 'condensed' | 'expanded';
export type Focus = 'in' | 'split' | 'out' | 'composite'; export type Focus = 'in' | 'split' | 'out' | 'composite';
export interface Axes { export interface Axes {

View file

@ -50,7 +50,9 @@ test('engine loads, spine propagates, console renders', async ({ page }) => {
// 4. The convertible Console rendered (assert on the dock drawer rail, which // 4. The convertible Console rendered (assert on the dock drawer rail, which
// is always present — the old "MEMLNaut" wordmark only shows outside // is always present — the old "MEMLNaut" wordmark only shows outside
// Particle mode now that the particle top bar is a heatmap strip). // Particle mode now that the particle top bar is a heatmap strip).
await expect(page.getByText('Learning')).toBeVisible(); // Drawers are closed by default, so assert on the rail button's title
// rather than the (hidden) drawer-header text.
await expect(page.getByTitle('Learning')).toBeVisible();
// 5. No "C15" anywhere in the rendered UI. // 5. No "C15" anywhere in the rendered UI.
const body = await page.evaluate(() => document.body.innerText); const body = await page.evaluate(() => document.body.innerText);