SPEC-150
ID:SPEC-150Status:draft

Design plugin composition audit

Summary

Fourth plugin audited against SPEC-145, and the first that comes back no. Design is 1,958 lines across seven runes, and it should stay a plugin: two runes compose cleanly, two more could with SPEC-147's segmented model, one is a misfiled documentation tool, and the remaining one is uncomposable by nature rather than by any gap the roadmap closes.

That the answer is sometimes no is the point of running the audit, and this is the result that establishes it. The previous three all resolved to retire, dissolve or shed a rune; this one does not.

It also produces a blocker category the first three did not, and shows the per-property method has a precondition.

The five-path method does not apply here

No design rune declares a schema table. Not one of the seven, confirmed by inspection and by their absence from contracts/seo-baseline/fixtures/ — the corpus covers 30 emitting runes and none of them is a design rune.

So SPEC-148's five resolution paths and SPEC-149's B-intrinsic / B-by-choice split have nothing to bite on. That is worth stating rather than passing over: the per-property method has a precondition — that the rune makes a structured-data claim at all. Where it does not, composability is decided entirely by content model, behaviour and pipeline, and the audit is a different shape.

The audit

RuneLinesVerdict
swatch76Composes today. sequence with no fields; renders a chip from attributes alone
mockup193Composes today. sequence body + a named viewport wrapper; its own comment notes the device chrome is data-* attributes "not in a postTransform", so it already follows the declarative convention
palette309Needs SPEC-147's segmented model (two groupByHeading sites); the coupling below is corrected and does not block it
spacing270Same — two groupByHeading sites, same correction
typography284No heading grouping; composes, gated on nothing
preview237Triply blocked, and misfiled — see below
design-context99Uncomposable by nature — see below

The new blocker — output that is derived data, not arranged content

design-context dispatches on its children's rune names and calls extractPaletteTokens, extractTypographyTokens and extractSpacingTokens, folding the result into a DesignTokens blob it emits as a field. Its job is not to arrange its children; it is to read them and compute a value.

Composition places content. It has no facility for computing over content, and ADR-036 is the reason it should not grow one — a general "read my children and derive a value" capability is an expression language by another name.

So this is a fourth blocker category, distinct from the three the earlier audits found (a peer schema type, a required parent, a client lifecycle):

A rune whose output is derived data rather than arranged content cannot be composed, and should not be.

Worth naming because it is not obvious from the outside: design-context looks like a container, is described in the catalog as "composing palette, typography, and spacing runes", and is the plugin's least composable rune.

Corrected: it does not constrain its children

This section claimed a coupling that does not exist, and the correction frees three runes. It read: "the extractors walk palette / typography / spacing's output trees… composing them changes that structure and breaks the extraction silently", and called it "the first cross-rune output coupling any audit has turned up". Read, the stage is wrong — and with the stage goes the conclusion.

design-context's transform extracts before its children are transformed, and says so in its own comment:

const children = asNodes(resolved.body) as Node[];
// Extract tokens from child AST nodes before transforming
for (const child of children) {
  if (child.type === 'tag') {
    const tagName = (child as any).tag;
    if (tagName === 'palette') tokens.colors = extractPaletteTokens(child);

And the extractors consume AST node types, not emitted tags — extractPaletteTokens(node: Node) walks child.type === 'heading', child.type === 'list', item.type === 'item' (tags/palette.ts:276).

So what design-context depends on is the authored markdown shape inside {% palette %} — headings and lists — which is the content model's input, not any rune's output. Composition changes what a rune emits; it cannot change what the author wrote. The extraction never sees palette's output and is therefore untouched.

Consequences, in order of how much they matter:

  1. palette, spacing and typography are composable in place, not merely in isolation. typography is gated on nothing at all; the other two need only SPEC-147's segmented model, as the table says.
  2. The "cross-rune output coupling" category is withdrawn. No audit has found one, and recording a blocker category on a single misread instance is the error ADR-039 rule 2 had to retract at larger scale.
  3. The fourth blocker category survives intact. design-context still computes a value from its children rather than arranging them, and is still uncomposable. What it does not do is propagate that property to its children.

The surviving dependency is weaker and worth stating so it is not rediscovered as a blocker: design-context dispatches on the child's tag name, so a child must still be a tag named palette / typography / spacing. A composed palette is still a rune named palette, so even that holds.

preview — triply blocked, and in the wrong plugin

Three independent blockers, any one sufficient:

  1. A custom content model — the plugin's only type: 'custom'.
  2. Behaviour-driven — listed among the behaviour runes in packages/svelte/src/registry.ts (tabs, accordion, datatable, form, reveal, preview, details).
  3. A postTransform that serialises HTML. plugins/design/src/config.ts:95 calls renderToHtml(contentChildren, { pretty: true }) to build a themed-source view, and its comment states why it cannot move: "This must happen in postTransform (not the rune) because it needs the fully-transformed tree with BEM classes and structural elements." Unlike plot's postTransform (SPEC-147 Finding 1, which only sets a data attribute) there is no plausible declarative form for rendering a tree to a string.

And it is misfiled. The catalog describes it as "Component preview with theme toggle and adjustable width for documentation" — a docs tool, not a design-domain rune, and adjacent to sandbox, which lives in core.

Third data point for the misfiled-capability pattern, and the second where the misfiled rune is behaviour-driven (map was the first). storytelling shed storyboard, places dissolved around map, business had none, design has preview.

The pipeline — half declarable, and the other half correctly imperative

HookWhat it doesDeclarable
registerFinds design-context runes, reads their tokens field, registers by scopeYes — SPEC-144's entity with idFrom: scope
aggregateCollects contexts into a scope → DesignTokens mapYes — generic
postProcessFinds every sandbox, reads its context attribute, injects a design-tokens meta into that sandbox's childrenNo

The postProcess is a different animal from storytelling's. That one was generic entity auto-linking, which SPEC-147 Finding 4 promotes to core because nothing in it knew about stories. This one is a specific wiring between two named runes — design-context feeding sandbox — and sandbox is a client-lifecycle rune composition can never produce. There is nothing generic to promote; it is plugin logic doing exactly what plugin logic is for.

That is a useful contrast to record: an imperative postProcess is a candidate for promotion when it is domain-agnostic, and correct as it stands when it wires two named runes together.

Verdict — design stays a plugin

Not "retires later" or "dissolves". Of its seven runes:

  • swatch and mockup could compose, and gain little from it — they are small and already declarative
  • palette, spacing and typography compose in place — the output coupling this audit first recorded was a misread of the extraction stage, corrected above
  • preview belongs elsewhere and cannot compose regardless
  • design-context cannot compose and should not

Removing preview would leave a coherent plugin whose core is a computation over its own runes' authored content, wired to an interactive core rune. That is precisely what the plugin mechanism exists for, and ADR-036 already names it as the escape hatch for anything composition cannot express.

Decisions

D1 — design is not a composition target

Recorded so it is not re-examined every time the composition vocabulary grows. The blocker is design-context's nature, not a missing feature, and the three display runes are coupled to it.

D2 — a rune whose output is derived data cannot be composed

The fourth blocker category, alongside a peer schema type (SPEC-145 D9), a required parent (D12) and a client lifecycle (SPEC-148 D3). Added to the authoring guide's list of what disqualifies a rune, because design-context demonstrates it is not visible from the rune's description.

D3 — preview moves out of design

To core beside sandbox, or to the docs plugin. It is a documentation tool by its own description, and its three blockers are independent of where it lives. Relocation does not require composition to exist.

D4 — an imperative postProcess is judged by whether it is domain-agnostic

storytelling's auto-linking is promoted to core (SPEC-147 Finding 4); design's sandbox-token injection stays plugin code. The test is whether the hook names specific runes, not whether it is imperative.

D5 — the per-property method applies only where a rune emits schema

Stated as a precondition. Design's audit is decided by content model, behaviour and pipeline alone, and a future audit should check for schema tables before reaching for the five-path table.

Implementation notes, deliberately not yet work items

  1. Relocate preview to core beside sandbox, or to docs. Independent of everything else here, and the only item with a clear payoff.
  2. Leave the rest. No composition work is proposed for design. If palette, spacing or typography are ever composed, design-context's extractors must move to reading a declared field rather than walking output trees — a prerequisite, not a side effect.

Non-goals

  • Composing any design rune; the plugin stays (D1)
  • Promoting design's postProcess to core (D4)
  • Changing design-context's token extraction; it is cited as correct for what it does
  • Auditing the remaining plugins (learning, media, docs, marketing)

Acceptance Criteria

  • preview renders from its new home with its existing fixtures unchanged, including the themed-source postTransform (D3)
  • The authoring guide lists all four disqualifying properties, with design-context named as the example for derived-data output (D2)
  • The authoring guide records the per-property method's precondition (D5)
  • plugins/design/ still builds and passes its tests after preview leaves
  • If any display rune is later composed, design-context reads a declared field rather than walking its children's output, asserted by a test that changes the child's structure and expects the tokens to survive

References

  • SPEC-147 — the storytelling audit; the segmented model palette and spacing would need, and the promotable postProcess this one contrasts with
  • SPEC-148 — the places audit; the per-property method and the client-lifecycle blocker preview shares with map
  • SPEC-149 — the business audit; the plugin with nothing misfiled, against which this one has preview
  • SPEC-145 — composed runes; the mechanism audited
  • SPEC-144 — entity registration; covers design's register and aggregate
  • ADR-036 — the plugin escape hatch, which design is a correct use of
  • SPEC-151 — the marketing audit; corroborates the derived-data blocker in a second plugin
  • SPEC-155 — the media audit; a third instance of the derived-data blocker, in playlist's inline player payload
  • SPEC-152 — the plan audit; the third plugin that stays, and the one that shows staying does not mean staying as-is