Files
suaveplan/openspec/archive/2026-05-19-enterprise-react-audio-components/specs/react-audio/spec.md
2026-05-27 23:29:53 +01:00

8.0 KiB
Raw Blame History

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"