@su-record/vibe 2.16.0 → 2.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@
11
11
  * computed.json — per-element computed CSS + box + pseudo-elements + shadow DOM
12
12
  * screenshot.png — full-page screenshot
13
13
  * stylesheets.json — @font-face + @keyframes harvested from all sheets
14
+ * states.json — non-default state rules (hover/focus/active/checked/tab/aria/data-state)
14
15
  * asset-map.json — remote URL → local path mapping
15
16
  * assets/images/*, assets/fonts/*
16
17
  *
@@ -233,8 +234,32 @@ const PAGE_EXTRACT = `
233
234
  (function (props) {
234
235
  const out = [];
235
236
  const stylesheets = { fontFaces: [], keyframes: [], cssVars: {} };
237
+ const stateRules = [];
236
238
  const pathById = new Map();
237
239
 
240
+ // ── Non-default state rule harvesting (hover/focus/active/checked/tab/aria/data-state) ──
241
+ // Deterministic: read state-dependent declarations straight from the stylesheets
242
+ // (no scripted clicking/hovering, so the output stays reproducible).
243
+ const propSet = new Set(props);
244
+ const STATE_RE = /:hover|:focus(?:-visible|-within)?|:active|:checked|:target|\\[aria-(?:expanded|selected|current|pressed)|\\[data-(?:state|active|open|selected)|\\[open\\]|\\.is-[a-z-]+|\\.(?:active|open|selected|expanded|current|show|visible)(?![\\w-])/i;
245
+ const harvestStateRule = (rule, media) => {
246
+ if (rule.selectorText && STATE_RE.test(rule.selectorText)) {
247
+ const decl = {};
248
+ for (let i = 0; i < rule.style.length; i++) {
249
+ const p = rule.style[i];
250
+ if (propSet.has(p)) decl[p] = rule.style.getPropertyValue(p).trim();
251
+ }
252
+ if (Object.keys(decl).length && stateRules.length < 1200) {
253
+ stateRules.push({ selector: rule.selectorText.trim(), media: media || null, css: decl });
254
+ }
255
+ }
256
+ // Recurse @media / @supports blocks so state rules nested under them are not lost
257
+ if (rule.cssRules && (rule.media || rule.conditionText)) {
258
+ const mq = rule.media ? rule.media.mediaText : (media || null);
259
+ for (const r of rule.cssRules) harvestStateRule(r, mq);
260
+ }
261
+ };
262
+
238
263
  // Stable ID via DOM path (no attribute mutation)
239
264
  const pathFor = (el, parent) => {
240
265
  if (!parent) return '0';
@@ -469,6 +494,7 @@ const PAGE_EXTRACT = `
469
494
  stylesheets.keyframes.push({ name: rule.name, frames });
470
495
  }
471
496
  }
497
+ for (const rule of rules) harvestStateRule(rule, null);
472
498
  }
473
499
 
474
500
  // :root CSS vars
@@ -484,6 +510,7 @@ const PAGE_EXTRACT = `
484
510
  nodes: out,
485
511
  assets: { images: Array.from(images), fonts: Array.from(fonts) },
486
512
  stylesheets,
513
+ stateRules,
487
514
  title: document.title,
488
515
  html: document.documentElement.outerHTML,
489
516
  docSize: {
@@ -666,6 +693,14 @@ async function capture({ url, opts }) {
666
693
  });
667
694
  }
668
695
 
696
+ // Rewrite remote asset URLs inside harvested state rules (e.g. :hover background-image)
697
+ for (const sr of extracted.stateRules) {
698
+ if (!sr.css) continue;
699
+ for (const prop of ['background-image', 'mask-image', 'border-image']) {
700
+ if (sr.css[prop]) sr.css[prop] = rewriteCssValue(sr.css[prop]);
701
+ }
702
+ }
703
+
669
704
  // Write outputs
670
705
  fs.writeFileSync(path.join(opts.out, 'rendered.html'), html);
671
706
  fs.writeFileSync(
@@ -686,11 +721,18 @@ async function capture({ url, opts }) {
686
721
  path.join(opts.out, 'stylesheets.json'),
687
722
  JSON.stringify(extracted.stylesheets, null, 2),
688
723
  );
724
+ fs.writeFileSync(
725
+ path.join(opts.out, 'states.json'),
726
+ JSON.stringify({
727
+ meta: { url, bp: opts.bp || null, capturedAt: new Date().toISOString() },
728
+ stateRules: extracted.stateRules,
729
+ }, null, 2),
730
+ );
689
731
  fs.writeFileSync(path.join(opts.out, 'asset-map.json'), JSON.stringify(assetMap, null, 2));
690
732
 
691
733
  const okCount = Object.values(assetMap).filter((a) => a.status === 'ok').length;
692
734
  console.log(`[clone-extract] done → ${opts.out}`);
693
- console.log(` nodes: ${extracted.nodes.length}, fontFaces: ${extracted.stylesheets.fontFaces.length}`);
735
+ console.log(` nodes: ${extracted.nodes.length}, fontFaces: ${extracted.stylesheets.fontFaces.length}, stateRules: ${extracted.stateRules.length}`);
694
736
  console.log(` assets: ${okCount}/${Object.keys(assetMap).length} downloaded`);
695
737
  } finally {
696
738
  await browser.close();
@@ -29,6 +29,7 @@ function parseArgs(argv) {
29
29
  else if (a.startsWith('--bp=')) opts.bp = a.slice(5);
30
30
  else if (a.startsWith('--stylesheets=')) opts.stylesheets = a.slice(14);
31
31
  else if (a.startsWith('--asset-map=')) opts.assetMap = a.slice(12);
32
+ else if (a.startsWith('--states=')) opts.states = a.slice(9);
32
33
  }
33
34
  return { htmlPath, computedPath, opts };
34
35
  }
@@ -452,6 +453,131 @@ function applyTokens(nodes, tokens) {
452
453
  for (const n of nodes) walkAndApply(n);
453
454
  }
454
455
 
456
+ // ─── Interaction model classification (static-DOM heuristic) ────────
457
+ // The live interaction (#1 clone failure mode) cannot be fully known from a
458
+ // static snapshot, so we surface ranked SIGNALS + a best-guess model that the
459
+ // builder confirms against the live site in Phase 5 — never a silent decision.
460
+ const CAROUSEL_RE = /slider|carousel|swiper|marquee|slick|embla|ticker|autoplay/;
461
+ const SCROLL_RE = /parallax|reveal|animate-on-scroll|scroll-|sticky|fade-in|fade-up|slide-in|in-view|gsap|lenis|locomotive/;
462
+ const TOGGLE_RE = /accordion|tabs?|dropdown|modal|toggle|menu|collaps|drawer|popover|expand|hamburger/;
463
+ const INTERACTIVE_TAGS = new Set(['button', 'summary', 'details', 'input', 'select', 'textarea', 'label']);
464
+ const TOGGLE_ROLES = new Set(['tab', 'tablist', 'switch', 'menuitem', 'menuitemcheckbox', 'button']);
465
+
466
+ function firstClass(cls, re) {
467
+ return (cls || '').split(/\s+/).find((c) => re.test(c)) || null;
468
+ }
469
+
470
+ function collectInteractionSignals(root) {
471
+ const sig = {
472
+ sticky: null, anim: null, infinite: null, transition: false, pointer: false,
473
+ interactiveTag: null, toggleClass: null, scrollClass: null, carouselClass: null, toggleRole: null,
474
+ };
475
+ const visit = (n) => {
476
+ const css = n.css || {};
477
+ const cls = (n.classes || '').toLowerCase();
478
+ const role = ((n.attrs && n.attrs.role) || '').toLowerCase();
479
+ if (!sig.sticky && (css.position === 'sticky' || css.position === 'fixed')) sig.sticky = `${css.position} on <${n.tag}>`;
480
+ if (css.animation) { sig.anim = sig.anim || n.tag; if (/infinite/.test(css.animation)) sig.infinite = sig.infinite || `animation infinite on <${n.tag}>`; }
481
+ if (css.transition) sig.transition = true;
482
+ if (css.cursor === 'pointer') sig.pointer = true;
483
+ if (!sig.interactiveTag && (INTERACTIVE_TAGS.has(n.tag) || (n.tag === 'a' && n.attrs && n.attrs.href))) sig.interactiveTag = n.tag;
484
+ sig.carouselClass = sig.carouselClass || firstClass(cls, CAROUSEL_RE);
485
+ sig.scrollClass = sig.scrollClass || firstClass(cls, SCROLL_RE);
486
+ sig.toggleClass = sig.toggleClass || firstClass(cls, TOGGLE_RE);
487
+ if (!sig.toggleRole && TOGGLE_ROLES.has(role)) sig.toggleRole = role;
488
+ for (const c of (n.children || [])) visit(c);
489
+ };
490
+ visit(root);
491
+ return sig;
492
+ }
493
+
494
+ function interactionSignalList(sig) {
495
+ const s = [];
496
+ if (sig.infinite) s.push(sig.infinite);
497
+ if (sig.carouselClass) s.push(`carousel class ".${sig.carouselClass}"`);
498
+ if (sig.scrollClass) s.push(`scroll class ".${sig.scrollClass}"`);
499
+ if (sig.sticky) s.push(sig.sticky);
500
+ if (sig.toggleClass) s.push(`toggle class ".${sig.toggleClass}"`);
501
+ if (sig.toggleRole) s.push(`role="${sig.toggleRole}"`);
502
+ if (sig.interactiveTag) s.push(`<${sig.interactiveTag}>`);
503
+ if (sig.anim) s.push(`animation on <${sig.anim}>`);
504
+ if (sig.transition) s.push('css transition');
505
+ if (sig.pointer) s.push('cursor:pointer');
506
+ return s.slice(0, 8);
507
+ }
508
+
509
+ function decideInteraction(sig) {
510
+ let model = 'static';
511
+ let confidence = 'low';
512
+ if (sig.infinite || sig.carouselClass) {
513
+ model = 'time-driven';
514
+ confidence = (sig.infinite || /swiper|slick|embla|carousel/.test(sig.carouselClass || '')) ? 'high' : 'medium';
515
+ } else if (sig.scrollClass || (sig.sticky && (sig.anim || sig.transition))) {
516
+ model = 'scroll-driven';
517
+ confidence = sig.scrollClass ? 'medium' : 'low';
518
+ } else if (sig.interactiveTag === 'details' || sig.toggleRole === 'tab' || sig.toggleClass) {
519
+ model = 'click-driven';
520
+ confidence = (sig.interactiveTag === 'details' || sig.toggleRole === 'tab') ? 'high' : 'medium';
521
+ } else if (sig.interactiveTag || sig.toggleRole) {
522
+ model = 'click-driven';
523
+ confidence = 'medium';
524
+ } else if (sig.pointer && sig.transition) {
525
+ model = 'hover';
526
+ confidence = 'medium';
527
+ }
528
+ return {
529
+ model,
530
+ confidence,
531
+ signals: interactionSignalList(sig),
532
+ note: 'Static-DOM heuristic — confirm the real interaction on the live site (Phase 5).',
533
+ };
534
+ }
535
+
536
+ // ─── State rule matching: attach harvested state rules to their section ─────
537
+ const LEAF_INTERACTIVE = ['button', 'a', 'input', 'summary', 'details', 'label', 'select'];
538
+
539
+ // Match a bare tag at a selector boundary (so `a:hover` matches but `.cta:hover` does not)
540
+ const _tagSelCache = {};
541
+ function tagSelRe(t) {
542
+ return _tagSelCache[t] || (_tagSelCache[t] = new RegExp('(^|[\\s>+~,(])' + t + '(?=[:.\\[\\s>+~,)]|$)'));
543
+ }
544
+
545
+ function collectClassTagSets(root) {
546
+ const classSet = new Set();
547
+ const tagSet = new Set();
548
+ const visit = (n) => {
549
+ tagSet.add(n.tag);
550
+ for (const c of (n.classes || '').split(/\s+/)) if (c) classSet.add(c.toLowerCase());
551
+ for (const c of (n.children || [])) visit(c);
552
+ };
553
+ visit(root);
554
+ return { classArr: Array.from(classSet), tagSet };
555
+ }
556
+
557
+ function sectionStateRules(stateRules, classArr, tagSet, limit = 60) {
558
+ if (!stateRules || !stateRules.length) return [];
559
+ const picked = [];
560
+ for (const sr of stateRules) {
561
+ const sel = sr.selector.toLowerCase();
562
+ const classHit = classArr.some((c) => sel.includes('.' + c));
563
+ const tagHit = !classHit && LEAF_INTERACTIVE.some((t) => tagSet.has(t) && tagSelRe(t).test(sel));
564
+ if (classHit || tagHit) {
565
+ picked.push({ selector: sr.selector, media: sr.media || null, css: sr.css });
566
+ if (picked.length >= limit) break;
567
+ }
568
+ }
569
+ return picked;
570
+ }
571
+
572
+ function loadStateRules() {
573
+ const statesPath = opts.states || path.join(path.dirname(computedPath), 'states.json');
574
+ try {
575
+ return JSON.parse(fs.readFileSync(statesPath, 'utf8')).stateRules || [];
576
+ } catch {
577
+ return [];
578
+ }
579
+ }
580
+
455
581
  // ─── Main ───────────────────────────────────────────────────────────
456
582
  function main() {
457
583
  const computed = JSON.parse(fs.readFileSync(computedPath, 'utf8'));
@@ -462,13 +588,17 @@ function main() {
462
588
 
463
589
  // Section detection
464
590
  const sectionEntries = findSections(roots);
465
- console.log(`[clone-refine] detected ${sectionEntries.length} sections, ${tokens.colors.length} colors, ${tokens.typography.length} typo, ${tokens.spacing.length} spacings`);
591
+ const stateRules = loadStateRules();
592
+ console.log(`[clone-refine] detected ${sectionEntries.length} sections, ${tokens.colors.length} colors, ${tokens.typography.length} typo, ${tokens.spacing.length} spacings, ${stateRules.length} state rules`);
466
593
 
467
594
  // Build refined sections
468
595
  const sections = sectionEntries.map(({ node, label }) => {
469
596
  const subtree = trimSubtree(node);
470
597
  const components = detectComponents(node);
471
598
  const images = classifyImages(node);
599
+ const interaction = decideInteraction(collectInteractionSignals(node));
600
+ const { classArr, tagSet } = collectClassTagSets(node);
601
+ const states = sectionStateRules(stateRules, classArr, tagSet);
472
602
  return {
473
603
  name: label,
474
604
  nodeRef: node.id,
@@ -476,8 +606,10 @@ function main() {
476
606
  classes: node.classes,
477
607
  box: node.box,
478
608
  css: node.css || {},
609
+ interaction,
479
610
  components,
480
611
  images,
612
+ states,
481
613
  children: (subtree.children || []),
482
614
  };
483
615
  });
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * clone-spec.js — sections.json → per-section build-contract spec (*.spec.md)
5
+ *
6
+ * Usage:
7
+ * node clone-spec.js <sections.json> --out=<dir> [--section=Name] [--feature=name]
8
+ *
9
+ * Why this exists (clone Phase 3, Step 0 — the spec gate):
10
+ * No section is built without a completed spec. The spec is the contract AND the
11
+ * audit trail: it forces extraction rigor (interaction model, states, assets, text,
12
+ * computed CSS) into a single reviewable file before any component code is written.
13
+ * Generated deterministically from sections.json — Claude fills the TODOs, never guesses.
14
+ *
15
+ * Output (in <dir>):
16
+ * <Section>.spec.md — one contract per detected section
17
+ */
18
+
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+
22
+ // ─── CLI ────────────────────────────────────────────────────────────
23
+ function parseArgs(argv) {
24
+ const [, , sectionsPath, ...rest] = argv;
25
+ const opts = {};
26
+ for (const a of rest) {
27
+ if (a.startsWith('--out=')) opts.out = a.slice(6);
28
+ else if (a.startsWith('--section=')) opts.section = a.slice(10);
29
+ else if (a.startsWith('--feature=')) opts.feature = a.slice(10);
30
+ }
31
+ return { sectionsPath, opts };
32
+ }
33
+
34
+ const { sectionsPath, opts } = parseArgs(process.argv);
35
+ if (!sectionsPath || !opts.out) {
36
+ console.error('Usage: node clone-spec.js <sections.json> --out=<dir> [--section=Name] [--feature=name]');
37
+ process.exit(1);
38
+ }
39
+
40
+ // ─── Helpers ────────────────────────────────────────────────────────
41
+ function safeName(name) {
42
+ return (name || 'Section').replace(/[^a-zA-Z0-9_-]/g, '-');
43
+ }
44
+
45
+ function collectText(section, limit = 25) {
46
+ const texts = [];
47
+ const seen = new Set();
48
+ const visit = (n) => {
49
+ if (n.text) {
50
+ const t = n.text.length > 120 ? n.text.slice(0, 120) + '…' : n.text;
51
+ if (!seen.has(t)) { seen.add(t); texts.push(t); }
52
+ }
53
+ for (const c of (n.children || [])) visit(c);
54
+ };
55
+ for (const c of (section.children || [])) visit(c);
56
+ return texts.slice(0, limit);
57
+ }
58
+
59
+ function cssBlock(css) {
60
+ const entries = Object.entries(css || {}).filter(([k]) => k !== '--vars');
61
+ if (!entries.length) return '(none captured)';
62
+ return entries.map(([k, v]) => `${k}: ${v};`).join('\n');
63
+ }
64
+
65
+ function interactionBlock(it) {
66
+ if (!it) return '- **Heuristic guess:** `unknown`\n';
67
+ const signals = (it.signals || []).length
68
+ ? it.signals.map((s) => ` - ${s}`).join('\n')
69
+ : ' - (no interaction signals detected — likely static)';
70
+ return [
71
+ `- **Heuristic guess:** \`${it.model}\` (confidence: ${it.confidence})`,
72
+ `- **Signals detected:**`,
73
+ signals,
74
+ `- **Confirmed model:** TODO — verify on the live site, then record the final model.`,
75
+ `- **Trigger & transition:** TODO — exact trigger (click/scroll/hover/time), duration, easing.`,
76
+ ].join('\n');
77
+ }
78
+
79
+ function statesBlock(states) {
80
+ if (!states || !states.length) {
81
+ return '- None harvested. TODO: confirm there are truly no hover/focus/active/open states.';
82
+ }
83
+ return states.map((sr) => {
84
+ const decls = Object.entries(sr.css || {}).map(([k, v]) => ` - ${k}: ${v}`).join('\n');
85
+ const media = sr.media ? ` _(under \`@media ${sr.media}\`)_` : '';
86
+ return `- \`${sr.selector}\`${media}\n${decls}`;
87
+ }).join('\n');
88
+ }
89
+
90
+ function componentsBlock(components) {
91
+ if (!components || !components.length) return '- None detected.';
92
+ return components.map((c) => {
93
+ const cls = c.exemplarClasses ? `.${String(c.exemplarClasses).split(/\s+/).slice(0, 3).join('.')}` : '';
94
+ return `- \`<${c.exemplarTag}>${cls}\` ×${c.count} → TODO: reuse existing component or create new?`;
95
+ }).join('\n');
96
+ }
97
+
98
+ function assetsBlock(images) {
99
+ const bg = (images && images.bg) || [];
100
+ const content = (images && images.content) || [];
101
+ const lines = [];
102
+ lines.push(bg.length ? `- **background:** ${bg.map((u) => `\`${u}\``).join(', ')}` : '- **background:** none');
103
+ lines.push(content.length ? `- **content (<img>):** ${content.map((u) => `\`${u}\``).join(', ')}` : '- **content (<img>):** none');
104
+ return lines.join('\n');
105
+ }
106
+
107
+ // ─── Spec rendering ─────────────────────────────────────────────────
108
+ function renderSpec(section, meta) {
109
+ const box = section.box || {};
110
+ const texts = collectText(section);
111
+ const textBlock = texts.length
112
+ ? texts.map((t) => `- "${t}"`).join('\n')
113
+ : '- (no direct text — likely visual/asset-only section)';
114
+ return `# ${section.name} — Clone Spec (${meta.bp})
115
+
116
+ > Auto-generated skeleton from sections.json by clone-spec.js. **This is a build contract.**
117
+ > Complete every \`TODO\` before dispatching the build. No section ships without a completed spec.
118
+
119
+ - **Feature:** ${meta.feature}
120
+ - **Breakpoint:** ${meta.bp} (${meta.viewport ? `${meta.viewport.width}×${meta.viewport.height}` : '?'})
121
+ - **Source URL:** ${meta.url || '?'}
122
+ - **Node ref:** \`${section.nodeRef}\` · **Root box:** ${Math.round(box.w || 0)}×${Math.round(box.h || 0)} @ (${Math.round(box.x || 0)}, ${Math.round(box.y || 0)})
123
+
124
+ ## Interaction model — ⚠ confirm before building
125
+ ${interactionBlock(section.interaction)}
126
+
127
+ ## States to reproduce
128
+ _Harvested non-default state rules (hover/focus/active/checked/tab/aria/data-state). Implement each._
129
+ ${statesBlock(section.states)}
130
+
131
+ ## Layout — root computed CSS (use as-is, no guessing)
132
+ \`\`\`css
133
+ ${cssBlock(section.css)}
134
+ \`\`\`
135
+
136
+ ## Assets (local paths only — never hotlink)
137
+ ${assetsBlock(section.images)}
138
+
139
+ ## Text content (replace copyrighted copy with placeholders)
140
+ ${textBlock}
141
+
142
+ ## Component candidates
143
+ ${componentsBlock(section.components)}
144
+
145
+ ## Build checklist
146
+ - [ ] HTML semantics + final tags chosen
147
+ - [ ] SCSS via clone-to-scss.js (computed values as-is — no eyeballing)
148
+ - [ ] Every state above implemented
149
+ - [ ] Interaction model confirmed on live site & wired
150
+ - [ ] All assets local (public/images/${meta.feature}/) — no hotlinks
151
+ - [ ] clone-validate.js PASS
152
+ - [ ] Pixel diff ≤ 0.05 (Phase 5)
153
+ `;
154
+ }
155
+
156
+ // ─── Main ───────────────────────────────────────────────────────────
157
+ function main() {
158
+ const data = JSON.parse(fs.readFileSync(sectionsPath, 'utf8'));
159
+ const meta = {
160
+ feature: opts.feature || (data.meta && data.meta.feature) || 'feature',
161
+ url: data.meta && data.meta.url,
162
+ viewport: data.meta && data.meta.viewport,
163
+ bp: opts.section ? (data.meta && data.meta.bp) : (data.meta && data.meta.bp) || '?',
164
+ };
165
+ let sections = data.sections || [];
166
+ if (opts.section) sections = sections.filter((s) => s.name === opts.section);
167
+ if (!sections.length) {
168
+ console.error(`[clone-spec] no sections${opts.section ? ` matching "${opts.section}"` : ''} in ${sectionsPath}`);
169
+ process.exit(1);
170
+ }
171
+
172
+ fs.mkdirSync(opts.out, { recursive: true });
173
+ for (const section of sections) {
174
+ const file = path.join(opts.out, `${safeName(section.name)}.spec.md`);
175
+ fs.writeFileSync(file, renderSpec(section, meta));
176
+ console.log(`[clone-spec] wrote ${file}`);
177
+ }
178
+ console.log(`[clone-spec] done → ${sections.length} spec(s) in ${opts.out}`);
179
+ }
180
+
181
+ try { main(); }
182
+ catch (e) {
183
+ console.error(`[clone-spec] FAIL: ${e.message}`);
184
+ if (process.env.DEBUG) console.error(e.stack);
185
+ process.exit(1);
186
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@su-record/vibe",
3
- "version": "2.16.0",
3
+ "version": "2.16.1",
4
4
  "description": "AI Coding Framework for Claude Code — 42+ agents, 70 skills, multi-LLM orchestration",
5
5
  "type": "module",
6
6
  "main": "dist/cli/index.js",
@@ -34,6 +34,13 @@ The rendered DOM is the source of truth for markup. Screenshots are for pixel ve
34
34
  4. Do NOT copy textual content verbatim from copyrighted sources for production use.
35
35
  This skill is for layout/markup learning ("클론 코딩"). Replace text with placeholders
36
36
  or user-provided copy when shipping a real product.
37
+
38
+ 5. Do NOT build a section without confirming its interaction model. The model in
39
+ sections.json is a static-DOM heuristic — verify scroll-driven vs click-driven vs
40
+ time-driven vs hover against the live site. Misidentifying it is the #1 clone failure mode.
41
+
42
+ 6. Do NOT ship default-state-only. Implement every harvested state (hover/focus/active/open/
43
+ tab-switch) from states.json / the section spec.
37
44
  ```
38
45
 
39
46
  ## Full Flow
@@ -43,8 +50,8 @@ Input: a URL (or multiple URLs for multi-page clones)
43
50
 
44
51
  → Phase 0: Setup (stack detection, feature naming, working dir)
45
52
  → Phase 1: Capture (Puppeteer → rendered HTML + computed CSS + screenshots + assets)
46
- → Phase 2: Refine (DOM → sections.json per breakpoint)
47
- → Phase 3: Scaffold (sections.json → SCSS auto-gen + Claude-authored HTML/components)
53
+ → Phase 2: Refine (DOM → sections.json per breakpoint; + interaction model + states)
54
+ → Phase 3: Scaffold (spec gate → SCSS auto-gen + Claude-authored HTML/components)
48
55
  → Phase 4: Compile gate
49
56
  → Phase 5: Pixel verification loop
50
57
 
@@ -55,7 +62,7 @@ Working directory:
55
62
  └── tokens.json — extracted design tokens (colors/fonts/spacing)
56
63
 
57
64
  Code output: placed directly in the project directory per detected stack
58
- components/{feature}/, styles/{feature}/, public/images/{feature}/
65
+ components/{feature}/, components/{feature}/_specs/, styles/{feature}/, public/images/{feature}/
59
66
  ```
60
67
 
61
68
  ---
@@ -111,6 +118,7 @@ node {{VIBE_PATH}}/hooks/scripts/clone-extract.js capture <URL> \
111
118
  ├── rendered.html — final DOM after JS execution
112
119
  ├── computed.json — per-element computed CSS + bounding box
113
120
  ├── screenshot.png — full-page screenshot
121
+ ├── states.json — non-default state rules (hover/focus/active/checked/tab/aria/data-state)
114
122
  ├── assets/
115
123
  │ ├── images/ — all <img>, background-image, <picture> sources
116
124
  │ └── fonts/ — @font-face srcs
@@ -127,6 +135,9 @@ node {{VIBE_PATH}}/hooks/scripts/clone-extract.js capture <URL> \
127
135
  5. Download assets in parallel (concurrency=8), preserve original extensions
128
136
  6. Rewrite asset URLs in rendered.html and computed.json to local paths
129
137
  7. Strip inline analytics/tracking scripts before saving rendered.html
138
+ 8. Harvest non-default state rules from all stylesheets → states.json
139
+ (deterministic: read :hover/:focus/:active/:checked/[aria-*]/[data-state]/.is-*/.active
140
+ declarations straight from CSS — NO scripted clicking, so output stays reproducible)
130
141
  ```
131
142
 
132
143
  ---
@@ -136,11 +147,12 @@ node {{VIBE_PATH}}/hooks/scripts/clone-extract.js capture <URL> \
136
147
  ### BLOCKING Command — Writing custom refine scripts is forbidden
137
148
 
138
149
  ```bash
139
- # MO
150
+ # MO (--states is optional — auto-resolved as states.json next to computed.json)
140
151
  node {{VIBE_PATH}}/hooks/scripts/clone-refine.js \
141
152
  /tmp/{feature}/mo/rendered.html \
142
153
  /tmp/{feature}/mo/computed.json \
143
154
  --out=/tmp/{feature}/mo/sections.json \
155
+ --states=/tmp/{feature}/mo/states.json \
144
156
  --bp=mo
145
157
 
146
158
  # PC
@@ -148,6 +160,7 @@ node {{VIBE_PATH}}/hooks/scripts/clone-refine.js \
148
160
  /tmp/{feature}/pc/rendered.html \
149
161
  /tmp/{feature}/pc/computed.json \
150
162
  --out=/tmp/{feature}/pc/sections.json \
163
+ --states=/tmp/{feature}/pc/states.json \
151
164
  --bp=pc
152
165
  ```
153
166
 
@@ -175,6 +188,13 @@ Refinement applied when converting rendered.html + computed.json → sections.js
175
188
  typography (font-*, line-height, letter-spacing, text-*, color),
176
189
  decoration (background, border, border-radius, box-shadow, opacity),
177
190
  transform/transition
191
+ 9. Classify interaction model per section (static-DOM heuristic) → section.interaction
192
+ = { model, confidence, signals[], note }. model ∈ static | click-driven | scroll-driven
193
+ | time-driven | hover. This is a best-guess + ranked SIGNALS, NOT a silent decision —
194
+ the builder confirms it against the live site (Phase 5). Misidentifying the interaction
195
+ model is the #1 clone failure mode (scroll-driven original built as a click UI, etc.).
196
+ 10. Attach matching state rules from states.json to each section → section.states
197
+ (rules whose selector references a class/leaf-tag inside that section's subtree)
178
198
  ```
179
199
 
180
200
  ### Output
@@ -188,8 +208,17 @@ Refinement applied when converting rendered.html + computed.json → sections.js
188
208
  {
189
209
  name: "Header" | "Hero" | "Features" | ...,
190
210
  nodeRef, tag, size, css,
211
+ interaction: { // static-DOM heuristic — builder confirms in Phase 5
212
+ model: "static" | "click-driven" | "scroll-driven" | "time-driven" | "hover",
213
+ confidence: "high" | "medium" | "low",
214
+ signals: [...], // ranked evidence (sticky, infinite animation, role=tab, …)
215
+ note
216
+ },
191
217
  text, // text content (placeholder candidates)
192
218
  components: [...], // detected repeated patterns
219
+ states: [ // non-default state rules scoped to this section
220
+ { selector, media, css }
221
+ ],
193
222
  children: [...], // full recursive subtree
194
223
  images: { bg, content: [...] }
195
224
  }
@@ -207,6 +236,12 @@ Refinement applied when converting rendered.html + computed.json → sections.js
207
236
  ### BLOCKING Command — SCSS must only use script output
208
237
 
209
238
  ```bash
239
+ # Step 0: Generate per-section build-contract specs (run once per BP)
240
+ node {{VIBE_PATH}}/hooks/scripts/clone-spec.js \
241
+ /tmp/{feature}/{bp}/sections.json \
242
+ --out=/path/to/project/components/{feature}/_specs/ \
243
+ --feature={feature}
244
+
210
245
  # Step A: Auto-generate SCSS skeleton (run once per BP)
211
246
  node {{VIBE_PATH}}/hooks/scripts/clone-to-scss.js \
212
247
  /tmp/{feature}/{bp}/sections.json \
@@ -220,22 +255,33 @@ node {{VIBE_PATH}}/hooks/scripts/clone-validate.js \
220
255
  --section={SectionName}
221
256
  ```
222
257
 
258
+ ⛔ **No section is built without a completed spec.** Step 0 emits `_specs/{Section}.spec.md`
259
+ (interaction model + states + computed CSS + assets + text + checklist). Before writing a
260
+ section's component, Claude reviews its spec and resolves every `TODO` (confirm interaction
261
+ model, list states to implement, choose tags, replace copyrighted text). The spec is the
262
+ contract AND the audit trail — it forces extraction rigor before any code is written.
223
263
  ⛔ **Writing SCSS files directly without calling clone-to-scss.js invalidates Phase 3.**
224
- ⛔ **Do NOT write custom SCSS generation scripts.**
264
+ ⛔ **Do NOT write custom SCSS / spec generation scripts.**
225
265
  ⛔ **Do NOT proceed to the next section without a clone-validate.js PASS.**
226
- ✅ Use clone-to-scss.js output as-is. If unsatisfactory, modify the script.
266
+ ✅ Use clone-spec.js / clone-to-scss.js output as-is. If unsatisfactory, modify the script.
227
267
 
228
268
  ```
229
269
  Phase 3A: MO Scaffold
230
270
  Input: /tmp/{feature}/mo/sections.json
271
+ Step 0 once: clone-spec.js → emit _specs/{Section}.spec.md for every section
231
272
  ⛔ No parallelism. Process one section at a time:
232
- 1. Read the target section from sections.json
273
+ 1. Read the target section's spec → resolve every TODO:
274
+ ├─ Confirm the interaction model (spec.interaction is a heuristic guess)
275
+ ├─ List the states to implement (spec.states)
276
+ └─ Mark copyrighted text for placeholder replacement
233
277
  2. Map component candidates against component-index.json
234
278
  ├─ Match → import existing
235
279
  └─ No match → create new in components/{feature}/
236
280
  3. clone-to-scss.js → auto-generate SCSS skeleton (computed values as-is) — Step A once
237
281
  4. Claude: HTML structure + semantic tags + framework-specific component file
238
282
  ⛔ No CSS written directly in <style> blocks — only @import/@use allowed
283
+ ⛔ Build for the CONFIRMED interaction model (don't build a click UI for a
284
+ scroll-driven original); wire every state from the spec (hover/active/open/…)
239
285
  Framework mapping:
240
286
  - React/Next → .tsx with CSS Modules or styled-components per stack
241
287
  - Vue/Nuxt → .vue with scoped <style lang="scss"> @import only
@@ -258,7 +304,8 @@ Claude's role (restricted):
258
304
  ✅ Component candidates: decide which patterns become reusable components
259
305
  ✅ HTML semantics: section/h1/p/button/nav tag selection
260
306
  ✅ Text replacement: substitute copyrighted copy with placeholders or user-supplied text
261
- Interactions: hover/focus states, click handlers, conditional rendering
307
+ Interaction model: confirm the spec's heuristic guess, then build for the real model
308
+ ✅ Interactions: implement every state in the spec (hover/focus/active/open), click handlers
262
309
  ❌ Do NOT modify SCSS CSS values (use clone-to-scss.js output as-is)
263
310
  ❌ Do NOT write CSS directly in <style> blocks
264
311
  ❌ Do NOT use vw/clamp/@media or create custom mixins in Phase 3A/3B
@@ -359,3 +406,5 @@ Claude must:
359
406
  | robots.txt disallows path | Halt Phase 1. Inform user; require explicit `--ignore-robots` flag to proceed. |
360
407
  | clone-refine.js produces empty sections | Site likely uses Shadow DOM or canvas rendering. Report and ask whether to fall back to screenshot-only mode. |
361
408
  | Pixel diff stuck > 0.05 after 5 rounds | Likely font fallback or anti-aliasing. Report metric, allow user to accept threshold. |
409
+ | Interaction model guess wrong (Phase 5) | section.interaction is a static-DOM heuristic. Re-observe the live site, correct the model in the spec, rebuild the section for the confirmed model. |
410
+ | states.json empty but site has hover/tabs | States may be set via inline JS, not CSS rules. Note in the spec and capture them manually from the live site during Phase 5. |
@@ -52,18 +52,23 @@ Phase 0: Setup
52
52
  Phase 1: Capture (병렬 — scope에 따라 MO/PC 동시)
53
53
  - node {{VIBE_PATH}}/hooks/scripts/clone-extract.js capture <url> \
54
54
  --out=/tmp/{feature}/{bp}/ --viewport={WxH} --bp={mo|pc}
55
- - 출력: rendered.html, computed.json, screenshot.png, asset-map.json, assets/
55
+ - 출력: rendered.html, computed.json, screenshot.png, states.json, asset-map.json, assets/
56
56
 
57
57
  Phase 2: Refine (BP마다 독립)
58
58
  - node {{VIBE_PATH}}/hooks/scripts/clone-refine.js \
59
59
  /tmp/{feature}/{bp}/rendered.html /tmp/{feature}/{bp}/computed.json \
60
- --out=/tmp/{feature}/{bp}/sections.json --bp={mo|pc}
60
+ --out=/tmp/{feature}/{bp}/sections.json --states=/tmp/{feature}/{bp}/states.json --bp={mo|pc}
61
+ - sections.json 에 section.interaction(인터랙션 모델 추정) + section.states(상태 규칙) 포함
61
62
 
62
63
  Phase 3: Scaffold (BP 순차 — MO 완료 후 PC)
64
+ - Step 0: node {{VIBE_PATH}}/hooks/scripts/clone-spec.js \
65
+ /tmp/{feature}/{bp}/sections.json \
66
+ --out=./components/{feature}/_specs/ --feature={feature}
67
+ → 섹션별 빌드 계약서(_specs/{Section}.spec.md). 빌드 전 TODO(인터랙션 모델 확정·상태 목록·텍스트 교체) 해결
63
68
  - Step A: node {{VIBE_PATH}}/hooks/scripts/clone-to-scss.js \
64
69
  /tmp/{feature}/{bp}/sections.json \
65
70
  --out=./styles/{feature}/ --feature={feature}
66
- - Claude: 섹션별로 HTML/컴포넌트 작성 (스택별 .tsx/.vue/.svelte/.html)
71
+ - Claude: 섹션별로 HTML/컴포넌트 작성 (스택별 .tsx/.vue/.svelte/.html) — 확정된 인터랙션 모델 + 모든 상태 반영
67
72
  - Step B: node {{VIBE_PATH}}/hooks/scripts/clone-validate.js \
68
73
  ./styles/{feature}/ /tmp/{feature}/{bp}/sections.json --section={Name}
69
74
  - 섹션마다 PASS 받고 다음 섹션 진행
@@ -83,10 +88,11 @@ Phase 5: Pixel Verification (P1=0까지 루프 — clone SKILL.md 규칙)
83
88
 
84
89
  ```
85
90
  /tmp/{feature}/ # 작업 디렉토리 (산출물 원본)
86
- ├── mo/, pc/ # rendered.html, computed.json, screenshot.png, sections.json, assets/
91
+ ├── mo/, pc/ # rendered.html, computed.json, screenshot.png, states.json, sections.json, assets/
87
92
  └── project-tokens.json # 기존 프로젝트 토큰 인덱스
88
93
 
89
94
  ./components/{feature}/ # Claude가 작성한 컴포넌트 (.tsx/.vue/.svelte/.html)
95
+ ./components/{feature}/_specs/ # clone-spec.js가 생성한 섹션별 빌드 계약서 (*.spec.md)
90
96
  ./styles/{feature}/ # clone-to-scss.js가 생성한 SCSS 파일
91
97
  ├── _tokens.scss # CSS 변수
92
98
  ├── _base.scss # @font-face