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 [health, setHealth] = useState(0.8);
const [rev, setRev] = useState(1);
const [active, setActive] = useState<DrawerKey | null>('learn');
const [depth, setDepth] = useState<DrawerDepth>('peek');
const [active, setActive] = useState<DrawerKey | null>(null);
const [depth, setDepth] = useState<DrawerDepth>('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}

View file

@ -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 ? <ModeIcon /> : '⊞'}
M
</button>
{open && (
<div
@ -196,7 +200,7 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan
title={s.label}
onClick={() => {
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 && (
<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 && (
// 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
style={
full
expanded
? {
position: 'fixed',
inset: 0,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '80vw',
height: '80vh',
zIndex: 90,
background: 'var(--glass)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
padding: 'var(--sp-6)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--sp-3)',
border: '1px solid var(--glass-line)',
borderRadius: 'var(--r-3)',
boxShadow: 'var(--shadow-2)',
overflow: 'visible',
}
: {
position: 'absolute',
top: 0,
right: 48,
bottom: 0,
width,
width: 360,
zIndex: 35,
background: 'var(--glass)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
borderLeft: '1px solid var(--glass-line)',
boxShadow: '-8px 0 24px rgba(0,0,0,0.4)',
padding: 'var(--sp-4)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--sp-2)',
overflow: 'auto',
overflow: 'visible',
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={{
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',
alignItems: 'center',
gap: 'var(--sp-2)',
borderBottom: '1px solid var(--glass-line)',
paddingBottom: 'var(--sp-2)',
justifyContent: 'center',
zIndex: 1,
}}
>
<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 }}>
{!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>
<ExpandIcon size={12} />
</button>
{/* Inner scroll wrapper — holds header + content, scrolls independently. */}
<div
style={{
height: '100%',
boxSizing: 'border-box',
overflow: 'auto',
padding: expanded ? 'var(--sp-6)' : 'var(--sp-4)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--sp-2)',
flex: 1,
...(full ? { maxWidth: 720 } : {}),
gap: expanded ? 'var(--sp-3)' : 'var(--sp-2)',
borderRadius: 'inherit',
}}
>
{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>
</aside>
)}

View file

@ -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) {
<SectionLabel>Down action · feedback mode</SectionLabel>
<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 }}>
{FEEDBACK_DESC[ctx.feedbackMode]}
</p>
)}
{depth !== 'peek' && (
{depth === 'expanded' && (
<>
<SectionLabel>Solo / arm scope</SectionLabel>
<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>
<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>
<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)' }}>
{active.status.message}
</span>
)}
{/* ---- 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 }}>
Drag the on-screen manifold / XY pad. Two axes feed the net directly this is the default.
</p>
)}
{/* ---- Game Controller ---- */}
{inp.inputMode === 'gamepad' && depth !== 'peek' && (
{inp.inputMode === 'gamepad' && depth === 'expanded' && (
<>
<SectionLabel>Sticks</SectionLabel>
<Segmented
@ -384,7 +384,7 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
)}
{/* ---- MIDI ---- */}
{inp.inputMode === 'midi' && depth !== 'peek' && (
{inp.inputMode === 'midi' && depth === 'expanded' && (
<>
<SectionLabel>Device</SectionLabel>
{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' && (
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
{/* 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) {
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>audio starts on the play gesture</span>
</div>
<Slider label="master volume" value={ctx.volume} min={0} max={1} step={0.01} onChange={ctx.setVolume} />
{depth !== 'peek' && (
{depth === 'expanded' && (
<>
<SectionLabel>Tempo</SectionLabel>
<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':
return depth !== 'peek' ? (
return depth === 'expanded' ? (
<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).
{/* 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' ? (
<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.
</p>
) : null;
case 'osc':
return depth !== 'peek' ? (
return depth === 'expanded' ? (
<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.
</p>
@ -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 (
<>
<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;
const expanded = depth === 'expanded';
const rows = expanded ? ctx.params : ctx.params.slice(0, 6);
return (
<>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
@ -596,15 +581,15 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
<BackendStatusChip ctx={ctx} />
</div>
{ModeConfig(ctx, depth)}
{/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden at peek. */}
{depth !== 'peek' && <OutputsBackendConfig ctx={ctx} backend={modeDesc.backend} />}
{/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden when condensed. */}
{expanded && <OutputsBackendConfig ctx={ctx} backend={modeDesc.backend} />}
<SectionLabel>Outputs · M mute · S arm · off/fixed/live</SectionLabel>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
maxHeight: depth === 'peek' ? 220 : 460,
maxHeight: expanded ? 460 : 220,
overflow: 'auto',
}}
>
@ -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}
/>
);
})}
</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>
)}
{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)}
label="Monochrome icons"
/>
{depth !== 'peek' && (
{depth === 'expanded' && (
<>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>Unfocused icon colour</div>
<PillToggle
@ -683,7 +674,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
onChange={(v) => set('inputMap', v as InputMapMode)}
options={INPUT_MAP_OPTS}
/>
{depth !== 'peek' && (
{depth === 'expanded' && (
<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"
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' && (
<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
buttons are intentionally exempt. Default 2px.
@ -716,7 +707,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
const KEYS: [string, string][] = [
['15', 'open drawers'],
['\\', 'full depth'],
['\\', 'expand drawer'],
['space / ↑', 'commit +'],
['↓', 'perturb / down '],
['z', 'undo'],

View file

@ -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 (
<div
onPointerEnter={() => 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,
}}
>
<button
type="button"
title={downTitle}
onPointerDown={perturbDown}
onPointerUp={perturbUp}
onPointerLeave={() => {
if (lp.current) clearTimeout(lp.current);
<div
style={{
display: 'flex',
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)',
}}
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. */}
{explore ? <span style={{ fontSize: 24, lineHeight: 1 }}></span> : <ThumbIcon down />}
</button>
<button
type="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
type="button"
title="Undo (z)"
onClick={onUndo}
disabled={!canUndo}
style={big({
width: 48,
height: 48,
fontSize: 20,
background: 'var(--bg-2)',
color: 'var(--fg-mute)',
opacity: canUndo ? 1 : 0.4,
cursor: canUndo ? 'pointer' : 'not-allowed',
})}
>
</button>
<button
type="button"
title="Undo (z)"
onClick={onUndo}
disabled={!canUndo}
style={big({
width: 36,
height: 36,
fontSize: 16,
background: 'var(--bg-2)',
color: 'var(--fg-mute)',
opacity: canUndo ? 1 : 0.4,
cursor: canUndo ? 'pointer' : 'not-allowed',
})}
>
</button>
<button
type="button"
title={upTitle}
onClick={onCommit}
style={big({
background: picking ? 'rgba(0,204,255,0.22)' : 'rgba(255,106,0,0.18)',
color: picking ? 'var(--accent-2)' : 'var(--accent)',
boxShadow: picking ? '0 0 16px var(--accent-2)' : '0 0 16px var(--glow-accent)',
})}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.08)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
<button
type="button"
title={upTitle}
onClick={onCommit}
style={big(
picking
? {
background: 'rgba(0,204,255,0.22)',
color: 'var(--accent-2)',
boxShadow: '0 0 16px var(--accent-2)',
}
: {
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. */}
{explore && exploring ? <span style={{ fontSize: 22, lineHeight: 1 }}></span> : <ThumbIcon />}
</button>
<button
type="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>
);
}

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 (✕). */
export function CloseIcon({ size = 14, style }: IconProps) {
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 DrawerDepth = 'peek' | 'expand' | 'full';
export type DrawerDepth = 'condensed' | 'expanded';
export type Focus = 'in' | 'split' | 'out' | 'composite';
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
// 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);