claude-code-system-prompts/system-prompts/skill-design-sync-storybook-source-shape.md
2026-06-10 13:10:24 -06:00

52 KiB
Raw Blame History

Storybook source shape

Storybook is the fidelity oracle, not the runtime. The converter bundles the package's compiled dist/ into _ds_bundle.js — the same bundle the claude.ai/design agent builds with — and generates each preview by compiling the story source module itself (hooks, fixtures, local helpers — the whole closure comes along), with every component import resolved to that shipped bundle (lib/story-imports.mjs redirects package and relative component imports to window.<Global>). The repo's own storybook render is the ground truth those previews must match: a compare harness screenshots each story in the reference storybook and the matching preview render side by side, and you iterate until they match. Nothing from storybook-static is uploaded, and no story code is ever evaluated at build time — stories run only in the browser, against the real artifact.

Requires React 18+. Playwright + chromium are required for this shape (the compare loop is the verification), not optional.

First sync or re-sync? A re-sync is marked by a config whose projectId and pkg were both in place before this run started — most of this document then doesn't apply; go to §7, where one driver run routes the work and untouched components cost nothing. Everything else takes the full flow (§2 build → §3 self-heal → §4 match → §6 upload), where every component gets verified and graded once — that includes a partial config left by an aborted run, and a pin this run itself just recorded in the base skill's §1. (Only the old design-sync.config.json present? Move it first and commit: mkdir -p .design-sync && mv -n design-sync.config.json .design-sync/config.json, then apply the same test.)

2. Build, then run the converter

  1. Build the DS package and its workspace dependencies. The converter bundles dist/ into window.<Global>. Run <pm> run build; in a monorepo use turbo run build --filter=<pkg> or pnpm -F "<pkg>..." build (the trailing ... is required — bare -F <pkg> skips dependencies and you'll see Cannot find module '@scope/tokens'). If package.json module/exports['.'] points at TS source, find the actual built entry and pass it via --entry. Do this before step 2 — storybook often imports sibling packages from their built dist/.

  2. Build the reference storybook ONCE into .design-sync/sb-reference/ — NOT under ds-bundle/ (the converter wipes --out on every rebuild, and storybook builds take minutes; the reference must survive the fix loop):

    npx storybook build -c <storybookConfigDir> -o .design-sync/sb-reference
    

    Run it from the directory whose package.json has the storybook devDependencies — usually the one containing .storybook/; monorepos often have several storybooks, so pick the one covering the package you're syncing. Make -o the repo-root path (e.g. -o "$(git rev-parse --show-toplevel)/.design-sync/sb-reference"): the converter and compare resolve .design-sync/ from the repo root, so a cwd-relative -o in a subpackage puts the reference where nothing will find it. Use npx storybook build directly, not the repo's npm run build-storybook script (wrong output dir). Then check .design-sync/sb-reference/iframe.html exists and is >10KB — index.json alone can exist with a failed build.

    Long builds: background them through your shell tool's background mode only and wait for the completion notification. Never a bare & (untracked — the notification never comes), and never a pgrep -f '<script>' poll loop (it matches its own command line and spins to timeout).

    .gitignore additions: .design-sync/sb-reference/, .design-sync/learnings/, .design-sync/.cache/, .ds-sync/, ds-bundle/ — build artifact, transient scratch, verification working state, staged scripts, regenerated output. Committed: previews/ (your authored files ONLY — generated story-module wrappers live in .design-sync/.cache/previews/ and regenerate every build; the converter never writes or deletes anything in previews/) and NOTES.md. Verification state is never committed — cross-machine carry-forward comes from the uploaded project's _ds_sync.json. Rebuild the reference only when stories or the DS source change.

  3. Write .design-sync/config.json — only pkg and globalName required. If it already exists, read it first and keep what's theretitleMap, overrides, and provider accumulate fixes from prior syncs. Also Read .design-sync/NOTES.md first — its Re-sync risks section is the prior run's watch-list; re-verify those items instead of assuming carry-forward covers them. The package-shape field table in ../non-storybook/SKILL.md §2.6 applies verbatim; the fields that matter most here:

    Field Value
    pkg / globalName pkg required; globalName auto-derived from it when omitted
    shape "storybook" — pins detection
    storybookStatic ".design-sync/sb-reference" — so re-syncs and compare find the reference without flags
    storybookConfigDir the .storybook/ dir (monorepos)
    buildCmd what to re-run before the converter on re-sync
    titleMap {title: ExportName} when story titles don't match export names; {title: null} excludes a non-visual/internal component from the sync entirely
    overrides {<Name>: {skip: [storyIds], cardMode: "single", primaryStory: "<Export>", viewport: "WxH"}}skip for stories that can't render statically; the card keys for overlay components (§4a.5, §5)
    provider usually unnecessary for previews.storybook/preview decorators are auto-bundled; set only when that fails. Before §6 upload, distill decorator-provided context into cfg.provider — README/prompt.md wrap guidance is generated from config only (decorator-only wrapping ships a generic note). Setting it also replaces the decorators as the preview wrapper on the next build: scoped-compare a themed component after the switch — an incomplete distillation regresses previews the decorators rendered fine, and carried-forward grades won't catch it. Format: {"component": "ThemeProvider", "props": {…}, "inner": {…}} — a nested chain, outermost first; each component must be a bundle export. Literal props are for small scalars ("theme": "light") and stable snippets. For data that already exists in the repo — a locale JSON, a theme object — prefer {"$ref": "<export>"} backed by a 2-line module added via cfg.extraEntries (e.g. export { default as previewI18n } from '../locales/en.json'): a $ref emits window.<Global>.<export>, so the data lives once in the bundle and re-reads from its source file on every build. Inlining a copy is acceptable for something tiny and stable, but know the cost — a literal duplicates into every card's html and silently rots when the source file changes, so anything sizable or evolving belongs behind a $ref. Path forms for extraEntries: a bare name resolves from node_modules; a repo-owned module needs an explicit .//../ package-relative path (workspace-bounded — the build logs ! extraEntries: … skipped if it escapes).
  4. Stage scripts + install converter deps (isolated in .ds-sync/, repo lockfile untouched):

    mkdir -p .ds-sync && cp -r "<skill-base-dir>"/package-build.mjs "<skill-base-dir>"/package-validate.mjs "<skill-base-dir>"/resync.mjs "<skill-base-dir>"/lib "<skill-base-dir>"/storybook "<skill-base-dir>"/non-storybook .ds-sync/
    echo '{"name":"ds-sync-deps","private":true}' > .ds-sync/package.json
    (cd .ds-sync && npm i esbuild ts-morph @types/react playwright && npx playwright install chromium)
    

    If chromium install fails, npx playwright install-deps chromium first; if the environment can't install chromium, set DS_CHROMIUM_PATH=<system-chromium>.

  5. Run the converter, validator, and compare — synchronously, stopping at the first non-zero exit (compare only runs once build + validate are clean — §3). Large DSes (≈100+ components) may need NODE_OPTIONS=--max-old-space-size=<MB> for the build; never pipe the build through head/tail (the pipeline masks the exit code — an OOM looks like success); redirect to a file and read it:

    node .ds-sync/package-build.mjs --config .design-sync/config.json --node-modules <pkg-node-modules> \
      --entry <built-dist-entry> --out ./ds-bundle
    node .ds-sync/package-validate.mjs ./ds-bundle
    node .ds-sync/storybook/compare.mjs --out ./ds-bundle --storybook-static .design-sync/sb-reference \
      --components <solo-phase picks>   # scope the FIRST compare to the §4b solo components
    

    In a monorepo, --node-modules is the DS package's own node_modules — unless hoisting leaves it sparse (yarn's node-modules linker keeps react only at the repo root): if react/ or react-dom/ is missing inside, pass the repo-root node_modules instead. In the DS's own source repo node_modules/<pkg> doesn't exist, hence --entry. The build logs [ICON_PKG] / [TOKENS_PKG] auto-detections and bundles .storybook/preview decorators as the preview wrapper (preview-decorators.js) so previews get the same provider chain stories do.

    Scope the first compare run: a full capture of a large DS is thousands of chromium navigations — pointless before the solo phase has flushed global issues (each global fix invalidates every capture). Run the full compare for the first time at §4b step 3. For a DS with >100 storied components, also tell the user the expected scale (components × stories) before fan-out and let them narrow scope if they want.

3. Self-heal loop (build + validate)

Fix [TAG] errors → rebuild → re-validate until both exit 0, before starting the compare loop in §4 — there's no point pixel-matching previews while the bundle itself is broken. Shared converter tags ([NO_DIST], [WORKSPACE_SIBLING], [CSS_*], [FONT_*], [TOKENS_MISSING], [DTS_*], [RENDER*], …) behave identically to the package shape — use the table in ../non-storybook/SKILL.md §3. Storybook-specific:

Tag Symptom Fix
[SB_REFERENCE_MISSING] compare can't find iframe.html Build the reference (§2.2); set cfg.storybookStatic.
[SB_BUILD_FAIL] converter's own storybook build failed You skipped §2.2 — build the reference yourself and set cfg.storybookStatic so the converter never needs to.
[ZERO_MATCH] (storybook flavor) no story entries matched Check the storybook config's stories glob; then titleMap.
[TITLE_UNMAPPED] N titles don't match an export cfg.titleMap {<title-name>: <export-name>}.
(preview: <Name> — no story exports paired …) index story names couldn't be matched to module export keys (pairing tries the display name, then the story ID's tail) the component shows the floor card; fix the pairing — usually an owned .tsx re-exporting the stories under matchable names.
a preview cell errors with undefined-component / wrong-context messages a story import resolved the wrong way — relative, tsconfig-alias, and bare-workspace imports all go through the same policy (see lib/story-imports.mjs's rules) cfg.storyImports.shim / cfg.storyImports.bundle substring patterns force the resolution per resolved path — the cheap fix before forking the seam.
! preview build failed: <Name> the story module didn't COMPILE (top-level await, an import of a package esbuild can't resolve, an asset extension with no loader) read the esbuild error above the line. Unknown asset extension → cfg.storyImports.loaders (merged over the defaults, e.g. {".yaml": "text"}); unresolvable import → own the .tsx and drop it. The component shows the floor card until fixed.
a story's own stylesheet is missing from its cell story-local .css/.scss side-effect imports compile as empty (component styles ship via the bundle css). Exception: .module.css IS compiled — classes resolve and _preview/<Name>.css is linked automatically usually nothing — the styles are decoration the storybook page adds. If the story genuinely depends on them, inline the styles in an owned .tsx.
[BUNDLE_EXPORT] components aren't functions on window.<Global> extraEntries for subpath/icon exports; check the dist entry is the full build.
[SCHEDULER_MISSING] dist imports scheduler react-dom leaked into the DS dist — check its build's externals.
! preview decorator bundle failed decorators couldn't be bundled Set cfg.provider manually, or run node .ds-sync/storybook/probe.mjs --storybook-static .design-sync/sb-reference to infer the chain from the live storybook (replace each $hint with a real value).
previews error at _vendor/preview-decorators.js load (storybook-API undefined errors) the .storybook/preview import graph reached a storybook-runtime module the stubs don't cover manager-api/preview-api are stubbed with functional no-op hooks and every other @storybook/*/msw module with inert callables (fn(), action(), setupWorker() at module scope all evaluate harmlessly); if some other API still crashes, set cfg.provider explicitly — it skips decorator bundling entirely.
[ASSETS_BLOCKED] from compare the capture browser inherited a network-sandboxed shell — story assets (CDN images/fonts) failed on both panels, so grades can falsely pass while end users see different output re-run package-validate.mjs + compare.mjs --force from a shell with egress to the listed hosts: approve running the command without the sandbox when prompted, or add the hosts to the sandbox allowlist. Don't grade image-bearing components while this prints.

Incremental path (base SKILL.md §3) — this is the open-the-channel gate. The first time build + validate both exit 0, open the upload channel before starting §4: the user approves once here, then watches components land as grading proceeds. Nothing uploads until the first graded batch — the shared base files ride with it — and the batch pushes come from §4b/§4c. (Atomic path: nothing uploads until §6.)

4. Match previews to storybook

compare.mjs is a capture harness — it photographs, you grade. It computes no similarity heuristics (pixel/text/font scores mislead whenever framing legitimately differs); the judgment is made from the two true screenshots. Compiled previews capture per story — each story renders alone via ?story=<Export> at the full capture viewport, exactly as storybook frames the reference side — so sibling stories can't interfere (portal stacking, shared radio-group names, focus, container measurement). Two output tiers:

  • Transient (under ds-bundle/, wiped by rebuilds): _screenshots/compare/<group>__<Name>.png — sheet with one row per story: the true storybook render | the true preview render, side by side. Sheet images are shrunk to fit; the full-resolution originals are in …/compare/raw/ (…__sb.png / …__ds.png) — Read those when the sheet is too small to judge confidently.
  • Campaign state (in .design-sync/.cache/compare/, gitignored): <Name>.grade.json — your verdicts — and <Name>.json — capture facts: story↔cell pairing, shot paths, previewKind, the component's srcSha (story-file fingerprint), spot-check anchors. Reconstructible — absence just means "capture again". The only verdicts the script emits are factual: sb-error (story doesn't render in storybook), unpaired (no preview cell for the story), error (cell threw); every rendered pair is needs-grade.

Compare captures at most 6 stories per component by default — [STORY_CAP] in the log names components with more, and --max-stories <n> raises the cap. The cap is NOT part of the grade contract: raising it just captures the tail stories for incremental grading, and existing verdicts survive. One consequence to know: a capped component that grades fully match/close is verified-by-upload in full on future syncs even though its tail stories were never individually graded — raise the cap when those tail stories carry distinct variants worth verifying. Fan-out subagents must not change it mid-wave (sheets would cover different story sets than the orchestrator's worklist assumed).

State across runs — the first run verifies everything once; after that, one rule: grades follow your sources — the story files, your owned previews, the story set, the preview-affecting config (provider/storyImports/extraEntries/overrides/titleMap), and committed .design-sync/overrides/ forks. Pipeline churn (a skill or toolchain update re-rendering everything) is auto-verified by a sampled [SPOT_CHECK] with grades kept; your edits re-grade only what they touch. Pixel jitter can never churn grades.

  • Sources unchanged + fully graded match/closeskipped outright (carried forward): no capture, no re-grade — even when the bundle, styling, storybook, or the converter itself were rebuilt. --force recaptures everything and clears all grades — systemic re-verification, not casual sheet regeneration.
  • Sources changed (story edited, .tsx edited, config/fork edited) → recapture, grade cleared, re-grade from the fresh sheet. [STORY_CHANGED] marks stories whose code moved — those are the ones where an OWNED .tsx must be updated (generated previews re-derive automatically); a recapture without [STORY_CHANGED] usually just needs the re-grade.
  • [SPOT_CHECK] → re-captures named components without clearing their grades; Read the fresh sheets and confirm they still match the recorded grades. It can arrive driver-triggered after pipeline churn — the normal verification of a skill/toolchain update, not a bug. Divergence remediation scales with the churned set: a couple of components → re-grade just those; widespread → stop, diagnose, then --force a full pass. --spot-check N tunes the full-run random sample (0 disables); --spot-check-components A,B names picks explicitly, honored on scoped runs too (the §7 step-4 audit).
  • [REFERENCE_STALE?] → the bundle changed but the reference storybook didn't. If the DS source changed, rebuild .design-sync/sb-reference before grading — a stale reference makes every grade a comparison against the old design.
  • A story renders differently every capture (new Date()/Math.random() content) → the fingerprint is the story FILE, so the contract is stable — but the pixels aren't, and grading judges pixels. The frozen capture clock stabilizes date renders; for truly random content, pin values in an owned .tsx or cfg.overrides.<Name>.skip the story with a NOTES.md line.

Captures are stabilized for grading comparability (animations fast-forwarded, reduced motion, frozen clock — both panels show the same settled frame, the same rendered date). This is verification-only: shipped previews are untouched and fully animated.

Grading is done by whoever is working the component — you in the solo phase, each subagent for its own components in fan-out. After each compare run: Read the sheet (and raw PNGs when in doubt), judge each story from the images alone, Write the verdicts to .design-sync/.cache/compare/<Name>.grade.json (campaign-local working state — what makes a verdict durable is the upload: the uploaded _ds_sync.json anchors verified-by-upload skips on every future sync, any machine):

{"stories": {"Default": {"verdict": "match"}, "Loading": {"verdict": "mismatch", "note": "spinner missing — story uses MSW mock"}}}

Rubric — grade what a designer would care about, looking at the two renders:

  • match — same content, composition, and styling. Ignore antialiasing fuzz, scrollbar slivers, sub-5px offsets, and framing differences (the storybook canvas and the preview page frame differently — judge the component, not its surroundings).
  • close — recognizably the same rendering with a minor delta (slightly different padding, focus ring, placeholder text). close is still a fix target, not an exit: if you can name the delta, you can usually name the knob — keep iterating. Accept close only after an iteration fails to improve it or no actionable cause remains, and the note must then say both what's off and what you tried / why it's not fixable (e.g. "focus ring color differs — storybook applies a global focus addon, not part of the DS").
  • mismatch — wrong/missing content, unstyled output, wrong variant, missing icons/images, default fonts. The note must say what differs — it drives the next fix.

When the REFERENCE side is the artifact — storybook gates the story behind UI chrome (a theme/control toggle message) while the preview renders the real component — judge the component render on its own and note the gating; a preview that renders more than the gated reference is not close.

4a. Fix decision tree — global first

Work top-down; a global fix repairs every component at once, a per-component fix repairs one:

  1. Most/all components wrong the same way → global, fix in config + full rebuild:
    • Context/provider errors in cells (use<X> must be inside <Provider>) → decorators didn't bundle (§3 ! preview decorator bundle failed rows) → cfg.provider.
    • Everything unstyled / default fonts → cfg.cssEntry (check [CSS_FROM_STORYBOOK] in the build log), cfg.tokensPkg, cfg.extraFonts.
    • [FONT_MISSING] — the compare loop cannot see this one. When neither side ships the font, both panels render the same chromium fallback, so the sheets look "matching" while every claude.ai/design user gets the wrong font — never accept "both sides fall back the same way" as a pass. Resolve per the [FONT_MISSING] row in ../non-storybook/SKILL.md §3; storybook-specific extras: cfg.extraFonts paths are bounded by the git repo enclosing dirname(--node-modules) — sibling typography packages in the monorepo work as-is; only with no .git ancestor does the bound narrow to dirname(--node-modules), and if you add a font the reference lacks, inject the same @font-face into .design-sync/sb-reference/iframe.html so the oracle verifies with the real font on both sides.
    • Icons missing everywhere → cfg.extraEntries (check [ICON_PKG]).
  2. One component, unpaired or fallback preview → its .tsx lacks a cell for that story. Previews compile the story MODULE whole (hooks, fixtures, local helpers all included — closures are not a failure mode), so the causes are: pairing failed (storyName override), the wrapper build failed (! preview build failed in the build log), or the module threw at load — check the sheet's (page) error row for the real exception (module-scope calls into a package the stubs don't cover). Open the wrapper (generated: .design-sync/.cache/previews/<Name>.tsx; owned: .design-sync/previews/<Name>.tsx), add/rename the export or drop the offending import — and if it's the generated one, save your fix as .design-sync/previews/<Name>.tsx WITHOUT the first-line marker (an in-place cache edit is preserved on this machine but gitignored — it vanishes on a fresh clone). Story imports use the location-independent @ds-stories/<repo-relative path> form, so the file works unchanged from either home.
  3. One component, you graded mismatch → wrong props/composition. Read the story source; mirror it in an owned .design-sync/previews/<Name>.tsx (copy the cache wrapper there minus its marker line). That's the only lever for compiled story previews.
  4. sb-error → the story doesn't render in storybook either (data-fetching, interaction-driven). Add its id to cfg.overrides.<Name>.skip and note why in NOTES.md.
  5. [PORTAL?] / overlay components (Dialog/Tooltip/Toast) → grading is already isolated (per-story capture), but the PRODUCT card renders the whole grid html, so open-overlay stories paint over sibling cells there too. Set cfg.overrides.<Name>.cardMode: "single" — the card renders one story (primaryStory picks it; first export otherwise) full-bleed in a wrapper that contains position:fixed descendants, and declares the grading viewport on the card so the product renders at the size you verified. Rebuild + re-grade that component.

Rebuild rules:

  • Config change (provider/css/fonts/entries/titleMap/skip) → full package-build.mjs + package-validate.mjs, then full compare.mjs. Styling changes (css/fonts/tokens) re-render every preview without moving any grade contract — grades carry forward. Provider, storyImports, extraEntries, and fork edits are part of the grade contract (they change what the preview mounts) — affected grades clear and re-grade on the rebuild. On a large DS, verify the fix is right BEFORE paying the full rebuild: run the targeted loop below on one affected component (or probe its rendered page) first — a wrong guess validated by a full rebuild costs the whole cycle. Intermediate validates can sample: global breakage is systemic by nature, so --render-sample 10 answers "did the fix work?" at a fraction of the cost; the FULL render-check is required only once, at the §4d/§6 upload gate.
  • .tsx-only edit → fast targeted loop, seconds not minutes:
    node .ds-sync/lib/preview-rebuild.mjs --config .design-sync/config.json --node-modules <nm> --out ./ds-bundle --components <Name>
    node .ds-sync/storybook/compare.mjs --out ./ds-bundle --storybook-static .design-sync/sb-reference --components <Name>
    

4b. Solo phase — one, then a few

Do NOT fan out immediately. Global issues must be flushed into config first, or every subagent rediscovers them.

  1. One component. Pick a simple, well-storied one (Button-like: several stories, no portals). Run the §4a loop until you've graded every story match from its images — settle for close only when an iteration stops improving it (rubric above). Every fix becomes a bullet in .design-sync/NOTES.md: symptom → root cause → fix, marked [GENERAL] when it isn't component-specific.
  2. Three more, chosen for diversity: one compound/overlay (Dialog/Tabs), one icon- or asset-heavy, one theme/provider-sensitive — and make sure the set spans one text-heavy component (font/typography bugs hide from button-only solos and then invalidate a whole grading wave). Same loop, solo. Incremental path: the solo set, once every story grades match (or close per the rubric's acceptance bar), is the first verified batch — push it (base SKILL.md §3).
  3. Full compare. If ≥30% of remaining components fail with the same reason, that's a global issue you missed — fix it in config and re-run before fanning out. Batch every skip and pairing fix the listing shows before rebuilding — each rebuild+compare cycle costs minutes; fixing them one at a time pays that cost per item.

4c. Fan-out — parallel subagents

Partition the remaining non-matching components into batches of 58. Launch up to 4 subagents per wave (Agent tool, in one message so they run concurrently), each with this prompt — fill every {…}, and paste the current NOTES.md content in (subagents inherit the solo phase's learnings through it):

Fix design-sync previews so they match the repo's own storybook render.
Repo: {REPO_ROOT}. Your components (yours alone): {COMPONENT_LIST}.

Why this matters: this design system is being synced to claude.ai/design, where
a design agent will build real UIs from this exact compiled bundle. The
storybook render is the proof of how each component is supposed to look; a
preview that matches it proves the component arrived intact, and one that
doesn't means every design the agent builds with it will be wrong the same way.

Artifacts per component (read these first):
- {OUT}/_screenshots/compare/<group>__<Name>.png — the true storybook render (left) vs the true preview render (right), per story. Full-res originals in {OUT}/_screenshots/compare/raw/.
- .design-sync/.cache/compare/<Name>.json — pairing facts + shot paths (no similarity scores — your eyes are the judge).
- The preview source (real JSX importing from '{PKG}'): .design-sync/previews/<Name>.tsx when owned, else the generated .design-sync/.cache/previews/<Name>.tsx. Your fixes are written to .design-sync/previews/<Name>.tsx (step 2).
- {OUT}/.stories-map.json — maps components to story ids; find each story's source file via its id in .design-sync/sb-reference/index.json (`importPath`). The story source is the authority on intended props/composition.
- .ds-sync/storybook/SKILL.md §4 — the grading rubric and fix decision tree.

Per component (max 3 iterations):
1. Read the sheet; judge each story FROM THE TWO IMAGES (raw PNGs when the sheet is too small); diagnose failures via the decision tree.
2. Copy .design-sync/.cache/previews/<Name>.tsx to .design-sync/previews/<Name>.tsx and DELETE its first-line `// @ds-preview generated …` marker (owned files live in previews/, win over the generated twin, and are durable + committed; an in-place cache edit survives rebuilds on this machine but is gitignored and vanishes on a fresh clone). The `@ds-stories/...` imports work unchanged from the new location. Mirror the story's JSX; inline story-local fixture data.
3. node .ds-sync/lib/preview-rebuild.mjs --config .design-sync/config.json --node-modules {NM} --out {OUT} --components <Name>
4. node .ds-sync/storybook/compare.mjs --out {OUT} --storybook-static {SB_REF} --components <Name>   (your edit changed the component's contract, so this clears its old grade — that's intended)
5. Re-Read the fresh sheet and Write your verdicts to .design-sync/.cache/compare/<Name>.grade.json ({"stories": {"<story>": {"verdict": "match|close|mismatch", "note": "…"}}}). Done when you grade every story match. A close story is still a fix target — if you can name the delta, try the knob for it; accept close only when an iteration didn't improve it or there's no actionable cause, and the note must say what's off AND what you tried. Blocked after 3 iterations → grade honestly (mismatch/close + note), record the exact blocker, move on.

HARD RULES — violating these corrupts other agents' work:
- Edit ONLY .design-sync/previews/{<your components>}.tsx, your components' .design-sync/.cache/compare/*.grade.json files, and .design-sync/learnings/{BATCH_ID}.md.
- NEVER edit .design-sync/config.json, .design-sync/NOTES.md, .ds-sync/, or any other component's files.
- NEVER run package-build.mjs or package-validate.mjs — they rewrite the shared bundle. preview-rebuild.mjs + compare.mjs scoped via --components are your only build commands.
- NEVER write a grade for images you haven't Read in this iteration.
- A story that doesn't render in storybook either (sb-error) needs cfg.overrides.<Name>.skip; likewise [PORTAL?] needs cfg.overrides.<Name>.cardMode "single". Both are config edits you may NOT make — record them in your learnings file and final report; the orchestrator applies them. NEVER "fix" overlay bleed by neutralizing a story's open state in the .tsx — that destroys the fidelity being verified.
- If ≥half your components fail identically (same provider/css/font/token error), STOP — it's global. Write it to your learnings file, report it, do not work around it per-component.

Learnings: append to .design-sync/learnings/{BATCH_ID}.md as you go — one bullet per discovery:
`<Component>: <symptom> → <root cause> → <fix>`, prefixed [GENERAL] if it applies beyond that component.

Known repo gotchas (read before starting):
{CURRENT_NOTES_MD_CONTENT}

Final report: per component — match/close/blocked + one-line reason; then any [GENERAL] learnings verbatim.

Between waves (orchestrator) — the learnings fold is mandatory, not optional:

  1. Read every .design-sync/learnings/*.md. Promote [GENERAL] bullets into .design-sync/NOTES.md (dedup; keep them terse), then delete each learnings file you've folded. Full compare.mjs runs print [LEARNINGS_UNMERGED] while any learnings file exists — that line is an upload blocker (§4d), so an overlooked fold can't silently ship.
  2. If any subagent reported a global issue → apply the config fix, full rebuild + validate + full compare. Components that fix repaired drop out of the queue.
  3. Incremental path: push the wave's components that now meet the §4d grade bar (every story match, or close per the rubric) as a verified batch (base SKILL.md §3) — after steps 12, so a global fix from this wave rebuilds them first.
  4. Next wave gets the updated NOTES.md content and the still-failing components. After the last wave, repeat step 1 for whatever remains and delete .design-sync/learnings/.

4d. Done criteria + report

  • The final compare.mjs run exits 0 (no error/unpaired/sb-error). First syncs and full-scope campaigns: a FULL run that does not print [LEARNINGS_UNMERGED]. Re-syncs: the gate is the §7 driver's verdict — ok: true with verification.pendingGrade empty (its capture scope is the capturable subset of the changed+added worklist — uncapturable members re-ship via the upload partition with nothing to grade; verified-by-upload components are outside the gate). The driver's scoped capture skips the learnings check and .compare-report.json aggregation — run ls .design-sync/learnings/ yourself (must be empty) before upload. On this final run (after the final rebuild) every in-scope component should print carried forward with zero grade cleared — that line IS the proof the next sync will be fast. A cleared grade on a no-change run means a nondeterministic source input (volatile story content) — chase it now; a driver-triggered [SPOT_CHECK] is not that (pipeline churn being auto-verified — confirm the sheets and move on).
  • Every IN-SCOPE storied component has a current .grade.json with every story match — or close meeting the rubric's acceptance bar (§4) — or skipped via cfg.overrides.<Name>.skip with a NOTES.md justification. On full runs .compare-report.json joins grades in; components with "grades": null or missing stories are not done (verified-by-upload components are exempt — they're not in the report's pending set on scoped runs).
  • package-validate.mjs still exits 0 after the final rebuild, with no unresolved [FONT_MISSING] (§4a — the one warning the compare oracle can't see).
  • Call DesignSync({method: 'report_validate', counts: {total, bad, thin, variantsIdentical, iterations}}) from the final ds-bundle/.render-check.json (written by package-validate.mjs; iterations = full rebuild passes).
  • NOTES.md has a current Re-sync risks section, written now while you still know them: what can silently go stale (data inlined into config, neutralized story exports, owned previews tied to upstream APIs), what was verified only partially (story caps, accepted close rationales), and what the build assumed (toolchain version, CDN-fetched assets). Fixes record what you did; this section tells the next run what to watch.
  • Tell the user: N/M components graded match, which are close (and why that's acceptable), which were skipped and why.

5. When the repo is strange — escape hatches

First runs against unusual repos WILL hit things the defaults don't cover. Every heuristic has a committed override — the rule is: never hand-patch generated output; put the fix in the file the next run reads. Map from failure class to knob:

The repo's strangeness Knob Lives in
Nonstandard build/entry (module points at TS source, exotic dist layout) cfg.entry, cfg.buildCmd config
CSS built by a separate pipeline / no dist sidecar / CSS-in-JS cfg.cssEntry if there's a file; otherwise rely on [CSS_FROM_STORYBOOK] — the converter scrapes the compiled CSS out of sb-reference, which is the universal catch-all: however weird the pipeline, its output is in the storybook build config
Tokens shipped as a separate package cfg.tokensPkg config
Fonts from a runtime service / proprietary CDN cfg.extraFonts, cfg.runtimeFontPrefixes config
Icons or components on subpath exports cfg.extraEntries config
Naming conventions (story titles ≠ export names) cfg.titleMap; story↔cell pairing also falls back to order config
Decorators/providers that won't bundle (vite-only plugins, MDX, aliases) cfg.provider — an explicit chain beats the decorator bundle; probe.mjs infers it from the live storybook; or compose providers inline in the component's own .tsx (an owned preview can import and wrap anything the package exports) config / previews
Stories that can't render statically (MSW, data fetching, interaction tests) cfg.overrides.<Name>.skip + a NOTES.md line saying why. Skip removes the story's cell, but the wrapper still imports the whole story MODULE — if the file crashes at import (module-scope fetch/worker), own the .tsx and drop the import instead config
[PORTAL?] — overlay/portal stories paint outside their cells in the grid card cfg.overrides.<Name>.cardMode: "single" (+ optional primaryStory, viewport: "WxH") — single-story card, fixed-position containment, declared product viewport. Compare still grades every story via ?story= config
[EXPORT_COLLISION] — a sibling package (icons etc.) exports names the main package also exports the main package wins the global merge, so stories importing the losing name from the sibling render the wrong thing the log names the fix: cfg.storyImports.bundle: ["<sibling>"]
[FILE_OVER_5MB] — a build output exceeds the upload's per-file cap usually a dev-only heavyweight bundled into a preview or the decorator bundle (syntax highlighters, icons-as-code) slim it NOW, before grading — a post-grade slim of an owned preview re-grades that component
[PROVIDER_UNEXPORTED] — a cfg.provider component isn't a bundle export the build exits 1 before emitting any component previews or docs — the output dir is left partial; rebuild after fixing use the exact exported name, or re-export it via cfg.extraEntries. The check reads the bundle's own export list, so absence is reliable; names hidden behind bundled CommonJS re-exports can't be enumerated — those build with a [PROVIDER_UNVERIFIED] warning instead; if every preview then fails "Element type is invalid", the name is wrong
A story import resolves the wrong way (shimmed when it should bundle, or vice versa — any import style) cfg.storyImports.shim / cfg.storyImports.bundle — substring patterns matched against resolved paths (bare package imports shim by specifier, without resolution — pattern-match the specifier for those). Unknown package subpaths (<pkg>/utils) bundle by default; if one should ride the global instead, add it to cfg.extraEntries. In the package's own source repo a bundled self-import has nothing to resolve to — symlink node_modules/<pkg> → the built dist/ first config
Story files import an asset type the defaults can't load (.yaml, ?raw, svg-as-component) cfg.storyImports.loaders — an esbuild loader map merged over the defaults (e.g. {".yaml": "text"}) config
Generated preview has wrong props/composition copy .design-sync/.cache/previews/<Name>.tsx to .design-sync/previews/<Name>.tsx minus its marker line (owned forever) previews
Source/docs discovery misses (unusual repo layout) cfg.componentSrcMap, cfg.docsMap, cfg.dtsPropsFor, cfg.srcDir config
Anything deeper — custom story format, exotic args extraction, CSS transform fork the adapter: copy the bundled lib module to .design-sync/overrides/<name>.mjs and declare it in cfg.libOverrides with a one-line reason (the build cross-checks both directions: [OVERRIDE_UNDECLARED] / [OVERRIDE_MISSING]). Forks are committed, so re-syncs use them automatically. emit.mjs and bundle.mjs are app-contract surface — never fork them. .design-sync/overrides/

For story handling specifically, the fork points by concern: story-imports.mjs (ALL import-resolution policy for preview compiles — the seam built for per-repo customization; honored by both the full build and preview-rebuild.mjs), source-storybook.mjs (index.json discovery, title→component mapping, story-source resolution + export pairing), preview-gen-storybook.mjs (the wrapper template / composeStories semantics), css-fallback.mjs (CSS/font scraping from the storybook build). Fork the narrowest module that owns the breakage, keep its export signature, and record what the repo does differently in NOTES.md — the next sync inherits all of it. A fork loads from .design-sync/overrides/ while its siblings stay in the staged scripts — repoint the fork's relative imports (./common.mjs etc.) at ../../.ds-sync/lib/. A fork that imports a bare converter dep (esbuild) also needs ln -sfn ../.ds-sync/node_modules .design-sync/node_modules so node can resolve it from the fork's location — once per clone, not once ever: the link is gitignored (node_modules rules) while the committed fork that needs it survives the clone, so recreating it is part of the fresh-clone setup.

The ladder's last rung, for repos genuinely outside the converter's envelope: the upload format is the contract, not the converter (see the base skill). Generate the layout however the repo allows — but package-validate.mjs and the compare/grading gate apply unchanged to whatever you produce. The oracle is never forked.

Everything in that table is a committed file, and §2.3 requires reading the existing config + NOTES.md before doing anything — so run N+1 replays every decision run N made. When you fix something on a strange repo, ask: "which committed file makes this automatic next time?" If the answer is none, that's a NOTES.md entry at minimum — and likely a missing row here worth reporting.

6. Upload

Which of the two paths applies was decided by the base skill §1 router (pinned-at-run-start → atomic; otherwise empty → incremental, non-empty → atomic):

Incremental path (first sync into an empty project): the plan has been open since this file's §3 gate and verified batches have already landed. After §4d passes, run the close-out in base SKILL.md §3 — sentinel fence → full content writes → reconciliation deletes → sentinel re-arm → _ds_sync.json last. This section's chunking, hygiene, and stays-local rules apply to those writes; projectId was already recorded in §1; the handoff audit at the end of this section still applies. Skip the rest of this section's sequence — it is the atomic path.

Atomic path (re-sync, or any non-empty target — it may be in active use, so it updates in one pass after everything is verified): everything below. Only after §4d. DesignSync(finalize_plan) with localDir: "./ds-bundle".

  • Writes — everything, always (full re-verifies and re-syncs alike): writes: ["components/**", "tokens/**", "fonts/**", "_vendor/**", "_preview/**", "guidelines/**", "_ds_bundle.js", "_ds_bundle.css", "styles.css", "README.md", "_ds_sync.json", "_ds_needs_recompile"]. Re-uploading unchanged files is idempotent and cheap. An under-scoped writes list silently and permanently desyncs the project — full writes are the safe default.
  • Deletes. Anchored re-syncs: verbatim from the diff — copy .sync-diff.json's upload.deletePaths exactly; never hand-derive the list, never pass [] when the diff lists paths. No anchor (a re-adopted or recovered non-empty project being fully re-verified): the diff can't see the project's history, so review its list_files NOW — before finalize_plan — for files this build doesn't produce, and put those reviewed paths in the plan's deletes (a delete not named in the plan is rejected).
  • Make the session's FINAL build a §7 driver run. Every package-build.mjs run wipes .sync-diff.json; the driver's diff stage regenerates it, so deletePaths and upload.any describe the exact bytes you upload.
  • upload.any === false → skip the upload entirely — the project already matches this build. (The handoff audit below still applies.)
  • _ds_sync.json is the absolute final write — after all content writes, all deletes, and the sentinel re-arm, in its own write_files call. Uploaded early, a mid-plan failure leaves the anchor vouching for files the project doesn't have, and deterministic rebuilds mean no later sync would repair them.
  • What stays local: _sb/** (storybook-static is a reference, never uploaded), dot-prefixed entries (.stories-map.json, .compare-report.json, .ds-build-meta.json, .sb-static/, .sync-diff.json), and _screenshots/. _vendor/ and _preview/ DO upload — the preview cards load React and the compiled previews from them.

If finalize_plan is denied, stop — denial means the session can't approve, not that the arguments were wrong. Tell the user what was denied and ask how they'd like to proceed: try the approval again, or take the validated ds-bundle/ and run the upload interactively themselves.

After plan approval, the upload is a fixed sequence:

  1. Sentinel first: DesignSync(write_files, [{path: "_ds_needs_recompile", localPath: "_ds_needs_recompile"}]) — it fences the app's manifest/copy machinery against a half-uploaded state.
  2. All content writes, chunked into ≤256-file write_files calls under the same planId. The server also bounds payload BYTES, not just file count — batch binary-heavy dirs (fonts/, images) into smaller chunks, and on a 500 halve the chunk size and retry.
  3. All deletes: DesignSync(delete_files) over every path in upload.deletePaths. (No anchor: the paths you reviewed into the plan's deletes at finalize_plan — the deletes bullet above.) If delete_files rejects paths that don't exist remotely (floor-card components have no _preview/ files), retry without the rejected entries — that not-found rejection is the ONLY failure you may continue past.
  4. Sentinel re-arm, then _ds_sync.json last. The anchor goes after deletes too — a failed delete would leave remote files the refreshed anchor can no longer see.

Any other write/delete failure that retries don't clear means STOP — no sentinel re-arm, no _ds_sync.json. An un-anchored project merely re-verifies next sync; a fresh anchor over a half-applied upload is permanent.

Upload hygiene: keep file lists and chunk manifests under .design-sync/ — never bare /tmp paths, where a stale list from another repo's sync uploads the wrong design system. Regenerate the list from the live ds-bundle/ immediately before upload, and sanity-check it: component names belong to THIS design system, and the bundle's window.<globalName> matches. Finish with DesignSync(list_files) to confirm the count.

Only after the post-upload list_files count verifies, record projectId in .design-sync/config.json if absent or different (this is a backstop — §1 records the id at target settlement for every route, so it's normally already present; what must never happen is recording an id here before the upload verifies, pinning a config to a project whose content isn't real yet) — it pins which project anchors future re-syncs. When done, tell the user: the project URL (https://claude.ai/design/p/<projectId>), component count, compare results summary, and that validate exited clean. The durable set — .design-sync/config.json, NOTES.md, previews/, .design-sync/overrides/ — must land in the repo for re-syncs to reuse every fix; verified-state lives with the uploaded _ds_sync.json, not in git. The handoff audit below covers the offer to commit.

Last step — audit the handoff. A future run is only as fast and correct as what this one leaves behind; verify it, don't assume it:

  1. git status — the durable set (.design-sync/config.json, NOTES.md, previews/, overrides/) is the sync's repo footprint; sb-reference/, learnings/, .cache/, .ds-sync/ are ignored. If this run created or changed any of the durable files, offer to commit them and open a PR (one commit, sync state only — no unrelated files). An uncommitted fix is a fix the next sync doesn't have.
  2. Re-read NOTES.md as if you were the next agent, knowing nothing from this session: could you skip today's debugging with only what's written? Every owned preview, skip, config knob, and lib fork should trace to a bullet, and the Re-sync risks section should be current (§4d). Write whatever's missing now — it costs a minute today and a re-derivation later.
  3. After a re-sync — however much it changed or re-graded — leave NOTES.md and the git state exactly as you found them unless the run produced something the next run needs to know; only hand the user something to commit when it adds value for a future sync.

7. Re-syncs — one command routes the work

The repo carries the sync's inputs (config, owned previews, NOTES.md); the uploaded project carries the anchor (_ds_sync.json). Read NOTES.md first (Re-sync risks is the watch-list), then:

  1. Refresh inputs. Re-copy the staged scripts (§2.4's cp -r line — instant; a stale .ds-sync/ runs an old converter against these instructions). Re-run buildCmd and rebuild .design-sync/sb-reference whenever the DS source may have changed — they must move together; when in doubt rebuild both (deterministic builds make an unnecessary rebuild a no-op; [REFERENCE_STALE?] in the capture log means you forgot). Fresh-clone extras: the §2.4 dep install + chromium, the §2.2 sb-reference build, and — if the repo carries .design-sync/overrides/ forks with bare imports — ln -sfn ../.ds-sync/node_modules .design-sync/node_modules.

  2. Fetch the anchor: DesignSync(get_file, path: "_ds_sync.json") → save to .design-sync/.cache/remote-sync.json. No sidecar in the project → first-sync scope (omit --remote below).

  3. Run the driver from the repo root:

    node .ds-sync/resync.mjs --config .design-sync/config.json --node-modules <nm> \
      [--entry <dist-entry>] --out ./ds-bundle --remote .design-sync/.cache/remote-sync.json
    

    It chains build → diff → validate → capture (scoped to new + contract-changed components) and prints one verdict JSON (also written to ds-bundle/.resync-verdict.json). Stage logs stream to stderr. The driver is idempotent — re-run it after fixes. For per-component preview iteration use the §4a targeted loop instead (seconds, not a full build + render-check); the driver re-run is the closing receipt.

  4. Act on the verdict — every field that needs you:

    Field Your work
    ok: false the failed stage (stages.<name>) logged its [TAG]s — fix per that stage's section above, re-run
    verification.pendingGrade grade those fresh sheets (§4 rubric). In the capture log: [STORY_CHANGED] → mirror the story in the owned .tsx first; unpaired → add the export; extraCells naming an owned export → prune it
    verification.canary pipeline churn (or a reference-storybook change) with your sources stable — grades kept; confirm the named [SPOT_CHECK] sheets against the recorded grades. A couple diverge → re-grade those components; widespread divergence → --force full pass
    warn lines in the validate log ([RENDER_THIN] etc.) check NOTES.md's known list — a warn recorded there was triaged on a prior sync (legitimately-short components read as thin forever); a warn NOT recorded there is new — look at that component, then fix it or record it in NOTES.md
    verification.removed components gone upstream — confirm the deletions are intentional
    upload.styling: true styling re-ships automatically; grades stay
    upload.any: false nothing to upload — done
    upload.any: true §6 upload — full writes by default, deletes verbatim from upload.deletePaths (never scope writes by the verification partition)

    Grades follow your sources by design — DS source, CSS, and bundle changes carry, and pipeline churn arrives as verification.canary rather than re-grades. To deliberately audit carried-forward grades anyway (after a major DS version bump, or on suspicion), run node .ds-sync/storybook/compare.mjs --out ./ds-bundle --components <A,B> --spot-check-components <A,B> — fresh sheets, grades kept — and confirm the sheets still match the recorded grades.

  5. Re-fetch the sidecar right before finalize_plan; if it moved (concurrent sync), re-run the driver and act on the fresh verdict.