Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
# constants
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The constants module owns the single source of truth for the current schema
version of `@suaveplan/control-surface-schema`. Every other module — the
Zod schemas, the validator, the upgrade engine, the migration registry —
imports `CURRENT_SCHEMA_VERSION` from this file so that bumping a release
touches exactly one literal. The version drives both the strict-root Zod
literal check and the loop-termination condition inside `upgradeSurface`.
## Features
- Single exported value `CURRENT_SCHEMA_VERSION` of literal type `"0.1.0"`.
- Companion `CurrentSchemaVersion` type alias for use in API surfaces that
pin a parameter to the current release.
- Zero runtime dependencies — pure const.
## Usage: Basic Example
```ts
import { CURRENT_SCHEMA_VERSION } from "@suaveplan/control-surface-schema";
const draft = {
id: "abc",
name: "draft",
version: CURRENT_SCHEMA_VERSION,
createdAt: Date.now(),
updatedAt: Date.now(),
skin: { id: "default" },
root: {
type: "group",
id: "root",
layout: { kind: "absolute" },
transform: { position: { x: 0, y: 0 } },
children: [],
},
} as const;
```
## Usage: Advanced Example
Use the literal type as a generic parameter for a build-time check:
```ts
import {
CURRENT_SCHEMA_VERSION,
type CurrentSchemaVersion,
upgradeSurface,
} from "@suaveplan/control-surface-schema";
declare function persistAtCurrent<V extends CurrentSchemaVersion>(
version: V,
): void;
const upgraded = upgradeSurface(rawDocFromDisk);
persistAtCurrent(upgraded.version as CurrentSchemaVersion);
// The signature pins the call site to whatever version this build emits;
// a stale literal becomes a typecheck error at the call site.
```
## API Reference
- `CURRENT_SCHEMA_VERSION` — readonly string literal `"0.1.0"`.
- `type CurrentSchemaVersion` — typeof `CURRENT_SCHEMA_VERSION`.
## Implementation Notes
The version literal is intentionally narrow (`as const`) so consumer code
can rely on type narrowing in switch statements over versioned documents.
A breaking change to the schema MUST bump this constant AND register the
forward migration before the release ships; the pre-publish gate fails if
`validateSurface` produces a different version than `CURRENT_SCHEMA_VERSION`
on the same document `upgradeSurface` returned, which catches missed bumps.
Consumers that hard-code the literal in their own typed shapes should
import the type alias rather than re-derive it, so a future schema
version bump only requires updating the version string in this file plus
the registered migration in `src/upgrade/upgrade.ts`. The constant lives
outside the schema module so circular imports between Zod schemas and the
upgrade pipeline never form.

View File

@@ -0,0 +1,93 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable error-code registry for
`@suaveplan/control-surface-schema`. Every entrypoint of the package
(`validateSurface`, `upgradeSurface`, `assertUniqueIds`,
`assertBindingReferences`, `MigrationRegistry.register`) throws or returns a
`SchemaError` whose `code` is drawn from this registry, so downstream code
can branch on stable string literals without parsing message text.
## Features
- Five namespaced codes under the `CONTROL_SURFACE_SCHEMA.*` prefix:
`INVALID_DOCUMENT`, `UNSUPPORTED_VERSION`, `UPGRADE_FAILED`,
`DUPLICATE_ID`, `BINDING_REFERENCE_MISSING`.
- Frozen `CONTROL_SURFACE_SCHEMA_ERROR_METADATA` table giving each code a
human-readable `description` and a `severity` (`error` for everything in
this package — nothing recoverable).
- Concrete `SchemaError` subclass widening the `SuaveplanError` constructor
to public so callers can rethrow without subclassing again.
- Typed `ControlSurfaceSchemaErrorCode` union for exhaustive `switch`
narrowing.
## Usage: Basic Example
```ts
import {
CONTROL_SURFACE_SCHEMA_CODES,
SchemaError,
validateSurface,
} from "@suaveplan/control-surface-schema";
const result = validateSurface(unknown);
if (result.error !== undefined) {
if (result.error.code === CONTROL_SURFACE_SCHEMA_CODES.DUPLICATE_ID) {
console.error("Duplicate ids:", result.error.context);
}
}
```
## Usage: Advanced Example
Exhaustive narrowing across every code in the registry:
```ts
import {
type ControlSurfaceSchemaErrorCode,
SchemaError,
} from "@suaveplan/control-surface-schema";
function report(err: SchemaError): string {
const code = err.code satisfies ControlSurfaceSchemaErrorCode;
switch (code) {
case "CONTROL_SURFACE_SCHEMA.INVALID_DOCUMENT":
return "Document failed Zod parse.";
case "CONTROL_SURFACE_SCHEMA.UNSUPPORTED_VERSION":
return "No migration registered for this version.";
case "CONTROL_SURFACE_SCHEMA.UPGRADE_FAILED":
return "A migration threw or produced an invalid output.";
case "CONTROL_SURFACE_SCHEMA.DUPLICATE_ID":
return `Duplicate ids: ${JSON.stringify(err.context)}`;
case "CONTROL_SURFACE_SCHEMA.BINDING_REFERENCE_MISSING":
return `Missing plugin: ${JSON.stringify(err.context)}`;
}
}
```
## API Reference
- `CONTROL_SURFACE_SCHEMA_CODES` — readonly registry built via `defineCodes`.
Keys are the bare code names (`INVALID_DOCUMENT`, etc); values are the
fully-namespaced strings (`"CONTROL_SURFACE_SCHEMA.INVALID_DOCUMENT"`).
- `type ControlSurfaceSchemaErrorCode` — union of every value in the registry.
- `interface ControlSurfaceSchemaErrorMetadata` — `{ description; severity }`.
- `CONTROL_SURFACE_SCHEMA_ERROR_METADATA` — frozen `Record<Code, Metadata>`.
- `class SchemaError extends SuaveplanError<ControlSurfaceSchemaErrorCode>` —
public constructor `(code, messageKey, options?)`.
## Implementation Notes
The metadata table is `Object.freeze`d on module load so consumers cannot
mutate per-code description text at runtime. The base `SuaveplanError`
constructor is `protected` to enforce a subclass-per-package discipline;
this module's `SchemaError` widens it to `public` so test fixtures and
integration adapters can synthesise schema errors without further
subclassing. The codes are stable across schema versions — `upgradeSurface`
re-uses `UPGRADE_FAILED` for every failure mode in the migration pipeline
to keep the consumer narrowing surface small.

View File

@@ -0,0 +1,84 @@
# identity
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The identity module owns the structural cross-checks that run after a
candidate surface passes its Zod parse: duplicate-id detection and
binding-reference cross-check against an optional plugin catalog.
`validateSurface` invokes these helpers in order and surfaces their
typed errors as part of its result union.
## Features
- `findDuplicateIds` — walks the tree, returns the ids that appear more
than once. No throws; pure inspection.
- `assertUniqueIds` — wraps `findDuplicateIds` with a typed throw of
`SchemaError("DUPLICATE_ID")` carrying `context.ids`.
- `collectPluginReferences` — walks the tree, returns the set of
`pluginId` strings referenced by every `pluginParam` binding,
including those nested inside a `compound`.
- `assertBindingReferences` — cross-checks every plugin reference against
a supplied `ReadonlySet<string>` and throws `BINDING_REFERENCE_MISSING`
with `context.missing` on the first offender.
## Usage: Basic Example
Detect duplicate ids without throwing:
```ts
import { findDuplicateIds } from "@suaveplan/control-surface-schema";
const offenders = findDuplicateIds(surface);
if (offenders.length > 0) console.warn("Duplicate node ids:", offenders);
```
## Usage: Advanced Example
Cross-check plugin references against a runtime-registered catalog:
```ts
import {
assertBindingReferences,
collectPluginReferences,
SchemaError,
} from "@suaveplan/control-surface-schema";
const catalog = new Set(pluginRegistry.list().map((p) => p.id));
const referenced = collectPluginReferences(surface);
console.log("Referenced plugins:", [...referenced]);
try {
assertBindingReferences(surface, catalog);
} catch (err) {
if (err instanceof SchemaError) {
renderMissingPluginPlaceholder(err.context);
}
}
```
## API Reference
- `findDuplicateIds(surface): ReadonlyArray<string>`.
- `checkUniqueIds(surface): SchemaError | undefined` — non-throwing variant.
- `assertUniqueIds(surface): void` — throws `SchemaError("DUPLICATE_ID")`.
- `collectPluginReferences(surface): ReadonlySet<string>`.
- `checkBindingReferences(surface, catalog): SchemaError | undefined` —
non-throwing variant.
- `assertBindingReferences(surface, catalog: ReadonlySet<string>): void` —
throws `SchemaError("BINDING_REFERENCE_MISSING")`.
## Implementation Notes
Both `assertUniqueIds` and `assertBindingReferences` are designed to
throw rather than return a result union because they are typically
composed inside `validateSurface`, which already owns the
`ValidateResult` shape. The throw-then-catch keeps the helper signatures
small and lets external callers reuse them directly when they prefer
exception-style control flow. The plugin reference walker descends into
`compound` bindings but stops there because compound bindings are flat
by spec — a nested compound is rejected at Zod parse time before this
walker can see it.

View File

@@ -0,0 +1,84 @@
# control-surface-schema (Zod)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the Zod schemas that mirror every TypeScript type in the
package: `TransformSchema`, `BindingSchema`, `RangeSchema`,
`AppearanceSchema`, `BehaviourSchema`, `GroupLayoutSchema`,
`ControlNodeSchema`, `ControlSurfaceSchema`, and the loose
`ControlSurfaceVersionedSchema` used inside the upgrade pipeline. The
schemas are the runtime source of truth for `validateSurface` and
`upgradeSurface`.
## Features
- One schema per exported type — symmetrical with the type module.
- Discriminated unions on `kind` (`Binding`, `GroupLayout`, `Action`)
and on `type` (`ControlNode`), so parse errors point at the precise
variant that failed.
- Recursive `ControlNodeSchema` via `z.lazy` so groups can nest to any
depth without exploding the schema graph.
- MIDI / DMX numeric ranges encoded with `.int().min().max()` matching
the hardware envelope; the validator rejects out-of-range channel and
CC values at parse time.
- Compound bindings use `LeafBindingSchema` (no nested compound) to
enforce the flat-only invariant declaratively.
- `ControlSurfaceSchema` pins `version` to `CURRENT_SCHEMA_VERSION` so a
document at a different version fails parse; `ControlSurfaceVersionedSchema`
loosens this to any non-empty string for use inside migrations.
## Usage: Basic Example
Parse an unknown document via `safeParse`:
```ts
import { ControlSurfaceSchema } from "@suaveplan/control-surface-schema";
const result = ControlSurfaceSchema.safeParse(raw);
if (!result.success) {
for (const issue of result.error.issues) {
console.error(issue.path.join("."), issue.message);
}
}
```
## Usage: Advanced Example
Compose a sub-schema for an editor that only wants to validate the root
group, not the full surface envelope:
```ts
import { ControlNodeSchema, GroupLayoutSchema } from "@suaveplan/control-surface-schema";
import z from "zod";
const RootGroupOnlySchema = z.object({
layout: GroupLayoutSchema,
children: z.array(ControlNodeSchema),
});
const parsed = RootGroupOnlySchema.parse(payload);
```
## API Reference
- `TransformSchema`, `RangeSchema`, `AppearanceSchema`, `BehaviourSchema`,
`BindingSchema`, `GroupLayoutSchema`, `ControlKindSchema`,
`ControlNodeSchema`, `ControlSurfaceSchema`,
`ControlSurfaceVersionedSchema` — Zod schemas mirroring the
corresponding types in `src/types/`.
## Implementation Notes
Validators downstream call `safeParse`, never `parse`, so a malformed
input becomes a typed `SchemaError("INVALID_DOCUMENT")` rather than a
raw thrown Zod error. The recursive `ControlNodeSchema` is typed as
`z.ZodType<unknown>` so the `exactOptionalPropertyTypes` mismatch
between Zod's `.optional()` (which yields `T | undefined`) and the
schema's `T?` fields doesn't force the lazy boundary to a less precise
type; the runtime check still rejects malformed nodes, and the consumer
casts `parsed.data` to `ControlSurface` after the structural checks
(z-axis, duplicate ids, binding refs) confirm the document is valid.

View File

@@ -0,0 +1,60 @@
# action types
## Purpose
The action type module declares the closed discriminated union of side
effects a behaviour hook can trigger. Actions are referenced from
`Behaviour.doubleTapAction` / `Behaviour.longPressAction` and are
interpreted by renderers — the schema itself never executes them.
## Features
- Four closed variants: `resetToDefault`, `setTo`, `toggleGroupCollapse`,
`emitEvent`.
- `payload` on `emitEvent` is typed as `unknown` so renderers can pass
arbitrary JSON; downstream listeners narrow the payload themselves.
- All variants are `readonly` value objects — actions never mutate the
surface document.
## Usage: Basic Example
A reset-to-default on double tap:
```ts
import type { Action } from "@suaveplan/control-surface-schema";
const reset: Action = { kind: "resetToDefault" };
```
## Usage: Advanced Example
Emit a custom event with a structured payload on long-press:
```ts
import type { Action } from "@suaveplan/control-surface-schema";
const punchIn: Action = {
kind: "emitEvent",
eventId: "transport.punchIn",
payload: { armedTracks: ["kick", "snare"], at: Date.now() },
};
```
## API Reference
- `interface ResetToDefaultAction` — `{ kind: "resetToDefault" }`.
- `interface SetToAction` — `{ kind: "setTo"; value: number }`.
- `interface ToggleGroupCollapseAction` — `{ kind; targetId: string }`.
- `interface EmitEventAction` — `{ kind; eventId: string; payload?: unknown }`.
- `type Action` — union of the four variants above.
## Implementation Notes
The `targetId` on `toggleGroupCollapse` is a string that must match a
`Group.id` somewhere in the surface tree. The schema does not validate
the reference at parse time — that check is the renderer's responsibility
because target groups can be created lazily by other actions. Tests that
exercise the toggle behaviour stage a known-good target before invoking
the action so the renderer's lookup is deterministic. `emitEvent.payload`
is `unknown` rather than a structured type to avoid leaking renderer
event vocabularies into the schema package.

View File

@@ -0,0 +1,71 @@
# appearance types
## Purpose
The appearance type module declares the cosmetic and skin-binding block
attached to a `Control` or `Group`. The block is the bridge between the
canonical surface document and the renderer's skin bundle: it carries the
asset key, the render hint (2D vs 3D), CSS variable overrides, the tooltip
string, and an optional inline icon.
## Features
- `skinKey` — string addressing a sub-asset in the surface's skin bundle.
- `renderHint` — `"auto" | "2d" | "3d"` for renderer dispatch.
- `cssVars` — string-to-string map applied as scoped custom properties.
- `tooltip` — free-form text shown on hover / focus.
- `icon` — discriminated `{ kind: "url" | "svg" | "skinAsset"; value }`.
## Usage: Basic Example
A knob with a tooltip and a skin key:
```ts
import type { Appearance } from "@suaveplan/control-surface-schema";
const appearance: Appearance = {
skinKey: "knob.brass-large",
tooltip: "Channel gain",
};
```
## Usage: Advanced Example
A 3D-only control with CSS variable overrides and an inline SVG icon:
```ts
import type { Appearance } from "@suaveplan/control-surface-schema";
const appearance: Appearance = {
skinKey: "fader.linear",
renderHint: "3d",
cssVars: {
"--fader-cap": "#ff5500",
"--fader-track": "rgba(255,255,255,0.2)",
},
tooltip: "Master output",
icon: {
kind: "svg",
value: "<svg viewBox=\"0 0 16 16\"><rect width=\"16\" height=\"16\"/></svg>",
},
};
```
## API Reference
- `type RenderHint` — `"auto" | "2d" | "3d"`.
- `type IconSource` — `"url" | "svg" | "skinAsset"`.
- `interface Icon` — `{ kind: IconSource; value: string }`.
- `interface Appearance` — fields `skinKey`, `renderHint`, `cssVars`,
`tooltip`, `icon`, all optional.
## Implementation Notes
Appearance is the only block whose contents may differ between renderers
on the same surface document — a 2D React renderer ignores the `3d`
render hint, and a 3D renderer falls back to the skin's "auto" variant
when the hint is missing. The `cssVars` map is intentionally `string ->
string` to mirror DOM custom properties; rich units (lengths, angles)
serialise to strings without loss. Inline SVG is supported because some
device skins want one-off pictograms that don't justify a separate skin
asset.

View File

@@ -0,0 +1,66 @@
# behaviour types
## Purpose
The behaviour type module declares the optional interaction modifier block
attached to a `Control`. The block tells renderers how to interpret the
control kind beyond its default behaviour — whether a button is momentary
or latching, what gesture sticky-latches it, what action fires on
double-tap or long-press, and whether the range step is overridden.
## Features
- `momentaryDelayMs` — minimum hold (ms) before release is reported.
- `latchSticky` — when true, latching controls stay on after gesture release.
- `doubleTapAction` — `Action` fired when the renderer detects a double tap.
- `longPressAction` — `Action` fired when the renderer detects a long press.
- `longPressDelayMs` — minimum hold (ms) before long-press fires.
- `stepOverride` — hard quantisation that takes precedence over `Range.step`.
- Every field is optional; an absent block means "default per ControlKind".
## Usage: Basic Example
A simple momentary button that fires `resetToDefault` on double tap:
```ts
import type { Behaviour } from "@suaveplan/control-surface-schema";
const behaviour: Behaviour = {
momentaryDelayMs: 50,
doubleTapAction: { kind: "resetToDefault" },
};
```
## Usage: Advanced Example
A sticky latch with a long-press emit:
```ts
import type { Behaviour } from "@suaveplan/control-surface-schema";
const armBehaviour: Behaviour = {
latchSticky: true,
longPressDelayMs: 400,
longPressAction: {
kind: "emitEvent",
eventId: "track.solo.exclusive",
},
stepOverride: 0.001,
};
```
## API Reference
- `interface Behaviour` — fields `momentaryDelayMs`, `latchSticky`,
`doubleTapAction`, `longPressAction`, `longPressDelayMs`, `stepOverride`,
all optional.
## Implementation Notes
The behaviour block is intentionally renderer-agnostic. The schema does
not specify how the renderer detects double taps or long presses — that
is OS- and device-specific. The shape only commits to the *intent*: if
the renderer detects the gesture, it fires the registered action. Step
override lives here, not on `Range`, because the same control might want
to expose different snap behaviour depending on gesture (coarse during
drag, fine during long-press) — the renderer composes both fields.

View File

@@ -0,0 +1,74 @@
# binding types
## Purpose
The binding type module declares the six closed variants of `Binding` —
the data structure that wires an atomic control to whatever it drives in
the runtime. Bindings are the bridge between the surface document and the
audio / MIDI / DMX / transport buses that downstream renderers and engines
own; everything else in the schema is layout and cosmetics.
## Features
- Five leaf variants: `pluginParam`, `transport`, `midiOut`, `midiIn`, `dmx`.
- One composite variant: `compound`, which holds an array of leaf bindings
so a single physical control can drive several destinations at once.
- Compound bindings are flat-only — the validator rejects compounds that
contain compounds, keeping the binding graph one level deep.
- `ParamCurve` discriminated subtype on `pluginParam`: `"linear"`, `"log"`,
`"exp"`, or a `{ kind: "custom"; points }` table.
## Usage: Basic Example
A simple plugin-parameter binding:
```ts
import type { Binding } from "@suaveplan/control-surface-schema";
const volume: Binding = {
kind: "pluginParam",
pluginId: "mixer",
paramPath: "channels/0/volume",
curve: "log",
};
```
## Usage: Advanced Example
A compound binding driving plugin + MIDI + DMX simultaneously:
```ts
import type { Binding } from "@suaveplan/control-surface-schema";
const masterKnob: Binding = {
kind: "compound",
bindings: [
{ kind: "pluginParam", pluginId: "master", paramPath: "level" },
{ kind: "midiOut", portId: "main", channel: 0, cc: 7 },
{ kind: "dmx", universe: 0, channel: 1, range: [0, 255] },
],
};
```
## API Reference
- `interface PluginParameterBinding` — `{ kind: "pluginParam"; pluginId; paramPath; curve? }`.
- `type ParamCurve` — `"linear" | "log" | "exp" | { kind: "custom"; points }`.
- `interface CurvePoint` — `{ at: number; value: number }`.
- `interface TransportBinding` — `{ kind: "transport"; field: TransportField }`.
- `type TransportField` — `"play" | "stop" | "tempo" | "position" | "loop"`.
- `interface MidiOutBinding` / `MidiInBinding` — `{ kind; portId; channel; cc; range? }`.
- `interface DmxBinding` — `{ kind: "dmx"; universe; channel; range? }`.
- `interface CompoundBinding` — `{ kind: "compound"; bindings: LeafBinding[] }`.
- `type LeafBinding` — union of the five non-compound variants.
- `type Binding` — `LeafBinding | CompoundBinding`.
## Implementation Notes
The compound variant deliberately excludes nested compounds: nesting would
make audience resolution recursive without adding expressive power because
arrays already flatten the destinations. Custom curves require at least
two points so the validator can interpolate; the Zod schema enforces
`.min(2)` at parse time. MIDI channel and CC values are bounded to the
hardware range (`0–15` and `0–127` respectively); DMX channels are
bounded to `1–512` per universe.

View File

@@ -0,0 +1,61 @@
# control-kind types
## Purpose
This module owns the closed, finite enumeration of the 18 atomic control
primitives every renderer must support. The set is intentionally final at
v0.1.0; expanding it requires a schema-version bump and a registered
migration so consumer renderers can refuse documents they cannot draw.
## Features
- `CONTROL_KIND_VALUES` — readonly tuple of the 18 string literals, in
spec order: `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`.
- `type ControlKind` — derived union of those 18 literals.
- The Zod schema (`ControlKindSchema`) consumes the same tuple so a
rename or addition requires touching exactly one source.
## Usage: Basic Example
Use the union in a typed renderer dispatch:
```ts
import type { ControlKind } from "@suaveplan/control-surface-schema";
function renderHint(kind: ControlKind): "knob" | "slider" | "other" {
if (kind === "knob") return "knob";
if (kind === "slider-v" || kind === "slider-h") return "slider";
return "other";
}
```
## Usage: Advanced Example
Iterate every kind to register a renderer factory per kind:
```ts
import { CONTROL_KIND_VALUES } from "@suaveplan/control-surface-schema";
const registry = new Map<string, () => HTMLElement>();
for (const kind of CONTROL_KIND_VALUES) {
registry.set(kind, () => document.createElement(kind === "led" ? "span" : "div"));
}
```
## API Reference
- `CONTROL_KIND_VALUES` — readonly tuple of the 18 literal strings.
- `type ControlKind` — `(typeof CONTROL_KIND_VALUES)[number]`.
## Implementation Notes
`CONTROL_KIND_VALUES` is exported as a runtime constant (not just a type)
so renderers can iterate, generate dispatch tables, build documentation,
and seed property-test arbitraries from a single source of truth. The
validator rejects unknown kinds at parse time; renderers that cannot
draw a registered kind should fall back to a generic placeholder rather
than throw, but the validator never permits a non-registered literal to
reach renderer code.

View File

@@ -0,0 +1,77 @@
# control-node types
## Purpose
This module declares the recursive `ControlNode = Group | Control`
discriminated union — the tree shape that makes up the body of every
`ControlSurface` document. Every renderer walks this shape; every editor
mutates this shape; every persistence layer serialises this shape.
## Features
- `Group` — internal node carrying a layout strategy, an optional label,
a transform, and an ordered list of children (other groups or controls).
- `Control` — leaf node carrying one of the 18 atomic `ControlKind`
primitives plus its optional binding, numeric range, behaviour
modifiers, and appearance data.
- `ControlNode` — discriminated union by the `type` field (`"group"` /
`"control"`); the validator uses this discriminant for fast parsing.
- `Group.children` is `ReadonlyArray<ControlNode>` so trees are
structurally immutable.
## Usage: Basic Example
A single-knob surface root:
```ts
import type { ControlNode } from "@suaveplan/control-surface-schema";
const knob: ControlNode = {
type: "control",
id: "vol",
kind: "knob",
transform: { position: { x: 0, y: 0 } },
};
```
## Usage: Advanced Example
A nested group tree with a collapsible drum-pad subgroup:
```ts
import type { Group } from "@suaveplan/control-surface-schema";
const drumGroup: Group = {
type: "group",
id: "drums",
label: "Drum pads",
collapsible: true,
layout: { kind: "grid", columns: 4, rows: 4, gap: 8 },
transform: { position: { x: 0, y: 0 } },
children: Array.from({ length: 16 }, (_, i) => ({
type: "control",
id: `pad-${i}`,
kind: "drum-pad",
transform: { position: { x: (i % 4) * 64, y: Math.floor(i / 4) * 64 } },
})),
};
```
## API Reference
- `interface Group` — `{ type; id; label?; layout; transform; children;
collapsible? }`.
- `interface Control` — `{ type; id; kind; label?; transform; appearance?;
binding?; range?; behaviour? }`.
- `type ControlNode` — `Group | Control`.
## Implementation Notes
Both node types share the discriminant field `type`, the `id` (which
must be unique across the entire surface tree), and the `transform`
block. Splitting layout (`Group.layout`) from kind (`Control.kind`) keeps
the validator's z-axis discipline check on the parent side rather than
forcing leaf controls to know which layout encloses them. The `children`
array is `ReadonlyArray` to discourage in-place tree mutation; renderers
that need to edit produce a new array rather than mutating the surface
document.

View File

@@ -0,0 +1,79 @@
# control-surface types
## Purpose
This module declares the canonical `ControlSurface` root document and its
`SkinReference` companion. These are the types persisted to storage,
exchanged over the wire, and validated at every save and load boundary.
Every other type in the package is reachable from `ControlSurface.root`.
## Features
- `id` — uuid identifying this specific surface document.
- `name` — free-form human-readable label.
- `version` — semver matching the schema this document targets; pinned
to `CURRENT_SCHEMA_VERSION` after `upgradeSurface`.
- `createdAt` / `updatedAt` — millisecond Unix timestamps.
- `skin` — `SkinReference` pointing at a separately-published skin
package plus optional CSS variable overrides.
- `root` — the top-level `ControlNode` (typically a `Group`).
- `SkinReference` — `{ id; vars? }`.
## Usage: Basic Example
A minimal valid surface with an empty root group:
```ts
import type { ControlSurface } from "@suaveplan/control-surface-schema";
import { CURRENT_SCHEMA_VERSION } from "@suaveplan/control-surface-schema";
const surface: ControlSurface = {
id: "01997cda-7340-7000-8000-000000000001",
name: "Empty surface",
version: CURRENT_SCHEMA_VERSION,
createdAt: 1700_000_000_000,
updatedAt: 1700_000_000_000,
skin: { id: "default" },
root: {
type: "group",
id: "root",
layout: { kind: "absolute" },
transform: { position: { x: 0, y: 0 } },
children: [],
},
};
```
## Usage: Advanced Example
A skin reference with CSS variable overrides applied surface-wide:
```ts
import type { SkinReference } from "@suaveplan/control-surface-schema";
const skin: SkinReference = {
id: "control-surface-skin-native-instruments",
vars: {
"--accent": "#ff7700",
"--knob-radius": "32px",
"--label-font": "Inter, sans-serif",
},
};
```
## API Reference
- `interface SkinReference` — `{ id: string; vars?: Record<string, string> }`.
- `interface ControlSurface` — `{ id; name; version; createdAt; updatedAt;
skin; root }`.
## Implementation Notes
Every field on `ControlSurface` is `readonly` because surface documents
are passed by reference and consumed by parallel renderers; mutation
would race. `version` is validated by the strict-root Zod schema as the
literal `CURRENT_SCHEMA_VERSION`; the loose `ControlSurfaceVersionedSchema`
accepts any non-empty string and is used only inside `upgradeSurface`.
`createdAt` and `updatedAt` are millisecond timestamps to match the
broader Suaveplan convention; the schema does not require `updatedAt`
to be >= `createdAt` because some migrations reset both.

View File

@@ -0,0 +1,57 @@
# group-layout types
## Purpose
This module declares the closed discriminated union of layout strategies
a `Group` node can adopt. The layout drives both visual placement and the
z-axis discipline check inside the validator: `free3d` is the only layout
that admits nonzero `position.z` on its descendants; every other layout
is strictly two-dimensional.
## Features
- Four closed variants: `grid`, `absolute`, `stack`, `free3d`.
- `grid` carries `columns`, `rows`, and `gap` (positive integers + gap≥0).
- `absolute` is fields-free — the renderer reads `transform.position`
directly.
- `stack` carries `direction: "row" | "column"` and `gap`.
- `free3d` is fields-free — descendants may carry full 3D transforms.
## Usage: Basic Example
An absolute layout group for hand-placed controls:
```ts
import type { GroupLayout } from "@suaveplan/control-surface-schema";
const layout: GroupLayout = { kind: "absolute" };
```
## Usage: Advanced Example
A 4×4 drum-pad grid with eight-pixel gutters and a per-row stack inside:
```ts
import type { GroupLayout } from "@suaveplan/control-surface-schema";
const pads: GroupLayout = { kind: "grid", columns: 4, rows: 4, gap: 8 };
const row: GroupLayout = { kind: "stack", direction: "row", gap: 4 };
```
## API Reference
- `interface GridLayout` — `{ kind: "grid"; columns; rows; gap }`.
- `interface AbsoluteLayout` — `{ kind: "absolute" }`.
- `interface StackLayout` — `{ kind: "stack"; direction; gap }`.
- `interface Free3DLayout` — `{ kind: "free3d" }`.
- `type GroupLayout` — union of the four above.
## Implementation Notes
The validator iterates the tree, switching the "z allowed" flag on when
it descends into a `free3d` group and off when it descends into anything
else. This means nesting a `grid` inside a `free3d` re-disables z-axis
transforms for that subtree — the rule is per-immediate-parent, not
global. The `stack` direction is intentionally a string literal so that
serialised documents stay self-describing; binary packers can map the two
options to a single bit if size matters.

View File

@@ -0,0 +1,56 @@
# range types
## Purpose
The range type module declares the optional numeric envelope attached to
a `Control` node. The fields cover the minimum information a renderer
needs to lay out tick marks, snap behaviour, default values, and read-out
labels without consulting an external plugin database.
## Features
- `min` / `max` — closed numeric interval (inclusive on both ends).
- `step` — optional quantisation increment; absent means continuous.
- `default` — optional initial value used by the `resetToDefault` action.
- `units` — optional free-form string for the read-out label ("dB", "Hz", "%").
- All fields are `readonly` — surfaces are immutable documents.
## Usage: Basic Example
A linear volume knob `0..1` with a default of 0.75 and a label "lin":
```ts
import type { Range } from "@suaveplan/control-surface-schema";
const volume: Range = { min: 0, max: 1, default: 0.75, units: "lin" };
```
## Usage: Advanced Example
A logarithmic frequency knob with a 1 Hz step quantum:
```ts
import type { Range } from "@suaveplan/control-surface-schema";
const cutoff: Range = {
min: 20,
max: 20_000,
step: 1,
default: 1_000,
units: "Hz",
};
```
## API Reference
- `interface Range` — `{ min: number; max: number; step?: number; default?: number; units?: string }`.
## Implementation Notes
`Range` is intentionally narrow — the schema does NOT encode the mapping
curve here. Curves live on the binding (`PluginParameterBinding.curve`)
because the same control kind can drive multiple bindings via a compound
binding, each with its own curve. Renderers that need a single visible
curve fall back to the first leaf in a compound. The `default` field is
the source of truth for the `resetToDefault` action; controls without a
`default` ignore that action rather than throw at runtime.

View File

@@ -0,0 +1,59 @@
# transform types
## Purpose
This module declares the geometric primitives shared by every node Transform
in `@suaveplan/control-surface-schema`. The types are intentionally minimal
so that a flat 2D renderer can ignore the optional 3D fields while a VR or
R3F renderer can populate them without re-validating the surface format.
## Features
- `Vec2` — base 2D coordinate (`x`, `y`).
- `Vec3` — extends `Vec2` with an optional `z` axis. `z` is only honoured
by descendants of a `free3d` layout group; the validator rejects nonzero
`z` under any other layout.
- `EulerRotation` — optional Euler angles in radians, each axis independent.
- `Scale3` — optional anisotropic scale, each axis independent.
- `Transform` — `{ position: Vec3; rotation?: EulerRotation; scale?: Scale3 }`.
## Usage: Basic Example
A flat 2D control transform:
```ts
import type { Transform } from "@suaveplan/control-surface-schema";
const flat: Transform = { position: { x: 120, y: 64 } };
```
## Usage: Advanced Example
A full 3D transform under a `free3d` layout group with rotation and scale:
```ts
import type { Transform } from "@suaveplan/control-surface-schema";
const tilted: Transform = {
position: { x: 0, y: 0, z: 0.5 },
rotation: { x: 0, y: Math.PI / 4, z: 0 },
scale: { x: 1.2, y: 1.2, z: 1 },
};
```
## API Reference
- `interface Vec2 { x: number; y: number }`.
- `interface Vec3 extends Vec2 { z?: number }`.
- `interface EulerRotation { x?: number; y?: number; z?: number }`.
- `interface Scale3 { x?: number; y?: number; z?: number }`.
- `interface Transform { position: Vec3; rotation?: EulerRotation; scale?: Scale3 }`.
## Implementation Notes
All fields are `readonly` — surfaces are immutable documents passed by
reference. The shape was chosen so a 2D JSON serialisation never carries
the optional axes; renderers that only support 2D can run a `position.x` /
`position.y` projection without conditional logic. The validator enforces
that `position.z` outside `free3d` is either undefined or zero, so the
two-dimensional invariant survives round-tripping through any consumer.

View File

@@ -0,0 +1,85 @@
# upgrade
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `upgradeSurface` and the `MigrationRegistry` chain that
walks an older schema-version document forward to the current version.
Without an explicit upgrade path consumers cannot read documents created
by previous releases of the package; with it, each release registers a
single forward edge and old documents survive arbitrarily many bumps.
## Features
- `MigrationRegistry` — ordered registry keyed by `from` version, with
`register`, `get`, and `list` accessors.
- `Migration` — `{ from; to; apply: (unknown) => unknown }` — pure
function transforming a versioned document one step forward.
- `defaultRegistry` — package-wide instance consumers extend as new
schema versions ship.
- `upgradeSurface` — walks the registry chain, validates the output
against the strict-root schema, and returns a typed `ControlSurface`.
- Five distinct failure modes mapped onto two error codes:
`INVALID_DOCUMENT` (envelope not parseable) and `UNSUPPORTED_VERSION`
(no migration registered) plus `UPGRADE_FAILED` (every other failure
during the chain walk).
## Usage: Basic Example
Read a v0.0.9 document and walk it forward through the default chain:
```ts
import { upgradeSurface } from "@suaveplan/control-surface-schema";
const surface = upgradeSurface(JSON.parse(await fs.readFile(path, "utf8")));
console.log("Loaded surface", surface.id, "at", surface.version);
```
## Usage: Advanced Example
Register a custom migration in an isolated registry for tests:
```ts
import {
MigrationRegistry,
upgradeSurface,
} from "@suaveplan/control-surface-schema";
const registry = new MigrationRegistry();
registry.register({
from: "0.0.9",
to: "0.1.0",
apply: (doc) => {
const next = { ...(doc as Record<string, unknown>) };
next.version = "0.1.0";
if (typeof next.skin === "string") next.skin = { id: next.skin };
return next;
},
});
const upgraded = upgradeSurface(legacyDoc, { registry });
```
## API Reference
- `interface Migration` — `{ from; to; apply }`.
- `class MigrationRegistry` — `register(migration)`, `get(from)`, `list()`.
- `defaultRegistry: MigrationRegistry` — package-wide chain.
- `interface UpgradeOptions` — `{ registry? }`.
- `upgradeSurface(input: unknown, options?: UpgradeOptions): ControlSurface`.
## Implementation Notes
Migrations are chained by `from` version, not by index, so the registry
is a true DAG keyed by source version. Duplicate registration of the
same `from` rejects with `UPGRADE_FAILED` because forking forward edges
would make the resolution non-deterministic. The walker keeps a hop
counter equal to `registry.list().length + 1` so a corrupt registry
that loops back to a prior version trips `UPGRADE_FAILED` instead of
running forever. After the chain completes the function re-runs
`validateSurface` to catch migrations that emit structurally invalid
documents — the schema is the source of truth, not the migration's word
that it produced a valid output.

View File

@@ -0,0 +1,76 @@
# validate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exports `validateSurface` — the canonical entrypoint for
parsing and structurally cross-validating a `ControlSurface` document.
Every save and load boundary in the wider system calls this function
before trusting an unknown payload. The return type is an explicit
discriminated union so callers always handle failure without throwing.
## Features
- Zod safe-parse against the strict-root `ControlSurfaceSchema`
(`version` pinned to `CURRENT_SCHEMA_VERSION`).
- Z-axis discipline check: rejects nonzero `transform.position.z` on
any descendant of a non-`free3d` layout.
- Duplicate-id detection across the entire tree.
- Optional binding-reference cross-check when `options.pluginCatalog`
is supplied.
- Typed `ValidateResult` union — `{ surface }` on success, `{ error }`
on any failure, with `SchemaError.code` carrying the precise reason.
## Usage: Basic Example
Parse a surface from disk and reject on any failure:
```ts
import { validateSurface } from "@suaveplan/control-surface-schema";
const raw = JSON.parse(await fs.readFile(path, "utf8"));
const result = validateSurface(raw);
if (result.error !== undefined) {
throw result.error;
}
const surface = result.surface;
```
## Usage: Advanced Example
Cross-check plugin references against a runtime catalog:
```ts
import {
CONTROL_SURFACE_SCHEMA_CODES,
validateSurface,
} from "@suaveplan/control-surface-schema";
const catalog = new Set(plugins.map((p) => p.id));
const result = validateSurface(raw, { pluginCatalog: catalog });
if (result.error?.code === CONTROL_SURFACE_SCHEMA_CODES.BINDING_REFERENCE_MISSING) {
showMissingPluginToast(result.error.context);
}
```
## API Reference
- `interface ValidateOptions` — `{ pluginCatalog?: ReadonlySet<string> }`.
- `type ValidateResult` — `{ surface: ControlSurface; error?: undefined }`
on success, `{ surface?: undefined; error: SchemaError }` on failure.
- `validateSurface(input: unknown, options?: ValidateOptions): ValidateResult`.
## Implementation Notes
The function never throws — every failure is captured and re-emitted as
a `SchemaError` on `result.error`. The Zod parse runs first because it
is the cheapest fail-fast; the structural checks (z-axis discipline,
duplicate id, binding reference) only see well-formed documents and can
therefore use the typed `ControlSurface` view directly. The pluginCatalog
check is opt-in because validating the surface against a catalog at load
time is a different concern from validating that the surface itself is
internally consistent — server-side persistence often skips the catalog
check until the document is mounted in a runtime.

View File

@@ -0,0 +1,67 @@
# walk
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exports `walkSurface` — the canonical depth-first traversal
helper for `ControlSurface` documents. Every cross-cutting check in the
package (duplicate-id detection, binding-reference collection, z-axis
discipline) consumes the same traversal so a future change to traversal
order updates one site rather than three.
## Features
- Pre-order DFS, visiting parent groups before their children.
- Provides the root-first parent stack to the visitor so consumers can
emit path-qualified diagnostics without re-deriving the path.
- Stack-safe for the bounded tree sizes the schema permits (depth and
width are bounded by what a renderer can sensibly draw).
- The visitor is read-only by contract; mutation during traversal is
undefined behaviour and not protected against.
## Usage: Basic Example
Count every node and print the id list:
```ts
import { walkSurface } from "@suaveplan/control-surface-schema";
const ids: string[] = [];
walkSurface(surface, (node) => ids.push(node.id));
console.log(ids.length, ids);
```
## Usage: Advanced Example
Emit path-qualified diagnostics by joining the parent stack:
```ts
import { walkSurface } from "@suaveplan/control-surface-schema";
walkSurface(surface, (node, parents) => {
const path = [...parents.map((p) => p.id), node.id].join(" / ");
if (node.type === "control" && node.binding?.kind === "pluginParam") {
console.log(`[${path}] -> ${node.binding.pluginId}/${node.binding.paramPath}`);
}
});
```
## API Reference
- `type SurfaceVisitor` — `(node: ControlNode, parents: ReadonlyArray<Group>) => void`.
- `walkSurface(surface: ControlSurface, visitor: SurfaceVisitor): void`.
## Implementation Notes
The visitor is called exactly once per node in pre-order DFS. Leaf
control nodes never receive a recursive visit because they have no
children; group nodes recurse via `node.children` in their declared
order. The parent stack is rebuilt at each level by spreading the
incoming stack with the current group appended, which trades a small
amount of allocation for visitor-side immutability. Callers that need
post-order traversal compose the visitor themselves; the package does
not expose a separate post-order helper because no consumer needed it
at v0.1.0.