Initial Commit
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# @suaveplan/control-surface-schema — universal control-surface JSON schema
|
||||
|
||||
- **Target path**: `packages/media/audio/control-surface-schema/`
|
||||
- **Tier**: universal (zero React/3D/audio coupling — pure data + Zod)
|
||||
- **Date**: 2026-05-23
|
||||
- **Status**: PROPOSED
|
||||
|
||||
## Why
|
||||
|
||||
Every downstream control-surface renderer — 2D React (`@suaveplan/react-control-surface`), 3D R3F (`@suaveplan/r3f-control-surface`), VR/XR runtimes, future native ports — needs to read the same surface definition: nested groups of atomic controls bound to plugin parameters / MIDI CCs / DMX channels, with persistable layout and skin information.
|
||||
|
||||
If each renderer owns its own format, they diverge. The same surface saved by a 2D editor can't be opened by the 3D editor. Cross-platform replication (the live-DJ-in-gaming-session scenario) becomes impossible.
|
||||
|
||||
This package owns the canonical schema. It is the source of truth for surface persistence, the validation layer at every save/load boundary, and the shared vocabulary every renderer + editor + replication layer agrees on.
|
||||
|
||||
## What changes
|
||||
|
||||
1. **NEW package** `@suaveplan/control-surface-schema` at `packages/media/audio/control-surface-schema/`.
|
||||
2. **`ControlSurface`** root document type:
|
||||
- `id: string` (uuid), `name`, `version` (semver of the schema), `createdAt`, `updatedAt`
|
||||
- `skin: { id: string; vars?: Record<string, string> }` — references a skin package + optional CSS-var overrides
|
||||
- `root: ControlNode` — nested group tree
|
||||
3. **`ControlNode`** discriminated union:
|
||||
- `Group` — `{ type: "group"; id; label?; layout: GroupLayout; transform: Transform; children: ControlNode[]; collapsible?: boolean }`
|
||||
- `Control` — `{ type: "control"; id; kind: ControlKind; label?; transform; appearance?; binding?; range?; behaviour? }`
|
||||
4. **`ControlKind`** enum — `knob`, `slider-v`, `slider-h`, `button-momentary`, `button-latching`, `switch-2`, `switch-3`, `led`, `digital-display`, `meter-vu`, `meter-peak`, `meter-spectral`, `xy-pad`, `jog-wheel`, `piano-key`, `drum-pad`, `text-label`, `image`.
|
||||
5. **`Transform`** — `{ position: { x; y; z? }; rotation?: { x?; y?; z? }; scale?: { x?; y?; z? } }`. `z` and rotation/scale fields are optional so 2D-only surfaces stay clean.
|
||||
6. **`GroupLayout`** discriminated union — `Grid` (`{ kind: "grid"; columns; rows; gap }`), `Absolute` (`{ kind: "absolute" }`), `Stack` (`{ kind: "stack"; direction: "row" | "column"; gap }`), `Free3D` (`{ kind: "free3d" }`).
|
||||
7. **`Binding`** discriminated union:
|
||||
- `PluginParameter` — `{ kind: "pluginParam"; pluginId; paramPath: string; curve?: "linear" | "log" | "exp" | { kind: "custom"; points: ReadonlyArray<{ at: number; value: number }> } }`
|
||||
- `TransportControl` — `{ kind: "transport"; field: "play" | "stop" | "tempo" | "position" | "loop" }`
|
||||
- `MidiOut` — `{ kind: "midiOut"; portId; channel; cc: number; range?: [number, number] }`
|
||||
- `MidiIn` — `{ kind: "midiIn"; portId; channel; cc: number; range?: [number, number] }` (control reflects external MIDI input)
|
||||
- `DmxChannel` — `{ kind: "dmx"; universe; channel; range?: [0, 255] }`
|
||||
- `Compound` — `{ kind: "compound"; bindings: ReadonlyArray<Binding> }` (one control drives many)
|
||||
8. **`Range`** — `{ min: number; max: number; step?: number; default?: number; units?: string }`.
|
||||
9. **`Behaviour`** — `{ momentaryDelayMs?; latchSticky?; doubleTapAction?: Action; longPressAction?: Action; …}`.
|
||||
10. **`Action`** discriminated union — `{ kind: "resetToDefault" } | { kind: "setTo"; value: number } | { kind: "toggleGroupCollapse"; targetId } | { kind: "emitEvent"; eventId; payload? }`.
|
||||
11. **`Appearance`** — `{ skinKey?: string; renderHint?: "auto" | "2d" | "3d"; cssVars?: Record<string, string>; tooltip?: string; icon?: { kind: "url" | "svg" | "skinAsset"; value: string } }`.
|
||||
12. **`validateSurface(unknown): { surface: ControlSurface } | { error: SchemaError }`** — Zod-driven safe parse, returns typed error union.
|
||||
13. **`upgradeSurface(unknown): ControlSurface`** — accepts schema version N-1 documents and migrates forward via registered upgraders.
|
||||
14. **`walkSurface(surface, visitor): void`** — DFS traversal helper that yields every node with its parent stack.
|
||||
15. **Error codes** — `CONTROL_SURFACE_SCHEMA.{INVALID_DOCUMENT, UNSUPPORTED_VERSION, UPGRADE_FAILED, DUPLICATE_ID, BINDING_REFERENCE_MISSING}` via `defineCodes`.
|
||||
16. **Schema versioning** — bump on every breaking change; `upgradeSurface` chains migrations.
|
||||
|
||||
## Impact
|
||||
|
||||
- New universal package. Zero runtime deps except `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`.
|
||||
- Unblocks renderers (`react-control-surface`, `r3f-control-surface`), editors, persistence layers, network replication.
|
||||
- Schema versioning means `0.1.0 → 0.2.0` requires registering an upgrader before consumers can read old documents.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Renderers — separate packages, no React/R3F imports here.
|
||||
- Skin packages — separate per-device families (`control-surface-skin-{native-instruments, mackie, piano, …}`).
|
||||
- Editor UI — separate (`react-control-surface-editor`).
|
||||
- Replication / persistence transports — separate (`network-control-surface`).
|
||||
@@ -0,0 +1,117 @@
|
||||
# control-surface-schema spec delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ControlSurface root document
|
||||
|
||||
`@suaveplan/control-surface-schema` SHALL export a `ControlSurface` type with: `id` (uuid v7), `name`, `version` (semver matching `CURRENT_SCHEMA_VERSION`), `createdAt`, `updatedAt`, `skin`, `root: ControlNode`. Every field is validated by the Zod schema.
|
||||
|
||||
#### Scenario: Empty surface validates
|
||||
|
||||
- **WHEN** `validateSurface({ id, name: "x", version: CURRENT_SCHEMA_VERSION, createdAt: 0, updatedAt: 0, skin: { id: "default" }, root: { type: "group", id: "root", layout: { kind: "absolute" }, transform: { position: { x:0, y:0 } }, children: [] } })` is called
|
||||
- **THEN** it returns `{ surface }` with no error
|
||||
|
||||
#### Scenario: Missing fields rejected
|
||||
|
||||
- **WHEN** `validateSurface({})` is called
|
||||
- **THEN** it returns `{ error }` with code `CONTROL_SURFACE_SCHEMA.INVALID_DOCUMENT` and Zod issues attached
|
||||
|
||||
**Verified by:** `src/validate/validate.test.ts`
|
||||
|
||||
### Requirement: ControlNode discriminated union
|
||||
|
||||
`ControlNode = Group | Control`. Validators distinguish by `type` field; cross-validation rejects controls inside controls or groups with mixed-type children that don't match the layout kind.
|
||||
|
||||
#### Scenario: Nested groups validate
|
||||
|
||||
- **WHEN** a surface contains a Group containing two Groups each containing three Controls
|
||||
- **THEN** validation succeeds and `walkSurface(surface, visitor)` visits 9 nodes in DFS order
|
||||
|
||||
**Verified by:** `src/walk/walk.test.ts`
|
||||
|
||||
### Requirement: 18 ControlKind variants
|
||||
|
||||
`ControlKind` SHALL be a closed union of 18 values: `knob`, `slider-v`, `slider-h`, `button-momentary`, `button-latching`, `switch-2`, `switch-3`, `led`, `digital-display`, `meter-vu`, `meter-peak`, `meter-spectral`, `xy-pad`, `jog-wheel`, `piano-key`, `drum-pad`, `text-label`, `image`. The validator rejects unknown kinds.
|
||||
|
||||
#### Scenario: Unknown kind rejected
|
||||
|
||||
- **WHEN** a Control has `kind: "not-a-thing"`
|
||||
- **THEN** `validateSurface` rejects with `INVALID_DOCUMENT`
|
||||
|
||||
**Verified by:** `src/validate/validate.test.ts::"unknown ControlKind"`
|
||||
|
||||
### Requirement: Six Binding variants
|
||||
|
||||
`Binding` SHALL cover `pluginParam`, `transport`, `midiOut`, `midiIn`, `dmx`, `compound`. The `compound` variant wraps an array of other bindings (no nested compounds — flat-only) so one knob can drive a plugin parameter, a MIDI CC, and a DMX channel simultaneously.
|
||||
|
||||
#### Scenario: pluginParam binding round-trips through validate + JSON
|
||||
|
||||
- **WHEN** a Control has `binding: { kind: "pluginParam", pluginId: "p1", paramPath: "channels/0/volume", curve: "log" }`
|
||||
- **THEN** `JSON.parse(JSON.stringify(validateSurface(surface).surface))` deep-equals the original
|
||||
|
||||
#### Scenario: Nested compound rejected
|
||||
|
||||
- **WHEN** a `compound` binding contains another `compound` in its `bindings` array
|
||||
- **THEN** validation rejects with `INVALID_DOCUMENT`
|
||||
|
||||
**Verified by:** `src/validate/validate.test.ts::"binding variants"`
|
||||
|
||||
### Requirement: GroupLayout — 4 variants
|
||||
|
||||
`GroupLayout` SHALL cover `grid`, `absolute`, `stack`, `free3d`. `free3d` is the only layout that admits z-axis transforms; the validator rejects nonzero `transform.position.z` under any other layout.
|
||||
|
||||
#### Scenario: 2D layout rejects z-transform
|
||||
|
||||
- **WHEN** a Group has `layout: { kind: "grid", columns: 4, rows: 4, gap: 8 }` and a child with `transform: { position: { x:0, y:0, z:1 } }`
|
||||
- **THEN** validation rejects with `INVALID_DOCUMENT`
|
||||
|
||||
**Verified by:** `src/validate/validate.test.ts::"layout z-axis discipline"`
|
||||
|
||||
### Requirement: Schema versioning + forward migration
|
||||
|
||||
`upgradeSurface(unknown)` SHALL accept any prior version registered in the migration chain and migrate forward to `CURRENT_SCHEMA_VERSION`. Unknown versions reject with `UNSUPPORTED_VERSION`.
|
||||
|
||||
#### Scenario: Upgrade 0.1.0 → 0.2.0 (illustrative)
|
||||
|
||||
- **WHEN** a v0.1.0 document is passed to `upgradeSurface` and a registered `0.1.0 → 0.2.0` migration exists
|
||||
- **THEN** the returned document validates against the current schema
|
||||
|
||||
#### Scenario: Unknown version rejects
|
||||
|
||||
- **WHEN** `upgradeSurface({ version: "99.0.0", ... })` is called
|
||||
- **THEN** it throws `SchemaError("CONTROL_SURFACE_SCHEMA.UNSUPPORTED_VERSION")`
|
||||
|
||||
**Verified by:** `src/upgrade/upgrade.test.ts`
|
||||
|
||||
### Requirement: Duplicate-id detection
|
||||
|
||||
`validateSurface` SHALL detect duplicate `id` values across the entire node tree and reject with `DUPLICATE_ID` carrying the offending ids on `context.ids`.
|
||||
|
||||
#### Scenario: Two controls with the same id rejected
|
||||
|
||||
- **WHEN** a surface has two `Control` nodes with `id: "x"`
|
||||
- **THEN** validation rejects with `DUPLICATE_ID` listing `["x"]`
|
||||
|
||||
**Verified by:** `src/identity/identity.test.ts`
|
||||
|
||||
### Requirement: Binding-reference cross-check
|
||||
|
||||
When a binding references a `pluginId` that is not present in the surface's optional `pluginCatalog` (passed alongside), validation SHALL reject with `BINDING_REFERENCE_MISSING` carrying the missing reference on context.
|
||||
|
||||
#### Scenario: Reference missing plugin
|
||||
|
||||
- **WHEN** `validateSurface(surface, { pluginCatalog: new Set(["p1"]) })` is called and a control references `pluginId: "p99"`
|
||||
- **THEN** validation rejects with `BINDING_REFERENCE_MISSING` carrying `{ missing: "p99" }`
|
||||
|
||||
**Verified by:** `src/identity/identity.test.ts::"binding ref"`
|
||||
|
||||
### Requirement: walkSurface DFS traversal
|
||||
|
||||
`walkSurface(surface, visitor)` SHALL visit every node depth-first, passing the node + its parent stack (root-first). Mutation during traversal is undefined behaviour (visitor is read-only by contract).
|
||||
|
||||
#### Scenario: DFS order
|
||||
|
||||
- **WHEN** a surface has root → A → [B, C → [D]] → E
|
||||
- **THEN** visitor is called with node ids in order `["root", "A", "B", "C", "D", "E"]`
|
||||
|
||||
**Verified by:** `src/walk/walk.test.ts`
|
||||
@@ -0,0 +1,54 @@
|
||||
# Tasks — enterprise-control-surface-schema
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [x] 0.2 Account for every `.claude/worktrees/*` entry; prune stale ones via `bun scripts/worktree-prune.ts --remove`.
|
||||
- [x] 0.3 Confirm `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json | wc -l`.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Draft `proposal.md`, `tasks.md`, `specs/control-surface-schema/spec.md`
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/error-codes.ts` — `CONTROL_SURFACE_SCHEMA.*` via `defineCodes`
|
||||
- [x] 2.2 `src/types/transform.types.ts` — `Transform`, `Vec3`, `Vec2`
|
||||
- [x] 2.3 `src/types/binding.types.ts` — `Binding` discriminated union (6 variants)
|
||||
- [x] 2.4 `src/types/range.types.ts`, `behaviour.types.ts`, `appearance.types.ts`, `action.types.ts`
|
||||
- [x] 2.5 `src/types/control-kind.types.ts` — 18-variant `ControlKind` enum
|
||||
- [x] 2.6 `src/types/control-node.types.ts` — `ControlNode` = `Group | Control` discriminated union
|
||||
- [x] 2.7 `src/types/group-layout.types.ts` — 4-variant `GroupLayout`
|
||||
- [x] 2.8 `src/types/control-surface.types.ts` — `ControlSurface` root
|
||||
- [x] 2.9 `src/schemas/control-surface-schema.ts` — Zod schemas mirroring every type
|
||||
- [x] 2.10 `src/validate/validate.ts` — `validateSurface(unknown)` returning typed result union
|
||||
- [x] 2.11 `src/upgrade/upgrade.ts` + `migrations/` — `upgradeSurface(unknown)`, chained migrations, registry pattern
|
||||
- [x] 2.12 `src/walk/walk.ts` — DFS visitor with parent-stack
|
||||
- [x] 2.13 `src/identity/identity.ts` — uuid generation, duplicate-id detection, binding-reference cross-check
|
||||
- [x] 2.14 `src/constants.ts` — `CURRENT_SCHEMA_VERSION`
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Per-feature `.test.ts` covering every scenario in `spec.md`
|
||||
- [x] 3.2 `tests/round-trip.test.ts` — JSON serialise → validate → walk → re-serialise; deep-equal
|
||||
- [x] 3.3 `tests/property.test.ts` — fast-check generators for every node type; validator accepts only well-formed documents
|
||||
- [x] 3.4 `tests/upgrade.test.ts` — registered migrations apply in order; unknown version surfaces typed error
|
||||
- [x] 3.5 Every `it(...)` has `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` per `.ts` (≥200 words, basic + advanced examples)
|
||||
- [x] 4.2 README with `## Surface document shape` reference + 4 worked examples (4-pad drum machine, 8-channel mixer, piano keyboard, Traktor-style DJ surface)
|
||||
|
||||
## 5. Pre-archive verification
|
||||
|
||||
- [x] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/control-surface-schema` — green
|
||||
- [x] 5.2 `bunx turbo test:coverage --filter=@suaveplan/control-surface-schema` — 100/100/100/100
|
||||
- [x] 5.3 `bunx turbo build --filter=@suaveplan/control-surface-schema` — green
|
||||
|
||||
## 6. Archive
|
||||
|
||||
- [x] 6.1 Fold spec delta → `openspec/specs/control-surface-schema/spec.md`
|
||||
- [x] 6.2 Move folder → `openspec/archive/<yyyy-mm-dd>-enterprise-control-surface-schema/`
|
||||
- [x] 6.3 Refresh `MANIFEST.md`
|
||||
- [x] 6.4 Prune worktree
|
||||
Reference in New Issue
Block a user