8.0 KiB
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:
- reading
plugin.metadatafor the slot header (name + version + category badge), - iterating
plugin.parameters(declaration order) and rendering each via a renderer chosen by the parameter'sunit/scale/min/maxdeclaration (boolean → toggle,unit: 'db'→ log slider,unit: 'hz'→ log knob with octave snap,unit: 'enum'→ segmented control, default → linear slider), - rendering a bypass toggle wired to the chain-level bypass,
- rendering a preset selector populated from
plugin.getPreset()snapshots stored via the provider's preset store, - exposing an
advancedtoggle that reveals parameters declared withcategory === '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
rolematching its parameter type and a label equal to the parameter'sname
Scenario: registered specialised renderer supersedes the generic slot
- WHEN the provider's
slotRenderersmapseqto<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
AudioFxErrorwhosecode === "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, pressesArrowUp, pressesEnter - 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-valuenowandaria-valuetextreflect 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
restorerejects withAudioFxErrorwhosecode === "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.tsxfile 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-controlsonly - 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"