Get started
What do you want to do?
Three situations bring people here, and they are genuinely different amounts of work. Find yours, then read only that section. Everything runs on the published @ds-contracts/cli and the companion Figma plugin; the terminal commands below are executed by the eval suite from the same file this page renders.
| Your situation | Ends with | |
|---|---|---|
| A | “I have a component on the canvas. I want code.” | A typed React component, CSS Modules and Storybook stories, in your repo. |
| B | “I have components in code. I want them in Figma.” | A designer clicks Check for updates and your real components appear on their canvas, token-bound. |
| C | “I already have a mature Figma library and a mature codebase.” | A property-by-property disagreement report, and a CI gate that stops the gap growing. |
One rule spans all three: the surfaces never sync side-to-side. A designer's change and an engineer's change both travel through the contract, as a reviewable diff. Nothing writes to your repo without a pull request, and nothing writes to the canvas except an Apply a human clicked. That is the protocol.
First, the zero-install door
ds-contracts-playground.pages.dev runs the repository's actual engine in your browser — no backend, no accounts, no analytics; credentials are session-only and never leave the browser. Ten minutes there teaches the model faster than any page here.
Try this first: open Examples, pick the Badge, and then break its contract on purpose — delete a required field, or point a token binding at a name that does not exist. The refusal appears on screen, named. That refusal is the product; everything else on this page is plumbing around it.
The playground also imports: a figma.com component URL (your token, with an honest degradation ladder when your plan gates the variables endpoint); a public GitHub file URL or pasted TSX + CSS Module; a pasted plugin dump (extract/figma/dump.plugin.js, in the JSON tab — native variable names on any plan); and your own DTCG tokens. One route is off, and the button says so: live relay from the plugin, whose Send to Playground tab was removed when its seven tabs were re-housed into Build / Changes / Send. Use the URL route or paste a dump instead.
A · “I have a component on the canvas; I want code”
A designer has a component set in Figma; you want a real, typed React component in your repo. The generation half of this is fully deterministic — the same contract produces byte-identical output on any machine, with no model in the path.
Read the set
In the plugin, open the Send tab. Select the set on canvas (or find it with Scan this file), leave the base-contract box empty if this tool did not build it, and click Read the set & diff. The engine reads the live set and proposes a contract from what is actually drawn: variants become props, layers become anatomy, bound variables become token refs. With a base contract you get a diff (what changed); without one you get a proposal (what this set is).
Get the contract into the repo — three doors, all reviewable, and the code comes with it
The PR carries both halves: the contract AND the component it generates. It used to carry a document nobody could run, with an invisible second hop where a human had to know to go away and run
generate. Which target it emits is never guessed — the plugin's--targetwins, otherwise thegeneratesection ofds-contracts.config.jsondecides; with neither recorded the change carries the contract alone and says so.- GitHub PR. Fill in
owner/repoand a fine-grained token scoped to that one repo (session-only, never stored; closing the plugin forgets it). Leave Dry run ticked first to see the exact plan — every file, both diffs, the provenance sentence — and send nothing. - Send to repo, no GitHub token. The developer runs
ds-contracts figma receive --out contracts, which prints a 6-character code; the designer types it into the plugin. The proposal travels the pairing bridge and lands as a reviewed local diff — the CLI writes nothing without--apply, and with--applyit writes the generated component alongside the contract from that same config. - Copy the JSON out and commit it yourself, then run
generate(next step).
- GitHub PR. Fill in
Generate
The design's contract (proposed from the Figma dump, with the captured + minted token layers) generates React + CSS Modules + Storybook stories straight into the repo. npx @ds-contracts/cli generate contracts --out src/generated --tokens tokens/captured.dtcg.json,tokens/minted.dtcg.json --storiesTyped React + CSS Modules + CSF3 Storybook stories, prettier-formatted — the same byte-guarded generator the reference repo ships. Styles are token names compiled to
var(--…)custom properties; how styles are applied walks the whole chain.--targetalso acceptshtml,react-inline,figma-script, or any emitter you register with--emitter; an unknown target is refused with the list of registered names.Review in Storybook, beside the frame
The emitted stories cover the default plus every enum value and boolean. Open them next to the Figma frame — you are reviewing two renderings of one contract, not a translation.
CI holds the line
On every PR that touches
contracts/, the design-led workflow regenerates and then runs the referee:ds-contracts diffexits0clean ·1drift (findings named) ·2config error. A PR that would leave code and contracts disagreeing cannot merge. Full flag-by-flag detail: the CLI reference.
The honest asymmetry — and the change states which one it is
For a set this tool generated — it carries a ds_contracts/contractId marker — journey A is a true round trip: re-running the emitters reproduces the component byte for byte from the contract in the PR, the proposal is measured against the contract that built it, and the reference repo's own components re-extract to zero mismatches in both directions, red-tested.
For a hand-built set — no marker — it is an inversion, not a reproduction. The proposal is what can be read off the canvas — real structure, real variants, real bound variables — but a canvas cannot tell you about a useEffect, a keyboard handler, or why a value is what it is. Treat the generated component as a strong, correct-by-construction starting point, not as your finished component, and review it as new code. That boundary is deliberate and permanent: see the sync boundary.
You do not have to remember which case you are in. The plugin stamps the provenance into the proposal envelope and the PR body prints the matching sentence; when a contract arrives with no canvas provenance recorded, the body says that rather than picking a side. Both surfaces read one module, so the preview a designer sees and the sentence a reviewer reads cannot drift apart.
B · “I have components in code; I want them in Figma”
Your real Button — its real padding, its real colors, its real variants — as a native Figma component set, built by a machine rather than redrawn by a person. No copy-paste, no manual redraw.
Read this before you start: what you cannot do
You cannot point the Figma plugin at a GitHub URL or an npm package name and get components. This is structural, not a missing feature:
- To know what your component looks like, the tool has to run it in a real browser and read the computed styles. That is what makes the result true rather than guessed.
- A Figma plugin is a sandboxed iframe. No Node, no npm, no bundler, no browser engine of its own. It cannot install your package and it cannot render it.
So the browser step happens on a machine you control — your laptop or your CI — and what travels to Figma is a finished JSON bundle. Which is fine: it means the slow, fallible step is on the side that can debug it.
The fast check: does your library even need the browser step?
Static extraction (ds-contracts extract, no --computed) runs anywhere in seconds and always proposes schema-valid contracts carrying your API surface — props, enum values, defaults, events. Whether it also gives you anatomy — the parts, their layout, and which token paints each channel — depends on how your library is styled:
| Your library | What static extraction produces |
|---|---|
React + co-located <Component>.module.css | API surface and anatomy — parts, token bindings, layout, states, read from the stylesheet. Best-effort, not guaranteed: a whole-library run against Polaris produced anatomy for 109 of 182 components; the rest came back as stubs. |
| React + StyleX | API surface and structure only — parts, no styling. Styling is flagged for review. |
| React + Tailwind, Emotion, styled-components, any runtime styling | API surface only. Anatomy comes back as the stub {"root": {}}. |
Web Components via a Custom Elements Manifest (cem) | API surface only. A manifest has no styling channel. |
Each proposal says which one you got: “API surface AND anatomy … read from source” versus “API surface only; anatomy, tokens, and design bindings await reconciliation and human review.”
The quiet failure, named. A stub anatomy is schema-valid, so nothing refuses it — and the Figma emitter will build the set anyway. What lands on the canvas is a correctly named component with the right variant axes and blank frames inside: no fills, no padding, no bound variables. The tool is not lying; it is faithfully rendering a contract that says nothing about appearance. If your canvas sets come out empty, this is why, and the fix is the computed capture below.
The path
One command, two phases. npm i -g @ds-contracts/cli
ds-contracts onboard @acme/ui # detect · sandbox · seed · draft · STOP
# …review the draft (step 3 below), then:
ds-contracts onboard --continue # capture · promote · emit · bundle · publish
Phase 2 re-checks the review gate before anything else runs, and there is no flag that skips it. The steps below are what those two commands run — worth reading, because they are what you are debugging when something goes wrong.
Point it at your repo
writes ds-contracts.config.json — --detect prefills adapter, root, tokens and styling hints from what it finds, marked "detected", never "confirmed" npm i -g @ds-contracts/cli ds-contracts init --detect--detectreads yourpackage.jsonand your source tree, prints every prefill with the reason it chose that value, and writes them into a"$detected"block in the config. Confirm each value, then delete that block — the config is not yours until you have. A wrongtokenspath is the one that bites: extraction bindsvar(--x)against a real token tree, so pointing it at a tree that does not exist means nothing binds.Read the source, and draft the capture config
proposed contracts from your API surface + a DRAFT capture config marked with "__review:*" on every field the tool could not infer ds-contracts extract --draft-capture-configThe draft is machine-generated and deliberately incomplete. It marks the things static source cannot tell it —
classAllow,varPrefix,mount,fixedProps— each with one line of guidance and no guessed value.Review the draft. This is the one irreducibly human step.
Answer each
__review:*field by reading your own library, delete the markers you have handled, then delete the top-level marker to approve it. Skip this and the next command refuses:the refusal, verbatim — draft is not approved REFUSED: extract/computed/configs/acme.json is an UNREVIEWED DRAFT capture config (top-level "__unreviewed-draft" marker). A draft never captures: review every "__review:*" field (classAllow, varPrefix, mount, stateProps, fixedProps, …), then delete the marker to approve it.The refusal exists because a wrong
classAllowfails silently: you get a contract that looks perfectly fine and describes the wrong box. The tool would rather stop than hand you that.Capture — the browser step
launches a real Chromium, mounts every enumerated prop combination, reads the browser’s computed styles ds-contracts extract --computed --config extract/computed/configs/acme.json \ --harness examples/acme/.acme-sandbox --out extract/computed/out/acme--harnessis a directory with your library actually installed at a pinned version — the capture renders your package, it does not read your package's source. The sweep runs twice and refuses if the two runs disagree; that double-run check catches uncontrolled component state, random ids and animation sampling before any of it reaches a contract.No browser on the machine? The verb degrades with a named message and exit code
3rather than an unnamed module crash — and every other verb keeps working. The exact text is on the CLI page.Promote
computed-capture artifacts → promoted contracts + a minted token tree, driven by the per-library manifest ds-contracts promote --config examples/acme/ds-library.jsonThis used to be a script you copied and retargeted — six near-identical copies under
examples/*/scripts/, which is why a fix in one of them stayed latent in the other five. It is now one module driven by a per-libraryds-library.json, and the four libraries it took over reproduce their committed artifacts byte-for-byte. Two keep their own scripts by name: polaris (a different contract generation) and astryx (its re-anchoring ledger must be re-applied after the mint merge). docs/21 §2.6 walks it, including the refusals it prints (a state preview that would render identically to Default is refused by name, not worked around).Bundle: one JSON file, everything inside
contracts + your token set + referenced icons → one self-contained CONTRACTS-BUNDLE; same inputs produce identical bytes ds-contracts figma bundle examples/acme/contracts --out acme.bundle.json \ --tokens examples/acme/tokens/acme.dtcg.json,examples/acme/tokens/acme-minted.dtcg.json \ --modes light.json,dark.json --name AcmeThe bundle carries its own token set, so a foreign library never depends on the reference repo's tokens. The plugin syncs it as a named variable collection with Light/Dark modes and native variable aliases, then builds every component set bound to it. JSON is the only thing anyone pastes; there is no script step.
Deliver it — the durable way
claim-channel mints a write key (a CI secret) and a read key = sha256(write key) — the half the designer holds, which can never publish ds-contracts figma claim-channel # once, ever ds-contracts figma publish acme.bundle.jsonYou keep the write key in CI; you send the designer the read key (it starts with
dscr_) and they paste it once into the plugin's Changes tab. From then on CI publishes whenever and the plugin checks whenever — no codes to trade, nobody waiting. Deliveries carry a monotonicseq, so an out-of-order delivery is named and starts every Apply box unchecked, and GitHub Actions provenance (“repo — CI run #N, commit abc1234, published 4 minutes ago”) renders above the change report.Named limit: deliveries are not signed. Anyone holding the write key can publish any provenance, so there is no “verified” badge on a delivery. Stated rather than papered over.
The designer reviews and applies
In the plugin's Changes tab: Check for updates → a per-set report in plain words (“interior/style changes, no API change”) → tick the rows you want → Apply selected. Applying is in place: same node ids, same component keys, so instances placed around the file keep their component-property overrides (text, variant, boolean). New components land as new sets. Rows whose set has been edited on canvas warn that applying would overwrite that edit, and start unchecked.
Two other ways to deliver the same bundle
Ad-hoc, both people online. The one-time 6-character pairing code, folded into “Other ways to receive” in the plugin's Build tab:
npx @ds-contracts/cli figma push contracts-bundle.json --code <CODE>Deliver-once, 15-minute TTL, and you both have to be there in that minute. It carries no ordering, so it gets no freshness warning — a limit we state rather than paper over. Good for a one-off from a laptop; bad as a daily habit.
No CLI at all. The bundle is plain JSON. Send it however you send files; the designer pastes it into the box on the plugin's Build tab and clicks Generate in this file. A designer with no repo access and no terminal can go end-to-end this way.
First landing from a single contract. A contract can also be compiled directly to a Figma Plugin API sync script — the same emitter the reference repo built its entire canvas library with:
npx @ds-contracts/cli figma contracts/badge.contract.json --out figma-sync --tokens tokens/polaris-light.dtcg.json,tokens/polaris-minted.dtcg.json --icons iconsThis is the older, lower-level door. Prefer figma bundle unless you are debugging the emitter.
C · “I already have both”
Brownfield: a mature Figma library your team drew by hand, a mature codebase, and no shared account of how far apart they are. Start diagnostic. Do not generate anything yet.
Look at the Figma side
The plugin's Send → Scan this file does a read-only pass over every local component set — including ones this tool never made — and reports what is there and which sets could come under contract. Nothing is changed. Any set can then be proposed as a contract with no base contract at all.
Get the disagreement report
code-side contracts vs a Figma dump — every property classified agree / options-differ / code-only / design-only ds-contracts extract --reconcileThis is the artifact that ends the “which one is right” argument, because it is per-property and mechanical rather than a judgement call.
Then hold the line
the standing referee — exit 0 clean · 1 drift (findings named) · 2 config error ds-contracts diffWire it into CI and the gap stops growing while you close it. This is the referee running over surfaces the CLI did not generate.
Not supported yet, stated plainly: adopting an existing set — stamping a hand-built Figma component as contract-backed so future syncs amend it in place — is not a verb this tool has. Coexistence inside a foreign enterprise kit is proven, and in-place amend of a set this tool created inside that kit is proven forensically; amending a hand-built set is not. Plan on the hand-built set being read and a new contract-backed set being built, not on the old one being upgraded.
What can I expect?
Two numbers pull in opposite directions, both are true, and neither is publishable without the other. The measured answers live on What works (what it does) and in Known Limitations (what it costs).
Fidelity per captured component is high — 89.6% mean. What lands on the canvas is the browser's own computed truth for your real component: not an approximation, not a screenshot, not a guess. Measured against the original npm package rendering in the same pinned Chromium, as an exact string match with no tolerance, 54 components across 6 libraries score 89.6% mean computed-style equality (88.6% cell-weighted over 379,861 cells; 31/54 at ≥90%, 47/54 at ≥80%). All 54 are listed worst-first, none omitted.
Coverage per library is partial — and it is 6.0%. A first pass will not be your whole library. Each foreign-library round in the reference repo committed a dozen or so components out of a library of one to two hundred: about 4% to 12% per library on the contracts-committed denominator, and 54 of 893 = 6.0% on the stricter measured-scorecard one. Read the fidelity number above as “on the easy 6.0%” — that is why the denominator is printed first. Budget hours per library for the recon and the config; the capture itself is machine time. The size denominators come from docs/22 §8.3.
Beyond that, four properties you can rely on:
- It refuses rather than guesses. A token ref outside the inventory, an illegal contract, an unreviewed draft config, a state preview that would render identically to Default — each stops with a message naming the thing. A plausible substituted value is treated as worse than a crash.
- Everything it cannot carry, it names. Every extraction writes a
*.extension.jsonsidecar listing each captured fact the vocabulary refuses, with the reason. Nothing is dropped on the floor. - Re-running is always safe. Same input, same bytes; applying an update preserves node ids, component keys and instance overrides.
- The known gaps are written down, not discovered. Three you will meet soon: overlay components (Dialog, Menu, Tooltip) have no hover/focus/active planes in the captured truth, so those contracts declare
states: []by design; text wrapping is not implemented, so a hugging text node inside a narrower fixed-width ancestor clips; the capture harness loads no webfonts, so absolute text widths are fallback-font widths. The complete inventory is docs/23 — Known Limitations, whose counterpart is What works; the evidence behind the generality claim and where it leaks is docs/22 §8.
These commands are tested, literally
Every npx @ds-contracts/cli command line above is rendered from evals/fixtures/journey-commands.json — the same manifest the journey-engineer and journey-designer evals execute end-to-end against the CLI build. The site build fails on any hand-typed command, and both evals refuse if the manifest drifts from what actually runs. Documented commands and tested commands cannot diverge. The remaining verb-shape lines (written ds-contracts …) are checked against the shipping usage block, which the CLI page renders verbatim from source at build time. The CI recipes are held to the same bar: every run: step in examples/ci/ has been executed verbatim against the published CLI (VALIDATION.md).
The packages
Everything above is published and MIT-licensed: @ds-contracts/cli@0.4.0 (the whole engine, esbuild-bundled, zero required runtime dependencies) and @ds-contracts/schema@16.0.0 (the contract schema, its generated JSON Schema, and the validateContract referee). Point the extraction at what you already have — proposals only, nothing overwritten:
npm i -g @ds-contracts/cli
ds-contracts init --detect # writes ds-contracts.config.json — confirm code.root and tokens
ds-contracts extract # your components → schema-valid PROPOSED contracts
ds-contracts diff # the continuous referee: contracts vs code (and design, when configured)Adapters ship for react-tsx (function components, forwardRef/memo, any props-type convention, cva variants, defaults, on* events, CSS Modules anatomy) and cem — any library publishing a Custom Elements Manifest. Every inference ships with a note, and nothing is invented. Want another output surface? Write an emitter — @ds-contracts/emitter-web-components is the worked example.
If you just want the contract format: point your editor's $schema at the published JSON Schema — @ds-contracts/schema/contract.schema.json — and read the reference, which is generated from the same source. Running the full reference machinery (evals, instruments, the census) still means cloning the repository: npm install && npm run build && npm run eval.
Installing the Figma plugin
The plugin is not on the Figma Community, and that is a decision rather than a pending task. Distribution is the manifest-upload developer-plugin path, with two routes to the manifest: no clone — download the packaged zip the playground serves at /ds-contracts-sync-runner-plugin.zip, unzip it, and import the manifest.json inside; or from a clone — npm run plugin:zip refreshes figma-sync/plugin-dist/ and you import figma-sync/plugin-dist/manifest.json. Either way, in Figma desktop use Plugins → Development → Import plugin from manifest…. Never import from figma-sync/plugin/, which is a stub with no engine. The consequence, stated as a property: someone imports the manifest once per file owner — only the clone route requires repo access.