| name | html-componentize |
| description | Convert static HTML+CSS into React or Vue components deterministically, with fidelity verified by render-and-pixel-diff. Use when the user wants to turn an HTML page/mockup/template into framework components, migrate legacy HTML to a component library, or "make this HTML a React/Vue component". Preserves authored CSS (no computed-style inlining), reuses existing components via an index, and proves the result renders identically. |
HTML → React/Vue Componentize
A scripted pipeline. The deterministic work (parse, classify, diff, CSS move,
index, screenshot-diff) is done by Node scripts in scripts/; you (the model)
do judgment (confirm boundaries, name, wire JSX/SFC, debug verify failures).
Read references/principles.md first. The one rule: restructure, never
re-author. Authored CSS, source text, and DOM structure are MOVED verbatim by
scripts — never regenerated by you. computed style is used only by phase 5 as a
verification oracle, never to write code.
Paths
Scripts ship with this skill, but commands run from the USER's project cwd. Set
a variable to this skill's scripts dir and use it (prefix $SCRIPTS/) for every
node scripts/... command below:
- Installed as a plugin:
SCRIPTS="$CLAUDE_PLUGIN_ROOT/skills/html-componentize/scripts"
- Running from a clone:
SCRIPTS=<repo>/skills/html-componentize/scripts
Artifacts are written to .componentize/ in the user's project (cwd). All
scripts print a compact JSON summary and write a full JSON artifact.
Setup (once per machine)
cd "$SCRIPTS" && npm install # cheerio, postcss, playwright, babel, etc.
npx playwright install chromium # needed only for phase 5 verification
Pipeline (run in order; each has a GATE — do not skip)
Phase 0 — Detect, then interview only the gaps
The skill usually runs INSIDE the target project, so detect first:
node scripts/detect-project.mjs --root . --out .componentize/detected.json
This infers framework, language, styling, mode, indexRoot, componentsDir, AND
routing/layout convention (routing = react-router/next/vue-router/nuxt +
existing layout path; layoutStrategy = reuse-layout | hoist | inline) from
package.json + config files + a file scan.
Detection is a fast-path HINT from known conventions, not the source of truth
(routing.basis = "known-conventions"). When routing.layoutPath is null, the
router is unusual, or the project is non-standard, VERIFY and extend it yourself:
scan the workspace index for a kind:"layout" component, read the router config,
check the app entry — then decide. Don't treat a hardcoded miss as "no layout".
(plan-files already cross-checks the scanned index for a layout.) Then:
- Present the detected config to the user for a one-line confirm.
- Ask the questions in
references/interview.md ONLY for fields not detected or
listed under ambiguities (e.g. both react+vue present, Tailwind in use,
outDir, granularity, asset strategy, viewports).
Write the merged result to .componentize/config.json.
GATE: framework + styling + mode + outDir settled. Never guess a detected-as-
ambiguous field — confirm it. Detected-with-high-confidence fields need only a
confirm, not a question.
Phase 0.5 — Index workspace (integrate mode only)
node scripts/index-workspace.mjs --root <indexRoot> --out .componentize/workspace-index.json
Builds the living index of existing components (name, props, structural
signature). Skip for greenfield. GATE: index exists if mode=integrate.
Phase 1 — Parse
node scripts/parse-source.mjs --html <file> --out .componentize/source-map.json
DOM tree + authored CSS rules + assets + flagged interactions. Also captures:
node.rawHTML for inline svg/math (icons) — verbatim outerHTML, so
glyphs aren't dropped. Render these faithfully in phase 4 (see PATTERN.md).
document.fontLinks / headLinks / fontFaceCount — <head> font assets
that live outside the body and MUST be wired into the app (phase 4), else the
font falls back and renders wrong.
GATE: parse ok; note any rawMarkupNodes and fonts in the summary.
Phase 2 — Classify boundaries
node scripts/detect-boundaries.mjs --in .componentize/source-map.json \
--out .componentize/boundaries.json [--index .componentize/workspace-index.json]
# RE-CONVERTING the same page? add: --manifest .componentize/manifest.json --self <source.html>
# (excludes THIS page's own prior output from reuse-matching so it regenerates,
# instead of matching itself as "reuse")
Labels each node layout | reuse | new-component | leaf-markup, finds
repetition groups (structural-hash), matches the index for reuse.
Your job: review boundaries.json — confirm/override low-confidence labels
(confidence < 0.5), confirm component names. GATE: every node has a label you
accept; layout vs component decided.
Phase 3 — Extract data
node scripts/extract-data.mjs --map .componentize/source-map.json \
--boundaries .componentize/boundaries.json --out .componentize/data-spec.json
For each repetition group: tree-diff the instances → props + a data array with
values copied VERBATIM. GATE: data values match the source character-for-character
(the script copies them; do not edit them when you emit the data file).
Phase 4 — Codegen
First, plan the directory layout (don't dump files flat):
node scripts/plan-files.mjs --boundaries .componentize/boundaries.json \
--data .componentize/data-spec.json --config .componentize/config.json \
--index .componentize/workspace-index.json --manifest .componentize/manifest.json \
--sharedDir <config.sharedDir> --out .componentize/file-plan.json
file-plan.json gives each component its target folder + file paths and import
graph. Default structure is co-location (one folder per component:
Card/{Card.tsx, Card.module.css, index.ts}; the list container also gets its
*.data.ts). Honor config.structure (co-location | nested | flat).
Shared components (file-plan sharedDir + per-component shared) — page
chrome and general primitives (Icon/Button/StatePanel/Skeleton/…) are placed in
sharedDir (default <componentsDir>/common), NOT the page folder, so later
pages never couple to a component buried in the first page's folder. Honor each
component's shared flag and put its files (component + .module.css + CSS) at
its planned dir. The shared Layout (hoist) also lands in sharedDir.
Re-conversion (file-plan.reconvert + per-component writeMode) — when a
target already exists, do NOT blind-overwrite. Honor writeMode:
create — write fresh.
overwrite — we generated it and it's unchanged → safe to regenerate.
reconcile — we generated it but it was HAND-EDITED since. Surgically
update: regenerate only the deterministic parts (the .module.css via
css-to-modules, the data file, props/structure to match the new source) and
apply them to the existing file, PRESERVING manual edits (handlers, extra
props) that don't conflict. Show the user a diff.
foreign — a file we never generated. Never overwrite. Surface it to the
user and ask (rename, pick another path, or explicit overwrite).
This is the default surgical update behavior — update existing source to
match reality, not blind regeneration.
Layout (file-plan.layout) — page chrome (header/nav/footer, marked
shellRole:'chrome') does NOT get duplicated into the page:
reuse-layout: render ONLY the page content into the existing layout's outlet
(routing.outlet, e.g. <Outlet/>, <router-view/>, Next layout children).
Don't regenerate chrome; reconcile only if the layout lacks it.
hoist: generate the planned shared Layout with an outlet; the page becomes
a route component holding only its content. Wire per routing.library.
inline: standalone page — chrome stays in the page component.
Follow file-plan.layout.instruction.
Then, for each planned component (new-component and list-container layout):
- Move its CSS into the planned
styles path:
node scripts/css-to-modules.mjs --map .componentize/source-map.json \
--classes "<comma,sep,classes,in,subtree>" --out <plan.files.styles> \
--globals <outDir>/global.css
- Write the component at
plan.files.component using templates/<framework>/PATTERN.md
as the exact idiom: className={styles.x} / :class, props from
data-spec.json, static text inline. Emit the data file (plan.files.data)
and the index re-export. Wire imports per plan.imports.
- Inline SVG/raw markup: for any node with
rawHTML, render it verbatim
(React dangerouslySetInnerHTML, Vue inline <svg>/v-html). Never emit an
empty element where the source had an icon.
- Head/font assets: wire
document.fontLinks/headLinks into the app
index.html <head> and import the global stylesheet (global.css, holds
@font-face) at the app entry.
- Neutralize scaffold CSS (
detected.scaffoldStyles): npm create vite/CRA
ship index.css/App.css with opinionated defaults — button{padding:.6em 1.2em},
:root{}, body{place-items:center}, #root{max-width;padding} — that OVERRIDE
the UA defaults the source relied on and silently break layout (e.g. an icon
button's padding eats its width → the inner <svg> collapses to a few px).
Remove or override these boilerplate rules so the authored CSS fully owns base
styling. Flag what you removed in the unknowns ledger.
GATE: files land at their planned paths; every rawHTML node rendered; head
font assets wired; scaffold defaults neutralized; no declaration is sourced from
computed style; CSS came only from the .module.css the script emitted.
Phase 4.5 — Reuse decision + hoist
For every node labeled reuse: import the indexed component, map data-spec
fields onto its existing props. Record reuse-vs-generate in
.componentize/reuse-decision.json.
Reuse-time hoist (file-plan.hoistPlan) — when a reused component is
ownership:"page-owned" (lives in another page's folder), reusing it in-place
couples pages. Per hoistPolicy (chrome-always | on-second-use | manual),
hoist it to sharedDir as ONE op (use writeMode:reconcile — surgical + diff):
(a) move pageA/X → common/X (component and its .module.css/CSS — P6:
don't leave shared styles duplicated in two page globals),
(b) rewrite the original owner's imports + drop X from its barrel,
(c) the new consumer imports X from common,
(d) update workspace-index + manifest canonical path to common so the
3rd page reuses from there.
Partial-shared (P5): if only PART of a subtree is shared (e.g. StatePanel
shell shared, EmptyState/ErrorState copy page-specific), hoist the reused
node only and keep the page-specific wrapper local — boundaries is node-level.
Icon dedup (P4): identical rawHTML icons across pages → one common/icons
set (match by rawHTML signature); keep page-only icons local.
GATE: nothing is generated that already exists in the index; no shared component
left page-owned. Re-index after generating/hoisting:
node scripts/index-workspace.mjs --root <indexRoot> --out .componentize/workspace-index.json --tag generated
Phase 4.6 — Manifest (record what we generated)
Snapshot the files this run produced so a future re-conversion can tell
overwrite/reconcile/foreign apart:
node scripts/manifest.mjs --plan .componentize/file-plan.json --source <source.html> \
--out .componentize/manifest.json
GATE: manifest updated after every codegen.
Phase 4.7 — Dependency-direction lint
Catch page→page coupling (a missed hoist):
node scripts/lint-deps.mjs --root <indexRoot> --out .componentize/coupling-report.json
Exit 1 = violations (a page imports a component from another page's folder).
Surface them (also copy into reuse-decision.json → couplingViolations) and
hoist the offending component to sharedDir. GATE: no coupling violations, or
each is explicitly accepted by the user.
Phase 5 — Verify fidelity (the proof)
Spin up a dev server rendering the converted top-level component in isolation
(e.g. a Vite page that imports it), then:
node scripts/verify-fidelity.mjs --original <source.html> --result <http://localhost:PORT> \
--viewports <from config> --threshold <from config> --out .componentize/verify
It waits for web fonts (document.fonts.ready) before shooting, captures the
full page (not just above-the-fold; --viewport-only to opt out), and gates
on THREE signals so subtle/localized differences can't slip through:
- global pixel diff ratio >
--threshold (default 1%), AND
- local worst-block ratio ≥
--blockThreshold (default 40% — a single
missing/wrong element like an icon or panel lights up its block), AND
- DOM structure signature mismatch (
domMatch:false — e.g. a dropped inline
SVG makes the DOM differ).
Besides verify-report.json and the per-viewport PNGs, it writes a
self-contained .componentize/verify/report.html — side-by-side original /
result / diff per viewport with global %, worst-block %, DOM match, and the
failReason. Always point the user to it.
Exit 0 = pass. On fail, read localDiff.failReason, open report.html, locate
the region, fix the cause (missed CSS rule, wrong class scope, dropped inline
SVG/icon, unwired web font, or a layout node treated as a component), and
re-run. Do not declare success until phase 5 passes.
Unknowns ledger
Maintain .componentize/unknowns.md: anything flagged — interactions from
phase 1, unmappable reuse props, low-confidence boundaries you couldn't resolve,
verify regions you couldn't close. Surface it to the user. Flag, don't guess.
Output to the user
- Files generated/reused (path list) + the reuse-decision summary.
- The fidelity result + a link to
.componentize/verify/report.html (the
side-by-side comparison report).
- The unknowns ledger.
Never claim visual fidelity without a passing
verify-report.json.