refactor(manifold): streamline output cards

This commit is contained in:
monkey-w1n5t0n 2026-07-25 15:36:50 +02:00
parent 5ab4d18523
commit b5891a6ab9
4 changed files with 159 additions and 185 deletions

2
MAP.md
View file

@ -59,7 +59,7 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
consts (mode_id, engine_id, ml dims, params, voice_spaces, ui) — the SOURCE OF TRUTH for `MF_MODES`.
Switching mode reshapes the WASM net to the mode's `ml` dims (ConsoleApp P5.3; boot mode paf_synth →
4→[10,10,14]→33).
- `manifold/src/dock/``OutputControlRow` (add/delete card identity + off/fixed/live + mute + solo/arm + min/max/curve, plus MIDI card fields), `output-state.ts`,
- `manifold/src/dock/``OutputControlRow` (editable names, corner delete, cycling off/fixed/live status, mute + solo/arm + min/max/curve, plus MIDI card fields), `output-state.ts`,
`OutputsBackendConfig.tsx` (per-backend specialised Outputs panel — the sole per-backend editor; the centered add-card control lives below the rows).
- `manifold/src/backends/``OutputBackend` adapter + `BackendManager` (spine consumer); `midi-backend.ts`
(WebMIDI), `osc-backend.ts`+`osc-client.ts` (OSC-over-WS), `vcv-backend.ts` (VCV-over-WS), `cv-backend.ts`

View file

@ -165,9 +165,10 @@ for the narrow pane.
- `output-state.ts` — the per-output control model: `OutputControl` (state/muted/armed/min/max/
curve/fixedValue + backend specs `MidiCcSpec`/`OscSpec`/`VcvSpec`), `toOutputControl()`,
`buildArmMask()` (solo focus).
- `OutputControlRow.tsx` — one output row: name · M(mute) · S(solo/arm) · off|fixed|live · dual-range
· curve pad · live value; MIDI mode adds the card's name, CC#, and channel fields. **Writes eagerly
to the shared `MFParam` store via `onChange`.** The centered `+` control follows the last visible card.
- `OutputControlRow.tsx` — one output card: editable name · M(mute) · S(solo/arm) · cycling
off/fixed/live status · dual-range · live value; the curve pad is a right-hand column and MIDI mode
adds CC#/channel fields. The delete `×` sits on the upper-right corner. **Writes eagerly to the shared
`MFParam` store via `onChange`.** The centered `+` control follows the last visible card.
- `OutputsBackendConfig.tsx` — preset bar (save/restore/rename/delete) + transport/device config (MIDI
port/templates, OSC path/range, VCV polarity); MIDI per-output fields live on `OutputControlRow`.

View file

@ -770,6 +770,7 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
flex: expanded ? 1 : undefined,
minHeight: expanded ? 0 : undefined,
maxHeight: expanded ? undefined : 220,
padding: expanded ? '8px 10px' : '6px 10px',
overflow: 'auto',
}}
>

View file

@ -2,7 +2,7 @@
* OutputControlRow the shared per-output baseline control row (dock-spec §3.2).
* Reused across the Routing, Synth and Visual drawers. Renders the FULL baseline:
*
* name · M (mute) · S (solo/arm) · [off|fixed|live] · dual-range · curve · value
* editable name · M (mute) · S (solo/arm) · cycling status · dual-range · curve · value
*
* Writes eagerly through `onChange` into the single shared MFParam store
* (ConsoleApp owns it) never a second data path (dock-spec §3.2, §8).
@ -65,7 +65,7 @@ export interface OutputControlRowProps {
onDelete?: () => void;
/** Show the curve pad inline (expand depth); hidden in compact rows. */
showCurve?: boolean;
/** Show the per-card MIDI name, CC, and channel controls. */
/** Show the per-card MIDI CC and channel controls. */
showMidi?: boolean;
}
@ -81,28 +81,37 @@ export function OutputControlRow({
const muted = param.muted ?? false;
const armed = param.armed ?? false;
const off = param.status === 'off';
const barVal = param.status === 'fixed' ? param.val : value;
const barVal = param.status === 'fixed' ? param.min + param.val * (param.max - param.min) : value;
const midi = param.midi ?? defaultMidiSpec(param.engineIndex ?? 0);
const stateIndex = Math.max(0, STATE_META.findIndex((s) => s.v === param.status));
const state = STATE_META[stateIndex];
const nextState = STATE_META[(stateIndex + 1) % STATE_META.length];
return (
<div
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: 4,
alignItems: 'center',
gap: 10,
background: 'var(--bg-2)',
border: `1px solid ${armed ? 'var(--accent)' : 'var(--line)'}`,
boxShadow: armed ? '0 0 0 1px var(--glow-accent)' : 'none',
borderRadius: 'var(--r-1)',
padding: '5px 7px',
padding: '8px 10px',
opacity: off ? 0.6 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{showMidi ? (
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 5 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
aria-label={`MIDI name for ${param.name}`}
value={midi.name}
onChange={(e) => onChange({ midi: { ...midi, name: e.target.value } })}
aria-label={`Output name for ${param.name}`}
value={param.name}
onChange={(e) =>
onChange({
name: e.target.value,
...(showMidi ? { midi: { ...midi, name: e.target.value } } : {}),
})
}
style={{
flex: 1,
minWidth: 0,
@ -116,195 +125,158 @@ export function OutputControlRow({
padding: '1px 0',
}}
/>
) : (
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 'var(--fs-xs)',
color: 'var(--fg)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.name}
</span>
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>{param.group}</span>
<GlyphToggle
on={muted}
glyph="M"
title={muted ? 'Muted (silenced downstream, still computed)' : 'Mute downstream'}
color="var(--danger)"
onClick={() => onChange({ muted: !muted })}
/>
<GlyphToggle
on={armed}
glyph="S"
title={armed ? 'Armed — focus training on this output' : 'Solo / arm (focus training)'}
color="var(--accent)"
onClick={() => onChange({ armed: !armed })}
/>
</div>
{showMidi && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, color: 'var(--fg-mute)' }}>
CC
<input
aria-label={`MIDI CC for ${param.name}`}
type="number"
min={0}
max={127}
value={midi.cc}
onChange={(e) =>
onChange({ midi: { ...midi, cc: Math.max(0, Math.min(127, Number(e.target.value) || 0)) } })
}
style={{ width: 54, background: 'var(--bg-1)', border: '1px solid var(--line)', borderRadius: 'var(--r-1)', color: 'var(--fg)', fontFamily: 'var(--font-mono)', fontSize: 10, padding: '2px 4px' }}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, color: 'var(--fg-mute)' }}>
ch
<input
aria-label={`MIDI channel for ${param.name}`}
type="number"
min={1}
max={16}
value={midi.channel}
onChange={(e) =>
onChange({ midi: { ...midi, channel: Math.max(1, Math.min(16, Number(e.target.value) || 1)) } })
}
style={{ width: 44, background: 'var(--bg-1)', border: '1px solid var(--line)', borderRadius: 'var(--r-1)', color: 'var(--fg)', fontFamily: 'var(--font-mono)', fontSize: 10, padding: '2px 4px' }}
/>
</label>
</div>
)}
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>{param.group}</span>
{onDelete && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
type="button"
aria-label={`Delete ${param.name} output`}
title={`Delete ${param.name} output`}
onClick={onDelete}
title={`Status: ${state.label} · click for ${nextState.label}`}
onClick={() => onChange({ status: nextState.v })}
style={{
width: 22,
height: 22,
borderRadius: 'var(--r-1)',
border: '1px solid var(--line)',
background: 'transparent',
color: 'var(--danger)',
cursor: 'pointer',
minWidth: 52,
fontSize: 9,
fontFamily: 'var(--font-mono)',
fontSize: 14,
lineHeight: 1,
textTransform: 'uppercase',
letterSpacing: '0.04em',
padding: '3px 6px',
cursor: 'pointer',
border: `1px solid ${state.color}`,
background: state.color,
color: 'var(--bg)',
borderRadius: 'var(--r-1)',
}}
>
×
{state.label}
</button>
)}
<GlyphToggle
on={muted}
glyph="M"
title={muted ? 'Muted (silenced downstream, still computed)' : 'Mute downstream'}
color="var(--danger)"
onClick={() => onChange({ muted: !muted })}
/>
<GlyphToggle
on={armed}
glyph="S"
title={armed ? 'Armed — focus training on this output' : 'Solo / arm (focus training)'}
color="var(--accent)"
onClick={() => onChange({ armed: !armed })}
/>
</div>
{showMidi && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, color: 'var(--fg-mute)' }}>
CC
<input
aria-label={`MIDI CC for ${param.name}`}
type="number"
min={0}
max={127}
value={midi.cc}
onChange={(e) =>
onChange({ midi: { ...midi, cc: Math.max(0, Math.min(127, Number(e.target.value) || 0)) } })
}
style={{
width: 54,
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
color: 'var(--fg)',
fontFamily: 'var(--font-mono)',
fontSize: 10,
padding: '2px 4px',
}}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, color: 'var(--fg-mute)' }}>
ch
<input
aria-label={`MIDI channel for ${param.name}`}
type="number"
min={1}
max={16}
value={midi.channel}
onChange={(e) =>
onChange({
midi: { ...midi, channel: Math.max(1, Math.min(16, Number(e.target.value) || 1)) },
})
}
style={{
width: 44,
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
color: 'var(--fg)',
fontFamily: 'var(--font-mono)',
fontSize: 10,
padding: '2px 4px',
}}
/>
</label>
<DualRange
min={param.min}
max={param.max}
onMin={(v) => onChange({ min: v })}
onMax={(v) => onChange({ max: v })}
/>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* tri-state segmented */}
<div style={{ display: 'flex', gap: 1, flex: '0 0 auto' }}>
{STATE_META.map((s) => {
const on = param.status === s.v;
return (
<button
key={s.v}
type="button"
onClick={() => onChange({ status: s.v })}
style={{
fontSize: 9,
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
padding: '2px 5px',
cursor: 'pointer',
border: `1px solid ${on ? s.color : 'var(--line)'}`,
background: on ? s.color : 'transparent',
color: on ? 'var(--bg)' : 'var(--fg-dim)',
borderRadius: 'var(--r-1)',
}}
>
{s.label}
</button>
);
})}
</div>
<DualRange
min={param.min}
max={param.max}
onMin={(v) => onChange({ min: v })}
onMax={(v) => onChange({ max: v })}
/>
</div>
{/* value bar (live model value, or held fixed value) */}
<div
style={{
position: 'relative',
height: 6,
background: 'var(--bg-1)',
borderRadius: 'var(--r-pill)',
overflow: 'hidden',
}}
title={`value ${barVal.toFixed(3)}${muted ? ' (muted)' : ''}`}
>
<div
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: `${Math.max(0, Math.min(1, barVal)) * 100}%`,
background: muted ? 'var(--fg-dim)' : gc,
opacity: muted ? 0.4 : 0.8,
transition: 'width 70ms linear',
position: 'relative',
height: 6,
background: 'var(--bg-1)',
borderRadius: 'var(--r-pill)',
overflow: 'hidden',
}}
/>
title={`value ${barVal.toFixed(3)}${muted ? ' (muted)' : ''}`}
>
<div
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: `${Math.max(0, Math.min(1, barVal)) * 100}%`,
background: muted ? 'var(--fg-dim)' : gc,
opacity: muted ? 0.4 : 0.8,
transition: 'width 70ms linear',
}}
/>
</div>
{param.status === 'fixed' && (
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 9, color: 'var(--fg-mute)', textTransform: 'uppercase' }}>held</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={param.val}
onChange={(e) => onChange({ val: parseFloat(e.target.value) })}
className="mf-slider-input"
style={{ flex: 1, ['--mf-pct' as string]: `${Math.max(0, Math.min(1, param.val))}` } as CSSProperties}
/>
</label>
)}
</div>
{param.status === 'fixed' && (
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 9, color: 'var(--fg-mute)', textTransform: 'uppercase' }}>held</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={param.val}
onChange={(e) => onChange({ val: parseFloat(e.target.value) })}
className="mf-slider-input"
style={{ flex: 1, ['--mf-pct' as string]: `${Math.max(0, Math.min(1, param.val))}` } as CSSProperties}
/>
</label>
)}
{showCurve && (
<div style={{ marginTop: 2 }}>
<div style={{ flex: '0 0 88px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CurvePad curve={param.curve} onChange={(c) => onChange({ curve: c })} size={88} />
</div>
)}
{onDelete && (
<button
type="button"
aria-label={`Delete ${param.name} output`}
title={`Delete ${param.name} output`}
onClick={onDelete}
style={{
position: 'absolute',
top: -7,
right: -7,
zIndex: 2,
width: 22,
height: 22,
borderRadius: '50%',
border: '1px solid var(--danger)',
background: 'var(--bg-2)',
color: 'var(--danger)',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: 14,
lineHeight: 1,
}}
>
×
</button>
)}
</div>
);
}