Files
2026-05-27 23:29:53 +01:00

107 lines
5.3 KiB
Markdown

# react-control-surface spec delta
## ADDED Requirements
### Requirement: BoundControlProps contract
Every atomic control component SHALL accept `BoundControlProps`: `{ value, onChange, min, max, step?, default?, disabled?, label?, ariaLabel?, renderHint? }`. No control reads from React context or runs side-effects beyond `onChange`. Pure value-in / value-out.
#### Scenario: Knob fires onChange on drag
- **WHEN** a `<Knob value={0.5} min={0} max={1} onChange={spy} />` receives a pointer-down + pointer-move 100 px upward
- **THEN** `spy` is called with a value > 0.5
#### Scenario: Slider fires onChange on keyboard arrow
- **WHEN** a `<SliderV value={0.5} step={0.1} onChange={spy} />` is focused and ArrowUp is pressed
- **THEN** `spy` is called with `0.6`
**Verified by:** `src/controls/knob/knob.test.tsx`, `src/controls/slider-v/slider-v.test.tsx`
### Requirement: 18 atomic controls
The package SHALL export 18 atomic controls covering every `ControlKind` in `@suaveplan/control-surface-schema`: `Knob`, `SliderV`, `SliderH`, `ButtonMomentary`, `ButtonLatching`, `Switch2`, `Switch3`, `LED`, `DigitalDisplay`, `MeterVU`, `MeterPeak`, `MeterSpectral`, `XYPad`, `JogWheel`, `PianoKey`, `DrumPad`, `TextLabel`, `Image`.
#### Scenario: Every kind is exported
- **WHEN** the package barrel is imported
- **THEN** every name in the closed `ControlKind` list resolves to a React component
**Verified by:** `tests/exports.test.tsx`
### Requirement: Surface runtime walks the schema
`<Surface schema={ControlSurface} bindings={BindingRegistry} />` SHALL DFS-walk the schema and render each node via the appropriate atomic component for its `kind`. Unknown kinds throw `ReactControlSurfaceError("REACT_CONTROL_SURFACE.UNKNOWN_KIND")`.
#### Scenario: 4-pad drum surface renders 4 buttons
- **WHEN** a surface with a 4-grid Group containing 4 `drum-pad` Controls is rendered
- **THEN** the DOM contains 4 `[role="button"]` elements with the proper labels
#### Scenario: Unknown kind throws
- **WHEN** a surface contains a Control with `kind: "not-a-thing"` (forced past schema validation in a test)
- **THEN** rendering throws `UNKNOWN_KIND`
**Verified by:** `tests/runtime.test.tsx`
### Requirement: BindingRegistry dispatch
`BindingRegistry` SHALL dispatch each binding kind (`pluginParam`, `transport`, `midiOut`, `midiIn`, `dmx`, `compound`) to its registered handler. Missing handlers throw `BINDING_HANDLER_MISSING`.
#### Scenario: pluginParam binding drives onChange to the param hook
- **WHEN** a knob with binding `{ kind: "pluginParam", pluginId: "p1", paramPath: "vol" }` is dragged and the `pluginParam` handler is a spy
- **THEN** the spy is called with `{ pluginId: "p1", paramPath: "vol", value }` matching the knob's emitted value
#### Scenario: compound binding fans out
- **WHEN** a knob has `{ kind: "compound", bindings: [pluginParam, midiOut] }`
- **THEN** every drag invokes both handlers, in registration order
**Verified by:** `tests/binding-fanout.test.tsx`
### Requirement: Accessibility — every control is operable without a mouse
Every control SHALL be focusable via Tab, operable via arrow keys, expose `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext` where applicable, respect `prefers-reduced-motion`, and have a non-empty `aria-label` derived from `label` or explicitly passed.
#### Scenario: Knob aria attributes
- **WHEN** a `<Knob value={0.5} min={0} max={1} label="Volume" />` is rendered
- **THEN** the rendered DOM has `role="slider"`, `aria-valuemin="0"`, `aria-valuemax="1"`, `aria-valuenow="0.5"`, `aria-label="Volume"`
**Verified by:** `tests/accessibility.test.tsx`
### Requirement: Skin contract via CSS variables
`<SurfaceSkinProvider id="skin-id" vars={overrides} />` SHALL inject `--cs-knob-track-color`, `--cs-knob-indicator-color`, `--cs-slider-thumb-color`, etc., consumable by every atomic control via standard CSS variable resolution. No JS-level skin dispatch.
#### Scenario: Skin overrides take effect
- **WHEN** a `<SurfaceSkinProvider id="nki" vars={{ "--cs-knob-track-color": "#ff8800" }}>` wraps a `<Knob>`
- **THEN** the knob's computed `--cs-knob-track-color` is `#ff8800`
**Verified by:** `src/skin/surface-skin-provider.test.tsx`
### Requirement: OTel instrumentation
Every control interaction SHALL increment `control_surface_interactions_total{kind}`. Every `<Surface>` render commit SHALL record `control_surface_render_duration_ms`.
#### Scenario: Counter increments on press
- **WHEN** a `<ButtonMomentary>` is pressed 5 times against an in-memory `MeterProvider`
- **THEN** the snapshot shows `control_surface_interactions_total{kind=button-momentary}=5`
**Verified by:** `src/telemetry/telemetry.test.ts`
### Requirement: MeterSpectral consumes analysis-spectrum output
`<MeterSpectral>` SHALL accept a `Float32Array` of magnitude bins (or an `AsyncIterable<Float32Array>` for streaming) and render the bins as a bar graph. The component does NOT import `@suaveplan/audio-analysis-spectrum` directly — it accepts pre-computed bins, keeping it tier-pure (no analysis dependency).
#### Scenario: Bin array renders bars
- **WHEN** `<MeterSpectral bins={new Float32Array([0.1, 0.5, 0.9, 0.3])} />` is rendered
- **THEN** the DOM contains 4 bar elements with heights proportional to the bins
**Verified by:** `src/controls/meter-spectral/meter-spectral.test.tsx`