Editor Composites.

A composite is how teams ship reusable block assemblies. Not a component. Not a template. A serialized block tree with a manifest, an input/output rules contract, and a flat array of blocks that the bridge layer can instantiate, expand, and serialize back out.

The format is defined in packages/composites/src/manifest.ts. The bridge utilities live in bridge.ts. The registry is in registry.ts. All three are Zod-first.


Manifest.

The manifest is what a composite knows about itself before any blocks are rendered. Schema defined at manifest.ts:49.

{
  id: string;           // kebab-case, /^[a-z0-9-]+$/
  name: string;
  category: string;     // free-form, teams define their own
  description: string;
  keywords: string[];
  cognitiveLoad: number; // integer 1–10
  solves?: string;
  appliesWhen?: string[];
  usagePatterns?: { do: string[]; never: string[] };
}

id is kebab-case enforced by the regex at line 53. It is the registry key. category is free-form: the schema enforces a non-empty string, nothing else. Teams define their own taxonomy.

cognitiveLoad is an integer 1–10 matching the five-dimension model documented in Cognitive Load Scoring. The bridge clamps this on serialization (see serializeToComposite below).

The three optional fields (solves, appliesWhen, usagePatterns) were added after the initial format shipped. Old composites in the wild will not have them; the schema accepts their absence. usagePatterns carries designer intent as do and never arrays, the same shape as the do-and-never rules documented in Editor Rules. These fields are not validated against the rules registry; they are narrative guidance encoded alongside the assembly.

The complete file schema wraps the manifest:

{
  manifest: CompositeManifest;
  input: string[];        // rule names on root blocks
  output: string[];       // rule names on leaf blocks
  blocks: CompositeBlock[]; // flat, min 1
}

blocks is flat. Children are ID arrays, not nested objects. Each block’s parentId and children fields hold string IDs that reference other blocks in the same array. See Editor Data Model for the full CompositeBlock shape.


Bridge.

The bridge layer (bridge.ts) translates composites into the editor and back out. Three functions do the work.

toBridgeItem

toBridgeItem(composite: CompositeFile): BlockPaletteItem  // bridge.ts:56

Projects the manifest into a sidebar palette entry: { id, label: name, category, keywords? }. No state change, no side effects. Keywords are included only when the manifest has at least one. The palette item is what the editor sidebar renders; it carries nothing from the block array.

instantiateBlocks

instantiateBlocks(blocks: CompositeBlock[], options?: InstantiateOptions): InstantiatedBlock[]
// bridge.ts:83

This is where a composite becomes a live canvas insertion. Two things happen unconditionally: ID regeneration and reference remapping.

Every block gets a fresh crypto.randomUUID(). The old ID maps to the new one in an idMap built at line 99. Once all new IDs exist, children arrays and parentId strings are remapped through that map at lines 161–166. A composite dropped twice onto the canvas produces two fully independent block trees with no shared IDs.

When a block’s type starts with composite:, the bridge treats it as a nested composite reference and calls expandBlocks recursively (line 108). The resolveComposite callback in InstantiateOptions is how the caller supplies the nested CompositeFile. If the callback returns null, the block is replaced with a plain text block reading Unknown composite: {id} (line 146). If no resolveComposite is provided, composite:* blocks fall through to normal instantiation.

Recursion is bounded by maxDepth, which defaults to 10 (line 95). At or beyond that depth, expandBlocks returns an empty array. Circular composite references will silently produce no output rather than a stack overflow.

One edge case worth knowing: when a nested composite’s blocks have no explicit parentId, the bridge remaps them to the parent composite’s new ID (line 117). This keeps the expanded tree connected.

serializeToComposite

serializeToComposite(blocks: SerializableBlock[], metadata: SaveCompositeMetadata): CompositeFile
// bridge.ts:239

Takes canvas blocks and save-dialog metadata, produces a valid CompositeFile. Two throws and several derivations.

The function throws if blocks is empty (line 244). It throws if metadata.name produces an empty kebab string from toKebabId() (line 247); a name must contain at least one alphanumeric character. The id field is always derived from name, never provided directly.

Input and output rule names are computed by deriveIO() (line 192). Input is the union of rule names on root blocks (blocks with no parentId). Output is the union of rule names on leaf blocks (blocks with no children or an empty children array). Both sets are sorted. This is a v1 heuristic: the rules that enter the assembly at the top and exit at the bottom define the contract. See Editor Rules for what AppliedRule carries.

Keywords are auto-derived from block types and rule names by deriveKeywords() (line 220). The manifest’s keywords field in a serialized composite reflects what was actually in the blocks, not what was typed by hand.

cognitiveLoad is clamped to 1–10 at line 255. If the caller passes nothing, it falls back to blocks.length, which is a rough proxy only. Pass an explicit value when the assembly’s load is known.

Optional intent fields are included in the manifest only if provided (lines 267–269). Omitting them keeps the serialized output clean for teams that do not use them.


Registry.

The registry (registry.ts) is a module-level Map<string, CompositeFile> keyed by manifest.id. There is no constructor; the module owns the map.

register(composite: CompositeFile): void   // registry.ts:17 — throws on duplicate ID
get(id: string): CompositeFile | undefined // registry.ts:29 — O(1)
getAll(): CompositeFile[]                  // registry.ts:36
getByCategory(category: string): CompositeFile[]  // registry.ts:43
search(query: string): CompositeFile[]     // registry.ts:51

register throws if the ID is already present. get is O(1) map lookup. getByCategory filters getAll() in registration order. search with an empty string returns getAll().

search runs a fuzzy score against name and all keywords, takes the maximum score across fields, filters to score > 0, and returns results sorted descending. Best match first. The fuzzy algorithm is fuzzyScore imported from @rafters/ui/primitives/typeahead (line 8), which is the same scorer the editor’s typeahead palette uses.

A clear() function exists for test isolation. It has no other use.


Architecture Notes.

Two import paths in this package cross a package boundary that should not exist.

bridge.ts:10 imports BlockPaletteItem as a type from @rafters/ui/primitives/block-palette. registry.ts:8 imports fuzzyScore from @rafters/ui/primitives/typeahead. Both pull from @rafters/ui. The composites package should depend only on zod. A shared primitives package or a @rafters/editor-types extraction is the correct fix. Both violations are tracked in Editor Known Gaps.

The type-only import in bridge.ts is zero runtime cost; the value import of fuzzyScore in registry.ts is a real runtime dependency on the UI package. The registry import is the higher-priority fix.