From cb28facc65f8737f52d805de5fffcc7e373fac38 Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Sun, 28 Jun 2026 21:39:54 +0200 Subject: [PATCH] feat(manifold): verdict-cluster + dock polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- manifold/src/console/ConsoleApp.tsx | 31 +++- manifold/src/console/Dock.tsx | 177 +++++++++++++-------- manifold/src/console/Drawers.tsx | 69 ++++---- manifold/src/console/VerdictCluster.tsx | 202 +++++++++++++++++------- manifold/src/console/icons.tsx | 16 ++ manifold/src/console/types.ts | 2 +- manifold/tests/e2e/smoke.spec.ts | 4 +- 7 files changed, 329 insertions(+), 172 deletions(-) diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx index 74a52a0..ca8dce4 100644 --- a/manifold/src/console/ConsoleApp.tsx +++ b/manifold/src/console/ConsoleApp.tsx @@ -106,8 +106,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp const [tame, setTame] = useState(0.85); const [health, setHealth] = useState(0.8); const [rev, setRev] = useState(1); - const [active, setActive] = useState('learn'); - const [depth, setDepth] = useState('peek'); + const [active, setActive] = useState(null); + const [depth, setDepth] = useState('condensed'); // Sandwich (parameter-landscape) centre-stage toggle — dock-bottom layers icon. const [sandwich, setSandwich] = useState(false); @@ -222,8 +222,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp setFollow(false); setPins([]); setMarkers([]); - setActive('learn'); - setDepth('peek'); + setActive(null); + setDepth('condensed'); if (engine) engine.setInput(0.5, 0.5); // eslint-disable-next-line react-hooks/exhaustive-deps }, [modeId]); @@ -411,6 +411,23 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp 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 / * 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]) { setActive((a) => (a === map[e.key] ? null : map[e.key])); - setDepth('peek'); - } else if (e.key === '\\') setDepth((d) => (d === 'full' ? 'peek' : 'full')); + setDepth('condensed'); + } else if (e.key === '\\') setDepth((d) => (d === 'expanded' ? 'condensed' : 'expanded')); else if (focus === 'composite' && e.key === '[') { e.preventDefault(); setSplit((s) => Math.max(0, s - 0.04)); @@ -903,6 +920,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp onUndo={undo} onCommit={commit} onReroll={reroll} + onNudge={nudgeNet} + onRandomise={reroll} canUndo={feedbackMode === 'explore-and-place' && exploring ? undoDepth > 0 : snapshots.length > 0} ab={ab} onToggleAB={toggleAB} diff --git a/manifold/src/console/Dock.tsx b/manifold/src/console/Dock.tsx index 3eede63..9481502 100644 --- a/manifold/src/console/Dock.tsx +++ b/manifold/src/console/Dock.tsx @@ -1,6 +1,8 @@ /** * 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): * - 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 { useSettings, unfocusedIconCss } from '../settings/settings-store'; import { - ModeIcon, ParticleIcon, MidiIcon, OscIcon, @@ -80,17 +81,20 @@ function ModeSelector({ width: 40, height: 40, borderRadius: 'var(--r-2)', - border: '1px solid var(--accent)', + border: '1px solid #ffffff', background: 'rgba(255,106,0,0.12)', color: 'var(--accent)', cursor: 'pointer', - fontSize: 18, + fontFamily: 'var(--font-mono)', + fontSize: 22, + fontWeight: 700, + lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', }} > - {mono ? : '⊞'} + M {open && (
{ setActive(on ? null : key); - setDepth('peek'); + setDepth('condensed'); }} style={{ position: 'relative', @@ -234,107 +238,146 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan ); }; - const width = depth === 'expand' ? 520 : 320; - const full = depth === 'full'; + const expanded = depth === 'expanded'; const section = active ? DRAWERS[active] : null; return ( <> + {/* Dim backdrop behind the centred modal; click to collapse back. */} + {section && expanded && ( +
setDepth('condensed')} + style={{ + position: 'fixed', + inset: 0, + zIndex: 85, + background: 'rgba(0,0,0,0.45)', + backdropFilter: 'blur(2px)', + WebkitBackdropFilter: 'blur(2px)', + }} + /> + )} {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. )} diff --git a/manifold/src/console/Drawers.tsx b/manifold/src/console/Drawers.tsx index 52f4aef..d0993ae 100644 --- a/manifold/src/console/Drawers.tsx +++ b/manifold/src/console/Drawers.tsx @@ -1,6 +1,6 @@ /** * 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 * inputs — Inputs : input source @@ -162,13 +162,13 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { Down action · feedback mode - {depth !== 'peek' && ( + {depth === 'expanded' && (

{FEEDBACK_DESC[ctx.feedbackMode]}

)} - {depth !== 'peek' && ( + {depth === 'expanded' && ( <> Solo / arm scope
@@ -204,7 +204,7 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { )} - {depth === 'full' && ( + {depth === 'expanded' && ( <> Feedback lab

@@ -339,21 +339,21 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { Input source - {active && depth !== 'peek' && ( + {active && depth === 'expanded' && ( {active.status.message} )} {/* ---- Internal (XY pad / manifold) ---- */} - {inp.inputMode === 'internal' && depth !== 'peek' && ( + {inp.inputMode === 'internal' && depth === 'expanded' && (

Drag the on-screen manifold / XY pad. Two axes feed the net directly — this is the default.

)} {/* ---- Game Controller ---- */} - {inp.inputMode === 'gamepad' && depth !== 'peek' && ( + {inp.inputMode === 'gamepad' && depth === 'expanded' && ( <> Sticks Device {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) ---- */} - {reshaping && depth !== 'peek' && ( + {reshaping && depth === 'expanded' && (

{/* 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 @@ -509,7 +509,7 @@ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) { audio starts on the play gesture

- {depth !== 'peek' && ( + {depth === 'expanded' && ( <> Tempo @@ -530,7 +530,7 @@ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) { ); case 'particles': - return depth !== 'peek' ? ( + return depth === 'expanded' ? (

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. */} @@ -539,13 +539,13 @@ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) { case 'midi': // The full MIDI config (port picker, CC count, per-output CC/channel/name) // + preset bar render via OutputsBackendConfig in RoutingDrawer below. - return depth !== 'peek' ? ( + return depth === 'expanded' ? (

Each output sends a real Web MIDI CC. Pick a port and set CC# / channel per output.

) : null; case 'osc': - return depth !== 'peek' ? ( + return depth === 'expanded' ? (

Each output sends to an OSC path with a physical range, over the WebSocket bridge.

@@ -568,23 +568,8 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { const nameFor = (idx: number, fallback: string) => ctx.outputMode === 'particles' ? VISUAL_NAMES[idx] ?? fallback : fallback; - if (depth === 'full') { - return ( - <> -
- {modeDesc.label} - -
- {ModeConfig(ctx, depth)} - {/* Specialised, editable per-backend config (MIDI/OSC) + named-preset bar. */} - - Advanced · {modeDesc.label} - - - ); - } - - const rows = depth === 'peek' ? ctx.params.slice(0, 6) : ctx.params; + const expanded = depth === 'expanded'; + const rows = expanded ? ctx.params : ctx.params.slice(0, 6); return ( <>
@@ -596,15 +581,15 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
{ModeConfig(ctx, depth)} - {/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden at peek. */} - {depth !== 'peek' && } + {/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden when condensed. */} + {expanded && } Outputs · M mute · S arm · off/fixed/live
@@ -617,14 +602,20 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { param={labelled} value={values[i] ?? 0} onChange={(patch) => ctx.setParam(i, patch)} - showCurve={depth !== 'peek'} + showCurve={expanded} /> ); })}
- {depth === 'peek' && ctx.params.length > 6 && ( + {!expanded && ctx.params.length > 6 && ( +{ctx.params.length - 6} more — expand to edit )} + {expanded && ( + <> + Advanced · {modeDesc.label} + + + )} ); } @@ -654,7 +645,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) { onChange={(v) => set('monochromeIcons', v)} label="Monochrome icons" /> - {depth !== 'peek' && ( + {depth === 'expanded' && ( <>
Unfocused icon colour
set('inputMap', v as InputMapMode)} options={INPUT_MAP_OPTS} /> - {depth !== 'peek' && ( + {depth === 'expanded' && (

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). @@ -700,7 +691,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) { unit="px" onChange={(v) => set('cornerRadius', Math.round(v))} /> - {depth !== 'peek' && ( + {depth === 'expanded' && (

Roundness of buttons, control rows, dock icons and panels. Pills and the circular verdict buttons are intentionally exempt. Default 2px. @@ -716,7 +707,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) { const KEYS: [string, string][] = [ ['1–5', 'open drawers'], - ['\\', 'full depth'], + ['\\', 'expand drawer'], ['space / ↑', 'commit +'], ['↓', 'perturb / down −'], ['z', 'undo'], diff --git a/manifold/src/console/VerdictCluster.tsx b/manifold/src/console/VerdictCluster.tsx index 2dddf54..60539ad 100644 --- a/manifold/src/console/VerdictCluster.tsx +++ b/manifold/src/console/VerdictCluster.tsx @@ -18,6 +18,7 @@ import { useRef, useState } from 'react'; import type { CSSProperties } from 'react'; import type { FeedbackModeUI } from './types'; +import { DiceIcon } from './icons'; function ThumbIcon({ size = 24, down = false }: { size?: number; down?: boolean }) { return ( @@ -44,6 +45,10 @@ export interface VerdictClusterProps { onUndo: () => void; onCommit: () => 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; ab: 'A' | 'B'; onToggleAB: () => void; @@ -62,6 +67,8 @@ export function VerdictCluster({ onUndo, onCommit, onReroll, + onNudge, + onRandomise, canUndo, ab, onToggleAB, @@ -116,6 +123,22 @@ export function VerdictCluster({ ...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 (

setHover(true)} @@ -126,75 +149,138 @@ export function VerdictCluster({ left: '50%', transform: 'translateX(-50%)', display: 'flex', + flexDirection: 'column', alignItems: 'center', - gap: 'var(--sp-3)', - 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)', + gap: 'var(--sp-2)', opacity: hover || firstSession ? 1 : 0.55, transition: 'opacity var(--dur-med) var(--ease-console)', zIndex: 40, }} > - + - + - +
+ + {/* Secondary pill — two halves: nudge (small perturbation) | randomise (full re-roll). */} +
- {/* Explore-mode up is "place" (a pin glyph) once exploring. */} - {explore && exploring ? : } - + + +
); } diff --git a/manifold/src/console/icons.tsx b/manifold/src/console/icons.tsx index 58e3cf7..a5da360 100644 --- a/manifold/src/console/icons.tsx +++ b/manifold/src/console/icons.tsx @@ -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, + <> + + + + + + + , + ); +} + /** Close (✕). */ export function CloseIcon({ size = 14, style }: IconProps) { return svg(size, style, ); diff --git a/manifold/src/console/types.ts b/manifold/src/console/types.ts index da5f74d..60a2154 100644 --- a/manifold/src/console/types.ts +++ b/manifold/src/console/types.ts @@ -43,7 +43,7 @@ export interface Snapshot { } 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 interface Axes { diff --git a/manifold/tests/e2e/smoke.spec.ts b/manifold/tests/e2e/smoke.spec.ts index 0a53e8e..c67c8db 100644 --- a/manifold/tests/e2e/smoke.spec.ts +++ b/manifold/tests/e2e/smoke.spec.ts @@ -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 // is always present — the old "MEMLNaut" wordmark only shows outside // 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. const body = await page.evaluate(() => document.body.innerText);