first commit

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

View File

@@ -0,0 +1,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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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>`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.

View File

@@ -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.

View File

@@ -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`.