Initial Commit
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# @suaveplan/react-control-surface — 2D atomic controls + runtime
|
||||
|
||||
- **Target path**: `packages/ui/react/react-control-surface/`
|
||||
- **Tier**: browser/react
|
||||
- **Date**: 2026-05-23
|
||||
- **Status**: PROPOSED
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/control-surface-schema` defines the JSON; `@suaveplan/audio-fx-plugin-base` defines the parameter machinery; `@suaveplan/react-audio` provides `useParameter` for chain-driven automation. What's missing is the actual atomic React components — `<Knob>`, `<Slider>`, `<Button>`, `<Meter>`, etc. — plus a runtime that walks a `ControlSurface` document and renders the live DOM tree, wiring every control's `onChange` to its bound consumer (plugin parameter, MIDI CC, transport state, DMX channel).
|
||||
|
||||
This is the 2D renderer. The 3D R3F sibling (`@suaveplan/r3f-control-surface`) reads the same schema; both stay in lock-step via the shared `BoundControlProps` contract.
|
||||
|
||||
## What changes
|
||||
|
||||
1. **NEW package** `@suaveplan/react-control-surface` at `packages/ui/react/react-control-surface/`.
|
||||
2. **`BoundControlProps`** — the shape every control component implements: `{ value: number; onChange: (v: number) => void; min: number; max: number; step?: number; default?: number; disabled?: boolean; label?: string; ariaLabel?: string; renderHint?: "auto" | "2d" }`. Pure value-in / value-out — no audio knowledge.
|
||||
3. **Atomic controls** (each is a feature directory with `.tsx` + `.types.ts` + `.test.tsx` + `.stories.tsx` + `.md`):
|
||||
- `<Knob>` — rotary, drag-vertical-to-change, double-click-resets, keyboard-arrow-step, fine-mode (Shift), aria-slider, range mapping via `curve` (linear/log/exp/custom)
|
||||
- `<SliderV>`, `<SliderH>` — vertical/horizontal sliders with detents
|
||||
- `<ButtonMomentary>`, `<ButtonLatching>` — press-and-hold / toggle behaviours
|
||||
- `<Switch2>`, `<Switch3>` — two- and three-position toggles
|
||||
- `<LED>` — output indicator (boolean or 0..1 brightness)
|
||||
- `<DigitalDisplay>` — 7-segment or LCD-style readout for numeric / text / scrolling
|
||||
- `<MeterVU>`, `<MeterPeak>` — single-channel level meters with optional peak-hold + ballistic curves
|
||||
- `<MeterSpectral>` — multi-band spectrum visualisation (consumes `@suaveplan/audio-analysis-spectrum` data)
|
||||
- `<XYPad>` — 2-axis touch surface
|
||||
- `<JogWheel>` — endless rotary with velocity
|
||||
- `<PianoKey>` — single piano key with state (idle/pressed/highlighted)
|
||||
- `<DrumPad>` — velocity-sensitive pad with optional aftertouch via pointer pressure
|
||||
- `<TextLabel>`, `<Image>` — static decorative elements
|
||||
4. **Grouping primitives** — `<Group>`, `<Rack>`, `<Panel>`. Provide layout (grid/absolute/stack) and aria-labelling.
|
||||
5. **Runtime** — `<Surface schema={ControlSurface} bindings={BindingRegistry} />`. Walks the schema, renders each node via the atomic component for its `kind`, and wires each control's `onChange` to its binding consumer via the registry.
|
||||
6. **`BindingRegistry`** — discriminated dispatcher consumers register handlers with:
|
||||
- `pluginParam` → calls `useParameter` from `@suaveplan/react-audio`
|
||||
- `transport` → calls transport hooks
|
||||
- `midiOut` → calls `useMidiOutput` from `@suaveplan/react-midi`
|
||||
- `midiIn` → subscribes via `useMidiInput`, drives the control's `value` back
|
||||
- `dmx` → calls into a `@suaveplan/react-dmx` hook (deferred — gated by feature flag)
|
||||
- `compound` → fans out to multiple handlers
|
||||
7. **Skin contract** — `<SurfaceSkinProvider id="native-instruments">` injects CSS variables consumed by every atomic control. Skin packages (`control-surface-skin-*`) supply the variables.
|
||||
8. **Accessibility** — every control is keyboard-operable, ARIA-labelled, supports `prefers-reduced-motion`, exposes `role="slider"` / `role="switch"` / etc. with proper `aria-valuenow` / `aria-valuetext`.
|
||||
9. **Error codes** — `REACT_CONTROL_SURFACE.{UNKNOWN_KIND, BINDING_HANDLER_MISSING, INVALID_SCHEMA}` via `defineCodes`.
|
||||
10. **OTel** — counter `control_surface_interactions_total{kind}`, histogram `control_surface_render_duration_ms`.
|
||||
|
||||
## Impact
|
||||
|
||||
- New browser-tier React package; depends on `@suaveplan/control-surface-schema@workspace:^`, `@suaveplan/react-audio@workspace:^`, `@suaveplan/react-midi@workspace:^`, React 19 peer.
|
||||
- Unblocks: `react-control-surface-editor`, `r3f-control-surface` (mirrors this surface 1:1 in R3F), every downstream DAW/instrument UI.
|
||||
- The 3D sibling reuses `BoundControlProps`, `BindingRegistry`, and the schema — only the rendering layer differs.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- 3D atomic controls — `r3f-control-surface` (Wave 2b).
|
||||
- Drag-and-drop layout editor — `react-control-surface-editor` (Wave 4).
|
||||
- Skin packages — separate per-device families.
|
||||
- Network replication — `network-control-surface` (Wave 3).
|
||||
@@ -0,0 +1,106 @@
|
||||
# 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`
|
||||
@@ -0,0 +1,71 @@
|
||||
# Tasks — enterprise-react-control-surface
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 Account for every `.claude/worktrees/*` entry; prune stale ones via `bun scripts/worktree-prune.ts --remove`.
|
||||
- [ ] 0.3 Confirm `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json | wc -l`.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Draft `proposal.md`, `tasks.md`, `specs/react-control-surface/spec.md`
|
||||
|
||||
## 2. Implementation — atomic controls (each: `.tsx`, `.types.ts`, `.test.tsx`, `.stories.tsx`, `.md`)
|
||||
|
||||
- [ ] 2.1 `src/controls/knob/`
|
||||
- [ ] 2.2 `src/controls/slider-v/` + `slider-h/`
|
||||
- [ ] 2.3 `src/controls/button-momentary/` + `button-latching/`
|
||||
- [ ] 2.4 `src/controls/switch-2/` + `switch-3/`
|
||||
- [ ] 2.5 `src/controls/led/`
|
||||
- [ ] 2.6 `src/controls/digital-display/`
|
||||
- [ ] 2.7 `src/controls/meter-vu/` + `meter-peak/`
|
||||
- [ ] 2.8 `src/controls/meter-spectral/`
|
||||
- [ ] 2.9 `src/controls/xy-pad/`
|
||||
- [ ] 2.10 `src/controls/jog-wheel/`
|
||||
- [ ] 2.11 `src/controls/piano-key/`
|
||||
- [ ] 2.12 `src/controls/drum-pad/`
|
||||
- [ ] 2.13 `src/controls/text-label/` + `image/`
|
||||
|
||||
## 3. Grouping + runtime
|
||||
|
||||
- [ ] 3.1 `src/grouping/group/`, `rack/`, `panel/`
|
||||
- [ ] 3.2 `src/runtime/surface.tsx` — `<Surface schema bindings />` walks + renders
|
||||
- [ ] 3.3 `src/runtime/binding-registry/binding-registry.ts` — discriminated dispatcher
|
||||
- [ ] 3.4 `src/runtime/binding-registry/handlers/{plugin-param, transport, midi-out, midi-in, dmx, compound}.ts`
|
||||
|
||||
## 4. Skin contract + accessibility
|
||||
|
||||
- [ ] 4.1 `src/skin/surface-skin-provider.tsx`
|
||||
- [ ] 4.2 `src/skin/skin.types.ts` + default CSS-var palette
|
||||
- [ ] 4.3 Accessibility audit per control: keyboard ops, ARIA, `prefers-reduced-motion`
|
||||
|
||||
## 5. Error codes + telemetry
|
||||
|
||||
- [ ] 5.1 `src/error-codes.ts` — `REACT_CONTROL_SURFACE.{UNKNOWN_KIND, BINDING_HANDLER_MISSING, INVALID_SCHEMA}` via `defineCodes`
|
||||
- [ ] 5.2 `src/telemetry/telemetry.ts` — counter `control_surface_interactions_total{kind}`, histogram `control_surface_render_duration_ms`
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- [ ] 6.1 Per-control `.test.tsx` covering pointer + keyboard + aria-attribute coverage
|
||||
- [ ] 6.2 `tests/runtime.test.tsx` — render a 4-pad drum surface from JSON, assert handler invocation on press
|
||||
- [ ] 6.3 `tests/binding-fanout.test.tsx` — `compound` binding fires every handler
|
||||
- [ ] 6.4 `tests/accessibility.test.tsx` — `@testing-library/jest-dom` aria + role assertions on every control
|
||||
- [ ] 6.5 Every `it(...)` carries `{ timeout: 5000 }` (30000 async)
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- [ ] 7.1 Sibling `.md` per `.tsx`/`.ts` (≥200 words, basic + advanced examples)
|
||||
- [ ] 7.2 README with `## Atomic controls` + `## Runtime + bindings` + 3 example surfaces (drum machine, mixer, piano)
|
||||
|
||||
## 8. Pre-archive verification
|
||||
|
||||
- [ ] 8.1 `bunx turbo lint typecheck test --filter=@suaveplan/react-control-surface` — green
|
||||
- [ ] 8.2 `bunx turbo test:coverage --filter=@suaveplan/react-control-surface` — 100/100/100/100
|
||||
- [ ] 8.3 `bunx turbo build --filter=@suaveplan/react-control-surface` — green
|
||||
|
||||
## 9. Archive
|
||||
|
||||
- [ ] 9.1 Fold spec delta → `openspec/specs/react-control-surface/spec.md`
|
||||
- [ ] 9.2 Move folder → `openspec/archive/<yyyy-mm-dd>-enterprise-react-control-surface/`
|
||||
- [ ] 9.3 Refresh `MANIFEST.md`
|
||||
- [ ] 9.4 Prune worktree
|
||||
Reference in New Issue
Block a user