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

5.3 KiB

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