@su-record/vibe 2.16.0 → 2.16.2
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: {
|
|
@@ -592,7 +619,9 @@ async function capture({ url, opts }) {
|
|
|
592
619
|
|
|
593
620
|
await freezeAnimations(page);
|
|
594
621
|
|
|
595
|
-
|
|
622
|
+
// PAGE_EXTRACT is a parenthesized function-expression string. page.evaluate treats a
|
|
623
|
+
// string arg as an expression and ignores extra args, so build the call ourselves.
|
|
624
|
+
const extracted = await page.evaluate(`(${PAGE_EXTRACT})(${JSON.stringify(CSS_PROPS)})`);
|
|
596
625
|
|
|
597
626
|
// Screenshot AFTER freezing animations
|
|
598
627
|
const shotPath = path.join(opts.out, 'screenshot.png');
|
|
@@ -666,6 +695,14 @@ async function capture({ url, opts }) {
|
|
|
666
695
|
});
|
|
667
696
|
}
|
|
668
697
|
|
|
698
|
+
// Rewrite remote asset URLs inside harvested state rules (e.g. :hover background-image)
|
|
699
|
+
for (const sr of extracted.stateRules) {
|
|
700
|
+
if (!sr.css) continue;
|
|
701
|
+
for (const prop of ['background-image', 'mask-image', 'border-image']) {
|
|
702
|
+
if (sr.css[prop]) sr.css[prop] = rewriteCssValue(sr.css[prop]);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
669
706
|
// Write outputs
|
|
670
707
|
fs.writeFileSync(path.join(opts.out, 'rendered.html'), html);
|
|
671
708
|
fs.writeFileSync(
|
|
@@ -686,11 +723,18 @@ async function capture({ url, opts }) {
|
|
|
686
723
|
path.join(opts.out, 'stylesheets.json'),
|
|
687
724
|
JSON.stringify(extracted.stylesheets, null, 2),
|
|
688
725
|
);
|
|
726
|
+
fs.writeFileSync(
|
|
727
|
+
path.join(opts.out, 'states.json'),
|
|
728
|
+
JSON.stringify({
|
|
729
|
+
meta: { url, bp: opts.bp || null, capturedAt: new Date().toISOString() },
|
|
730
|
+
stateRules: extracted.stateRules,
|
|
731
|
+
}, null, 2),
|
|
732
|
+
);
|
|
689
733
|
fs.writeFileSync(path.join(opts.out, 'asset-map.json'), JSON.stringify(assetMap, null, 2));
|
|
690
734
|
|
|
691
735
|
const okCount = Object.values(assetMap).filter((a) => a.status === 'ok').length;
|
|
692
736
|
console.log(`[clone-extract] done → ${opts.out}`);
|
|
693
|
-
console.log(` nodes: ${extracted.nodes.length}, fontFaces: ${extracted.stylesheets.fontFaces.length}`);
|
|
737
|
+
console.log(` nodes: ${extracted.nodes.length}, fontFaces: ${extracted.stylesheets.fontFaces.length}, stateRules: ${extracted.stateRules.length}`);
|
|
694
738
|
console.log(` assets: ${okCount}/${Object.keys(assetMap).length} downloaded`);
|
|
695
739
|
} finally {
|
|
696
740
|
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
|
-
|
|
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
package/skills/clone/SKILL.md
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
✅
|
|
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
|