| name | recomponentize-ui |
| description | Refactor a project's UI into atomic components (atoms/molecules/organisms/templates), sourcing primitives from a design-system registry when one is configured (default @aihero) or stock shadcn otherwise, and codemod off-token styling. Use when asked to recomponentize, refactor UI, adopt a design system, or clean up component structure. |
Recomponentize UI — Atomic Components, Design-System Sourced
Refactor an app's UI into a proper atomic component hierarchy, and stop
hand-rolling primitives that already exist upstream.
Two things always happen: components get recomponentized into atomic layers,
and off-token styling gets codemodded. A third happens when a registry is
available: local primitives get replaced by registry components.
Pipeline DAG
preflight → enforce → inventory → map → install → recomponentize → codemod → verify
Print the DAG line at the start of each step:
[N/8] (✓) preflight → (▶) enforce → ( ) inventory → ( ) map → ( ) install → ( ) recomponentize → ( ) codemod → ( ) verify
Now running: enforce
Enforcement is installed early, before any code is rewritten, so the path-scoped
rule guides the migration itself rather than only future work.
When no registry is configured, map and install still run — against stock
shadcn or the project's existing UI library instead of a private registry.
Arguments
$ARGUMENTS:
- (none) — full pass using the component source resolved in Step 0
--audit-only — run preflight, inventory, map; report the plan, change nothing. Skips enforce too: installing the rule and hook writes files, which --audit-only promises not to do.
REGISTRY_NAMESPACE — override the registry (e.g. @acme)
Step 0: Resolve the component source
Producer repos must opt out — check this first
If HERO.md says role: producer (or enabled: false) under ## Design System, stop immediately. Report that this repo publishes the design system
and exit without changing anything. A registry repo's pipeline runs mockup →
design system; running this skill there would invert it and try to consume its
own output.
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
sed -n '/## Design System/,/^## /p' "$ROOT/HERO.md" 2>/dev/null `# hero-lint: allow-inline` || echo "NO_DESIGN_SYSTEM_CONFIG"
A repo is a producer if it builds a registry.json, serves /r/*, or its
components.json aliases ui to an internal atomic directory rather than
@/components/ui. If those signals are present but HERO.md says consumer,
trust the signals, stop, and tell the user HERO.md looks wrong.
Pick the component source
Resolve in this order and state which one you picked before proceeding:
| Condition | Source |
|---|
HERO.md ## Design System present with namespace | That registry |
| No config, but the user wants one | Offer @aihero / https://design.aihero.studio — needs a token (Step 1) |
No registry, components.json exists | Stock shadcn — npx shadcn@latest add ITEM |
No registry, another UI lib in package.json (MUI, Chakra, Mantine, Ant) | That library's primitives; do not migrate libraries uninvited |
| Nothing — plain HTML/CSS | Recomponentize and codemod only; ask before introducing any dependency |
Expected HERO.md keys: namespace, registry-url, token-env-var, docs,
atomic-layers. AI Hero defaults:
- namespace:
@aihero
- registry-url:
https://design.aihero.studio/r/{name}.json
- token-env-var:
REGISTRY_TOKEN
- docs:
https://design.aihero.studio
If the registry publishes a consumer handbook, read it first and let it win
over this skill. For @aihero that is handbook/consuming-the-registry.md in
the ai-hero/design-system repo — it ships a canonical consumer AGENTS.md stanza
and consumer SKILL.md. Install those rather than re-deriving them.
Never introduce a UI library into a project that has none without asking. The
atomic refactor is valuable on its own and carries no new dependency.
Step 1: Preflight (preflight)
Skip to Step 2 when the source is "recomponentize only" — the enforcement layer still applies. Otherwise, never run
npx shadcn init on an existing project — it does not add the registries block
and may pick a conflicting style.
| Check | Requirement |
|---|
components.json | Has a registries block for the namespace; "ui": "@/components/ui" |
| Token expansion | Header is Bearer ${REGISTRY_TOKEN} — the plain form ONLY |
.env | Holds the token; .gitignore covers .env BEFORE the token is written |
src/lib/utils.ts | Exports cn (clsx + tailwind-merge) |
tsconfig.json | baseUrl: "." and paths: { "@/*": ["./src/*"] } |
| CSS entry | Imports tailwind (v4); named in components.json → tailwind.css |
| Runtime deps | react@^19, react-dom@^19, tailwindcss@^4, clsx@^2, tailwind-merge@^3, shadcn@^4.13.0 |
Trap — never write ${VAR:-default}. The CLI's expansion regex is
/\$\{(\w+)\}/g, so the default form ships as a literal header string and
surfaces as a confusing 401 instead of a missing-variable error.
Trap — do not copy the design system's own components.json. A registry
repo's config points at localhost and aliases ui to its internal atomic dir.
Those are wrong in a consumer.
If the project has a committed .env, rename it to .env.example, strip the
value, and gitignore the real one. Never commit a token.
Trap — .env must sit next to components.json, not in the cwd. Verified
empirically against shadcn 4.13:
.env location | cwd | Result |
|---|
| repo root | repo root, -c ui | fails |
| repo root | ui/ | fails |
ui/ | repo root, -c ui | works |
ui/ | ui/ | works |
none, REGISTRY_TOKEN exported | anywhere | works |
So in a monorepo whose UI lives in ui/, the token goes in ui/.env even when
the repo's house convention keeps every other secret in a root .env. A root
.env produces Set the required environment variables to your .env or .env.local file — which reads like a missing variable rather than a
wrong-directory problem, so it is easy to misdiagnose. Exporting the variable in
the shell or a task-runner recipe also works and beats duplicating the secret.
Confirm the wiring before going further — install the theme item first so tokens
land before any component references them:
npx shadcn@latest add NAMESPACE/theme
grep -- "--primary" src/styles.css
Step 2: Install enforcement (enforce)
A skill only fires when the model chooses it, and model-discretion triggering is
least reliable for exactly this kind of well-trained task. These layers do not
depend on that choice — install both:
"$PLUGIN_ROOT/scripts/install-design-system.sh" "$ROOT"
It writes, without overwriting customized files (exit 2 on drift, same contract
as install-auto-approve.sh):
.claude/rules/design-system.md — path-scoped to **/*.{tsx,jsx,css}, so the
constraints load whenever Claude reads a UI file rather than when a description
happens to match.
.claude/hooks/check-design-tokens.sh + PostToolUse wiring — flags raw hex,
palette classes, and component-root margins on write.
Then add the registry's AGENTS.md stanza (Part 8 of the @aihero handbook) and
the consumer SKILL.md (Part 9). If the registry ships them, paste verbatim.
Optionally port the registry's lint config (for @aihero,
eslint.taste.config.mjs — Tailwind correctness, token discipline, a11y floor).
Its atomic-boundaries block does apply once Step 6's layers exist; add ui
and blocks as the lowest elements in the layer matrix.
Step 3: Inventory the current UI (inventory)
Find every hand-rolled element, every oversized component, and every off-token
style. Use an Explore subagent for a large codebase.
grep -rnE '<(button|input|select|textarea|table|dialog|nav|header|footer)\b' --include="*.tsx" --include="*.jsx" src/
find src -name "*.tsx" -exec wc -l {} + | sort -rn | head -20
grep -rnE 'className="[^"]*\b(bg|text|border|ring)-(slate|gray|zinc|neutral|stone|red|blue|green|amber)-[0-9]' --include="*.tsx" src/
grep -rnE '#[0-9a-fA-F]{3,8}\b|oklch\(' --include="*.tsx" --include="*.ts" src/
grep -rnE 'className="[^"]*\bshadow-|z-\[|rounded-\[|text-\[[0-9]' --include="*.tsx" src/
grep -rnE 'className="[^"]*\bdark:(bg|text|border)-' --include="*.tsx" src/
Record for each finding: file, line, what it is, and the UI concept it expresses
("a primary action", "a labelled form field with error text"). The concept — not
the markup — is what you search for in Step 4.
Step 4: Map local → upstream (map)
For every concept in the inventory, find its upstream equivalent. Use both
paths — they surface different things:
Search the catalog (finds by category keyword):
npx shadcn@latest search NAMESPACE -q "form"
npx shadcn@latest view NAMESPACE/field
There is no --registry flag; registries come only from components.json. For
@aihero the full catalog is GET /r/registry.json — there is no
/r/index.json.
Browse the docs/gallery site (finds by appearance): open it with the browser
tools and look at the rendered components. Search matches keywords; the gallery
matches look. A local "stat card" may be a tile or a kpi-strip — only the
gallery makes that obvious. For stock shadcn, use ui.shadcn.com/docs/components.
Optionally wire the registry's MCP server for in-editor search:
npx shadcn@latest mcp init --client claude
It reads components.json for registries and auth headers — no separate MCP
auth. If /mcp reports no tools, run npx clear-npx-cache.
Produce a mapping table and show it to the user before installing:
LOCAL → UPSTREAM ITEM CONFIDENCE NOTE
src/components/Btn.tsx → @aihero/button high variant=default|destructive
src/components/StatCard.tsx → @aihero/tile medium confirm against gallery
src/components/Wizard.tsx → (none) — keep; recomponentize only
Items with no upstream equivalent stay local — they still get recomponentized
(Step 6) and codemodded (Step 7). Do not force a bad match.
Step 5: Install and rewrite call sites (install)
npx shadcn@latest add NAMESPACE/button NAMESPACE/card
npx shadcn@latest add NAMESPACE/button --dry-run --diff
registryDependencies resolve transitively — asking for field also brings
label and separator. add defaults to --overwrite false.
Installed files are vendored, not authored. This is the rule that matters
most: rewrite call sites, never the vendored files. A local edit to
components/ui/* is silently reverted by the next shadcn add --overwrite. To
change a vendored component: compose around it, pass className (every item
merges via cn()), or change it upstream and re-add.
Delete the local component only after its last call site is migrated and
typecheck passes.
Step 6: Recomponentize (recomponentize)
This step always runs — it is the point of the skill. It runs for components
with no upstream match, for projects with no registry at all, and for the app code
that composes vendored primitives. Swapping in new components without
recomponentizing leaves the same monolith wearing new classes.
Target layout — vendored code stays flat; the app's own components go atomic:
src/components/
├── ui/ # vendored primitives (registry or shadcn) — never edit
├── blocks/ # vendored sections — never edit
├── atoms/ # app-specific primitives upstream lacks (should be rare)
├── molecules/ # compositions of ui/ + atoms/
├── organisms/ # domain-aware sections; may import domain types
└── templates/ # layout shells with slot props — structure only, zero copy
Layer rules, enforced in review:
- atoms / molecules are stateless and generic: no fetching, no auth, no domain
types. Props in, UI out. Every one accepts and merges
className.
- organisms may import domain types and compose molecules — still no data
fetching; data arrives via props.
- templates define where things go via slot props (
header, sidebar,
children); they never hardcode copy or fetch data.
- Imports flow downward only: templates → organisms → molecules → atoms →
(
ui/, blocks/). ui/ and blocks/ are the floor — any layer may import
them. An atom importing a molecule is a defect; restructure instead of
suppressing it.
- Same-layer imports only for family relationships. Test: can you describe
the importer without naming a different concept? "A row of buttons" is still
buttons → atom. The moment a component combines distinct siblings
(
input + button = search-bar), it belongs one layer up.
- Place each component at the lowest layer that fits. Promote only when it
gains domain knowledge or composition — never preemptively.
Signals a component needs recomponentizing: boolean-prop explosion, a molecule
fetching data, two organisms sharing copy-pasted JSX, a component importing from a
higher layer, a file well above the codebase's median length.
Migrate call sites as you move files; never leave a re-export shim behind as
"temporary" — finish the move or don't start it.
Step 7: Codemod off-token styling (codemod)
| Found | Replace with |
|---|
Raw palette (bg-zinc-100, text-gray-500) | Semantic token (bg-muted, text-muted-foreground) |
Hex / oklch() literal in TSX | A token in the @theme layer |
dark: color override | Delete it — a dark: color means the wrong token was used |
Margin on a component root (m-*, mt-*, ms-*) | gap-* / space-* on the parent; parents own layout |
Arbitrary spacing (p-[13px], gap-[7px]) | The spacing scale; if a step is missing, change the scale |
z-[9999] | The named z-scale (z-dropdown < z-sticky < z-overlay < z-modal < z-toast) |
rounded-[10px], text-[15px], any [Npx] | The radius / type / spacing scale |
shadow-* | Remove — elevation is borders and hairlines (@aihero house rule) |
| Non-Lucide icons | Lucide, sized size-4 / size-5, aria-hidden unless it is the only label |
| Text on a colored surface | The paired foreground token (bg-primary → text-primary-foreground) |
| Removed focus outline | A visible focus-visible: ring — removing one without a replacement is a defect |
| Opacity hack for disabled | disabled: variants + aria-disabled semantics |
Also: variants via cva with typed props, never ternary/string-concat className
soup. Never hand-sort classes — prettier-plugin-tailwindcss owns the order.
The no-shadow rule and the exact token vocabulary are registry-specific. With
stock shadcn, keep its default token names (bg-background,
text-muted-foreground are shared) and do not strip shadows — that is an
@aihero house rule, not a shadcn one.
Step 8: Verify (verify)
npx tsc --noEmit
Then, using the browser tools, render the migrated screens and confirm:
- Each replaced component renders correctly in light and dark. Dark mode is
the
.dark class — never prefers-color-scheme.
- Transitive deps arrived (installing
field must also produce label.tsx and
separator.tsx).
grep -- "--primary" CSS_ENTRY finds the theme's variables.
- No visual regression on screens you did not intend to touch.
- No import-direction violations: nothing in
atoms/ imports from molecules/
or above.
Run the project's own lint/typecheck/test commands from HERO.md before
reporting done.
Report
Recomponentize UI Summary
=========================
Source: NAMESPACE (REGISTRY_URL) | stock shadcn | recomponentize-only
Components: N replaced upstream, M kept local
Recomponentized: N files → atoms/molecules/organisms/templates
Codemods: N palette → token, N margins → parent gap, N arbitrary values
Enforcement: .claude/rules/design-system.md, PostToolUse hook, AGENTS.md stanza
Verified: tsc clean · light+dark checked on N screens · import direction clean
Unmapped (kept local, recomponentized only):
- PATH — reason
Next step: hero-skills:push-pr
Key Principles
- Recomponentizing is the job. Sourcing from a registry is the bonus, not the
point. A pass that swaps components without restructuring has failed.
- Search before you build. Hand-rolling an existing component is the defect
this skill prevents.
- Vendored, not authored. Rewrite call sites; never edit
ui/ or blocks/.
- The concept, not the markup. Search for what an element means.
- Gallery and search are complementary. Search matches keywords; the gallery
matches appearance.
- Never force a match, never add a library uninvited. No upstream equivalent →
keep it local, recomponentize it, and say so.