first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
# `BindingRegistry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BindingRegistry` is the discriminated dispatcher that links every bound
control on a surface to the consumer-side logic that actually performs the
side-effect. Atomic controls call `registry.dispatch(binding, value)`
whenever a user-initiated `onChange` fires; the registry resolves the
binding's `kind` and invokes the registered handler.
The constructor accepts a partial handler table. Any kind left unset falls
back to the built-in default handler, which is the appropriate consumer
hook adapter (for `pluginParam`, `transport`, `midiOut`, `midiIn`) or a
working fan-out (for `compound`). Every leaf default throws
`REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING` until a consumer registers
production wiring — silently dropping a control update is never
acceptable.
## Features
- Closed kind dispatch for all six `Binding["kind"]` values.
- Default compound fan-out via `defaultCompoundHandler`.
- Per-kind override via the constructor or `withHandler(kind, handler)`
builder.
- Type-narrowed handler signatures so consumers can't accidentally cross
binding kinds.
## Usage: Basic
```ts
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
const registry = new BindingRegistry({
pluginParam: (binding, value) =>
chain.get(binding.pluginId)?.setParameter(binding.paramPath, value),
});
registry.dispatch(binding, knob.value);
```
## Usage: Advanced
```ts
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
const base = new BindingRegistry({
midiOut: (binding, value) =>
output.sendMessage({
kind: "controlChange",
channel: binding.channel,
controller: binding.cc,
value: Math.round(value * 127),
}),
});
// Layer a recording spy on top of the production handler for an audit run.
const spy = new BindingRegistry();
const audited = base.withHandler("pluginParam", (binding, value) => {
audit.push({ binding, value });
spy.dispatch(binding, value);
});
```
## API Reference
- `new BindingRegistry(table?: BindingHandlerTable)` — constructor.
- `registry.dispatch(binding: Binding, value: number): void` — full
dispatch, including compound fan-out.
- `registry.dispatchLeaf(binding: LeafBinding, value: number): void` —
leaf-only dispatch; called by the default compound handler to avoid
re-entering the compound check.
- `registry.has(kind: BindingKind): boolean` — `true` when a handler is
registered.
- `registry.withHandler(kind, handler)` — returns a new registry with the
given handler overridden.
## Implementation Notes
The dispatcher is built around an exhaustive `switch` over the leaf
discriminator so adding a new binding kind is a compile-time fail — the
switch needs a new branch and `BindingHandlerTable` needs a new optional
slot. The compound default takes the owning registry as a parameter so it
can route per-leaf calls through `dispatchLeaf`, sidestepping a recursive
compound check that would otherwise be required by every test path.

View File

@@ -0,0 +1,56 @@
# `compound` handler
## Purpose
The `compound` handler is the binding-registry slot responsible for
fanning a single control's value out to multiple leaf bindings — for
example, a knob that simultaneously drives a plugin parameter, a MIDI CC,
and a DMX channel. Unlike the leaf handlers, the compound handler always
has a working default because compounding is a pure structural operation
that doesn't depend on any external integration.
The default iterates `binding.bindings` in registration order and calls
`registry.dispatchLeaf(leaf, value)` for each — every leaf is sent the
same value, so the per-leaf range/scale logic stays in the leaf handlers.
The schema rejects nested compounds at parse time, so the iteration is
guaranteed to bottom out after one step.
## Features
- Default implementation needs no consumer wiring.
- Stable iteration order (registration order) for deterministic test
assertions.
- Takes the owning `BindingRegistry` as a parameter so callers can
customise the fan-out target (e.g. dispatch through a wrapping spy
registry).
## Usage: Basic
```ts
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
// No compound override needed; the default fans out automatically.
const registry = new BindingRegistry({ pluginParam: handler });
```
## Usage: Advanced
```ts
import { defaultCompoundHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
// Manually fan out, perhaps after a custom pre-flight check.
defaultCompoundHandler(compoundBinding, 0.42, registry);
```
## API Reference
- `defaultCompoundHandler(binding: CompoundBinding, value: number, registry: BindingRegistry): void`
— iterates `binding.bindings` and dispatches each leaf through `registry.dispatchLeaf`.
## Implementation Notes
The schema's nested-compound rejection is enforced at parse time, but the
default handler defends in depth by routing through `dispatchLeaf`, which
accepts only the `LeafBinding` union. A nested compound would fail the
TypeScript narrowing at the boundary even if a malicious caller bypassed
the schema validator.

View File

@@ -0,0 +1,54 @@
# `dmx` handler
## Purpose
The `dmx` handler is the binding-registry slot reserved for DMX universe /
channel updates. The `@suaveplan/react-dmx` integration has not yet shipped,
so this default throws `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`
unconditionally. When the DMX integration lands the production handler will
replace this default with a closure that writes the scaled value into the
named universe/channel via the DMX transport.
Throwing here is doubly important: stage-lighting bindings tend to be
load-bearing in live performance, and a silent drop of a DMX update can
plunge a stage into darkness mid-cue. Failing loud at the default ensures
operators see the gap before they take the surface live.
## Features
- Strongly-typed signature `(binding: DmxBinding, value: number) => never`.
- Error context preserves `universe`, `channel`, and the dropped `value`.
- Default behaviour matches the other unwired handlers for uniform
error-handling at the call site.
## Usage: Basic
```ts
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
const registry = new BindingRegistry({
dmx: (binding, value) =>
dmxTransport.send(binding.universe, binding.channel, value),
});
```
## Usage: Advanced
```ts
import { dmxHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
expect(() =>
dmxHandler({ kind: "dmx", universe: 1, channel: 33 }, 0.75)
).toThrow(/BINDING_HANDLER_MISSING/);
```
## API Reference
- `dmxHandler(binding: DmxBinding, value: number): never`
— always throws with code `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`.
## Implementation Notes
DMX traffic must be ordered and rate-limited; the production handler will
need to coalesce rapid drag-driven updates. None of that is the default's
problem — the default's only job is to refuse to silently drop traffic.

View File

@@ -0,0 +1,57 @@
# `midi-in` handler
## Purpose
The `midiIn` handler is the binding-registry slot that hosts an inbound
MIDI subscription — a control bound this way reflects external CC traffic
rather than driving it. The default export is the "unwired" implementation
that throws `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`; consumers
replace it with a closure that captures a subscription created by
`useMidiInput(binding.portId)` and pumps the inbound value back into the
control via the runtime's `setValue` channel.
Throwing at the default site is the right call even for a "passive" inbound
binding: silently dropping inbound updates would leave the control's
visual state out of sync with the device.
## Features
- Strongly-typed signature `(binding: MidiInBinding, value: number) => never`.
- Error context preserves `portId`, `channel`, `cc`, and the most recent
inbound value.
## Usage: Basic
```ts
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
const registry = new BindingRegistry({
midiIn: (binding, value) => surfaceValueStore.set(binding, value),
});
```
## Usage: Advanced
```ts
import { midiInHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
try {
midiInHandler({ kind: "midiIn", portId: "p1", channel: 1, cc: 7 }, 0);
} catch (err) {
// err.context.kind === "midiIn"
}
```
## API Reference
- `midiInHandler(binding: MidiInBinding, value: number): never`
— always throws with code `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`.
## Implementation Notes
Because inbound bindings are an "inverse" of outbound ones, the same
handler signature is reused with the understanding that the production
handler treats the `value` argument as the most recent inbound observation
rather than a value to send. Keeping the signatures unified simplifies the
generic dispatcher and lets test spies swap inbound + outbound paths with
identical types.

View File

@@ -0,0 +1,64 @@
# `midi-out` handler
## Purpose
The `midiOut` handler is the binding-registry slot that converts a
control's value into an outbound MIDI Control Change. The default export
is the "unwired" implementation that throws
`REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`; consumers replace it with
a closure that captures a `MidiOutputHandle` from
`useMidiOutput(binding.portId)` and emits CC traffic on the bound
`channel`/`cc` pair, scaled into the binding's optional `range`.
Throwing at the default keeps a partially-wired surface from masquerading
as live. Once `useMidiOutput` is bridged the runtime forwards every drag,
press, or keyboard increment as a single CC message.
## Features
- Strongly-typed signature `(binding: MidiOutBinding, value: number) => never`.
- Error context preserves `portId`, `channel`, `cc`, and the dropped value.
- Encoding/range scaling are deferred to the production handler — this
default is value-agnostic.
## Usage: Basic
```ts
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
const registry = new BindingRegistry({
midiOut: (binding, value) =>
output.sendMessage({
kind: "controlChange",
channel: binding.channel,
controller: binding.cc,
value: Math.round(value * 127),
}),
});
```
## Usage: Advanced
```ts
import { midiOutHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
expect(() =>
midiOutHandler(
{ kind: "midiOut", portId: "p1", channel: 1, cc: 7 },
0.5
)
).toThrow();
```
## API Reference
- `midiOutHandler(binding: MidiOutBinding, value: number): never`
— always throws with code `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`.
## Implementation Notes
Range scaling is the consumer's responsibility because the schema's
optional `range` can flip min/max, and the production handler may want to
interpret it differently (linear vs. log) per surface. Keeping the default
value-agnostic ensures the consumer never inherits an opinionated mapping
they can't override.

View File

@@ -0,0 +1,61 @@
# `plugin-param` handler
## Purpose
The `pluginParam` handler is the binding-registry slot that dispatches a
control's value to a plugin parameter via `useParameter` from
`@suaveplan/react-audio`. The default export shipped here is the
"unwired" implementation — it throws `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`
unconditionally, on the principle that a consumer who has not registered a
production handler should fail fast rather than silently drop control
updates.
Production wiring lives one layer up: the `<Surface>` runtime, which has
React-render scope, resolves `binding.pluginId` against the live
`PluginChain`, invokes `useParameter(plugin, paramId)`, and registers a
closure that calls `param.set(value)` into a `BindingRegistry` clone.
## Features
- Strongly-typed signature `(binding: PluginParameterBinding, value: number) => never`.
- Error context captures `pluginId`, `paramPath`, `value`, and `kind`.
- Stable error code suitable for log-scraping and dashboards.
## Usage: Basic
```ts
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
const registry = new BindingRegistry({
pluginParam: (binding, value) => {
// call into the user's resolved plugin chain
chain.get(binding.pluginId)?.setParameter(binding.paramPath, value);
},
});
```
## Usage: Advanced
```ts
import { pluginParamHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
try {
pluginParamHandler({ kind: "pluginParam", pluginId: "synth", paramPath: "cutoff" }, 0.42);
} catch (err) {
// err.code === REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING
}
```
## API Reference
- `pluginParamHandler(binding: PluginParameterBinding, value: number): never`
— always throws `ReactControlSurfaceError` with code
`REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`.
## Implementation Notes
The handler never returns; its TypeScript return type is `never` so the
type system propagates the unreachable-after-call constraint up to callers.
The error includes the binding's full identity payload so log scrapers can
attribute a missing-handler incident to the precise binding without
correlating against the source surface.

View File

@@ -0,0 +1,57 @@
# `transport` handler
## Purpose
The `transport` handler is the binding-registry slot that dispatches a
control's value to the audio transport — play/stop toggles, tempo dials, the
play-head position, and loop on/off. The default export is the "unwired"
implementation that throws `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`;
consumers replace it with a closure that captures the resolved transport
from `useTransport` (in `@suaveplan/react-audio`) and switches on
`binding.field`.
Throwing at the default site enforces a fail-fast policy. A surface with a
transport-bound play button must never silently drop presses — either the
runtime wired the handler, or the runtime crashes loudly on the first
press.
## Features
- Strongly-typed signature `(binding: TransportBinding, value: number) => never`.
- Captures `field` and `value` in the error context.
- Code identical to the other unwired defaults so consumers can write a
single `catch` filter.
## Usage: Basic
```ts
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
const registry = new BindingRegistry({
transport: (binding, value) => transport[binding.field](value),
});
```
## Usage: Advanced
```ts
import { transportHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
// Inside a unit test that asserts the failure path
expect(() =>
transportHandler({ kind: "transport", field: "play" }, 1)
).toThrow(/BINDING_HANDLER_MISSING/);
```
## API Reference
- `transportHandler(binding: TransportBinding, value: number): never`
— always throws `ReactControlSurfaceError` with code
`REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`.
## Implementation Notes
The `field` discriminator is preserved in the error context so the consumer
can correlate the missing-handler crash with the precise transport field
being driven. The function's return type is `never`, which keeps callers
honest about the unreachable branch even in pre-wired test code.