118 lines
8.0 KiB
Markdown
118 lines
8.0 KiB
Markdown
<!-- 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 -->
|