Initial Commit
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# OpenSpec Change: enterprise-react-audio-components
|
||||
|
||||
- **Change ID**: enterprise-react-audio-components
|
||||
- **Package**: `@suaveplan/react-audio`
|
||||
- **Category**: react
|
||||
- **Target path**: `packages/react/react-audio`
|
||||
- **Status**: PROPOSED — Phase 2 of 3
|
||||
- **Date**: 2026-05-19
|
||||
- **Spec delta**: `./specs/react-audio/spec.md`
|
||||
|
||||
> Phase 2 of the decomposed `enterprise-react-audio` rollout. Phase 1 (`enterprise-react-audio-foundation`) shipped at commit `c3460248`. Ships ONLY the components the spec hard-mandates; the composed-plugin UIs ship in a sibling change `enterprise-react-audio-components-extras`.
|
||||
|
||||
## Summary
|
||||
|
||||
Add the four spec-mandated components plus their primitives to `@suaveplan/react-audio`:
|
||||
|
||||
1. `<PluginSlot>` — generic renderer for any `AudioFxPlugin`, parameterised by `slotRenderers` for specialised overrides (spec req 1).
|
||||
2. `<PluginChainView>` — drag/drop + keyboard reorder + IO-validation toast (spec req 2).
|
||||
3. `<PluginParameterKnob>` + `<PluginParameterSlider>` — keyboard + ARIA per the accessibility requirement (spec req 3).
|
||||
4. `<PresetManager>` — `ChainSnapshot` save/restore via the provider's preset store (spec req 4).
|
||||
|
||||
Plus the cross-cutting infrastructure:
|
||||
- Storybook coverage CI gate enforcing default + interactive + edge-case stories per component (spec req 5).
|
||||
- `./components` + `./components/<name>` subpath exports (spec req 6).
|
||||
|
||||
These six requirements are the package's external contract for Phase 2. Every requirement has explicit `**Verified by:**` test names in the spec delta.
|
||||
|
||||
## Deferred to `enterprise-react-audio-components-extras`
|
||||
|
||||
The following components were listed in the original umbrella proposal but are NOT load-bearing for Phase 2's spec contract — they ship in a sibling change after Phase 2 archives:
|
||||
|
||||
- `<TransportControls>`, `<TrackList>` + `<TrackRow>`, `<MasterMeter>`
|
||||
- `<Mixer>` (over `@suaveplan/audio-fx-plugin-mixer`)
|
||||
- `<AudioVisualizer>` (waveform / spectrum / level modes)
|
||||
- `<AudioPlayer>` + `<BasicPlayer>` + `<PremiumPlayer>` (over `@suaveplan/audio-fx-plugin-audio-player`)
|
||||
- `<MicrophoneControls>` (over `@suaveplan/audio-fx-plugin-microphone`)
|
||||
- `<GLaDOSControls>` (over `@suaveplan/audio-fx-plugin-glados`)
|
||||
- `<PluginLoader>` — dynamic registration UI over `PluginRegistry.list()`
|
||||
|
||||
These are composed-plugin UIs whose value depends on the four core primitives shipping first. Splitting them out keeps Phase 2 to a scope a single agent dispatch has consistently shipped successfully across this migration (audio-controllers, audio-engine-browser, audio-fx-plugin-base — each in the 17–40 src-file range).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Provider/hook/store surface (Phase 1, shipped at `c3460248`).
|
||||
- `<MidiControllerSetup>` learn-mode panel + `useMidiController` hook (Phase 3, `enterprise-react-audio-midi`).
|
||||
- The deferred composed-plugin UIs above (`enterprise-react-audio-components-extras`).
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
- `@suaveplan/react-audio-fingerprint`, `@suaveplan/react-audio-player`, `@suaveplan/react-audio-stream`, `@suaveplan/r3f-audio` — all currently `–` in STATUS.md; each composes against the core primitives once Phase 2 lands; full migration also requires the extras + Phase 3.
|
||||
|
||||
After Phase 2 archives, STATUS row `@suaveplan/react-audio` description updates to "Phase 2 of 3 shipped: foundation + 4 spec-mandated components; remaining UIs in `enterprise-react-audio-components-extras`; MIDI Phase 3".
|
||||
@@ -0,0 +1,117 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: react-audio (enterprise-react-audio-components)
|
||||
|
||||
**Package**: `@suaveplan/react-audio`
|
||||
**Status**: PROPOSED — Phase 2 of 3 (components)
|
||||
**Blocked on**: `enterprise-react-audio-foundation` shipping first.
|
||||
|
||||
> Every requirement carries `**Verified by:**`.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Generic PluginSlot renders any AudioFxPlugin
|
||||
|
||||
The package SHALL export `<PluginSlot plugin />` that renders any `AudioFxPlugin` by:
|
||||
|
||||
1. reading `plugin.metadata` for the slot header (name + version + category badge),
|
||||
2. iterating `plugin.parameters` (declaration order) and rendering each via a renderer chosen by the parameter's `unit` / `scale` / `min`/`max` declaration (boolean → toggle, `unit: 'db'` → log slider, `unit: 'hz'` → log knob with octave snap, `unit: 'enum'` → segmented control, default → linear slider),
|
||||
3. rendering a bypass toggle wired to the chain-level bypass,
|
||||
4. rendering a preset selector populated from `plugin.getPreset()` snapshots stored via the provider's preset store,
|
||||
5. exposing an `advanced` toggle that reveals parameters declared with `category === 'advanced'`.
|
||||
|
||||
The provider's `slotRenderers` option SHALL accept `Record<string /* plugin.metadata.id */, ComponentType<PluginSlotRendererProps>>`; a registered renderer SHALL supersede the generic slot for that plugin id. Every rendered parameter control SHALL be keyboard-driven per the "Accessibility" requirement below.
|
||||
|
||||
#### Scenario: every parameter renders a control bound to its plugin
|
||||
|
||||
- **WHEN** `<PluginSlot plugin={eq}>` is rendered for an EQ with 5 parameters
|
||||
- **THEN** 5 control elements are rendered, each with `role` matching its parameter type and a label equal to the parameter's `name`
|
||||
|
||||
#### Scenario: registered specialised renderer supersedes the generic slot
|
||||
|
||||
- **WHEN** the provider's `slotRenderers` maps `eq` to `<EQPanel />` and `<PluginSlot plugin={eq}>` is rendered
|
||||
- **THEN** `<EQPanel />` renders in place of the generic slot
|
||||
|
||||
**Verified by:** `src/components/plugin-slot/plugin-slot.test.tsx::"renders one control per parameter with correct role and label"` AND `src/components/plugin-slot/plugin-slot.test.tsx::"registered specialised renderer supersedes the generic slot"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PluginChainView drag/drop with IO validation
|
||||
|
||||
`<PluginChainView chain />` SHALL render the chain's plugins as a list of `<PluginSlot>`s and SHALL support drag-to-reorder (HTML5 DnD with a keyboard alternative using `Space` to pick up / `Arrow` to move / `Enter` to drop) and drop-to-insert from a plugin picker. Insertions SHALL be validated against `chain.add()` and rejections SHALL surface in a non-blocking toast with the underlying `AudioFxError`'s `code`. A keyboard "Add FX" menu SHALL list `PluginRegistry.list()` filtered by IO compatibility with the chain's current effective output channel count.
|
||||
|
||||
#### Scenario: drop with incompatible IO surfaces the error
|
||||
|
||||
- **WHEN** a user drops a 5.1-channel plugin into a 2-channel chain
|
||||
- **THEN** insertion is rejected with `AudioFxError` whose `code === "AUDIO_FX.CHAIN_IO_INCOMPATIBLE"`, a toast appears with the error message, and the chain is unchanged
|
||||
|
||||
#### Scenario: keyboard reorder moves a slot
|
||||
|
||||
- **WHEN** a user focuses the third slot, presses `Space`, presses `ArrowUp`, presses `Enter`
|
||||
- **THEN** `chain.move(2, 1)` was called and the slot's new index is 1
|
||||
|
||||
**Verified by:** `src/components/plugin-chain-view/plugin-chain-view.test.tsx::"drop with incompatible IO surfaces AUDIO_FX.CHAIN_IO_INCOMPATIBLE"` AND `src/components/plugin-chain-view/plugin-chain-view.test.tsx::"keyboard reorder moves a slot"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Accessibility — keyboard and screen-reader
|
||||
|
||||
Every parameter control rendered by `<PluginSlot>` SHALL implement: `←`/`→` for fine step (1 % of declared range), `↑`/`↓` for coarse step (10 %), `PageUp`/`PageDown` for ×10 coarse, `Home`/`End` for `min`/`max`, `Enter` to type-edit a value, `Shift` multiplier ×10, `Alt` divider ÷10. Each SHALL carry `role="slider"`, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, and `aria-valuetext` formatted with the parameter's `unit` (e.g. `"+3.5 dB"`, `"880 Hz"`, `"50 %"`). Animations SHALL respect `prefers-reduced-motion`. Focus indicators SHALL pass WCAG AAA contrast.
|
||||
|
||||
#### Scenario: arrow keys adjust a knob by the declared step
|
||||
|
||||
- **WHEN** a user focuses a parameter knob whose range is `[-24, 24]` (dB) and presses `→`
|
||||
- **THEN** the parameter advances by 0.48 (1 % of 48); `aria-valuenow` and `aria-valuetext` reflect the new value
|
||||
|
||||
#### Scenario: aria-valuetext includes the parameter unit
|
||||
|
||||
- **WHEN** a user inspects the aria attributes of a frequency knob set to 880
|
||||
- **THEN** `aria-valuetext === "880 Hz"`
|
||||
|
||||
**Verified by:** `src/components/plugin-parameter-knob/plugin-parameter-knob.test.tsx::"arrow keys adjust the knob by the declared step"` AND `src/components/plugin-parameter-knob/plugin-parameter-knob.test.tsx::"aria-valuetext includes the parameter unit"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ChainSnapshot save / restore through PresetManager
|
||||
|
||||
`<PresetManager chain />` SHALL render save / load / delete controls for the chain. `Save` SHALL call `chain.snapshot()`, validate the result against the `ChainSnapshot` Zod schema (re-exported from `@suaveplan/audio-fx-plugin-base`), and persist it to the provider's preset store (default: `localStorage`-backed; consumers MAY inject a different store). `Load` SHALL call `chain.restore(snapshot, provider.registry)`. A failure SHALL surface as a toast carrying the underlying `AudioFxError`'s `code`. Cross-component subscriptions to the preset list SHALL re-render on save / delete via `useChainSnapshot(chain)`.
|
||||
|
||||
#### Scenario: save persists a snapshot that load round-trips
|
||||
|
||||
- **WHEN** a user clicks `Save`, then clears the chain by removing every plugin, then clicks the saved preset
|
||||
- **THEN** the chain is restored to the saved order with the saved parameters and bypass states
|
||||
|
||||
#### Scenario: load failure surfaces the error
|
||||
|
||||
- **WHEN** a saved snapshot references a plugin type no longer in the registry
|
||||
- **THEN** `restore` rejects with `AudioFxError` whose `code === "AUDIO_FX.CHAIN_SLOT_NOT_FOUND"` and a toast displays the missing pluginType
|
||||
|
||||
**Verified by:** `src/components/preset-manager/preset-manager.test.tsx::"save persists a snapshot that load round-trips"` AND `src/components/preset-manager/preset-manager.test.tsx::"load failure surfaces AUDIO_FX.CHAIN_SLOT_NOT_FOUND"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Storybook coverage gate
|
||||
|
||||
Every exported component (including the foundation's `<AudioProvider>` once Phase 2 lands) SHALL ship at least three Storybook stories: a default (representative props), an interactive (a control that demonstrates state transitions), and an edge-case (empty / loading / error / max-state). The CI gate SHALL refuse a change that introduces a component without all three story types.
|
||||
|
||||
#### Scenario: every component has at least three story types
|
||||
|
||||
- **WHEN** `tests/exports.test.ts::"every component has default, interactive, and edge-case Storybook stories"` runs
|
||||
- **THEN** for every exported component the corresponding `*.stories.tsx` file declares stories whose names match the regex `/^(Default|Interactive|Edge.*)$/`
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every component has default, interactive, and edge-case Storybook stories"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Component subpath exports
|
||||
|
||||
`package.json` SHALL declare `./components` (barrel of every component) AND `./components/<component-name>` for every exported component. Foundation subpath exports from Phase 1 SHALL remain. Tree-shaking SHALL keep `./hooks/use-transport` independent of any component bundle (verified via build artifact inspection).
|
||||
|
||||
#### Scenario: importing one component does not pull in other components
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/react-audio/components/transport-controls` only
|
||||
- **THEN** the tree-shaken bundle excludes the other components' modules
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every component has a subpath export entry"` AND `tests/bundle-size/component-isolation.size.test.ts::"importing one component does not pull in others"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,16 @@
|
||||
# Tasks: enterprise-react-audio-components
|
||||
|
||||
**Phase**: 2 of 3
|
||||
**Blocked on**: `enterprise-react-audio-foundation` shipping green.
|
||||
|
||||
Tasks pending detailed scoping. The 6 spec requirements at `./specs/react-audio/spec.md` drive the work. Re-scope `tasks.md` at the point Phase 1 archives and this phase enters execution; the foundation's hook surface + a11y helpers will be on disk by then, and the component file list can be enumerated against the actual main-branch component folder count.
|
||||
|
||||
Outline (to flesh out at execution time):
|
||||
|
||||
1. Author every component listed in the proposal's Summary, sibling `.md` + `.test.tsx` + `.stories.tsx`.
|
||||
2. Author parameter-knob + parameter-slider primitives meeting the accessibility requirement.
|
||||
3. Author `<PresetManager>` + chain-snapshot store wiring.
|
||||
4. Add `./components` + `./components/<name>` subpath exports to `package.json` (foundation entries stay).
|
||||
5. Add Storybook-coverage CI gate at `tests/exports.test.ts`.
|
||||
6. Add `tests/bundle-size/` suite proving component isolation.
|
||||
7. 5-gate triple + archive + commit. STATUS row `@suaveplan/react-audio` description updates to "Phase 2 of 3 shipped: hooks + provider + component library".
|
||||
Reference in New Issue
Block a user