Design System Contracts

Reference

Writing an emitter

A contract is the single source of truth; an emitter is one projection of it — a pure function from contract to file texts. The four built-ins prove the spread (scoped-CSS React, static HTML, inline-styles React, and the Figma sync script — the canvas itself is just another emit target). A new surface for Angular, SwiftUI, or Compose is a new pure function over the same contract; nothing upstream changes.

The interface Generated

core/emitter.ts — the three types every emitter is written against; extracted at build time
export interface EmittedFile {
  /** Suggested file name (relative), e.g. "Badge.tsx", "badge.html". */
  path: string;
  contents: string;
}

export interface EmitterCtx {
  /** Parsed DTCG token trees (see core/tokens.ts TokenTreeInput). */
  tokens: TokenTreeInput;
  /** Icon asset name → SVG markup. */
  icons: Map<string, string>;
  /** Every known contract by id — composition refs resolve through it. */
  contracts: Map<string, Contract>;
  /** figma-script: overrides the anchor file key in the WRONG FILE guard. */
  fileKey?: string;
  /** figma-script: minted provisional tokens (`imported.*` DTCG tree) — the
   *  script gains a preamble that upserts them as Figma variables, so it runs
   *  in files that never synced them. Absent/empty → no preamble. */
  mintedTokens?: Record<string, unknown>;
  /** react-inline: token resolution mode (default 'light'). */
  mode?: 'light' | 'dark';
}

export interface Emitter {
  name: string;
  label: string;
  emit(contract: Contract, ctx: EmitterCtx): EmittedFile[];
}

Three rules the registry holds you to:

Registering: two doors, one registry

the --emitter module may export an Emitter as `default`, `emitter`, or an `emitters` array — anything else is refused by name
// direct, when you embed the engine (core/emitter.ts — the engine-as-a-library barrel):
import { registerEmitter } from './core/emitter.js';
import webComponents from '@ds-contracts/emitter-web-components';
registerEmitter(webComponents);

// or let the CLI do it — the module loads and registers BEFORE generation:
ds-contracts generate contracts/ --out wc/ \
  --target web-components \
  --emitter @ds-contracts/emitter-web-components \
  --tokens tokens.json --icons icons/

--target then selects your emitter by its name; an unknown target is refused with the list of registered names. Full flag detail: the CLI reference.

The worked example: @ds-contracts/emitter-web-components

The published plugin (packages/emitter-web-components) turns each contract into vanilla Custom Elements — zero runtime dependencies, shadow DOM, constructable stylesheets, real <slot>s, real events. One contract becomes four files:

FileWhat it is
<tag>.tsan HTMLElement subclass (ds.badge<ds-badge>): observedAttributes from the contract's props; enum/boolean/number/text props reflect property ⇄ attribute with contract defaults; the children text prop rides the default slot; events dispatch CustomEvents; form-associated when the contract's root is input-like. arrayOf props are JS properties only — attributes cannot carry lists, a named limit.
<tag>.css.tsthe anatomy compiled to a constructable stylesheet — see the selector-translation recipe below.
<tag>.demo.htmlthe story-equivalent showcase grid: default + every enum value + every boolean.
<tag>.custom-elements.jsona Custom Elements Manifest generated from the contract — deterministic, no analyzer — and the raw material for the closure receipt below.

Committed eyeball receipts for five contracts (including a Polaris import) live in samples/.

The selector-translation recipe

Don't invent styling semantics — translate an existing emitter's semantics to your surface's selector dialect at identical specificity. The WC emitter reuses the static HTML emitter's CSS generation wholesale and only swaps the selector spelling: class-per-variant becomes shadow-scoped [part=…] with :where() wrappers tuned so every rule lands at the same specificity as the light-DOM original. Same cascade in, same computed values out — which turns "the styles match" from a hope into a measurement: the wc-emitter-css-parity receipt loads both emitters' output in real Chromium and compares computed styles across every showcase item — 165/165 channels equal (9 computed properties + width/height × 15 items). Token values arrive as CSS custom properties, which inherit through the shadow boundary — the two-stage application works unchanged.

The closure receipt: round-trip your own output

The strongest receipt an emitter can carry: feed your emitted surface back through an extractor and diff the round-tripped contract against the source contract. The WC emitter's roundtrip-check does exactly this — the manifests it emits are fed through the repo's own CEM extraction adapter (the same adapter any brownfield Web Components library goes through), and the resulting proposal is diffed against the contracts it started from: props, enums, defaults, and events survive the loop, and every non-surviving fact is named with its mechanism. That closure is the proof that a plugin surface preserves truth rather than merely resembling it. The recipe, generalized:

  1. Emit

    Run your emitter over a handful of shipping contracts — include at least one enum axis, one boolean, one default, one event.

  2. Extract

    Run an existing extractor over the output — the cem adapter if your surface can publish a Custom Elements Manifest, the react-tsx adapter for React-family output. No extractor for your surface yet? Emitting a CEM from the contract (as the WC emitter does) gives you a deterministic bridge into one.

  3. Diff, and name the losses

    Compare the round-tripped proposal to the source contracts. Facts that survive are your fidelity claim; facts that don't must be named with their mechanism — a named loss is a spec-grade answer, a silent one is a bug.

Community doors: Angular, Swift, Kotlin, anything

This guide is the path for the surfaces this project hasn't built: an Angular emitter, SwiftUI or Compose for native design systems, a Vue SFC emitter, an email-safe HTML emitter. Each is the same shape — a pure function, registered, selected with --target, shipped as an npm package under your own name (registerEmitter is public API; no fork required, nothing upstream changes). Hold your emitter to the house bar: pure and browser-safe, canvas-only concepts as named no-ops, a parity measurement where one is possible, and a closure receipt through an extractor. If your surface exposes a real gap in the schema instead, that's a field case — exactly how the schema has grown every round so far.

Receipts for the worked example: wc-emitter-roundtrip and wc-emitter-css-parity in the eval suite, plus the committed samples/. The four built-ins are receipted by core/emitters-check.ts; only react is byte-guarded by the golden manifest — its output is the shipping library.