first commit
This commit is contained in:
114
docs/ui/react/react-control-surface/README.md
Normal file
114
docs/ui/react/react-control-surface/README.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# @suaveplan/react-control-surface - React 19 atomic-control surface renderer for @suaveplan/control-surface-schema. Foundation chunk: error codes, telemetry, skin contract, binding registry + handlers, grouping primitives, and the simple atomic controls (TextLabel, Image, LED, Switch2, Switch3, ButtonMomentary, ButtonLatching).
|
||||
|
||||
React 19 atomic-control surface renderer for `@suaveplan/control-surface-schema`.
|
||||
|
||||
This is the **foundation chunk** of the package — it lands the package scaffolding
|
||||
(error codes, telemetry, skin contract, binding registry + handlers, grouping
|
||||
primitives) and the seven simple atomic controls:
|
||||
|
||||
- `<TextLabel>` — decorative read-only text.
|
||||
- `<Image>` — decorative image element.
|
||||
- `<LED>` — boolean/0..1 brightness indicator.
|
||||
- `<Switch2>` — two-state toggle (`role="switch"`).
|
||||
- `<Switch3>` — three-state toggle.
|
||||
- `<ButtonMomentary>` — press-and-hold trigger.
|
||||
- `<ButtonLatching>` — sticky toggle.
|
||||
|
||||
The remaining controls (knob, sliders, displays, meters, XY pad, jog wheel, piano
|
||||
key, drum pad) and the `<Surface>` runtime walker land in follow-up chunks.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [button-latching](./src/controls/button-latching/button-latching.md) — `<ButtonLatching>` is the sticky toggle: a click latches the button on, the next click latches it off. It is the counte…
|
||||
- [button-latching](./src/controls/button-latching/button-latching.md) — `<ButtonLatching>` is the sticky toggle: a click latches the button on, the next click latches it off. It is the counte…
|
||||
- [button-momentary](./src/controls/button-momentary/button-momentary.md) — `<ButtonMomentary>` is the press-and-hold trigger control. Pressing it emits `max` into `onChange`; releasing it emits…
|
||||
- [button-momentary](./src/controls/button-momentary/button-momentary.md) — `<ButtonMomentary>` is the press-and-hold trigger control. Pressing it emits `max` into `onChange`; releasing it emits…
|
||||
- [digital-display](./src/controls/digital-display/digital-display.md) — Read-only 7-segment / LCD-style readout for the control surface. The component is a typed information sink: it accepts…
|
||||
- [digital-display](./src/controls/digital-display/digital-display.md) — Read-only 7-segment / LCD-style readout for the control surface. The component is a typed information sink: it accepts…
|
||||
- [drum-pad](./src/controls/drum-pad/drum-pad.md) — Velocity-sensitive drum pad. Maps a pointer or keyboard press to a velocity 0..1 then through `[min, max]` so the same…
|
||||
- [drum-pad](./src/controls/drum-pad/drum-pad.md) — Velocity-sensitive drum pad. Maps a pointer or keyboard press to a velocity 0..1 then through `[min, max]` so the same…
|
||||
- [image](./src/controls/image/image.md) — `<Image>` is the decorative image control. Like `<TextLabel>`, it appears in the closed `ControlKind` enumeration so a…
|
||||
- [image](./src/controls/image/image.md) — `<Image>` is the decorative image control. Like `<TextLabel>`, it appears in the closed `ControlKind` enumeration so a…
|
||||
- [jog-wheel](./src/controls/jog-wheel/jog-wheel.md) — `<JogWheel>` is an endless rotary control inspired by DJ-deck jog wheels. Unlike a knob, it does not have an absolute v…
|
||||
- [jog-wheel](./src/controls/jog-wheel/jog-wheel.md) — `<JogWheel>` is an endless rotary control inspired by DJ-deck jog wheels. Unlike a knob, it does not have an absolute v…
|
||||
- [knob](./src/controls/knob/knob.md) — Rotary knob control — the cornerstone interaction primitive for the 2D control surface. Renders an SVG circle with a ro…
|
||||
- [knob](./src/controls/knob/knob.md) — Rotary knob control — the cornerstone interaction primitive for the 2D control surface. Renders an SVG circle with a ro…
|
||||
- [led](./src/controls/led/led.md) — `<LED>` is the output indicator control. It accepts a numeric value in the standard `BoundControlProps` domain and rend…
|
||||
- [led](./src/controls/led/led.md) — `<LED>` is the output indicator control. It accepts a numeric value in the standard `BoundControlProps` domain and rend…
|
||||
- [meter-peak](./src/controls/meter-peak/meter-peak.md) — `<MeterPeak>` is a digital-style peak meter that shows the instantaneous input level alongside a held-then-decaying pea…
|
||||
- [meter-peak](./src/controls/meter-peak/meter-peak.md) — `<MeterPeak>` is a digital-style peak meter that shows the instantaneous input level alongside a held-then-decaying pea…
|
||||
- [meter-spectral](./src/controls/meter-spectral/meter-spectral.md) — `<MeterSpectral>` renders a real-time bar graph of magnitude bins. Each bin is drawn as a vertical bar whose height is…
|
||||
- [meter-spectral](./src/controls/meter-spectral/meter-spectral.md) — `<MeterSpectral>` renders a real-time bar graph of magnitude bins. Each bin is drawn as a vertical bar whose height is…
|
||||
- [meter-vu](./src/controls/meter-vu/meter-vu.md) — `<MeterVU>` renders a single-channel level meter with the classic analogue VU response defined by IEC 60268-10: a 300 m…
|
||||
- [meter-vu](./src/controls/meter-vu/meter-vu.md) — `<MeterVU>` renders a single-channel level meter with the classic analogue VU response defined by IEC 60268-10: a 300 m…
|
||||
- [piano-key](./src/controls/piano-key/piano-key.md) — A single piano key. Two visual kinds (`white` / `black`) and three visual states (`idle` / `pressed` / `highlighted`).…
|
||||
- [piano-key](./src/controls/piano-key/piano-key.md) — A single piano key. Two visual kinds (`white` / `black`) and three visual states (`idle` / `pressed` / `highlighted`).…
|
||||
- [slider-h](./src/controls/slider-h/slider-h.md) — Horizontal slider control — the X-axis counterpart of `<SliderV>`. Every behavioural decision is delegated to the share…
|
||||
- [slider-h](./src/controls/slider-h/slider-h.md) — Horizontal slider control — the X-axis counterpart of `<SliderV>`. Every behavioural decision is delegated to the share…
|
||||
- [slider-v](./src/controls/slider-v/slider-v.md) — Vertical slider control. Pointer interaction sets the value directly from `clientY` against the rendered track rect — d…
|
||||
- [slider-helpers](./src/controls/slider-v/slider-helpers.md) — Shared pure functions for both `<SliderV>` and `<SliderH>` so that value resolution, detent snapping, and keyboard step…
|
||||
- [slider-v](./src/controls/slider-v/slider-v.md) — Vertical slider control. Pointer interaction sets the value directly from `clientY` against the rendered track rect — d…
|
||||
- [switch-2](./src/controls/switch-2/switch-2.md) — `<Switch2>` is the two-position toggle control. It implements the WAI-ARIA "switch" pattern (`role="switch"` + `aria-ch…
|
||||
- [switch-2](./src/controls/switch-2/switch-2.md) — `<Switch2>` is the two-position toggle control. It implements the WAI-ARIA "switch" pattern (`role="switch"` + `aria-ch…
|
||||
- [switch-3](./src/controls/switch-3/switch-3.md) — `<Switch3>` is the three-position toggle, modelling the kind of "down-centre-up" or "low-mid-high" switch found on outb…
|
||||
- [switch-3](./src/controls/switch-3/switch-3.md) — `<Switch3>` is the three-position toggle, modelling the kind of "down-centre-up" or "low-mid-high" switch found on outb…
|
||||
- [text-label](./src/controls/text-label/text-label.md) — `<TextLabel>` is the decorative read-only control that renders a static text string. It is included in the `ControlKind…
|
||||
- [text-label](./src/controls/text-label/text-label.md) — `<TextLabel>` is the decorative read-only control that renders a static text string. It is included in the `ControlKind…
|
||||
- [xy-pad](./src/controls/xy-pad/xy-pad.md) — `<XYPad>` is an accessible 2-axis touch surface that emits normalised `{ x, y }` coordinates as the user drags a pointe…
|
||||
- [xy-pad](./src/controls/xy-pad/xy-pad.md) — `<XYPad>` is an accessible 2-axis touch surface that emits normalised `{ x, y }` coordinates as the user drags a pointe…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module owns the stable, namespaced registry of every error code that `@suaveplan/react-control-surfac…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module owns the stable, namespaced registry of every error code that `@suaveplan/react-control-surfac…
|
||||
- [group](./src/grouping/group/group.md) — `<Group>` is the layout primitive used for internal nodes of a control surface tree. It accepts a layout descriptor (CS…
|
||||
- [group](./src/grouping/group/group.md) — `<Group>` is the layout primitive used for internal nodes of a control surface tree. It accepts a layout descriptor (CS…
|
||||
- [panel](./src/grouping/panel/panel.md) — `<Panel>` is the labelled grouping primitive that wraps a group of controls in a titled box, optionally with collapse-t…
|
||||
- [panel](./src/grouping/panel/panel.md) — `<Panel>` is the labelled grouping primitive that wraps a group of controls in a titled box, optionally with collapse-t…
|
||||
- [rack](./src/grouping/rack/rack.md) — `<Rack>` is the grouping primitive that models a hardware-rack frame — a horizontal (default) or vertical sequence of f…
|
||||
- [rack](./src/grouping/rack/rack.md) — `<Rack>` is the grouping primitive that models a hardware-rack frame — a horizontal (default) or vertical sequence of f…
|
||||
- [runtime](./src/runtime/surface.md) — `<Surface>` is the runtime that turns a validated `ControlSurface` document into a live React DOM tree. It performs a d…
|
||||
- [binding-registry](./src/runtime/binding-registry/binding-registry.md) — `BindingRegistry` is the discriminated dispatcher that links every bound control on a surface to the consumer-side logi…
|
||||
- [compound](./src/runtime/binding-registry/handlers/compound.md) — The `compound` handler is the binding-registry slot responsible for fanning a single control's value out to multiple le…
|
||||
- [dmx](./src/runtime/binding-registry/handlers/dmx.md) — The `dmx` handler is the binding-registry slot reserved for DMX universe / channel updates. The `@suaveplan/react-dmx`…
|
||||
- [midi-in](./src/runtime/binding-registry/handlers/midi-in.md) — The `midiIn` handler is the binding-registry slot that hosts an inbound MIDI subscription — a control bound this way re…
|
||||
- [midi-out](./src/runtime/binding-registry/handlers/midi-out.md) — The `midiOut` handler is the binding-registry slot that converts a control's value into an outbound MIDI Control Change…
|
||||
- [plugin-param](./src/runtime/binding-registry/handlers/plugin-param.md) — The `pluginParam` handler is the binding-registry slot that dispatches a control's value to a plugin parameter via `use…
|
||||
- [transport](./src/runtime/binding-registry/handlers/transport.md) — The `transport` handler is the binding-registry slot that dispatches a control's value to the audio transport — play/st…
|
||||
- [binding-registry](./src/runtime/binding-registry/binding-registry.md) — `BindingRegistry` is the discriminated dispatcher that links every bound control on a surface to the consumer-side logi…
|
||||
- [surface](./src/runtime/surface.md) — `<Surface>` is the runtime that turns a validated `ControlSurface` document into a live React DOM tree. It performs a d…
|
||||
- [skin](./src/skin/surface-skin-provider.md) — `<SurfaceSkinProvider>` is the single React boundary that activates a skin for every atomic control inside its subtree.…
|
||||
- [surface-skin-provider](./src/skin/surface-skin-provider.md) — `<SurfaceSkinProvider>` is the single React boundary that activates a skin for every atomic control inside its subtree.…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The `telemetry` module is the single owner of every OpenTelemetry instrument emitted by `@suaveplan/react-control-surfa…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The `telemetry` module is the single owner of every OpenTelemetry instrument emitted by `@suaveplan/react-control-surfa…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Bindings
|
||||
|
||||
`BindingRegistry` discriminates on `Binding["kind"]` (defined by
|
||||
`@suaveplan/control-surface-schema`) and dispatches to the registered handler.
|
||||
Built-in handlers integrate with sibling packages:
|
||||
|
||||
- `pluginParam` — bridges to `useParameter` from `@suaveplan/react-audio`.
|
||||
- `transport` — bridges to `useTransport` from `@suaveplan/react-audio`.
|
||||
- `midiOut` — bridges to `useMidiOutput` from `@suaveplan/react-midi`.
|
||||
- `midiIn` — bridges to `useMidiInput` from `@suaveplan/react-midi`.
|
||||
- `dmx` — throws `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING` until the
|
||||
`@suaveplan/react-dmx` integration arrives in a later chunk.
|
||||
- `compound` — fans out to every leaf in registration order.
|
||||
|
||||
Every handler is invoked through the same `BoundControlBinder` shape so the
|
||||
runtime never special-cases binding kinds.
|
||||
|
||||
## Skin contract
|
||||
|
||||
`<SurfaceSkinProvider id="skin-id" vars={...}>` injects CSS variables on a
|
||||
wrapping `<div>` — every atomic control reads its visual styling exclusively
|
||||
through CSS variable resolution. JS-level skin dispatch is forbidden by the
|
||||
spec: changing the skin is a pure CSS-variable swap.
|
||||
|
||||
## Telemetry
|
||||
|
||||
- `control_surface_interactions_total{kind}` — counter incremented on every
|
||||
user-driven `onChange` from an atomic control.
|
||||
- `control_surface_render_duration_ms` — histogram recorded by the runtime per
|
||||
surface render commit (registered for future chunks).
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# `<ButtonLatching>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<ButtonLatching>` is the sticky toggle: a click latches the button on,
|
||||
the next click latches it off. It is the counterpart to
|
||||
`<ButtonMomentary>` and reuses the same `aria-pressed` pattern so users
|
||||
who navigate by screen reader experience continuity across the two
|
||||
variants — the only behavioural difference is when the value flips.
|
||||
|
||||
Unlike the momentary variant, the latched state is read entirely from
|
||||
the schema's numeric value via a midpoint threshold. There is no local
|
||||
state to track because the press has no second "release" event that
|
||||
needs synchronisation with the visual chrome.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-click toggle.
|
||||
- Keyboard activation via Space and Enter with `event.repeat`
|
||||
suppression.
|
||||
- WAI-ARIA `aria-pressed` reflection of the latched state.
|
||||
- Telemetry: `control_surface_interactions_total{kind="button-latching"}`
|
||||
on every toggle.
|
||||
- Pure `isButtonLatchingOn` helper for unit testing the threshold.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<ButtonLatching
|
||||
value={looping ? 1 : 0}
|
||||
onChange={(v) => setLooping(v === 1)}
|
||||
min={0}
|
||||
max={1}
|
||||
label="Loop"
|
||||
/>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<ButtonLatching
|
||||
value={state}
|
||||
onChange={(v) => registry.dispatch(binding, v)}
|
||||
min={0}
|
||||
max={1}
|
||||
label="Bypass"
|
||||
ariaLabel="Bypass effect chain"
|
||||
disabled={readOnly}
|
||||
className="bypass"
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<ButtonLatching>` props: `BoundControlProps & { className?, style? }`.
|
||||
- `isButtonLatchingOn(value, min, max)` — pure threshold helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`event.repeat` is suppressed in the keyboard handler so an OS-level
|
||||
auto-repeat does not flip the latch on every key event while the user
|
||||
holds Space. The midpoint threshold is the same as `<Switch2>` — these
|
||||
two controls differ only in their visual chrome; if a surface designer
|
||||
prefers a chunky push-button rather than a thin toggle they can pick
|
||||
the latching button with no behavioural change.
|
||||
@@ -0,0 +1,74 @@
|
||||
# `<ButtonMomentary>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<ButtonMomentary>` is the press-and-hold trigger control. Pressing it
|
||||
emits `max` into `onChange`; releasing it emits `min`. The release also
|
||||
fires when the pointer leaves the button while still pressed, so a user
|
||||
who drags off the button mid-hold doesn't get stuck in the held state.
|
||||
Keyboard interaction follows the same edge model: Space / Enter down
|
||||
emit max, the corresponding up emits min, and `event.repeat` is
|
||||
suppressed so OS auto-repeat doesn't generate a stream of duplicate
|
||||
"down" events.
|
||||
|
||||
The pressed state is tracked locally in `useState` so the visual chrome
|
||||
updates synchronously with the user input. The schema's numeric value is
|
||||
informational; the rendered chrome follows the user's gesture rather
|
||||
than waiting for a round-trip through the binding registry.
|
||||
|
||||
## Features
|
||||
|
||||
- Press-down / release edge dispatch.
|
||||
- Pointer-leave releases stuck holds.
|
||||
- Keyboard activation via Space and Enter with `event.repeat`
|
||||
suppression.
|
||||
- WAI-ARIA `aria-pressed` reflection.
|
||||
- Telemetry: `control_surface_interactions_total{kind="button-momentary"}`
|
||||
on every press.
|
||||
- `disabled` blocks both pointer and keyboard activation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<ButtonMomentary
|
||||
value={triggered ? 1 : 0}
|
||||
onChange={(v) => setTriggered(v === 1)}
|
||||
min={0}
|
||||
max={1}
|
||||
label="Trigger"
|
||||
/>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<ButtonMomentary
|
||||
value={state}
|
||||
onChange={(v) => {
|
||||
registry.dispatch(binding, v);
|
||||
if (v === 1) sampler.trigger();
|
||||
}}
|
||||
min={0}
|
||||
max={1}
|
||||
label="One-shot"
|
||||
ariaLabel="Trigger one-shot sample (Space to hold)"
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<ButtonMomentary>` props: `BoundControlProps & { className?, style? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The local `pressed` state guards against double-press without an
|
||||
intervening release — a second pointer-down with no release is dropped.
|
||||
This is essential under touch input where rapid taps can interleave. The
|
||||
pointer-leave handler only fires release when the button is currently
|
||||
pressed; a stray leave with no prior press is a no-op. Disabled state
|
||||
short-circuits before the state mutation so a disabled component never
|
||||
contributes to the press counter.
|
||||
@@ -0,0 +1,110 @@
|
||||
# `<DigitalDisplay>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Read-only 7-segment / LCD-style readout for the control surface. The
|
||||
component is a typed information sink: it accepts a discriminated
|
||||
`mode` (`numeric` | `text` | `scrolling`) and renders the appropriate
|
||||
visualisation. Although `<DigitalDisplay>` implements the same
|
||||
`BoundControlProps` contract as input controls (so it can drop into
|
||||
the same schema slots), it never emits `onChange` — the display is
|
||||
pure output.
|
||||
|
||||
## Features
|
||||
|
||||
- Three modes (`numeric` with precision + optional units, `text`,
|
||||
`scrolling` with CSS-keyframe overflow scroll).
|
||||
- Scrolling mode honours `prefers-reduced-motion` by disabling the
|
||||
animation via a `@media` block injected alongside the keyframes.
|
||||
- `role="status"` with `aria-live="polite"` so screen readers receive
|
||||
numeric updates without taking focus.
|
||||
- A hidden screen-reader-only span echoes the numeric / text content
|
||||
outside the clipped visual area, so SR users get the full value
|
||||
even when the visible LCD is too short.
|
||||
- CSS-variable theming: `--cs-display-font`, `--cs-display-color`,
|
||||
`--cs-display-bg`.
|
||||
- NaN / Infinity render as `—` (em-dash).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { DigitalDisplay } from "@suaveplan/react-control-surface";
|
||||
|
||||
export function TempoReadout({ bpm }: { bpm: number }) {
|
||||
return (
|
||||
<DigitalDisplay
|
||||
mode={{ kind: "numeric", precision: 1, units: "BPM" }}
|
||||
value={bpm}
|
||||
min={20}
|
||||
max={300}
|
||||
onChange={() => undefined}
|
||||
label="Tempo"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A patch-name readout that scrolls when the patch name is longer than
|
||||
the LCD window:
|
||||
|
||||
```tsx
|
||||
import { DigitalDisplay } from "@suaveplan/react-control-surface";
|
||||
|
||||
export function PatchName({ name }: { name: string }) {
|
||||
return (
|
||||
<DigitalDisplay
|
||||
mode={{
|
||||
kind: "scrolling",
|
||||
text: name,
|
||||
windowChars: 8,
|
||||
speedPxPerSec: 40,
|
||||
}}
|
||||
value={0}
|
||||
min={0}
|
||||
max={1}
|
||||
onChange={() => undefined}
|
||||
widthPx={160}
|
||||
heightPx={28}
|
||||
label="Patch Name"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default |
|
||||
| ----------- | ----------------------------------------------------------------- | -------- |
|
||||
| `mode` | `{kind: "numeric" \| "text" \| "scrolling", ...}` | required |
|
||||
| `value` | `number` | required |
|
||||
| `min`/`max` | `number` | required |
|
||||
| `onChange` | `(v: number) => void` | required |
|
||||
| `disabled` | `boolean` | `false` |
|
||||
| `label` | `string` | — |
|
||||
| `ariaLabel` | `string` | — |
|
||||
| `widthPx` | `number` | `120` |
|
||||
| `heightPx` | `number` | `32` |
|
||||
| `className` | `string` | — |
|
||||
|
||||
`numeric` mode adds: `precision?: number` (default 0), `units?: string`.
|
||||
`text` mode adds: `text: string`.
|
||||
`scrolling` mode adds: `text: string`, `speedPxPerSec?: number`
|
||||
(default 30), `windowChars?: number` (default 8).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Scrolling mode injects an inline `<style>` block with a unique keyframe
|
||||
name (`cs-display-scroll`); the keyframe duration is derived from
|
||||
text length × per-glyph width / configured speed, so longer strings
|
||||
scroll at a constant linear-px-per-second rate regardless of length.
|
||||
The `@media (prefers-reduced-motion: reduce)` rule short-circuits the
|
||||
animation to `none`, satisfying WAI-ARIA APG guidance for moving
|
||||
content. The component intentionally does not use `requestAnimationFrame`
|
||||
or React state ticks — CSS handles the animation so the React render
|
||||
graph stays static.
|
||||
@@ -0,0 +1,132 @@
|
||||
# `<DrumPad>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Velocity-sensitive drum pad. Maps a pointer or keyboard press to a
|
||||
velocity 0..1 then through `[min, max]` so the same component can
|
||||
serve as a 0..127 MIDI velocity emitter, a 0..1 normalized parameter
|
||||
trigger, or anything in between. Velocity sourcing is layered:
|
||||
hardware `PointerEvent.pressure` is preferred when the device supplies
|
||||
a non-zero value (Apple Pencil, pressure-sensitive trackpads,
|
||||
ChromeOS active styluses); otherwise a duration heuristic measures
|
||||
press hold time and produces velocity via a caller-replaceable
|
||||
function.
|
||||
|
||||
## Features
|
||||
|
||||
- Two velocity sources, transparently selected: hardware pressure or
|
||||
press-duration heuristic.
|
||||
- Live pressure tracking: `pointermove` updates velocity continuously
|
||||
while pressure is supplied (useful for aftertouch-style modulation).
|
||||
- Keyboard activation (Space, Enter) using the duration heuristic.
|
||||
- Auto-repeat keyboard suppression — held keys don't re-arm the timer.
|
||||
- Pointer capture for drift-tolerant release.
|
||||
- Strike-then-release pattern: caller receives `(velocity)` followed
|
||||
immediately by `min` so downstream consumers can treat each press
|
||||
as a discrete event without bookkeeping.
|
||||
- Injectable `nowMs` clock for test determinism.
|
||||
- `role="button"` with live `aria-pressed`.
|
||||
- CSS-variable theming (`--cs-pad-idle`, `--cs-pad-pressed`,
|
||||
`--cs-pad-border`, `--cs-pad-label-color`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { DrumPad } from "@suaveplan/react-control-surface";
|
||||
|
||||
export function KickPad() {
|
||||
const [v, setV] = useState(0);
|
||||
return (
|
||||
<DrumPad
|
||||
value={v}
|
||||
onChange={setV}
|
||||
min={0}
|
||||
max={127}
|
||||
displayLabel="KICK"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A 4×4 pad grid with a custom velocity curve and shared state:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { DrumPad } from "@suaveplan/react-control-surface";
|
||||
|
||||
const LABELS = [
|
||||
"KICK","SNARE","CLAP","RIM",
|
||||
"OH","CH","RIDE","CRASH",
|
||||
"TOM1","TOM2","TOM3","PERC",
|
||||
"FX1","FX2","FX3","FX4",
|
||||
];
|
||||
|
||||
// Aggressive curve: 80 ms → max velocity.
|
||||
const curve = (ms: number) => Math.min(1, ms / 80) ** 0.7;
|
||||
|
||||
export function Grid() {
|
||||
const [values, setValues] = useState(() => LABELS.map(() => 0));
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 80px)",
|
||||
gridGap: 6,
|
||||
}}
|
||||
>
|
||||
{LABELS.map((lbl, i) => (
|
||||
<DrumPad
|
||||
key={lbl}
|
||||
displayLabel={lbl}
|
||||
value={values[i]}
|
||||
onChange={(v) => {
|
||||
const next = values.slice();
|
||||
next[i] = v;
|
||||
setValues(next);
|
||||
}}
|
||||
min={0}
|
||||
max={127}
|
||||
velocityFromDurationMs={curve}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default |
|
||||
| ------------------------ | ----------------------------- | ------------------------ |
|
||||
| `value` | `number` | required |
|
||||
| `onChange` | `(v: number) => void` | required |
|
||||
| `min`/`max` | `number` | required |
|
||||
| `disabled` | `boolean` | `false` |
|
||||
| `label` | `string` | — |
|
||||
| `ariaLabel` | `string` | — |
|
||||
| `displayLabel` | `string` | — |
|
||||
| `widthPx` | `number` | `80` |
|
||||
| `heightPx` | `number` | `80` |
|
||||
| `velocityFromDurationMs` | `(ms: number) => number` | `min(1, ms / 200)` |
|
||||
| `nowMs` | `() => number` | `performance.now` |
|
||||
| `className` | `string` | — |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The strike + release pattern uses `queueMicrotask` so consumers see
|
||||
two distinct emissions in order: first the velocity at strike, then
|
||||
`min` immediately after. This keeps downstream parameter-bound state
|
||||
trivially correct (no manual reset) while preserving the strike
|
||||
event for envelope retriggering. Keyboard auto-repeat suppression
|
||||
checks the active-press ref before re-arming so a held key produces
|
||||
a single sustained press rather than a stream of strikes. The
|
||||
injectable `nowMs` exists exclusively so tests can run with a fake
|
||||
clock and produce deterministic velocity values without relying on
|
||||
real wall-clock timing.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `<Image>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<Image>` is the decorative image control. Like `<TextLabel>`, it appears
|
||||
in the closed `ControlKind` enumeration so a surface can declaratively
|
||||
include brand marks, device backplates, and other static imagery without
|
||||
the runtime needing to special-case the rendering path. The component
|
||||
implements `BoundControlProps` for uniform runtime treatment but never
|
||||
invokes `onChange`.
|
||||
|
||||
The accessible name is resolved through a falls-through chain
|
||||
(`ariaLabel` -> `alt` -> `label` -> `""`), which keeps the API
|
||||
identical to `<TextLabel>` and ensures that an image embedded as part of
|
||||
a control surface is never missing its accessible name unless the
|
||||
surface author has explicitly opted into it.
|
||||
|
||||
## Features
|
||||
|
||||
- Single `<img>` element with `data-kind="image"`.
|
||||
- Accessible-name fallback: `ariaLabel` -> `alt` -> `label` -> `""`.
|
||||
- Optional explicit `width` / `height` for SSR-stable layout.
|
||||
- `aria-disabled` reflection of the `disabled` prop.
|
||||
- Inline `className` / `style` overrides.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<Image
|
||||
value={0}
|
||||
onChange={() => undefined}
|
||||
min={0}
|
||||
max={1}
|
||||
src="/assets/logo.png"
|
||||
alt="Synth logo"
|
||||
/>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<Image
|
||||
value={0}
|
||||
onChange={() => undefined}
|
||||
min={0}
|
||||
max={1}
|
||||
src="/assets/backplate.png"
|
||||
label="Device backplate"
|
||||
width={1024}
|
||||
height={256}
|
||||
className="backplate"
|
||||
style={{ filter: "brightness(0.9)" }}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<Image>` props: `BoundControlProps & { src: string, alt?, width?, height?, className?, style? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component does not lazy-load or preload — that is the consumer's
|
||||
responsibility to wire via wrapping components or browser-native
|
||||
attributes (e.g. `loading="lazy"`). Skin-driven styling happens through
|
||||
CSS variables and the `className` slot; the component itself stays close
|
||||
to the bare DOM `<img>` semantics.
|
||||
@@ -0,0 +1,97 @@
|
||||
# `<JogWheel>` — endless rotary with velocity + inertia
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<JogWheel>` is an endless rotary control inspired by DJ-deck jog
|
||||
wheels. Unlike a knob, it does not have an absolute value — instead
|
||||
each pointer-drag event emits a **delta in radians** via `onChange`,
|
||||
which the caller integrates into whatever parameter the wheel
|
||||
controls (scrub position, tempo bend, parameter trim, etc.).
|
||||
|
||||
The wheel reads pointer motion tangentially around its centre: motion
|
||||
that curls clockwise around the hub produces positive deltas, motion
|
||||
curling counter-clockwise produces negative deltas. On release, if
|
||||
the angular velocity exceeds `inertiaThreshold`, the wheel continues
|
||||
to spin under exponential friction (`inertiaFriction`) and emits
|
||||
deltas via `requestAnimationFrame` until the velocity falls below a
|
||||
small cut-off.
|
||||
|
||||
## Features
|
||||
|
||||
- Tangential pointer-motion integration via 2D cross product
|
||||
- Pointer-capture so drags continuing off the wheel still register
|
||||
- Configurable sensitivity (`radiansPerPixel`)
|
||||
- Inertial spin after release, with caller-tunable threshold + friction
|
||||
- Tab focus + arrow-key navigation (±0.1 rad per arrow press)
|
||||
- `role="slider"` with descriptive `aria-valuetext`
|
||||
- `data-spinning` attribute for E2E and CSS visual feedback
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { JogWheel } from "@suaveplan/react-control-surface";
|
||||
|
||||
function Scrubber() {
|
||||
const [pos, setPos] = useState(0);
|
||||
return (
|
||||
<JogWheel onChange={(d) => setPos((p) => p + d)} label="Scrub" />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { JogWheel } from "@suaveplan/react-control-surface";
|
||||
|
||||
function PitchBend({ engine }: { engine: { bend: (cents: number) => void } }) {
|
||||
return (
|
||||
<JogWheel
|
||||
onChange={(delta) => engine.bend(delta * 60)}
|
||||
radiansPerPixel={0.04}
|
||||
inertiaThreshold={2}
|
||||
inertiaFriction={3.5}
|
||||
size={180}
|
||||
label="Pitch bend"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------------- | ----------------------------- | ------- | -------------------------------------------------------- |
|
||||
| `onChange` | `(deltaRadians: number) => void` | req | Called per drag step + inertia tick. |
|
||||
| `radiansPerPixel` | `number` | `0.02` | Sensitivity (≈1.15 deg / pixel by default). |
|
||||
| `inertiaThreshold` | `number` | `4` | Min release velocity (rad/s) to start spinning. |
|
||||
| `inertiaFriction` | `number` | `2.5` | Exponential decay coefficient (per second). |
|
||||
| `size` | `number` | `120` | Render size in CSS pixels (the wheel is square). |
|
||||
| `disabled` | `boolean` | `false` | When `true`, ignores all input. |
|
||||
| `label` | `string` | — | Visible + accessible label. |
|
||||
| `ariaLabel` | `string` | derived | Override the accessible label. |
|
||||
| `className` | `string` | — | Appended to `rcs-jog-wheel`. |
|
||||
|
||||
The exported `decayVelocity(velocity, friction, dtMs)` helper is the
|
||||
pure exponential-decay primitive used internally.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tangential pixel motion is computed as `(rx * dy - ry * dx) /
|
||||
radius`, i.e. the 2D cross product of the radius vector and the
|
||||
motion vector divided by the radius magnitude. This produces a signed
|
||||
scalar that is positive for clockwise motion (in standard
|
||||
screen-coordinates where Y points down) and negative for
|
||||
counter-clockwise. We guard against radius-zero starts by skipping
|
||||
motion samples that occur within 1 pixel of the centre.
|
||||
|
||||
Inertia is driven by a separate `useEffect` that runs only while
|
||||
`spinning` is `true`. The effect captures the current `inertiaFriction`
|
||||
and emits a delta per frame: `step = velocity * dt`. Once the absolute
|
||||
velocity falls below `0.05` rad/s, the effect clears its own `spinning`
|
||||
flag and unmounts the RAF loop.
|
||||
132
docs/ui/react/react-control-surface/src/controls/knob/knob.md
Normal file
132
docs/ui/react/react-control-surface/src/controls/knob/knob.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# `<Knob>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Rotary knob control — the cornerstone interaction primitive for the
|
||||
2D control surface. Renders an SVG circle with a rotating indicator
|
||||
line, supports vertical-drag-to-change with optional fine-mode (Shift),
|
||||
full keyboard operation (arrows, Home/End, PageUp/PageDown), and
|
||||
double-click-to-reset to a configured default value. The component
|
||||
is purely value-in / value-out (`BoundControlProps`) and emits its
|
||||
quantized value via `onChange`; consumers wire that signal to plugin
|
||||
parameters, MIDI CCs, transport state, etc.
|
||||
|
||||
## Features
|
||||
|
||||
- Vertical-drag input with pointer capture (no lost events on fast drags).
|
||||
- Keyboard accessibility: `ArrowUp/Down/Left/Right`, `Home`, `End`,
|
||||
`PageUp/PageDown`, plus Shift fine-mode.
|
||||
- Double-click reset to `default` when provided.
|
||||
- Range mapping via `curve`: `linear`, `log`, `exp`, or a piecewise
|
||||
`custom` curve with caller-supplied breakpoints.
|
||||
- `aria-slider` semantics with `aria-valuemin/max/now/text`,
|
||||
`aria-orientation="vertical"`, `aria-disabled`.
|
||||
- CSS-variable theming (`--cs-knob-body-color`,
|
||||
`--cs-knob-track-color`, `--cs-knob-indicator-color`).
|
||||
- Pointer capture cleanup on `pointerup` / `pointercancel`.
|
||||
- Step quantization respects `step` (default `0.01`).
|
||||
- Per-control configurable `dragRangePx` (default 200 px → full range).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { Knob } from "@suaveplan/react-control-surface";
|
||||
|
||||
export function VolumeKnob() {
|
||||
const [v, setV] = useState(0.5);
|
||||
return (
|
||||
<Knob
|
||||
value={v}
|
||||
onChange={setV}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
default={0.5}
|
||||
label="Volume"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Logarithmic frequency knob with custom drag travel + fine mode:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { Knob } from "@suaveplan/react-control-surface";
|
||||
|
||||
export function CutoffKnob() {
|
||||
const [freq, setFreq] = useState(440);
|
||||
return (
|
||||
<Knob
|
||||
value={freq}
|
||||
onChange={setFreq}
|
||||
min={20}
|
||||
max={20000}
|
||||
step={1}
|
||||
curve="log"
|
||||
dragRangePx={300}
|
||||
fineModeFactor={0.05}
|
||||
sizePx={96}
|
||||
label="Cutoff"
|
||||
ariaLabel="Filter cutoff frequency (Hz)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Custom piecewise mapping (compress lower 50%):
|
||||
|
||||
```tsx
|
||||
<Knob
|
||||
value={x}
|
||||
onChange={setX}
|
||||
min={0}
|
||||
max={1}
|
||||
curve={{
|
||||
kind: "custom",
|
||||
points: [
|
||||
{ at: 0, value: 0 },
|
||||
{ at: 0.5, value: 0.1 },
|
||||
{ at: 1, value: 1 },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ---------------- | ------------------------------------- | ---------- | ---------------------------------- |
|
||||
| `value` | `number` | required | Current value (within `min..max`). |
|
||||
| `onChange` | `(v: number) => void` | required | Fired on every quantized change. |
|
||||
| `min` | `number` | required | |
|
||||
| `max` | `number` | required | |
|
||||
| `step` | `number` | `0.01` | Quantization step. |
|
||||
| `default` | `number` | — | Double-click target. |
|
||||
| `disabled` | `boolean` | `false` | Suppresses all input. |
|
||||
| `label` | `string` | — | aria-label fallback. |
|
||||
| `ariaLabel` | `string` | — | Overrides `label` for a11y only. |
|
||||
| `curve` | `"linear" \| "log" \| "exp" \| {...}` | `"linear"` | Range mapping. |
|
||||
| `dragRangePx` | `number` | `200` | Pixels for full 0..1 sweep. |
|
||||
| `fineModeFactor` | `number` | `0.1` | Shift-held multiplier. |
|
||||
| `sizePx` | `number` | `48` | Rendered SVG diameter. |
|
||||
| `className` | `string` | — | Forwarded to root SVG. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The drag is anchored at `pointerdown` (clientY + value snapshot); each
|
||||
`pointermove` re-derives the candidate value from total delta-Y rather
|
||||
than incrementally accumulating, which keeps the curve mapping stable
|
||||
even under jitter. The component never queries layout — `dragRangePx`
|
||||
defines the response curve and is independent of the rendered `sizePx`.
|
||||
Keyboard step is `max(step, span / 100)` so small `step` values still
|
||||
produce a perceptible per-key delta when the range is large. The custom
|
||||
curve uses piecewise-linear interpolation, with a degenerate-points
|
||||
fallback (empty array ⇒ identity) for safety.
|
||||
64
docs/ui/react/react-control-surface/src/controls/led/led.md
Normal file
64
docs/ui/react/react-control-surface/src/controls/led/led.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# `<LED>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<LED>` is the output indicator control. It accepts a numeric value in
|
||||
the standard `BoundControlProps` domain and renders it as a coloured dot
|
||||
whose brightness is driven either by a boolean threshold (`mode="boolean"`)
|
||||
or a continuous mapping (`mode="continuous"`). The component is read-only:
|
||||
it does not invoke `onChange` because indicators are downstream consumers
|
||||
of bindings, not upstream sources.
|
||||
|
||||
The visual rendering pulls from the skin's CSS custom properties
|
||||
(`--cs-led-on-color`, `--cs-led-off-color`) and uses `color-mix()` to
|
||||
interpolate between them in continuous mode. This keeps brightness purely
|
||||
a CSS computation — no per-frame inline-style churn — so the LED can be
|
||||
driven from an animation frame loop without re-rendering React.
|
||||
|
||||
## Features
|
||||
|
||||
- Boolean and continuous brightness modes.
|
||||
- Skin-driven colour via CSS custom properties.
|
||||
- Accessible `role="status"` with `aria-live="polite"`.
|
||||
- Default aria-label includes on/off state and percentage.
|
||||
- Pure `ledBrightness` helper for unit testing the value clamp.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<LED value={1} min={0} max={1} onChange={() => undefined} label="Power" />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<LED
|
||||
value={signalLevel}
|
||||
min={0}
|
||||
max={1}
|
||||
onChange={() => undefined}
|
||||
mode="continuous"
|
||||
size={24}
|
||||
ariaLabel="Signal-present indicator"
|
||||
className="signal-led"
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<LED>` props: `BoundControlProps & { mode?: "boolean" | "continuous", size?, className?, style? }`.
|
||||
- `ledBrightness(value, min, max, mode)` — pure helper returning the
|
||||
normalised brightness in `[0, 1]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`color-mix(in srgb, ...)` is widely supported in modern browsers but
|
||||
absent from Safari Technology Preview prior to v16; consumers targeting
|
||||
legacy engines should override the inline `background` via a skin or
|
||||
custom `style`. The component intentionally does NOT animate brightness
|
||||
transitions — animation is left to the consumer's stylesheet because
|
||||
audio-meter LEDs typically need ballistic curves driven outside React.
|
||||
@@ -0,0 +1,96 @@
|
||||
# `<MeterPeak>` — peak meter with hold + decay
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<MeterPeak>` is a digital-style peak meter that shows the instantaneous
|
||||
input level alongside a held-then-decaying peak marker. After every new
|
||||
sample exceeds the held maximum, the marker freezes for `holdMs` and
|
||||
then decays linearly at `decayPerSec` level-units per second. The
|
||||
default 1000 ms hold + 1.4-per-second decay matches the legibility
|
||||
profile common in commercial DAWs.
|
||||
|
||||
This component intentionally does not include ballistics — for analogue
|
||||
VU response use `<MeterVU>`. `<MeterPeak>` is the right choice for
|
||||
clip detection, true-peak indication, and any time the user must see
|
||||
the actual recent maximum rather than a perceptual average.
|
||||
|
||||
## Features
|
||||
|
||||
- Independent `value` (filled bar) and `peak` (hold marker) channels
|
||||
- Configurable hold window (`holdMs`) and decay rate (`decayPerSec`)
|
||||
- Optional `peak` prop — when omitted, derived from `value`
|
||||
- `role="meter"` with `aria-valuetext` describing both level and peak
|
||||
- CSS-variable themed fill and hold marker
|
||||
- `data-level` / `data-peak` deterministic E2E hooks
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { MeterPeak } from "@suaveplan/react-control-surface";
|
||||
|
||||
<MeterPeak value={0.4} peak={0.7} label="L" />;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { MeterPeak } from "@suaveplan/react-control-surface";
|
||||
|
||||
function ClipDetector({ stream }: { stream: AsyncIterable<number> }) {
|
||||
const [v, setV] = useState(0);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
for await (const s of stream) {
|
||||
if (cancelled) return;
|
||||
setV(s);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [stream]);
|
||||
return (
|
||||
<MeterPeak
|
||||
value={v}
|
||||
holdMs={2000}
|
||||
decayPerSec={0.6}
|
||||
label="Clip"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------- | -------- | ------- | ---------------------------------------------------- |
|
||||
| `value` | `number` | required| Instantaneous level in `[0, 1]`. |
|
||||
| `peak` | `number` | derived | External peak source. Falls back to `value`. |
|
||||
| `holdMs` | `number` | `1000` | Hold time after each new maximum. |
|
||||
| `decayPerSec` | `number` | `1.4` | Linear decay rate once the hold expires. |
|
||||
| `width` | `number` | `160` | Root width in CSS pixels. |
|
||||
| `height` | `number` | `24` | Root height in CSS pixels. |
|
||||
| `label` | `string` | — | Visible + accessible label. |
|
||||
| `ariaLabel` | `string` | derived | Override the accessible label. |
|
||||
| `className` | `string` | — | Appended to `rcs-meter-peak`. |
|
||||
|
||||
The exported `stepPeakHold(held, heldUntil, incoming, now, holdMs,
|
||||
decayPerSec, dtMs)` helper is the pure-function hold envelope used
|
||||
internally. It is exported for re-use and is unit-tested independently.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
State is held in a `useRef` (`{ held, heldUntil, last }`) so smoothing
|
||||
does not require re-renders on every RAF tick — only the display value
|
||||
updates via `setState`. Hold deadlines are stored as absolute timestamps
|
||||
(`Date.now() + holdMs`) which makes the math identical between live
|
||||
runs and the synchronous unit tests of `stepPeakHold`.
|
||||
|
||||
The hold marker is rendered as a 2-pixel-wide absolutely-positioned div
|
||||
that tracks the held value's percentage position; the fill bar tracks
|
||||
the instantaneous value. Both are themable via CSS variables.
|
||||
@@ -0,0 +1,94 @@
|
||||
# `<MeterSpectral>` — multi-band spectrum visualiser
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<MeterSpectral>` renders a real-time bar graph of magnitude bins. Each
|
||||
bin is drawn as a vertical bar whose height is proportional to the
|
||||
bin's magnitude in `[0, 1]`. The component is deliberately tier-pure:
|
||||
it has no dependency on `@suaveplan/audio-analysis-spectrum` or any
|
||||
audio code at all. Bins are pre-computed by the caller — wire it up to
|
||||
whatever analyser is appropriate (web-audio `AnalyserNode`, the
|
||||
spectrum-analysis package, a network stream, or a synthetic generator).
|
||||
|
||||
This separation keeps the React rendering layer free of audio
|
||||
processing and makes it trivial to use the same component for any
|
||||
1-D magnitude signal — FFT magnitudes, wavelet bands, frequency-warped
|
||||
mel bins, or live envelope follower output.
|
||||
|
||||
## Features
|
||||
|
||||
- Static `Float32Array` or streaming `AsyncIterable<Float32Array>` source
|
||||
- Configurable `width`, `height`, and inter-bar `gap`
|
||||
- Per-bin `data-value` attributes for E2E and screen-reader inspection
|
||||
- `role="img"` with a single `aria-label` describing the visualisation
|
||||
- CSS-variable themed bars (`--cs-meter-spectral-bar-color`)
|
||||
- Out-of-range bin values are clamped to `[0, 1]` automatically
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { MeterSpectral } from "@suaveplan/react-control-surface";
|
||||
|
||||
<MeterSpectral
|
||||
bins={new Float32Array([0.1, 0.5, 0.9, 0.3])}
|
||||
label="Output spectrum"
|
||||
/>;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { MeterSpectral } from "@suaveplan/react-control-surface";
|
||||
|
||||
async function* spectrumStream(
|
||||
analyser: AnalyserNode
|
||||
): AsyncIterable<Float32Array> {
|
||||
const buf = new Float32Array(analyser.frequencyBinCount);
|
||||
while (true) {
|
||||
analyser.getFloatFrequencyData(buf);
|
||||
const out = new Float32Array(buf.length);
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
out[i] = Math.max(0, Math.min(1, (buf[i] + 100) / 100));
|
||||
}
|
||||
yield out;
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||
}
|
||||
}
|
||||
|
||||
<MeterSpectral
|
||||
bins={spectrumStream(analyserNode)}
|
||||
width={480}
|
||||
height={160}
|
||||
label="Master spectrum"
|
||||
/>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ------------------------------------------------- | ------- | ---------------------------------------- |
|
||||
| `bins` | `Float32Array \| AsyncIterable<Float32Array>` | req | Magnitude bins in `[0, 1]`. |
|
||||
| `width` | `number` | `240` | Total width in CSS pixels. |
|
||||
| `height` | `number` | `80` | Total height in CSS pixels. |
|
||||
| `gap` | `number` | `1` | Inter-bar gap in CSS pixels. |
|
||||
| `label` | `string` | — | Visible + accessible label. |
|
||||
| `ariaLabel` | `string` | derived | Override the accessible label. |
|
||||
| `className` | `string` | — | Appended to `rcs-meter-spectral`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
When `bins` is a `Float32Array`, the component sets its frame state
|
||||
directly on mount and whenever the prop identity changes. When `bins`
|
||||
is an `AsyncIterable`, an effect subscribes via `for await … of` and
|
||||
calls `setFrame` for every yielded frame. The subscription is torn down
|
||||
via a `cancelled` flag in the cleanup function; errors thrown from the
|
||||
iterator are swallowed because visualisation is best-effort.
|
||||
|
||||
Bars are absolutely-positioned `div`s aligned to the bottom of the
|
||||
container; bar width is `(width - totalGap) / count`. This keeps the
|
||||
layout pure CSS and avoids `<canvas>` — every bar is independently
|
||||
inspectable in DOM and accessible tooling.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `<MeterVU>` — VU meter with IEC 60268-10 ballistics
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<MeterVU>` renders a single-channel level meter with the classic analogue
|
||||
VU response defined by IEC 60268-10: a 300 ms attack and 300 ms release
|
||||
single-pole low-pass smoothing the displayed needle relative to the
|
||||
instantaneous input level. The component is a pure value-in /
|
||||
DOM-out leaf — consumers feed a normalised level in `[0, 1]` and the
|
||||
meter handles ballistic smoothing on a `requestAnimationFrame` loop.
|
||||
|
||||
Use it for output-bus meters, mixer-strip meters, and any
|
||||
human-readable loudness indication where peak-precise behaviour is
|
||||
less important than the perception of "musical loudness".
|
||||
|
||||
## Features
|
||||
|
||||
- IEC 60268-10 default ballistics (300 ms attack, 300 ms release)
|
||||
- Caller-tunable `attackMs` and `releaseMs` for non-standard ballistics
|
||||
- Pure `BoundControlProps`-compatible value contract (value only)
|
||||
- `role="meter"` with `aria-valuemin` / `aria-valuemax` / `aria-valuenow`
|
||||
- CSS-variable themed fill (`--cs-meter-vu-fill-color`)
|
||||
- RAF-driven smoothing, automatic cleanup on unmount
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { MeterVU } from "@suaveplan/react-control-surface";
|
||||
|
||||
<MeterVU value={0.6} label="Master Out" />;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { MeterVU } from "@suaveplan/react-control-surface";
|
||||
|
||||
// Live-driven, fast attack / slow release for a punchy display
|
||||
function LiveMeter({ source }: { source: () => number }) {
|
||||
const [v, setV] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setV(source()), 16);
|
||||
return () => clearInterval(id);
|
||||
}, [source]);
|
||||
return <MeterVU value={v} attackMs={40} releaseMs={600} width={260} />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------------- | --------------------------------------------- |
|
||||
| `value` | `number` | required | Source level in `[0, 1]`, clamped on input. |
|
||||
| `attackMs` | `number` | `300` | Single-pole attack time-constant. |
|
||||
| `releaseMs` | `number` | `300` | Single-pole release time-constant. |
|
||||
| `width` | `number` | `160` | Root width in CSS pixels. |
|
||||
| `height` | `number` | `48` | Root height in CSS pixels. |
|
||||
| `label` | `string` | — | Visible + accessible label. |
|
||||
| `ariaLabel` | `string` | derived | Overrides the accessible label. |
|
||||
| `className` | `string` | — | Appended to `rcs-meter-vu`. |
|
||||
|
||||
The exported `stepBallistic(previous, target, dtMs, attackMs, releaseMs)`
|
||||
helper is the single-pole smoothing primitive used internally; it is
|
||||
exported for re-use in custom meters and is exhaustively unit-tested.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter holds a `useRef` containing `{ level, last }` so that the
|
||||
ballistic state persists across React renders without forcing a
|
||||
re-render on every smoothing tick. The animation loop is created once
|
||||
on mount and torn down via `cancelAnimationFrame` on unmount. Time
|
||||
deltas are measured with `Date.now()` rather than the RAF callback's
|
||||
high-resolution timestamp to keep the math identical to the
|
||||
`stepBallistic` unit tests, which run without `requestAnimationFrame`.
|
||||
|
||||
The fill bar uses a single absolutely-positioned div whose width is
|
||||
recomputed from the smoothed level on every frame. The `data-level`
|
||||
attribute is provided as a deterministic hook for E2E assertions.
|
||||
@@ -0,0 +1,128 @@
|
||||
# `<PianoKey>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A single piano key. Two visual kinds (`white` / `black`) and three
|
||||
visual states (`idle` / `pressed` / `highlighted`). Consumers compose
|
||||
many keys to build a full keyboard component — `<PianoKey>` itself
|
||||
deliberately renders a single rect so it stays compositional, theming
|
||||
flexible, and reusable in non-piano contexts (e.g. step sequencer cells
|
||||
themed as keys). The component implements `BoundControlProps` and
|
||||
emits `onChange(max)` on press, `onChange(min)` on release.
|
||||
|
||||
## Features
|
||||
|
||||
- Pointer and keyboard activation (Space, Enter).
|
||||
- Pointer-leave release (no stuck keys when you glide off the key).
|
||||
- Key-auto-repeat suppression via active-ref tracking.
|
||||
- Explicit `state` prop overrides derived state so callers can
|
||||
paint a key as `highlighted` (e.g. for chord-shape display) without
|
||||
changing `value`.
|
||||
- Default dimensions tuned per `kind` (white: 32×140, black: 20×90).
|
||||
- `role="button"` with `aria-pressed` reflecting the current state.
|
||||
- CSS-variable theming: `--cs-piano-white`, `--cs-piano-white-pressed`,
|
||||
`--cs-piano-white-highlight`, `--cs-piano-white-stroke`,
|
||||
`--cs-piano-black`, `--cs-piano-black-pressed`,
|
||||
`--cs-piano-black-highlight`, `--cs-piano-black-stroke`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { PianoKey } from "@suaveplan/react-control-surface";
|
||||
|
||||
export function MiddleC() {
|
||||
const [v, setV] = useState(0);
|
||||
return (
|
||||
<PianoKey
|
||||
value={v}
|
||||
onChange={setV}
|
||||
min={0}
|
||||
max={1}
|
||||
note={60}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose an octave with black keys overlaid on whites:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { PianoKey } from "@suaveplan/react-control-surface";
|
||||
|
||||
export function Octave() {
|
||||
const [pressed, setPressed] = useState<Set<number>>(new Set());
|
||||
const flip = (n: number, on: boolean) =>
|
||||
setPressed((p) => {
|
||||
const next = new Set(p);
|
||||
if (on) next.add(n);
|
||||
else next.delete(n);
|
||||
return next;
|
||||
});
|
||||
const whites = [0, 2, 4, 5, 7, 9, 11];
|
||||
const blacks = [{ off: 1, x: 22 }, { off: 3, x: 54 }, { off: 6, x: 118 }];
|
||||
return (
|
||||
<div style={{ position: "relative", height: 140, width: 224 }}>
|
||||
{whites.map((o, i) => (
|
||||
<div key={o} style={{ position: "absolute", left: i * 32 }}>
|
||||
<PianoKey
|
||||
value={pressed.has(60 + o) ? 1 : 0}
|
||||
onChange={(v) => flip(60 + o, v > 0)}
|
||||
min={0}
|
||||
max={1}
|
||||
note={60 + o}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{blacks.map(({ off, x }) => (
|
||||
<div key={off} style={{ position: "absolute", left: x }}>
|
||||
<PianoKey
|
||||
kind="black"
|
||||
value={pressed.has(60 + off) ? 1 : 0}
|
||||
onChange={(v) => flip(60 + off, v > 0)}
|
||||
min={0}
|
||||
max={1}
|
||||
note={60 + off}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default |
|
||||
| ----------- | ----------------------------- | -------------- |
|
||||
| `value` | `number` | required |
|
||||
| `onChange` | `(v: number) => void` | required |
|
||||
| `min`/`max` | `number` | required |
|
||||
| `disabled` | `boolean` | `false` |
|
||||
| `kind` | `"white" \| "black"` | `"white"` |
|
||||
| `state` | `"idle" \| "pressed" \| "highlighted"` | derived |
|
||||
| `note` | `number` | — |
|
||||
| `label` | `string` | — |
|
||||
| `ariaLabel` | `string` | — |
|
||||
| `widthPx` | `number` | per-kind |
|
||||
| `heightPx` | `number` | per-kind |
|
||||
| `className` | `string` | — |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The active state tracks an internal ref rather than React state so the
|
||||
press / release pair is event-loop-stable and immune to auto-repeat
|
||||
storms from held keys. Pointer capture is set on `pointerdown` and
|
||||
released on the matching `pointerup` / `pointercancel` / `pointerleave`,
|
||||
which gives correct release semantics even when the pointer drifts off
|
||||
the SVG (touch glissando). The explicit `state` prop fully overrides
|
||||
the derived state so the component can be driven from an external
|
||||
signal (incoming MIDI from another player, computer-aided
|
||||
visualisation) without coupling `value` to the visual state.
|
||||
@@ -0,0 +1,105 @@
|
||||
# `<SliderH>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Horizontal slider control — the X-axis counterpart of `<SliderV>`.
|
||||
Every behavioural decision is delegated to the shared
|
||||
`slider-helpers` module so the two orientations stay in lockstep:
|
||||
detent snapping, step quantization, keyboard delta sizing, fine-mode
|
||||
factor, and clamp logic are all identical to `<SliderV>`. The only
|
||||
differences are: arrow-key mapping (`ArrowLeft` / `ArrowRight` instead
|
||||
of `ArrowDown` / `ArrowUp`), pointer math reads `clientX` against the
|
||||
rect width, and the SVG rendering is horizontal.
|
||||
|
||||
## Features
|
||||
|
||||
- Pointer capture for drag past the SVG bounds.
|
||||
- Detent snapping (shared with `<SliderV>`).
|
||||
- Keyboard accessibility: arrow keys, Home, End, PageUp, PageDown,
|
||||
Shift fine-mode.
|
||||
- `aria-slider` with `aria-orientation="horizontal"`.
|
||||
- CSS-variable theming reuses the same `--cs-slider-*` tokens as
|
||||
`<SliderV>`.
|
||||
- Auto-rendered detent ticks on the track.
|
||||
- Step-quantized output via `step` (default `0.01`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { SliderH } from "@suaveplan/react-control-surface";
|
||||
|
||||
export function PanSlider() {
|
||||
const [v, setV] = useState(0);
|
||||
return (
|
||||
<SliderH
|
||||
value={v}
|
||||
onChange={setV}
|
||||
min={-1}
|
||||
max={1}
|
||||
step={0.01}
|
||||
label="Pan"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A blend slider with a centre detent, wider track, custom snap radius:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { SliderH } from "@suaveplan/react-control-surface";
|
||||
|
||||
export function DryWetBlend() {
|
||||
const [mix, setMix] = useState(0.5);
|
||||
return (
|
||||
<SliderH
|
||||
value={mix}
|
||||
onChange={setMix}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.001}
|
||||
label="Dry / Wet"
|
||||
detents={[{ at: 0.5, label: "50%" }]}
|
||||
snapThreshold={0.02}
|
||||
trackPx={280}
|
||||
thicknessPx={28}
|
||||
fineModeFactor={0.05}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default |
|
||||
| ---------------- | --------------------- | -------- |
|
||||
| `value` | `number` | required |
|
||||
| `onChange` | `(v: number) => void` | required |
|
||||
| `min` | `number` | required |
|
||||
| `max` | `number` | required |
|
||||
| `step` | `number` | `0.01` |
|
||||
| `disabled` | `boolean` | `false` |
|
||||
| `label` | `string` | — |
|
||||
| `ariaLabel` | `string` | — |
|
||||
| `detents` | `SliderDetent[]` | — |
|
||||
| `snapThreshold` | `number` | 2% span |
|
||||
| `trackPx` | `number` | `160` |
|
||||
| `thicknessPx` | `number` | `24` |
|
||||
| `fineModeFactor` | `number` | `0.1` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`<SliderH>` imports its helpers from `../slider-v/slider-helpers.js`
|
||||
rather than from the package root so the helper module remains
|
||||
package-private and never leaks into the public barrel. The pointer
|
||||
math reads the rendered rect width so a `width: 100%` host container
|
||||
behaves correctly — value tracks the actual on-screen geometry rather
|
||||
than the configured `trackPx`. When the rect collapses to width 0
|
||||
(offscreen / display:none), the math falls back to `trackPx`.
|
||||
@@ -0,0 +1,93 @@
|
||||
# slider-helpers (module-private)
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared pure functions for both `<SliderV>` and `<SliderH>` so that
|
||||
value resolution, detent snapping, and keyboard stepping are identical
|
||||
across the two orientations. The helpers live under `slider-v/` for a
|
||||
stable physical home, and `<SliderH>` reaches across with a single
|
||||
relative import. They are not re-exported from the package barrel —
|
||||
consumers of the package only see `<SliderV>` and `<SliderH>`.
|
||||
|
||||
## Features
|
||||
|
||||
- `clamp` / `quantize` — primitive numeric helpers.
|
||||
- `applyDetents` — within-threshold snap to the nearest detent `at`.
|
||||
- `resolveSliderValue` — full pipeline: clamp → step quantize →
|
||||
detent snap.
|
||||
- `fractionForValue` — normalize value to 0..1 for thumb-position math.
|
||||
- `keyboardStep` — returns the next value for arrow / Home / End /
|
||||
PageUp / PageDown keys, orientation-aware, Shift-fine-mode aware.
|
||||
Returns `null` for unrecognised keys.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
resolveSliderValue,
|
||||
fractionForValue,
|
||||
} from "./slider-helpers.js";
|
||||
|
||||
const next = resolveSliderValue({
|
||||
raw: 0.42,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
});
|
||||
const pct = fractionForValue(next, 0, 1); // 0.4
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Keyboard-driven step with custom fine factor and detents:
|
||||
|
||||
```ts
|
||||
import {
|
||||
keyboardStep,
|
||||
resolveSliderValue,
|
||||
} from "./slider-helpers.js";
|
||||
|
||||
const proposed = keyboardStep({
|
||||
key: event.key,
|
||||
value: current,
|
||||
min: -60,
|
||||
max: 12,
|
||||
step: 0.5,
|
||||
fineFactor: 0.1,
|
||||
shiftKey: event.shiftKey,
|
||||
orientation: "vertical",
|
||||
});
|
||||
if (proposed !== null) {
|
||||
const snapped = resolveSliderValue({
|
||||
raw: proposed,
|
||||
min: -60,
|
||||
max: 12,
|
||||
step: 0.5,
|
||||
detents: [{ at: 0, label: "0 dB" }],
|
||||
snapThreshold: 0.5,
|
||||
});
|
||||
onChange(snapped);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Function | Signature |
|
||||
| -------------------- | ------------------------------------------------------------------------------ |
|
||||
| `clamp` | `(value, min, max) => number` |
|
||||
| `quantize` | `(value, step, min) => number` |
|
||||
| `applyDetents` | `(value, detents, threshold) => number` |
|
||||
| `resolveSliderValue` | `({ raw, min, max, step, detents?, snapThreshold? }) => number` |
|
||||
| `fractionForValue` | `(value, min, max) => number` (always clamped to 0..1) |
|
||||
| `keyboardStep` | `(args) => number \| null`; returns `null` for unrecognised keys. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`applyDetents` greedily picks the closest detent within `threshold`
|
||||
distance; `threshold` defaults to 2% of the span when not supplied.
|
||||
`quantize` short-circuits when `step <= 0` so consumers can opt out
|
||||
of stepping with `step: 0`. `keyboardStep` derives a perceptible
|
||||
per-key delta of `max(step, span / 100)` so wide ranges still respond
|
||||
to a single arrow key even when `step` is tiny. The orientation flip
|
||||
between vertical / horizontal is encoded purely in the key mapping;
|
||||
all downstream math is direction-agnostic.
|
||||
@@ -0,0 +1,109 @@
|
||||
# `<SliderV>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Vertical slider control. Pointer interaction sets the value directly
|
||||
from `clientY` against the rendered track rect — drag is a single
|
||||
continuous gesture from `pointerdown` through `pointermove` until
|
||||
`pointerup` / `pointercancel`. Keyboard handling delegates to the
|
||||
shared `keyboardStep` helper so the up/down behaviour stays in lockstep
|
||||
with `<SliderH>` (which uses left/right). Detents snap nearby values
|
||||
to caller-provided breakpoints, useful for mix-engineering faders
|
||||
that want a hard 0 dB stop.
|
||||
|
||||
## Features
|
||||
|
||||
- Pointer capture for jitter-free drag past the SVG bounds.
|
||||
- Optional detent snapping with a per-control or default threshold.
|
||||
- Keyboard accessibility: arrow keys, Home, End, PageUp, PageDown,
|
||||
Shift fine-mode.
|
||||
- `aria-slider` with `aria-orientation="vertical"`.
|
||||
- CSS-variable theming (`--cs-slider-track-color`,
|
||||
`--cs-slider-thumb-color`, `--cs-slider-detent-color`).
|
||||
- Auto-rendered detent ticks on the track.
|
||||
- Step-quantized output via `step` (default `0.01`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { SliderV } from "@suaveplan/react-control-surface";
|
||||
|
||||
export function VolumeSlider() {
|
||||
const [v, setV] = useState(0.5);
|
||||
return (
|
||||
<SliderV
|
||||
value={v}
|
||||
onChange={setV}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
label="Volume"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Mixer-style fader in dB with detents at -∞ / -20 / 0:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { SliderV } from "@suaveplan/react-control-surface";
|
||||
|
||||
export function ChannelFader() {
|
||||
const [db, setDb] = useState(0);
|
||||
return (
|
||||
<SliderV
|
||||
value={db}
|
||||
onChange={setDb}
|
||||
min={-60}
|
||||
max={12}
|
||||
step={0.5}
|
||||
label="Channel 1"
|
||||
detents={[
|
||||
{ at: -60, label: "-∞" },
|
||||
{ at: -20, label: "-20" },
|
||||
{ at: 0, label: "0 dB" },
|
||||
]}
|
||||
snapThreshold={0.5}
|
||||
trackPx={240}
|
||||
thicknessPx={32}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default |
|
||||
| ---------------- | --------------------- | -------- |
|
||||
| `value` | `number` | required |
|
||||
| `onChange` | `(v: number) => void` | required |
|
||||
| `min` | `number` | required |
|
||||
| `max` | `number` | required |
|
||||
| `step` | `number` | `0.01` |
|
||||
| `disabled` | `boolean` | `false` |
|
||||
| `label` | `string` | — |
|
||||
| `ariaLabel` | `string` | — |
|
||||
| `detents` | `SliderDetent[]` | — |
|
||||
| `snapThreshold` | `number` | 2% span |
|
||||
| `trackPx` | `number` | `160` |
|
||||
| `thicknessPx` | `number` | `24` |
|
||||
| `fineModeFactor` | `number` | `0.1` |
|
||||
| `className` | `string` | — |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The slider derives value from the rendered SVG rect (not the bare
|
||||
`trackPx`) so it remains accurate when the host container resizes it
|
||||
via CSS. The internal helper module `slider-helpers.ts` is shared
|
||||
verbatim with `<SliderH>`; the orientation flip lives entirely inside
|
||||
`keyboardStep` and the visual rendering. When the rect height
|
||||
collapses to 0 (offscreen / display:none), the math falls back to
|
||||
`trackPx` so drag interactions continue to produce a sensible value.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `<Switch2>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<Switch2>` is the two-position toggle control. It implements the WAI-ARIA
|
||||
"switch" pattern (`role="switch"` + `aria-checked`) and accepts user input
|
||||
from pointer clicks, Space, and Enter. The numeric `value` is mapped into
|
||||
a boolean via a midpoint threshold; toggling emits the schema's `min`
|
||||
(off) or `max` (on) into `onChange`.
|
||||
|
||||
The component is purely controlled — it never holds local state for the
|
||||
checked value. Consumers wire the value back through their state store or
|
||||
the binding registry, which keeps the control flow predictable when the
|
||||
same switch is driven by both UI interaction and external MIDI input.
|
||||
|
||||
## Features
|
||||
|
||||
- WAI-ARIA "switch" role with `aria-checked` reflection.
|
||||
- Keyboard activation via Space and Enter.
|
||||
- Pointer activation via click.
|
||||
- Disabled state via `disabled` prop (also writes `aria-disabled`).
|
||||
- Telemetry: increments `control_surface_interactions_total{kind="switch-2"}`
|
||||
on every toggle.
|
||||
- Pure `isSwitch2Checked` helper for unit testing the threshold.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<Switch2
|
||||
value={muted ? 1 : 0}
|
||||
onChange={(v) => setMuted(v === 1)}
|
||||
min={0}
|
||||
max={1}
|
||||
label="Mute"
|
||||
/>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<Switch2
|
||||
value={state}
|
||||
onChange={(v) => registry.dispatch(binding, v)}
|
||||
min={0}
|
||||
max={1}
|
||||
label="Bypass"
|
||||
ariaLabel="Bypass effect — Cmd+B"
|
||||
disabled={readOnly}
|
||||
className="bypass-switch"
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<Switch2>` props: `BoundControlProps & { className?, style? }`.
|
||||
- `isSwitch2Checked(value, min, max)` — pure threshold helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component renders as a `<button type="button">` instead of an
|
||||
`<input type="checkbox">` so the WAI-ARIA "switch" semantics dominate the
|
||||
native checkbox semantics that some screen readers still announce
|
||||
inconsistently. The implementation explicitly handles Space + Enter
|
||||
because native buttons activate on Space but not Enter on some Linux
|
||||
distributions; the explicit handler unifies behaviour across platforms.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `<Switch3>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<Switch3>` is the three-position toggle, modelling the kind of
|
||||
"down-centre-up" or "low-mid-high" switch found on outboard audio gear.
|
||||
Click cycles forward (off -> mid -> on -> off), keyboard arrows step
|
||||
stepwise between adjacent positions, and Space / Enter cycle in the same
|
||||
direction as a click. The numeric value is mapped onto positions 0/1/2
|
||||
using a third-band threshold; the emitted `onChange` values are `min`,
|
||||
`(min+max)/2`, and `max`.
|
||||
|
||||
The component renders as a `<button role="slider">` with
|
||||
`aria-valuemin=0`, `aria-valuemax=2`, `aria-valuenow=position`, and
|
||||
`aria-valuetext` matching the human-readable position name. This keeps
|
||||
the switch operable from a screen reader while still allowing click-to-
|
||||
cycle interaction for sighted users.
|
||||
|
||||
## Features
|
||||
|
||||
- Click + Space/Enter cycle forward.
|
||||
- Arrow keys step (Right/Up forwards, Left/Down backwards), clamped at
|
||||
the ends.
|
||||
- Customisable position labels via `positionLabels`.
|
||||
- Telemetry: increments `control_surface_interactions_total{kind="switch-3"}`
|
||||
per change.
|
||||
- Pure helpers `switch3Position` and `switch3ValueForPosition`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<Switch3
|
||||
value={routing}
|
||||
onChange={setRouting}
|
||||
min={0}
|
||||
max={1}
|
||||
label="Routing"
|
||||
/>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<Switch3
|
||||
value={state}
|
||||
onChange={(v) => registry.dispatch(binding, v)}
|
||||
min={-1}
|
||||
max={1}
|
||||
label="Polarity"
|
||||
positionLabels={["Inverted", "Bypass", "Normal"]}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<Switch3>` props: `BoundControlProps & { className?, style?, positionLabels? }`.
|
||||
- `switch3Position(value, min, max)` — pure helper returning 0/1/2.
|
||||
- `switch3ValueForPosition(position, min, max)` — inverse helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component uses `role="slider"` rather than three native radio inputs
|
||||
because that better matches the visual model of a single-button cycling
|
||||
toggle while still meeting WCAG keyboard requirements. The third-band
|
||||
threshold for `switch3Position` uses inclusive `<=` at the lower edge and
|
||||
exclusive `<` at the upper edge to ensure that a value of exactly `min`
|
||||
maps to position 0 (off) and not to position 1.
|
||||
@@ -0,0 +1,62 @@
|
||||
# `<TextLabel>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<TextLabel>` is the decorative read-only control that renders a static
|
||||
text string. It is included in the `ControlKind` enumeration so a surface
|
||||
can lay out static legends and chrome alongside interactive controls
|
||||
without the runtime needing to special-case decoration. The component
|
||||
implements the full `BoundControlProps` shape so the runtime walker stamps
|
||||
it identically to every other control, but it never invokes `onChange`.
|
||||
|
||||
The text content is taken from the explicit `text` prop, falling back to
|
||||
the `label` prop if not provided, and finally to the empty string. The
|
||||
component renders as a `<span role="text">` with an accessible name
|
||||
mirroring the resolved label, so assistive technology announces the label
|
||||
as a separate landmark when navigating the surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Read-only `role="text"` element.
|
||||
- Text fallback chain: `text` -> `label` -> `""`.
|
||||
- `aria-label` override via the optional `ariaLabel` prop.
|
||||
- `aria-disabled` reflection of the `disabled` prop.
|
||||
- `data-kind="text-label"` attribute for runtime correlation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<TextLabel value={0} onChange={() => undefined} min={0} max={1} text="Master" />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<TextLabel
|
||||
value={0}
|
||||
onChange={() => undefined}
|
||||
min={0}
|
||||
max={1}
|
||||
label="Volume"
|
||||
ariaLabel="Output bus master fader label"
|
||||
className="bold"
|
||||
style={{ color: "var(--cs-control-fg)" }}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<TextLabel>` props: `BoundControlProps & { text?, className?, style? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`role="text"` is used (rather than no role at all) so labels participate
|
||||
in screen-reader landmark navigation. The component never sets focus or
|
||||
attaches keyboard listeners because the schema considers labels
|
||||
non-interactive. Skinning is achieved entirely through CSS variable
|
||||
resolution; the component itself only forwards `className` and inline
|
||||
`style` to the wrapper `<span>`.
|
||||
@@ -0,0 +1,104 @@
|
||||
# `<XYPad>` — 2-axis touch surface
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<XYPad>` is an accessible 2-axis touch surface that emits normalised
|
||||
`{ x, y }` coordinates as the user drags a pointer or steps with the
|
||||
arrow keys. The Y axis follows musical-software convention — the top
|
||||
of the pad is `y = 1` and the bottom is `y = 0`. Both axes are
|
||||
normalised to `[0, 1]` regardless of the pad's pixel size, so the
|
||||
caller can map them onto any parameter range with a simple scale.
|
||||
|
||||
Common applications include morphing two modulation sources, mapping
|
||||
filter cutoff against resonance, controlling pan + send, or any pair
|
||||
of correlated parameters that benefit from a single gestural surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Pointer-drag emission via Pointer Events with capture for off-pad drags
|
||||
- Keyboard navigation — Arrows step by `step`, PageUp/PageDown by `step * 4`,
|
||||
Home/End jump the X axis to the extremes
|
||||
- Full Tab focusability with `role="application"`
|
||||
- Clamping to `[0, 1]` on every emission, including out-of-bounds drags
|
||||
- `disabled` removes the element from the Tab order and silences events
|
||||
- CSS-variable themed background and cursor
|
||||
- `data-x` / `data-y` deterministic attributes for E2E inspection
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { XYPad } from "@suaveplan/react-control-surface";
|
||||
|
||||
function Pad() {
|
||||
const [v, setV] = useState({ x: 0.5, y: 0.5 });
|
||||
return <XYPad value={v} onChange={setV} label="Morph" />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { XYPad } from "@suaveplan/react-control-surface";
|
||||
|
||||
function FilterPad({
|
||||
onCutoff,
|
||||
onResonance,
|
||||
}: {
|
||||
onCutoff: (hz: number) => void;
|
||||
onResonance: (q: number) => void;
|
||||
}) {
|
||||
const [v, setV] = useState({ x: 0.5, y: 0.5 });
|
||||
return (
|
||||
<XYPad
|
||||
value={v}
|
||||
onChange={(next) => {
|
||||
setV(next);
|
||||
onCutoff(40 + next.x * 19960);
|
||||
onResonance(0.5 + next.y * 9.5);
|
||||
}}
|
||||
step={0.01}
|
||||
width={320}
|
||||
height={240}
|
||||
label="Filter morph"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ---------------------------------------- | ------- | ------------------------------------------ |
|
||||
| `value` | `{ x: number; y: number }` | req | Current normalised value, both in `[0, 1]`.|
|
||||
| `onChange` | `(next: XYValue) => void` | req | Called for every drag and keyboard step. |
|
||||
| `step` | `number` | `0.05` | Step size for arrow keys. |
|
||||
| `width` | `number` | `200` | Render width in CSS pixels. |
|
||||
| `height` | `number` | `200` | Render height in CSS pixels. |
|
||||
| `disabled` | `boolean` | `false` | When `true`, ignores all input. |
|
||||
| `label` | `string` | — | Visible + accessible label. |
|
||||
| `ariaLabel` | `string` | derived | Override the accessible label. |
|
||||
| `className` | `string` | — | Appended to `rcs-xy-pad`. |
|
||||
|
||||
The exported `pointerToValue(rect, clientX, clientY)` helper performs
|
||||
the pure rect-to-`[0, 1]` conversion (including Y-inversion and
|
||||
clamping) and is exported for re-use in custom pads.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Pointer events are used uniformly across mouse + touch + pen. On
|
||||
`pointerdown` the pad calls `setPointerCapture` so subsequent drags
|
||||
outside the bounding rect continue to fire `pointermove` events. The
|
||||
`draggingRef` flag avoids spurious moves before the user has pressed.
|
||||
|
||||
Keyboard handling is exhaustive: every recognised key calls
|
||||
`preventDefault` and emits a clamped value; unrecognised keys do
|
||||
nothing (the event bubbles for the consumer to handle). The
|
||||
`aria-valuetext` attribute is updated on every render with the
|
||||
current `x`/`y` truncated to two decimals, providing screen-reader
|
||||
support without a polling cycle.
|
||||
@@ -0,0 +1,90 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `error-codes` module owns the stable, namespaced registry of every error
|
||||
code that `@suaveplan/react-control-surface` is allowed to throw, plus the
|
||||
`ReactControlSurfaceError` subclass that wraps the foundation
|
||||
`SuaveplanError` base. Codes are produced through `defineCodes("REACT_CONTROL_SURFACE", ...)`
|
||||
so a single source of truth governs both the runtime string values and the
|
||||
TypeScript literal-union narrowing that downstream packages depend on.
|
||||
|
||||
This module never throws on its own — it is a passive registry. Throwing is
|
||||
the responsibility of higher layers (the binding registry for missing
|
||||
handlers, the runtime walker for unknown kinds, the validation layer for
|
||||
malformed schemas). Centralising metadata here keeps the documentation
|
||||
shipped next to the code that emits it and lets the test suite cross-check
|
||||
the codes set against the metadata table on every CI run.
|
||||
|
||||
## Features
|
||||
|
||||
- Three documented codes: `UNKNOWN_KIND`, `BINDING_HANDLER_MISSING`,
|
||||
`INVALID_SCHEMA`.
|
||||
- Frozen metadata table keyed by code with a human-readable description and
|
||||
severity (`"warning" | "error"`).
|
||||
- `ReactControlSurfaceError` subclass with category narrowing, severity
|
||||
reflected in the error's `name`, structured `context`, and a `cause`
|
||||
chain.
|
||||
- `registerReactControlSurfaceErrorCodes()` idempotent registration hook for
|
||||
parity with sibling packages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_CONTROL_SURFACE_CODES,
|
||||
ReactControlSurfaceError,
|
||||
} from "@suaveplan/react-control-surface/error-codes";
|
||||
|
||||
throw new ReactControlSurfaceError(
|
||||
REACT_CONTROL_SURFACE_CODES.UNKNOWN_KIND,
|
||||
"react-control-surface.unknown-kind",
|
||||
{ context: { kind: "not-a-thing" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_CONTROL_SURFACE_CODES,
|
||||
REACT_CONTROL_SURFACE_ERROR_METADATA,
|
||||
ReactControlSurfaceError,
|
||||
} from "@suaveplan/react-control-surface/error-codes";
|
||||
|
||||
function failBinding(kind: string, cause?: unknown): never {
|
||||
const code = REACT_CONTROL_SURFACE_CODES.BINDING_HANDLER_MISSING;
|
||||
const severity = REACT_CONTROL_SURFACE_ERROR_METADATA[code].severity;
|
||||
if (severity === "error") {
|
||||
throw new ReactControlSurfaceError(
|
||||
code,
|
||||
"react-control-surface.binding-handler-missing",
|
||||
{ context: { kind }, cause }
|
||||
);
|
||||
}
|
||||
return undefined as never;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_CONTROL_SURFACE_CODES` — frozen record `{ UNKNOWN_KIND, BINDING_HANDLER_MISSING, INVALID_SCHEMA }`.
|
||||
- `ReactControlSurfaceErrorCode` — literal union of the three code strings.
|
||||
- `REACT_CONTROL_SURFACE_ERROR_METADATA` — frozen `Record<code, { description, severity }>`.
|
||||
- `ReactControlSurfaceError` — `SuaveplanError` subclass with `category = "react-control-surface"`.
|
||||
- `registerReactControlSurfaceErrorCodes(): void` — idempotent no-op hook.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The metadata is created via `Object.freeze` at module load so consumers cannot
|
||||
mutate it at runtime. `defineCodes` returns a `Readonly<Record<key, string>>`
|
||||
whose value strings are the namespaced literal codes (e.g.
|
||||
`"REACT_CONTROL_SURFACE.UNKNOWN_KIND"`). Severity is encoded both in the
|
||||
metadata table and the constructed `Error#name`, which keeps log scrapers
|
||||
that filter by name in sync with the severity model. The class is final from
|
||||
the consumer's point of view — extending it further would create a parallel
|
||||
hierarchy and is explicitly out of scope.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `<Group>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<Group>` is the layout primitive used for internal nodes of a control
|
||||
surface tree. It accepts a layout descriptor (CSS grid or flexbox stack)
|
||||
and a list of children, applies the chosen layout via inline `style`, and
|
||||
exposes the region through `role="group"` with an `aria-label` for
|
||||
assistive-technology consumers.
|
||||
|
||||
The component is intentionally minimal: layout is presentation, the
|
||||
children are arbitrary React content, and the only behaviour it
|
||||
contributes is the `data-group-id` attribute used by integration tests and
|
||||
the runtime walker to associate a rendered region with its schema node.
|
||||
|
||||
## Features
|
||||
|
||||
- `grid` and `stack` layouts via a discriminated `GroupVisualLayout`.
|
||||
- Default `gap` of 8 pixels when not specified by the layout.
|
||||
- `role="group"` with `aria-label` derived from `label` or `id`.
|
||||
- `data-group-id` attribute mirroring the schema id.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<Group id="mixer" label="Mixer" layout={{ kind: "grid", columns: 4 }}>
|
||||
<Channel />
|
||||
<Channel />
|
||||
<Channel />
|
||||
<Channel />
|
||||
</Group>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<Group
|
||||
id="transport-row"
|
||||
layout={{ kind: "stack", direction: "row", gap: 16 }}
|
||||
className="transport"
|
||||
style={{ padding: 8, borderRadius: 8 }}
|
||||
>
|
||||
<PlayButton />
|
||||
<StopButton />
|
||||
<TempoKnob />
|
||||
</Group>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<Group>` props: `id`, `label?`, `layout: GroupVisualLayout`,
|
||||
`className?`, `style?`, `children?`.
|
||||
- `GroupVisualLayout` — discriminated union of `{ kind: "grid", columns, gap? }`
|
||||
and `{ kind: "stack", direction, gap? }`.
|
||||
- `groupLayoutStyle(layout)` — pure helper returning the inline `style`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component never reads from React context — it is a pure presentational
|
||||
primitive. Custom `style` is merged on top of the layout-derived style with
|
||||
`style` last so consumers can override layout-derived values (e.g. swap
|
||||
`display: grid` for `display: contents` in advanced compositions). The
|
||||
`groupLayoutStyle` helper is exported separately so unit tests and the
|
||||
runtime walker can compute styles without instantiating the component.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `<Panel>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<Panel>` is the labelled grouping primitive that wraps a group of controls
|
||||
in a titled box, optionally with collapse-toggle behaviour. Unlike
|
||||
`<Group>` (pure layout) and `<Rack>` (slot frame), `<Panel>` carries
|
||||
chrome — a header, an `aria-label`ed body region, and a stable
|
||||
`section` landmark for screen readers.
|
||||
|
||||
The collapse-toggle is implemented as a `<button>` with `aria-expanded`
|
||||
and `aria-controls` linked to a uniquely-id'd body region. When the panel
|
||||
collapses, the body is unmounted (not just hidden via CSS) so descendant
|
||||
focus is moved naturally and tab order shrinks to skip the collapsed
|
||||
content.
|
||||
|
||||
## Features
|
||||
|
||||
- Always-present labelled `<section>` landmark.
|
||||
- Optional collapse-toggle button driven by local React state.
|
||||
- `defaultCollapsed` initial state for SSR-friendly hydration.
|
||||
- Stable `aria-controls` linkage via `useId`.
|
||||
- `data-panel-id` attribute mirroring the schema id.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<Panel id="reverb" label="Reverb">
|
||||
<Knob label="Decay" />
|
||||
<Knob label="Mix" />
|
||||
</Panel>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<Panel
|
||||
id="compressor"
|
||||
label="Compressor"
|
||||
collapsible
|
||||
defaultCollapsed
|
||||
className="dark"
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
<Knob label="Threshold" />
|
||||
<Knob label="Ratio" />
|
||||
<Switch2 label="Auto-makeup" />
|
||||
</Panel>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<Panel>` props: `id`, `label?`, `collapsible?` (default `false`),
|
||||
`defaultCollapsed?` (default `false`), `className?`, `style?`,
|
||||
`children?`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The toggle button text uses ASCII `+` and `-` glyphs rather than Unicode
|
||||
chevrons so the rendered output stays consistent across font stacks (the
|
||||
icon glyphs are skin-driven in skinned deployments). The label is reused
|
||||
for both the section's `aria-label` and the inner region's `aria-label`
|
||||
because the section landmark name is what screen readers announce when
|
||||
the user navigates to the region, while the inner region's name is what
|
||||
the toggle's `aria-controls` resolves against.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `<Rack>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<Rack>` is the grouping primitive that models a hardware-rack frame — a
|
||||
horizontal (default) or vertical sequence of fixed-width slots. It is
|
||||
specifically designed for surfaces that mimic 19-inch rack-mount audio
|
||||
gear, where the visible unit count and slot positioning carry semantic
|
||||
weight that maps onto stage hardware. Each rack advertises its slot count
|
||||
through `aria-colcount` (horizontal) or `aria-rowcount` (vertical) so
|
||||
screen readers can announce the user's progress through the frame.
|
||||
|
||||
The component is presentational only. It does not enforce that children
|
||||
are uniformly sized or that the rack actually contains the advertised
|
||||
number of slots — those checks live at the schema validation layer in
|
||||
`@suaveplan/control-surface-schema`.
|
||||
|
||||
## Features
|
||||
|
||||
- Horizontal or vertical orientation.
|
||||
- Configurable unit count with sensible default (8).
|
||||
- ARIA grid metadata via `aria-colcount` / `aria-rowcount`.
|
||||
- `data-rack-id` attribute mirroring the schema id.
|
||||
- Default gap of 4 pixels between slots.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<Rack id="effects" label="Effects rack" units={4}>
|
||||
<Reverb />
|
||||
<Delay />
|
||||
<Chorus />
|
||||
<Compressor />
|
||||
</Rack>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<Rack
|
||||
id="patchbay"
|
||||
label="Patchbay"
|
||||
orientation="vertical"
|
||||
units={12}
|
||||
className="dark"
|
||||
style={{ padding: 8, background: "#0a0a0d" }}
|
||||
>
|
||||
{channels.map((c) => (
|
||||
<PatchRow key={c.id} {...c} />
|
||||
))}
|
||||
</Rack>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<Rack>` props: `id`, `label?`, `units?` (default 8), `orientation?`
|
||||
(default `"horizontal"`), `className?`, `style?`, `children?`.
|
||||
- `rackStyle(orientation, units)` — pure helper returning the inline
|
||||
`style`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The minimum size (`minWidth` or `minHeight`) is computed as `units * 24`
|
||||
to reserve space for the announced slot count even when the rack is
|
||||
empty. Consumers needing different slot pitches override the value via
|
||||
the merged `style` prop. The rack uses `role="group"`; the more specific
|
||||
`role="grid"` would imply selectable cells with row/column navigation,
|
||||
which is not how a hardware rack behaves.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
102
docs/ui/react/react-control-surface/src/runtime/surface.md
Normal file
102
docs/ui/react/react-control-surface/src/runtime/surface.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# `<Surface>` — schema-driven runtime walker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<Surface>` is the runtime that turns a validated `ControlSurface`
|
||||
document into a live React DOM tree. It performs a depth-first
|
||||
traversal of the schema, renders every `Control` node via the
|
||||
appropriate atomic component for its `kind`, and lays out every
|
||||
`Group` node via one of the three grouping primitives (`<Rack>` /
|
||||
`<Panel>` / `<Group>`) chosen by the group's `layout.kind`. Every
|
||||
control's `onChange` is automatically wired to
|
||||
`bindings.dispatch(control.binding, value)` so the consumer never
|
||||
plumbs handlers manually.
|
||||
|
||||
This is the entry point for every consumer of the package. The atomic
|
||||
components are exported separately for cases where the consumer wants
|
||||
to compose a static UI, but the typical pattern is to author the
|
||||
surface as JSON, validate it with `@suaveplan/control-surface-schema`,
|
||||
populate a `BindingRegistry`, and let `<Surface>` do the rest.
|
||||
|
||||
## Features
|
||||
|
||||
- DFS-walk via `walkSurface` from `@suaveplan/control-surface-schema`
|
||||
- 18 atomic controls dispatched by `kind`
|
||||
- Three grouping primitives chosen by `layout.kind` (`grid` → `<Rack>`,
|
||||
`stack` → `<Panel>`, `absolute` / `free3d` → `<Group>`)
|
||||
- `bindings.dispatch` invoked on every control change
|
||||
- Unbound controls render but emit nothing (the no-op handler is a
|
||||
closure, not a per-render allocation)
|
||||
- Unknown control kinds throw `REACT_CONTROL_SURFACE.UNKNOWN_KIND`
|
||||
- `buildSurfaceTree(schema, bindings)` exported for custom shells
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Surface,
|
||||
BindingRegistry,
|
||||
} from "@suaveplan/react-control-surface";
|
||||
import drumKitSurface from "./drum-kit.surface.json";
|
||||
|
||||
const registry = new BindingRegistry();
|
||||
registry.register("pluginParam", ({ pluginId, paramPath, value }) => {
|
||||
engine.setParameter(pluginId, paramPath, value);
|
||||
});
|
||||
|
||||
<Surface schema={drumKitSurface} bindings={registry} />;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import {
|
||||
buildSurfaceTree,
|
||||
BindingRegistry,
|
||||
} from "@suaveplan/react-control-surface";
|
||||
|
||||
function SkinnedSurface({ schema, registry, skinId }: Props) {
|
||||
const tree = useMemo(
|
||||
() => buildSurfaceTree(schema, registry),
|
||||
[schema, registry]
|
||||
);
|
||||
return (
|
||||
<SurfaceSkinProvider id={skinId}>
|
||||
<ErrorBoundary fallback={<FailedToRender />}>{tree}</ErrorBoundary>
|
||||
</SurfaceSkinProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ---------- | ----------------- | ---------------------------------------------------- |
|
||||
| `schema` | `ControlSurface` | Pre-validated surface document. |
|
||||
| `bindings` | `BindingRegistry` | Registry whose `dispatch` is wired to each control. |
|
||||
|
||||
The exported `buildSurfaceTree(schema, bindings)` returns the rendered
|
||||
`ReactNode` without the `<Surface>` memoisation wrapper — use it when
|
||||
you need to mount the tree under a custom provider tree without an
|
||||
extra render layer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The walker runs `walkSurface` once to collect every node in pre-order
|
||||
DFS, then iterates the collection in reverse to render leaves first
|
||||
and bubble each rendered element up into the parent group's `children`
|
||||
prop. This single-pass approach is `O(n)` and avoids recursive React
|
||||
re-renders during traversal — every component is constructed once and
|
||||
React reconciles them all in a single commit.
|
||||
|
||||
Each control's `onChange` is wrapped in a closure that calls
|
||||
`bindings.dispatch(binding, value)`. When `binding` is `undefined` we
|
||||
return a no-op closure so the control still receives a stable handler
|
||||
identity (avoiding spurious re-renders inside the atomic components).
|
||||
Unknown layout kinds and control kinds fall through to TypeScript's
|
||||
exhaustiveness guard and throw `REACT_CONTROL_SURFACE.UNKNOWN_KIND`
|
||||
with the offending value embedded in the message.
|
||||
@@ -0,0 +1,88 @@
|
||||
# `SurfaceSkinProvider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<SurfaceSkinProvider>` is the single React boundary that activates a skin for
|
||||
every atomic control inside its subtree. It writes the resolved palette of
|
||||
CSS custom properties (`--cs-*`) onto a wrapping `<div>`'s inline `style`
|
||||
attribute; every atomic control reads its colours through `var(--cs-…)`
|
||||
declarations, so changing skin is purely a CSS-variable swap with no
|
||||
React-side dispatch.
|
||||
|
||||
This is the only path through which a skin influences rendering. The schema
|
||||
spec is explicit on this point: there is no JS-level skin registry, no
|
||||
`React.useContext` consumed by controls, and no themed component variants.
|
||||
Keeping skin enforcement on the CSS side enables a single physical surface
|
||||
to host two side-by-side skinned subtrees without forking the React tree.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed `SurfaceSkinVarName` enumeration of canonical variables.
|
||||
- Frozen `DEFAULT_SURFACE_SKIN_VARS` palette.
|
||||
- Pure `buildSkinStyle(vars)` helper for unit testing.
|
||||
- Caller-supplied `className` appended to the default `rcs-skin` class.
|
||||
- `data-skin-id` attribute on the wrapper for integration-test selectors.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { SurfaceSkinProvider } from "@suaveplan/react-control-surface/skin";
|
||||
|
||||
export function App(): React.ReactNode {
|
||||
return (
|
||||
<SurfaceSkinProvider id="dark">
|
||||
<Surface />
|
||||
</SurfaceSkinProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { SurfaceSkinProvider } from "@suaveplan/react-control-surface/skin";
|
||||
|
||||
const orangeKnobOverride = {
|
||||
"--cs-knob-track-color": "#ff8800",
|
||||
"--cs-knob-indicator-color": "#ffe0b2",
|
||||
} as const;
|
||||
|
||||
export function NkiSurface(): React.ReactNode {
|
||||
return (
|
||||
<SurfaceSkinProvider
|
||||
id="native-instruments"
|
||||
vars={orangeKnobOverride}
|
||||
className="device-frame"
|
||||
>
|
||||
<Surface />
|
||||
</SurfaceSkinProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SurfaceSkinProvider(props): React.ReactNode`
|
||||
- `id: string` — written to `data-skin-id` on the wrapper.
|
||||
- `vars?: SurfaceSkinVars` — partial override map merged on top of the
|
||||
defaults; entries whose value is `undefined` keep the default.
|
||||
- `className?: string` — appended to the `"rcs-skin"` base class.
|
||||
- `children?: React.ReactNode`
|
||||
- `buildSkinStyle(vars?: SurfaceSkinVars): CSSProperties` — pure merge helper.
|
||||
- `DEFAULT_SURFACE_SKIN_VARS`, `SURFACE_SKIN_VAR_NAMES` — re-exported defaults
|
||||
and the canonical CSS variable name list.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The provider intentionally renders a `<div>` rather than a fragment because
|
||||
CSS variables only cascade through real DOM ancestors. Inline styles are
|
||||
used (instead of injecting a stylesheet) so server-side rendering remains
|
||||
deterministic and consumers see exactly the colours they configured without
|
||||
load-order pitfalls. Overrides are processed as a string-keyed loop so
|
||||
arbitrary `--cs-foo` properties — useful for one-off device-specific themes
|
||||
— pass straight through to the DOM without requiring an addition to the
|
||||
closed `SurfaceSkinVarName` union.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `telemetry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `telemetry` module is the single owner of every OpenTelemetry instrument
|
||||
emitted by `@suaveplan/react-control-surface`. Centralising instrument
|
||||
creation here keeps the meter name (`@suaveplan/react-control-surface`)
|
||||
consistent across both the foundation chunk and the runtime chunk that lands
|
||||
later, so dashboards built early never need to be re-keyed.
|
||||
|
||||
Two instruments are created at module load: the `control_surface_interactions_total`
|
||||
counter (one increment per user-driven `onChange`) and the
|
||||
`control_surface_render_duration_ms` histogram (one observation per
|
||||
`<Surface>` render commit). Both are wired into the global `metrics`
|
||||
provider; consumers attaching an in-memory provider for tests automatically
|
||||
see both instruments.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable meter name `@suaveplan/react-control-surface`.
|
||||
- Counter `control_surface_interactions_total` tagged `{kind}`.
|
||||
- Histogram `control_surface_render_duration_ms` in milliseconds.
|
||||
- Convenience helpers `recordInteraction(kind)` and `recordRenderDuration(ms)`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordInteraction } from "@suaveplan/react-control-surface/telemetry";
|
||||
|
||||
function onPress(): void {
|
||||
recordInteraction("button-momentary");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
controlSurfaceInteractionsCounter,
|
||||
recordInteraction,
|
||||
} from "@suaveplan/react-control-surface/telemetry";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
|
||||
// Drive a few interactions then export a snapshot in a test:
|
||||
recordInteraction("switch-2");
|
||||
recordInteraction("switch-2");
|
||||
void controlSurfaceInteractionsCounter.add(1, { kind: "led" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `controlSurfaceInteractionsCounter: Counter` — base instrument.
|
||||
- `controlSurfaceRenderDurationHistogram: Histogram` — base instrument.
|
||||
- `recordInteraction(kind: string): void` — increments the counter by one
|
||||
with the `kind` attribute.
|
||||
- `recordRenderDuration(durationMs: number): void` — records a single
|
||||
observation on the histogram.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Instruments are created exactly once per process via the module-static
|
||||
`METER` constant. The OTel SDK is intentionally not initialised by this
|
||||
module — application code (or tests) owns wiring `MeterProvider`. When no
|
||||
provider is set the OTel default no-op provider absorbs every call cheaply,
|
||||
so wrapping calls in `if (devMode)` is unnecessary. Histograms accept any
|
||||
finite number; callers are responsible for converting their measurement
|
||||
into milliseconds before invoking `recordRenderDuration`.
|
||||
Reference in New Issue
Block a user