22 KiB
name: design-sync description: Push a React design system to claude.ai/design. This runs a converter that bundles the real component code (from Storybook or a bare package) and uploads it. Use when the user runs /design-sync or says "sync my design system to Claude Design".
Sync a design system to claude.ai/design
What this is for
Claude Design (claude.ai/design) is Claude's design tool: users prompt a design agent and it builds working UI — screens, flows, prototypes — rendered live in the browser from real React code. Out of the box it designs with generic components. This skill changes that: it converts the user's design-system repo into the format Claude Design consumes and uploads it, so from then on the design agent builds with the customer's actual components — every design it produces is on-brand, made of their real parts, and maps 1:1 onto code their engineers can ship.
That framing should drive every judgment call in this skill, because each uploaded artifact is an input to that agent (or to the humans steering it):
| Uploaded artifact | Consumed by | For |
|---|---|---|
_ds_bundle.js + _vendor/ |
the design agent's runtime | every design it produces renders these real compiled components from window.<globalName>.* |
styles.css, fonts/, tokens/, _ds_bundle.css |
every rendered design | the look — tokens, fonts, and component styles, all reachable from styles.css's @import closure (designs receive only that closure) |
<Name>.d.ts (<Name>Props) |
the design agent | the API contract it codes against |
<Name>.prompt.md |
the design agent | its usage reference — how to compose the component, with examples |
<Name>.html preview card |
humans in the component picker | how they find components and trust the sync |
_ds_sync.json |
future syncs | the sync anchor — content hashes that let a re-sync (any machine) skip re-verifying unchanged components AND compute exactly what to upload/delete |
This is why fidelity is the whole game: a component that renders wrong here renders wrong in every design the agent ever builds with it, and a wrong .d.ts or misleading .prompt.md makes the agent misuse the API everywhere. The verification loops in the sub-skills exist because of this — they are not bureaucracy.
The converter builds all of the above deterministically from the repo's own dist/. With a Storybook, previews come from the repo's stories and are verified against its own storybook render (kept as a local reference, never uploaded). Without one, every component still ships fully functional, and rich previews are authored from the repo's own usage examples for the components the user scopes in, graded on an absolute rubric. Core principle: ship what the customer already built — the bundle is their compiled dist/, never a reimplementation.
You have a DesignSync tool that reads and writes the user's claude.ai/design projects.
0. First sync? Set expectations before any work
A completed sync always leaves .design-sync/config.json holding both a projectId and a pkg. If both are present, this is a re-sync — skip this section (§2 covers honoring prior state). (If design-sync.config.json exists instead — the config's old name and location — move it: mkdir -p .design-sync && mv -n design-sync.config.json .design-sync/config.json, commit the move, then apply the same test.) Anything less — no config at all, or a partial one left by a run that never finished — gets first-time treatment: tell the user up front, before doing anything else:
- No completed sync was found — this is a first-time import.
- This skill attempts a high-fidelity import of their design system: by default that means iterating on the build and visually verifying the quality of every component preview, which can take up to a few hours on a large repo.
- They can interrupt at any time — a message mid-run to check progress or redirect the effort is welcome and won't break anything.
- A first-time import goes into a new Claude Design project created for it (§1). Everything that needs their approval happens near the start — creating that project, and one approval that covers this run's uploads into it. After that, verified components appear in the project as the run progresses: they can open the project at any time and watch it fill in, and nothing waits on their approval at the end.
- The run records config and notes as it goes, so future syncs are faster and mostly deterministic.
(If §1 routes this run into an existing project — the user re-adopting one, or a projectId left pinned by an aborted run — parts of this won't apply; scale the expectations to what §1 routes them to.)
Then confirm they want to proceed — this process can use a significant number of tokens (AskUserQuestion: proceed with the full high-fidelity sync, or adjust scope first). If their request already acknowledged the time/cost, note that and continue without re-asking.
1. Pick the target project
If DesignSync isn't already in your tool list, load it via ToolSearch(query: "select:DesignSync") first. A target gets picked one of three ways, in precedence order:
- Pinned:
.design-sync/config.jsonhas aprojectId→ that's the target.DesignSync(get_project)to confirm it still exists and isPROJECT_TYPE_DESIGN_SYSTEM, mention which project you're syncing to, and re-ask only if it's gone or the user redirects. - Fresh — the first-time default: no pin → create a new project. A fresh project is the only target whose entire contents this run owns; that ownership is what makes the incremental upload (§3) safe to approve in one shot, and it's why existing projects are never offered here — pouring a first import into a project that already has files would show a half-imported mix to anyone using it, with no sync anchor to tell its files apart from this run's. Use
DesignSync(list_projects)to pick a NON-colliding name (a duplicate gets rejected and costs a round-trip), confirm the name viaAskUserQuestion, and only then callDesignSync(create_project)— it raises its own permission prompt, and an unconfirmed creation can stall an unattended session. If that prompt is denied, stop and ask the user what to do differently; never retry unasked, never continue without a target. One salvage case: a project evidently left by a prior aborted run of this repo (it has the name this skill would propose —list_filesit to confirm it's actually empty, sincelist_projectsshows no file counts) may be offered for reuse instead of creating another, or noted as safe to delete. - Re-adopted — on the user's explicit ask only: the user names an existing project (by name or UUID; typically re-adopting the project a previous sync uploaded to, after the config was lost).
DesignSync(get_project), checktypeisPROJECT_TYPE_DESIGN_SYSTEM, then warn them in plain language (no tool jargon) that syncing can overwrite or delete files already in it — e.g. "Heads up: syncing into that existing project means I may replace or remove files it already contains so it ends up matching this repo. If anything in there isn't from this repo, it could be lost — want me to continue, or create a fresh project instead?" — and proceed only on their confirmation. This explicit ask is the ONLY way an unpinned run ends up in a pre-existing project.
Record the pin at settlement. The moment the target is settled — created, reused, or re-adopted — record its projectId in .design-sync/config.json, before anything uploads. This is the skill's one recording rule: a death at any later point leaves a pinned config, so the retry repairs the SAME project through the atomic path instead of creating a duplicate and orphaning the original. (The post-upload record step in the sub-skills' atomic sections is just the backstop for this rule.)
Route the upload path. A projectId pinned before this run started always takes the atomic path (the sub-skill's upload section) — even when its project turns out empty; a bulk re-upload is fine there, and one rule beats a special case. Otherwise the remote decides, via a prompt-free DesignSync(list_files) on the target:
- Empty (the normal case — this run just created it) → incremental path (§3): one upfront approval, then verified components upload as the run progresses.
- Non-empty (a re-adopted project) → atomic path: it may be in active use, so it updates in one pass at the end of the run, after everything is verified.
The router decides only the upload path. Verification scope is the anchor's job: a project with _ds_sync.json lets the re-sync driver skip unchanged components; no anchor means everything gets verified, whichever upload path applies.
2. Explore, then write config
The workflow is explore the repo → write .design-sync/config.json (§1's pin has already created the directory and the file — read it and add to it, never dropping projectId; mkdir -p .design-sync stays as a harmless safety net for legacy states) → run the converter deterministically from it. The converter's discovery is heuristic-based; each heuristic has a config override (after the sub-skill stages the scripts: grep -r ASSUMPTION .ds-sync/*.mjs .ds-sync/lib/*.mjs lists them) so repos that don't match the defaults write config, not code. Edit lib/*.mjs only as a last resort (see the sub-skill's escape-hatch section: storybook §5, package §Troubleshooting).
The upload format is the contract; the converter is the deterministic path to it, not the only path. What the app consumes is fully specified by the output layout: _ds_bundle.js + @ds-bundle header, styles.css, components/<group>/<Name>/{.html,.jsx,.d.ts,.prompt.md} with the @dsCard first line, _preview/, _vendor/, fonts/, _ds_sync.json (see the sub-skill's layout and upload sections).
An off-script layout should also produce _ds_sync.json when it can. For the package shape, lib/sync-hashes.mjs gives styleShaFor/renderHashFor/sourceKeyFor; the envelope is {shape, styleSha, renderHashes, sourceKeys, keyRecipe, scriptsSha, sourceHashes, auxSha, bundleSha12} (see the sidecar block in package-build.mjs — sourceHashes itself comes from stampHeader in lib/bundle.mjs; sourceKeys may be omitted, which just means changed artifacts re-verify). The storybook shape's recipe needs story facts an off-script generator may not have; omitting the sidecar is then the honest choice — the next sync simply has no anchor and re-verifies everything, which is correct.
One invariant that's easy to miss when producing the layout by hand: rendered designs receive only styles.css's transitive @import closure. Any real component CSS (_ds_bundle.css) must be @imported from styles.css — a card linking it directly proves nothing about designs.
For a repo genuinely outside the converter's envelope (non-esbuild-bundlable builds, exotic toolchains), produce the layout by whatever means the repo allows. The gates don't move: package-validate.mjs must exit clean, and every story must be graded before upload — from true screenshot pairs in the storybook shape, on the absolute rubric in the package shape. Off-script generation is legitimate; off-script verification is not.
State from prior runs. If .design-sync/config.json or .design-sync/NOTES.md already exist, Read both first and honor what's there — they hold corrections from earlier syncs. Whenever the user tells you about an issue mid-run (a path, a build flag, a component to skip, a package-manager quirk), persist it immediately so the next sync doesn't need telling again: a value that maps to a cfg.* field goes into .design-sync/config.json; anything else goes as a bullet in .design-sync/NOTES.md. Both get committed at the end (the sub-skill says when).
- Faithful install with the repo's own package manager. Use the repo's pinned node version (
.nvmrc/engines.node), then detect via lockfile:yarn.lock→yarn install --immutable;pnpm-lock.yaml→pnpm i --frozen-lockfile;bun.lockb/bun.lock→bun install --frozen-lockfile;package-lock.json→npm ci. - Determine the source shape. If
.design-sync/config.jsonalready exists and has a"shape"field, use that. OtherwiseGlobfor**/.storybook/main.*and**/storybook/main.*(some repos drop the dot; excludenode_modules) — monorepo DSes keep it in a subpackage, so never assume it's at repo root:- Any match →
shape = 'storybook'. The match's grandparent is the package to run from. Found several →AskUserQuestionwhich one is the design system's; that dir becomesstorybookConfigDir. Do not fall back to package just because.storybookisn't at repo root. - Found
*.stories.*files but no.storybook/dir in the target →AskUserQuestion: "Found story files but no.storybook/here — is there a Storybook config elsewhere in this repo (e.g.apps/storybook/.storybookin a monorepo)?" If they point at one →shape = 'storybook', record that path asstorybookConfigDir. If they say no →shape = 'package'. - No
.storybook/and no*.stories.*→AskUserQuestionwhether a Storybook exists at all. If they point at one, record it asstorybookConfigDirandshape = 'storybook'. If no,shape = 'package'.
- Any match →
Then Read <skill-base-dir>/storybook/SKILL.md or <skill-base-dir>/non-storybook/SKILL.md and follow it from there (the storybook one points back into the package one's shared tables where they overlap). Record "shape" (and "storybookConfigDir" when set) in .design-sync/config.json when you write it so re-sync skips detection. Both shapes run <skill-base-dir>/package-build.mjs as the converter entry and <skill-base-dir>/resync.mjs as the single re-sync driver (build → diff → validate → scoped capture, one verdict JSON); shared adapters live at <skill-base-dir>/lib/, and <skill-base-dir>/storybook/ holds the storybook-only harness (compare.mjs — preview-vs-storybook matching; probe.mjs — provider inference fallback).
3. The incremental upload sequence (first syncs into an empty project)
On the incremental path (§1), the user approves the upload once, early, and then watches verified components appear in their project while the run is still going — instead of waiting hours for one bulk upload at the end. This section is the shared mechanics; the sub-skill says when each step fires (its own build and verification gates, marked "incremental path" there). The sub-skill upload section's mechanics apply to every write here too: ≤256 files per write_files call and smaller chunks for binary-heavy dirs, upload hygiene, and the what-stays-local list.
Open the upload channel — at the sub-skill's first-clean-build gate
- Explain the approval in plain language first. Before asking, tell the user what they're about to approve, with no tool jargon (no "plan", "glob", or tool-method names): e.g. "I'll ask for one approval now that covers uploading everything this run produces into the new project — and cleaning up any files a later rebuild drops. You won't be prompted again; components will appear in the project as they're verified." The approval dialog shows a structured path list on its own; this message is what makes that dialog make sense to someone who's never synced before.
DesignSync(finalize_plan)withlocalDir: "./ds-bundle",writes: ["components/**", "tokens/**", "fonts/**", "_vendor/**", "_preview/**", "guidelines/**", "_ds_bundle.js", "_ds_bundle.css", "styles.css", "README.md", "_ds_sync.json", "_ds_needs_recompile"], anddeletes: ["components/**", "tokens/**", "fonts/**", "_vendor/**", "_preview/**", "guidelines/**"]. The delete globs are what make the end-of-run reconciliation below prompt-free — and they're consent-trivial here: the project started empty, so anything deletable is something this same run uploaded. The returnedplanIdserves the whole run (it lives for the session). Lost mid-run to a context reset →finalize_planagain, one fresh approval, before uploading anything more. A whole-session death doesn't resume this path at all: the retry arrives pinned (§1) and correctly goes atomic — expected, not a bug to work around.- If the approval is denied, stop and ask — never continue silently, never re-prompt unasked. Say in plain language what was denied and what it covered ("the one-time approval for uploading this run's output into the new project"), then offer: try the approval again; target a different project; or finish the build and verification locally with no upload. Local-only → the run proceeds normally except nothing uploads, and the end-of-run report hands over both the
ds-bundle/path and the project's URL (https://claude.ai/design/p/<projectId>— the pin is already recorded, so a later sync finds this project rather than orphaning it). A different project → it goes through §1's re-adoption ask and the router like any other explicit choice, pin included: non-empty → atomic path, this plan abandoned; empty → resume here with a fresh approval.
Push each verified batch
Nothing uploads until the first batch of components passes the sub-skill's done-bar. The first push carries the shared base files together with that first batch: _ds_bundle.js, _ds_bundle.css, styles.css, README.md, _vendor/**, tokens/**, fonts/**, guidelines/**, plus the batch's components/<group>/<Name>/ dirs and _preview/<Name>.* files. Two reasons they travel together: the first thing the user sees in the project is real components, not an empty shell that claims something was uploaded — and by first-batch time the shared files have earned their place, because grading those components exercised the very same bundle, CSS, and fonts. This first push is the project's first content and its largest, so it takes the full fence: sentinel first (write_files _ds_needs_recompile — it fences the app's manifest/copy machinery against a half-uploaded state), then the files, then the sentinel re-write (every push on this path ends by re-writing the sentinel — that's what makes the app refresh its view of the project next time it's opened). Output the project URL prominently with this push — https://claude.ai/design/p/<projectId> — it's the moment the project first has something to see.
Every later batch that passes the done-bar: write_files its components/<group>/<Name>/ dirs and _preview/<Name>.* files, then re-write the sentinel — the new cards appear next time the user opens or refreshes the project. When you report batch progress, include the project URL so the new cards are one click away. If a full rebuild has run since the last push (a global config fix landed), include the shared base files again: the fix rewrote the bundle/CSS/fonts locally, and without re-pushing them every component verified after it renders against stale remote versions until close-out. They're in the approved plan and idempotent, so the re-push costs nothing.
Later batch pushes need no leading fence — they're short and always end re-armed, so the unfenced window is negligible (the first push above and the long close-out below are the ones that fence first). And batches are progressive visibility, not the correctness mechanism: the close-out guarantees the final state, so don't agonize over batch composition — a component pushed early then reworked later simply gets re-pushed.
Close out — after the sub-skill's final gate
- Sentinel first, then full content writes. Re-write
_ds_needs_recompilebefore anything else — the app clears the sentinel whenever the user opens the project (which this path invites mid-run), and the close-out is the longest write+delete stretch, so re-fencing here is what keeps a half-applied state from ever being consumed. Then everything in the plan's writes EXCEPT_ds_sync.json, chunked. Re-uploading unchanged files is idempotent and cheap; this pass covers anything the batches missed and anything the final rebuild changed, so the project ends up exactly matching the final verified build no matter how the batches went. - Reconciliation deletes — mandatory, not conditional.
DesignSync(list_files)the project anddelete_filesevery remote path undercomponents/,_preview/,tokens/,fonts/,_vendor/,guidelines/that the finalds-bundle/does not contain (the plan's delete globs cover them — no new prompt). Why this pass exists: a component uploaded by an earlier batch and then dropped, renamed, or regrouped later in the run is invisible to every future re-sync diff — anchor-based diffs only see what the anchor records — so this is the only moment it can ever be cleaned up; skip it and the orphan is permanent. The deletes also retire the orphan's card: the app rebuilds its component index from the currently-uploaded files, so the card disappears once the sentinel is re-armed (next step) and the project is opened. - Sentinel re-arm, then
_ds_sync.jsonabsolutely last, in its ownwrite_filescall — same rule, same reason as the atomic path: the anchor must only ever vouch for a fully-applied state, and it goes after the deletes so a failed delete can't leave remote files the anchor no longer sees. Then output the project URL —https://claude.ai/design/p/<projectId>— with the final summary.
A mid-run abort anywhere on this path (user stops the run, session dies) leaves the project un-anchored — the documented safe state: the next sync re-verifies everything and re-uploads, nothing silently rots. And as in the sub-skill upload sections, any write/delete failure that retries don't clear means STOP — no sentinel re-arm, no _ds_sync.json.