| name | sync-shadcn |
| description | Sync upstream shadcn/ui updates into our forked primitives in packages/storefront-ui/src/components/ui/ via a 3-way merge that preserves our customizations (relative imports, rounded-ui/shadow-ui/border-ui shape tokens, data-slot attributes, added props), AND apply our house-style shape tokens to ANY component via `restyle`. Use when onboarding a brand-new shadcn primitive in one house-style-correct step — fork + baseline + shape tokens + unified `radix-ui`→individual `@radix-ui/react-*` imports (`add`); when shadcn ships new variants, accessibility fixes, or dependency bumps and you want to pull them into our forks without clobbering local changes (`sync`); when applying/normalizing our shape tokens on a new or existing component (`restyle`); when checking which primitives have drifted behind upstream (`status`) or off our house style (`infer`); or to see exactly what we customized for a component (`diff`). Repo-agnostic: resolves the ui dir from components.json, so it runs in a flattened customer/mirror repo, not just the monorepo.
SKIP when: editing fashion/cosmetic primitives directly (those derive from packages/template via the mvt-* mirror skills — restyle the storefront-ui source instead); making a one-off manual tweak to a single primitive.
|
sync-shadcn — 3-way merge for upstream shadcn updates
Our src/components/ui/ primitives are forks of shadcn/ui (copy-paste, not an npm
package), edited in place with our customizations. This skill pulls upstream updates in
without losing those edits, using a 3-way merge:
| input | what it is | where it lives |
|---|
| base | pristine upstream we last synced from | packages/<pkg>/.shadcn-baseline/<name>.tsx |
| theirs | current upstream, freshly fetched | shadcn registry (new-york-v4) |
| ours | our customized fork | packages/<pkg>/src/components/ui/<name>.tsx |
git merge-file replays the base→theirs delta (what upstream changed) onto ours.
Our customizations are the ours↔base delta, so they survive automatically; a conflict
appears only where upstream changed a line we also customized.
The .shadcn-baseline/ snapshots are not imported by anything — they exist solely as the
merge's reference point. They contain vanilla @/ imports and no copyright header, so the
root ESLint config ignores **/.shadcn-baseline/** and tsconfig (include: src/**) never
compiles them.
Two composable mechanisms
The skill is customization-as-merge plus customization-as-transform:
| mechanism | what it handles | needs a baseline? |
|---|
sync | 3-way git merge-file | STRUCTURAL deltas: added props, data-slot, behavior, variants | yes (the .shadcn-baseline/ anchor) |
restyle | declarative ruleset (ruleset.json) | MECHANICAL shape tokens: rounded-*→rounded-ui, shadow-*→shadow-ui, border→border-ui (Card), import convention, unified radix-ui→individual @radix-ui/react-* | no — works on any file |
They compose: sync brings upstream structure in, then restyle normalizes any raw shape tokens
the merge reintroduced. A brand-new component (no baseline) gets its house style from restyle
alone.
When to run
- shadcn announced a release, or you want to check for drift →
status.
- A primitive needs an upstream a11y/variant fix →
sync <name>.
- Onboarding a NEW shadcn primitive →
add <name> (one step: fork + baseline + house style).
- Want to confirm forks haven't drifted off our house style →
restyle --all --check / infer --all.
- Periodically (e.g. quarterly) to keep forks from drifting far.
Default package is storefront-ui (the canonical fork off upstream; the only package with
.shadcn-baseline/ snapshots). For any other layout — including a flattened customer/mirror
repo — the skill discovers the ui dir from the nearest components.json (walk up from --path
or cwd), so no --package is needed. Fashion/cosmetic primitives are out of scope for direct
edits — they mirror packages/template (which inlines storefront-ui), so restyle the
storefront-ui source, not the mirror output.
Commands
S=.claude/skills/sync-shadcn/sync.mjs
node $S add slider
node $S status
node $S sync button
node $S sync button dialog card
node $S sync --all
node $S diff button
node $S advance button
node $S restyle button
node $S restyle --all
node $S restyle --path src/components/ui/toggle.tsx
node $S restyle --all --check
node $S infer --all
node $S infer card --emit
node $S sync --all --bootstrap
Restyle — the house-style ruleset
ruleset.json (beside this skill) is the declarative source of truth — token families, exclude
list, the Card-only border scope, and the import convention. restyle rewrites only
className / cn() / cva() string literals, matching tokens by exact variant-stripped core:
rounded-md/-lg/-xl/… → rounded-ui; shadow-sm/-xs/… → shadow-ui.
border → border-ui only on Card (families.border.scope.only). Every other primitive
keeps literal border — border-ui is Card-only (see docs/README-SHAPE-TOKENS.md).
- Preserved (never members):
rounded-full, rounded-none, directional/arbitrary radii
(rounded-t-lg, rounded-[2px]), border-2, border-input, shadow-none.
- Import convention auto-adapts: where the
@/ alias resolves via tsconfig paths (the
customer/mirror convention), @/ imports are KEPT; where it does not (storefront-ui bans @/
under ui/), they are relativized (@/lib/utils → ../../lib/utils).
- Radix unbundle (
imports.unbundle): upstream's unified import { Dialog as DialogPrimitive } from "radix-ui" → the individual import * as DialogPrimitive from "@radix-ui/react-dialog" our
forks use (package derived from the export name, so Dialog as SheetPrimitive → react-dialog
with the alias kept). Slot is an exceptions entry: it becomes a named import from
@radix-ui/react-slot and its Slot.Root usage collapses to bare Slot. Matches only the exact
bare radix-ui specifier, so it is a no-op on already-individual forks.
restyle is idempotent — the replacements are never members, so running twice is a no-op.
New component flow
One command onboards a new primitive — it fetches from the correct new-york-v4 path
(not the stale new-york that npx shadcn add uses), writes a house-styled fork
(shape tokens + radix unbundle + import convention), and seeds the baseline from the same
raw upstream in one step:
cd packages/storefront-ui
node $S add <name>
pnpm lint && pnpm typecheck
Because the fork and its baseline come from a single fetch, status/diff immediately show
only our customizations — no phantom drift on the first sync. add refuses if the fork already
exists (use sync to update it, or add <name> --force to overwrite). The baseline stays the
pristine raw upstream (unified radix-ui, @/ imports) — it is the 3-way merge anchor.
Customer brand layer (dogfood path)
A generated customer project gets this skill in .claude/skills/sync-shadcn/. Customers tailor it
without editing ruleset.json: drop a ruleset.customer.json beside it (copy
ruleset.customer.json.example). It deep-merges OVER ours — precedence upstream → our ruleset →
customer overlay. Semantics are additive: members UNION (with explicit removeMembers to
subtract — so you can't accidentally un-protect rounded-full, which was never a member),
replacement overrides, scope.only and relativizeAliases UNION (with removeAliases).
Workflow
- Check drift.
node $S status → lists which primitives are BEHIND.
- Sync.
node $S sync <name> for each behind component. The merged result (with any
conflict markers) is written in place into the fork file. On a clean merge, sync
auto-runs restyle to normalize any raw shape tokens the merge reintroduced (reported as
merged clean (restyled N token(s))). The command reports per component:
up-to-date / merged clean / MERGED WITH CONFLICTS (N hunks), plus any anomalies.
- Resolve conflicts (only if reported — see below). Conflicted files are not
auto-restyled during
sync (the engine won't tokenize conflict-marker lines). Once you've
removed the markers, advance (step 5) normalizes any raw shape tokens you kept before
promoting the baseline — or run node $S restyle <name> yourself to see the changes first.
- Verify (always):
cd packages/storefront-ui && pnpm lint && pnpm typecheck
pnpm lint is the safety net: storefront-ui's ESLint errors on any @/* import in
src/components/ui/**, so an alias import pulled in from upstream fails here, not in prod.
If your change altered rendered DOM, also run the consuming package's snapshot tests
(cd packages/template && pnpm storybook:test --type=snapshot).
- Advance the baseline. Once the fork is clean and verified:
node $S advance <name>
This promotes base → current upstream so the next sync is conflict-free for those lines.
advance refuses while any conflict marker remains — the baseline never advances from a
half-merged tree. It also runs restyle on the (marker-free) fork first, normalizing any raw
shape tokens a manual conflict resolution kept — idempotent, a no-op after a clean merge.
- Commit the merged fork file(s) and the updated
.shadcn-baseline/ together.
Conflicts — what to expect
A conflict means upstream changed a line you also customized. With --zdiff3 you see all three
versions, so you can tell what each side changed:
<<<<<<< ours (our fork)
outline: "border bg-background shadow-ui hover:bg-accent ...",
||||||| base (last synced upstream)
outline: "border bg-background shadow-xs hover:bg-accent ...",
=======
outline: "border bg-background shadow-xs hover:bg-accent ... dark:bg-input/30",
>>>>>>> theirs (current upstream)
Read base → ours (we changed shadow-xs→shadow-ui) and base → theirs (upstream added
dark:bg-input/30). Usually the resolution is the union: keep our token, take their addition.
Delete the four marker lines, leaving the resolved line. Then re-run verify + advance.
Common customizations you'll be protecting: rounded-md→rounded-ui, shadow-xs→shadow-ui,
the border-ui utility, data-slot/data-variant/data-size attributes, relative
../../lib/utils imports, and added props (e.g. dialog showCloseButton, card CardAction).
Anomalies the skill reports (does not auto-merge)
| report | meaning | action |
|---|
[new-dependency] | upstream declares a dep we don't have (e.g. unified radix-ui) | expected — the unbundle rule rewrites it to individual @radix-ui/*; review only if it's a non-radix dep |
[dep-missing] | the fork imports a package (or an upstream dep) that isn't in package.json | install it at a version consistent with the other forks, then re-run add/advance |
[multi-file] | upstream split the component into several files | merge the primary <name>.tsx; handle extra files manually |
[renamed] | upstream's file basename ≠ <name>.tsx | likely rename/split; inspect before trusting the merge |
[missing-fork] | exists upstream, not in our set | add <name> — onboards fork + baseline + house style in one step |
NOT FOUND (404) | not published upstream (e.g. our custom native-select) | skipped; nothing to sync |
Manifest fields (.shadcn-baseline/manifest.json)
Each component entry records:
contentSha256 — sha of the pristine upstream baseline (the change-detection key).
dependencies — raw upstream dep names (e.g. ["radix-ui"]), unchanged.
resolvedDependencies — the fork's actual packages → installed versions (e.g.
{ "@radix-ui/react-slot": "1.2.3" }), derived from the fork's post-unbundle imports and
package.json. Written by add/advance/sync --bootstrap; pins what our fork ships.
syncedAt — last promotion (seed / advance / add).
checkedAt — last drift-check (also stamped by sync's up-to-date path). status is
read-only and does not write it.
Notes
- No npm dependencies; uses Node global
fetch + node:crypto + system git.
- The registry URL uses the
-v4 style suffix. The un-suffixed new-york path serves a
stale, pre-data-slot snapshot older than our fork — sync.mjs resolves the suffix from
components.json automatically; don't hardcode the un-versioned path.
storefront-ui is a private package → no changeset needed for changes confined to it.
- This skill ships into generated customer projects: the customer-skills allowlist in
scripts/lib/ship-skills.mjs copies it into the mirror artifact (packages/template's
mirror.mjs) and the standalone template (scripts/generate-storefront.js). Tests and fixtures
are excluded from the shipped copy.