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

118 lines
8.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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 -->