@su-record/vibe 2.16.2 → 2.16.3
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.
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clone active-interaction-sweep helpers
|
|
3
|
+
* - diffStyles: before/after computed-style differ (clone-extract.js)
|
|
4
|
+
* - sectionHasNode / behaviorsBlock: behavior→section attachment (clone-spec.js)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { diffStyles } from '../clone-extract.js';
|
|
9
|
+
import { sectionHasNode, behaviorsBlock } from '../clone-spec.js';
|
|
10
|
+
|
|
11
|
+
describe('diffStyles', () => {
|
|
12
|
+
it('reports only changed props with from/to', () => {
|
|
13
|
+
const a = { 'background-color': 'rgba(0, 0, 0, 0)', height: '80px' };
|
|
14
|
+
const b = { 'background-color': 'rgb(255, 255, 255)', height: '80px' };
|
|
15
|
+
expect(diffStyles(a, b)).toEqual({
|
|
16
|
+
'background-color': { from: 'rgba(0, 0, 0, 0)', to: 'rgb(255, 255, 255)' },
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('captures props that appear or disappear', () => {
|
|
21
|
+
const d = diffStyles({}, { 'box-shadow': '0 4px 20px rgba(0,0,0,0.1)' });
|
|
22
|
+
expect(d['box-shadow']).toEqual({ from: null, to: '0 4px 20px rgba(0,0,0,0.1)' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns empty when nothing changed or a snapshot is missing', () => {
|
|
26
|
+
expect(diffStyles({ color: 'red' }, { color: 'red' })).toEqual({});
|
|
27
|
+
expect(diffStyles(null, { color: 'red' })).toEqual({});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('behavior → section attachment', () => {
|
|
32
|
+
const section = {
|
|
33
|
+
name: 'Header',
|
|
34
|
+
children: [{ tag: 'header', classes: 'site-nav top', children: [{ tag: 'button', classes: 'tab', children: [] }] }],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
it('matches a node by tag and class', () => {
|
|
38
|
+
expect(sectionHasNode(section, 'header', 'site-nav')).toBe(true);
|
|
39
|
+
expect(sectionHasNode(section, 'header', 'missing')).toBe(false);
|
|
40
|
+
expect(sectionHasNode(section, 'button')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('attaches a scroll behavior to the matching section only', () => {
|
|
44
|
+
const behaviors = {
|
|
45
|
+
scroll: [{ label: 'header.site-nav', tag: 'header', cls: 'site-nav', triggerScrollY: 700, changed: { height: { from: '80px', to: '56px' } } }],
|
|
46
|
+
interactive: [{ kind: 'tab-group', count: 3, tabLabels: ['A', 'B', 'C'], contentSwapsOnClick: true }],
|
|
47
|
+
};
|
|
48
|
+
const block = behaviorsBlock(section, behaviors);
|
|
49
|
+
expect(block).toContain('Scroll-triggered');
|
|
50
|
+
expect(block).toContain('height');
|
|
51
|
+
expect(block).toContain('content SWAPS on click');
|
|
52
|
+
|
|
53
|
+
const footer = { name: 'Footer', children: [{ tag: 'p', classes: '', children: [] }] };
|
|
54
|
+
expect(behaviorsBlock(footer, behaviors)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* screenshot.png — full-page screenshot
|
|
13
13
|
* stylesheets.json — @font-face + @keyframes harvested from all sheets
|
|
14
14
|
* states.json — non-default state rules (hover/focus/active/checked/tab/aria/data-state)
|
|
15
|
+
* behaviors.json — ACTIVE interaction sweep: scroll-triggered header/nav diffs +
|
|
16
|
+
* click-driven tab groups (content-swap detection). Captures JS-set
|
|
17
|
+
* state that static CSS harvesting can't see. Skip with --no-interact.
|
|
15
18
|
* asset-map.json — remote URL → local path mapping
|
|
16
19
|
* assets/images/*, assets/fonts/*
|
|
17
20
|
*
|
|
@@ -78,7 +81,7 @@ const CSS_PROPS = [
|
|
|
78
81
|
// ─── CLI parse ──────────────────────────────────────────────────────
|
|
79
82
|
function parseArgs(argv) {
|
|
80
83
|
const [, , cmd, urlArg, ...rest] = argv;
|
|
81
|
-
const opts = { stealth: false, ignoreRobots: false };
|
|
84
|
+
const opts = { stealth: false, ignoreRobots: false, interact: true };
|
|
82
85
|
for (const arg of rest) {
|
|
83
86
|
if (arg.startsWith('--out=')) opts.out = arg.slice(6);
|
|
84
87
|
else if (arg.startsWith('--viewport=')) opts.viewport = arg.slice(11);
|
|
@@ -86,6 +89,7 @@ function parseArgs(argv) {
|
|
|
86
89
|
else if (arg.startsWith('--wait=')) opts.wait = Number(arg.slice(7));
|
|
87
90
|
else if (arg === '--stealth') opts.stealth = true;
|
|
88
91
|
else if (arg === '--ignore-robots') opts.ignoreRobots = true;
|
|
92
|
+
else if (arg === '--no-interact') opts.interact = false;
|
|
89
93
|
}
|
|
90
94
|
return { cmd, url: urlArg, opts };
|
|
91
95
|
}
|
|
@@ -241,7 +245,7 @@ const PAGE_EXTRACT = `
|
|
|
241
245
|
// Deterministic: read state-dependent declarations straight from the stylesheets
|
|
242
246
|
// (no scripted clicking/hovering, so the output stays reproducible).
|
|
243
247
|
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;
|
|
248
|
+
const STATE_RE = /:hover|:focus(?:-visible|-within)?|:active|:checked|:target|\\[aria-(?:expanded|selected|current|pressed)|\\[data-(?:state|active|open|selected)|\\[open\\]|\\.is-[a-z-]+|\\.has-[a-z-]+|\\.(?:active|open|selected|expanded|current|show|visible|scrolled|sticky|stuck|pinned|fixed|shrink|shrunk|compact|affix|headroom|scrolling)(?![\\w-])/i;
|
|
245
249
|
const harvestStateRule = (rule, media) => {
|
|
246
250
|
if (rule.selectorText && STATE_RE.test(rule.selectorText)) {
|
|
247
251
|
const decl = {};
|
|
@@ -556,6 +560,155 @@ async function freezeAnimations(page) {
|
|
|
556
560
|
});
|
|
557
561
|
}
|
|
558
562
|
|
|
563
|
+
// ─── Active interaction sweep ───────────────────────────────────────
|
|
564
|
+
// Static CSS harvesting (states.json) only sees declared :hover/:active/[data-state]
|
|
565
|
+
// rules. It is blind to JS-set state: a header that gains a class on scroll, a tab
|
|
566
|
+
// group that swaps content on click, Framer/GSAP inline-style animations. This sweep
|
|
567
|
+
// actually drives the page and diffs computed styles before/after — the #1 accuracy fix.
|
|
568
|
+
|
|
569
|
+
// Subset of props worth diffing for scroll/click state changes (kept small on purpose).
|
|
570
|
+
const BEHAVIOR_PROPS = [
|
|
571
|
+
'background-color', 'background-image', 'box-shadow', 'backdrop-filter',
|
|
572
|
+
'height', 'min-height', 'padding-top', 'padding-bottom',
|
|
573
|
+
'transform', 'opacity', 'position', 'top',
|
|
574
|
+
'border-bottom-width', 'border-bottom-color', 'color',
|
|
575
|
+
];
|
|
576
|
+
|
|
577
|
+
// Pure style differ — exported for testing. Returns { prop: { from, to } } for changed props.
|
|
578
|
+
function diffStyles(a, b) {
|
|
579
|
+
const out = {};
|
|
580
|
+
if (!a || !b) return out;
|
|
581
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
582
|
+
for (const k of keys) {
|
|
583
|
+
const from = a[k] ?? null;
|
|
584
|
+
const to = b[k] ?? null;
|
|
585
|
+
if (from !== to && (from || to)) out[k] = { from, to };
|
|
586
|
+
}
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// In-page: tag sticky/fixed/top-bar elements with a probe attr so we can re-find them.
|
|
591
|
+
const TAG_SCROLL_CANDIDATES = `(function () {
|
|
592
|
+
const els = Array.from(document.querySelectorAll(
|
|
593
|
+
'header, nav, [class*="header" i], [class*="nav" i], [class*="sticky" i], [class*="fixed" i]'
|
|
594
|
+
));
|
|
595
|
+
const out = []; let i = 0;
|
|
596
|
+
for (const el of els) {
|
|
597
|
+
if (i >= 6) break;
|
|
598
|
+
const cs = getComputedStyle(el);
|
|
599
|
+
const r = el.getBoundingClientRect();
|
|
600
|
+
const sticky = cs.position === 'fixed' || cs.position === 'sticky';
|
|
601
|
+
const topBar = r.top >= 0 && r.top < 120 && r.width > window.innerWidth * 0.5;
|
|
602
|
+
if (!sticky && !topBar) continue;
|
|
603
|
+
if (el.closest('[data-clone-probe]')) continue;
|
|
604
|
+
el.setAttribute('data-clone-probe', 'sc' + i);
|
|
605
|
+
const cls = (el.getAttribute('class') || '').trim().split(/\\s+/).filter(Boolean)[0] || '';
|
|
606
|
+
out.push({ probe: 'sc' + i, tag: el.tagName.toLowerCase(), cls });
|
|
607
|
+
i++;
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
})`;
|
|
611
|
+
|
|
612
|
+
const SNAP_PROBES = `(function (props) {
|
|
613
|
+
const out = {};
|
|
614
|
+
document.querySelectorAll('[data-clone-probe]').forEach((el) => {
|
|
615
|
+
const cs = getComputedStyle(el); const o = {};
|
|
616
|
+
for (const p of props) { const v = cs.getPropertyValue(p); if (v) o[p] = v.trim(); }
|
|
617
|
+
out[el.getAttribute('data-clone-probe')] = o;
|
|
618
|
+
});
|
|
619
|
+
return out;
|
|
620
|
+
})`;
|
|
621
|
+
|
|
622
|
+
// In-page: tag tab-like groups (≥2 siblings) so we can click them.
|
|
623
|
+
const TAG_TAB_GROUPS = `(function () {
|
|
624
|
+
const els = Array.from(document.querySelectorAll(
|
|
625
|
+
'[role="tab"], [aria-selected], button[class*="tab" i], li[class*="tab" i], [data-state="active"], [data-state="inactive"]'
|
|
626
|
+
));
|
|
627
|
+
const groups = new Map();
|
|
628
|
+
for (const el of els) {
|
|
629
|
+
const p = el.parentElement; if (!p) continue;
|
|
630
|
+
if (!groups.has(p)) groups.set(p, []);
|
|
631
|
+
groups.get(p).push(el);
|
|
632
|
+
}
|
|
633
|
+
const out = []; let g = 0;
|
|
634
|
+
for (const [, items] of groups) {
|
|
635
|
+
if (g >= 4) break;
|
|
636
|
+
if (items.length < 2) continue;
|
|
637
|
+
items.forEach((el, idx) => el.setAttribute('data-clone-tab', g + '_' + idx));
|
|
638
|
+
out.push({ group: g, count: items.length, labels: items.map((el) => (el.textContent || '').trim().slice(0, 40)).filter(Boolean) });
|
|
639
|
+
g++;
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
})`;
|
|
643
|
+
|
|
644
|
+
// Fingerprint the visible text near a tab group, to detect content swaps on click.
|
|
645
|
+
const TAB_CONTENT_FP = `(function (g) {
|
|
646
|
+
const first = document.querySelector('[data-clone-tab="' + g + '_0"]');
|
|
647
|
+
if (!first) return null;
|
|
648
|
+
const box = first.closest('section, main, div');
|
|
649
|
+
const root = box ? (box.parentElement || box) : document.body;
|
|
650
|
+
return (root.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 4000);
|
|
651
|
+
})`;
|
|
652
|
+
|
|
653
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
654
|
+
|
|
655
|
+
async function runInteractionSweep(page) {
|
|
656
|
+
const behaviors = { scroll: [], interactive: [] };
|
|
657
|
+
|
|
658
|
+
// ── Scroll-triggered state (header/nav shrink, background, shadow on scroll) ──
|
|
659
|
+
const candidates = await page.evaluate(`(${TAG_SCROLL_CANDIDATES})()`);
|
|
660
|
+
if (candidates.length) {
|
|
661
|
+
const before = await page.evaluate(`(${SNAP_PROBES})(${JSON.stringify(BEHAVIOR_PROPS)})`);
|
|
662
|
+
const innerH = await page.evaluate('window.innerHeight');
|
|
663
|
+
const triggerY = Math.min(700, Math.max(200, Math.round(innerH * 0.9)));
|
|
664
|
+
await page.evaluate(`window.scrollTo(0, ${triggerY})`);
|
|
665
|
+
await wait(500);
|
|
666
|
+
const after = await page.evaluate(`(${SNAP_PROBES})(${JSON.stringify(BEHAVIOR_PROPS)})`);
|
|
667
|
+
await page.evaluate('window.scrollTo(0, 0)');
|
|
668
|
+
await wait(300);
|
|
669
|
+
for (const c of candidates) {
|
|
670
|
+
const changed = diffStyles(before[c.probe], after[c.probe]);
|
|
671
|
+
if (Object.keys(changed).length) {
|
|
672
|
+
behaviors.scroll.push({
|
|
673
|
+
label: c.cls ? `${c.tag}.${c.cls}` : c.tag,
|
|
674
|
+
tag: c.tag, cls: c.cls, triggerScrollY: triggerY, changed,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ── Click-driven tab groups (content swap detection) ──
|
|
681
|
+
const groups = await page.evaluate(`(${TAG_TAB_GROUPS})()`);
|
|
682
|
+
for (const grp of groups) {
|
|
683
|
+
try {
|
|
684
|
+
const beforeFp = await page.evaluate(`(${TAB_CONTENT_FP})(${grp.group})`);
|
|
685
|
+
const clicked = await page.evaluate(
|
|
686
|
+
`(function (g) { const t = document.querySelector('[data-clone-tab="' + g + '_1"]'); if (!t) return false; t.click(); return true; })(${grp.group})`
|
|
687
|
+
);
|
|
688
|
+
if (!clicked) continue;
|
|
689
|
+
await wait(450);
|
|
690
|
+
const afterFp = await page.evaluate(`(${TAB_CONTENT_FP})(${grp.group})`);
|
|
691
|
+
// restore default state
|
|
692
|
+
await page.evaluate(
|
|
693
|
+
`(function (g) { const t = document.querySelector('[data-clone-tab="' + g + '_0"]'); if (t) t.click(); })(${grp.group})`
|
|
694
|
+
);
|
|
695
|
+
await wait(200);
|
|
696
|
+
behaviors.interactive.push({
|
|
697
|
+
kind: 'tab-group',
|
|
698
|
+
count: grp.count,
|
|
699
|
+
tabLabels: grp.labels,
|
|
700
|
+
contentSwapsOnClick: beforeFp != null && afterFp != null && beforeFp !== afterFp,
|
|
701
|
+
});
|
|
702
|
+
} catch { /* one bad group must not abort the whole sweep */ }
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Clean up probe attributes BEFORE static extraction so they don't pollute output.
|
|
706
|
+
await page.evaluate(
|
|
707
|
+
'document.querySelectorAll("[data-clone-probe],[data-clone-tab]").forEach((e) => { e.removeAttribute("data-clone-probe"); e.removeAttribute("data-clone-tab"); })'
|
|
708
|
+
);
|
|
709
|
+
return behaviors;
|
|
710
|
+
}
|
|
711
|
+
|
|
559
712
|
// HTML sanitization: strip scripts/analytics before saving
|
|
560
713
|
function sanitizeHtml(html) {
|
|
561
714
|
return html
|
|
@@ -617,6 +770,17 @@ async function capture({ url, opts }) {
|
|
|
617
770
|
if (opts.wait) await new Promise((r) => setTimeout(r, opts.wait));
|
|
618
771
|
else await new Promise((r) => setTimeout(r, 1200));
|
|
619
772
|
|
|
773
|
+
// Active interaction sweep (before freezing — it observes real transitions/JS state).
|
|
774
|
+
let behaviors = null;
|
|
775
|
+
if (opts.interact) {
|
|
776
|
+
try {
|
|
777
|
+
behaviors = await runInteractionSweep(page);
|
|
778
|
+
console.log(`[clone-extract] interaction sweep: ${behaviors.scroll.length} scroll-state, ${behaviors.interactive.length} tab-group(s)`);
|
|
779
|
+
} catch (e) {
|
|
780
|
+
console.log(`[clone-extract] interaction sweep skipped: ${e.message}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
620
784
|
await freezeAnimations(page);
|
|
621
785
|
|
|
622
786
|
// PAGE_EXTRACT is a parenthesized function-expression string. page.evaluate treats a
|
|
@@ -731,6 +895,22 @@ async function capture({ url, opts }) {
|
|
|
731
895
|
}, null, 2),
|
|
732
896
|
);
|
|
733
897
|
fs.writeFileSync(path.join(opts.out, 'asset-map.json'), JSON.stringify(assetMap, null, 2));
|
|
898
|
+
if (behaviors) {
|
|
899
|
+
// Rewrite any remote asset URLs captured in scroll-state background-image diffs.
|
|
900
|
+
for (const s of behaviors.scroll) {
|
|
901
|
+
for (const ch of Object.values(s.changed)) {
|
|
902
|
+
if (typeof ch.from === 'string') ch.from = rewriteCssValue(ch.from);
|
|
903
|
+
if (typeof ch.to === 'string') ch.to = rewriteCssValue(ch.to);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
fs.writeFileSync(
|
|
907
|
+
path.join(opts.out, 'behaviors.json'),
|
|
908
|
+
JSON.stringify({
|
|
909
|
+
meta: { url, bp: opts.bp || null, capturedAt: new Date().toISOString() },
|
|
910
|
+
...behaviors,
|
|
911
|
+
}, null, 2),
|
|
912
|
+
);
|
|
913
|
+
}
|
|
734
914
|
|
|
735
915
|
const okCount = Object.values(assetMap).filter((a) => a.status === 'ok').length;
|
|
736
916
|
console.log(`[clone-extract] done → ${opts.out}`);
|
|
@@ -742,15 +922,20 @@ async function capture({ url, opts }) {
|
|
|
742
922
|
}
|
|
743
923
|
|
|
744
924
|
// ─── Entry ──────────────────────────────────────────────────────────
|
|
745
|
-
const
|
|
925
|
+
const isMain = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('clone-extract.js');
|
|
926
|
+
if (isMain) {
|
|
927
|
+
const { cmd, url, opts } = parseArgs(process.argv);
|
|
928
|
+
|
|
929
|
+
if (cmd !== 'capture') {
|
|
930
|
+
console.error('Usage: node clone-extract.js capture <URL> --out=<dir> --viewport=WxH[@DPR] --bp=mo|pc [--stealth] [--ignore-robots] [--no-interact] [--wait=ms]');
|
|
931
|
+
process.exit(1);
|
|
932
|
+
}
|
|
746
933
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
934
|
+
capture({ url, opts }).catch((err) => {
|
|
935
|
+
console.error(`[clone-extract] FAIL: ${err.message}`);
|
|
936
|
+
if (process.env.DEBUG) console.error(err.stack);
|
|
937
|
+
process.exit(1);
|
|
938
|
+
});
|
|
750
939
|
}
|
|
751
940
|
|
|
752
|
-
|
|
753
|
-
console.error(`[clone-extract] FAIL: ${err.message}`);
|
|
754
|
-
if (process.env.DEBUG) console.error(err.stack);
|
|
755
|
-
process.exit(1);
|
|
756
|
-
});
|
|
941
|
+
export { diffStyles };
|
|
@@ -27,15 +27,12 @@ function parseArgs(argv) {
|
|
|
27
27
|
if (a.startsWith('--out=')) opts.out = a.slice(6);
|
|
28
28
|
else if (a.startsWith('--section=')) opts.section = a.slice(10);
|
|
29
29
|
else if (a.startsWith('--feature=')) opts.feature = a.slice(10);
|
|
30
|
+
else if (a.startsWith('--behaviors=')) opts.behaviors = a.slice(12);
|
|
30
31
|
}
|
|
31
32
|
return { sectionsPath, opts };
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
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
36
|
|
|
40
37
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
41
38
|
function safeName(name) {
|
|
@@ -95,6 +92,40 @@ function componentsBlock(components) {
|
|
|
95
92
|
}).join('\n');
|
|
96
93
|
}
|
|
97
94
|
|
|
95
|
+
// True if any node in the section subtree matches a behavior's tag (+ optional class).
|
|
96
|
+
function sectionHasNode(section, tag, cls) {
|
|
97
|
+
const hit = (n) => {
|
|
98
|
+
if (n.tag === tag && (!cls || (n.classes || '').split(/\s+/).includes(cls))) return true;
|
|
99
|
+
return (n.children || []).some(hit);
|
|
100
|
+
};
|
|
101
|
+
return (section.children || []).some(hit);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function behaviorsBlock(section, behaviors) {
|
|
105
|
+
if (!behaviors) return null;
|
|
106
|
+
const scroll = (behaviors.scroll || []).filter((b) => sectionHasNode(section, b.tag, b.cls));
|
|
107
|
+
// Tab groups aren't tag-keyed; attach to any section that contains a [role=tab]-ish node.
|
|
108
|
+
const tabs = (behaviors.interactive || []).filter(() =>
|
|
109
|
+
sectionHasNode(section, 'button') || sectionHasNode(section, 'li') || sectionHasNode(section, 'a'),
|
|
110
|
+
);
|
|
111
|
+
if (!scroll.length && !tabs.length) return null;
|
|
112
|
+
|
|
113
|
+
const lines = [];
|
|
114
|
+
for (const b of scroll) {
|
|
115
|
+
const diffs = Object.entries(b.changed)
|
|
116
|
+
.map(([k, v]) => ` - ${k}: \`${v.from}\` → \`${v.to}\``).join('\n');
|
|
117
|
+
lines.push(`- **Scroll-triggered** on \`${b.label}\` (past ~${b.triggerScrollY}px scroll):\n${diffs}`);
|
|
118
|
+
}
|
|
119
|
+
for (const t of tabs) {
|
|
120
|
+
const labels = (t.tabLabels || []).map((l) => `"${l}"`).join(', ') || '(unlabeled)';
|
|
121
|
+
const swap = t.contentSwapsOnClick
|
|
122
|
+
? '**content SWAPS on click** → extract every tab\'s content/assets, build click-driven'
|
|
123
|
+
: 'no content swap detected (styling-only tabs)';
|
|
124
|
+
lines.push(`- **Tab group** ×${t.count} [${labels}]: ${swap}`);
|
|
125
|
+
}
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
98
129
|
function assetsBlock(images) {
|
|
99
130
|
const bg = (images && images.bg) || [];
|
|
100
131
|
const content = (images && images.content) || [];
|
|
@@ -105,12 +136,16 @@ function assetsBlock(images) {
|
|
|
105
136
|
}
|
|
106
137
|
|
|
107
138
|
// ─── Spec rendering ─────────────────────────────────────────────────
|
|
108
|
-
function renderSpec(section, meta) {
|
|
139
|
+
function renderSpec(section, meta, behaviors) {
|
|
109
140
|
const box = section.box || {};
|
|
110
141
|
const texts = collectText(section);
|
|
111
142
|
const textBlock = texts.length
|
|
112
143
|
? texts.map((t) => `- "${t}"`).join('\n')
|
|
113
144
|
: '- (no direct text — likely visual/asset-only section)';
|
|
145
|
+
const behavior = behaviorsBlock(section, behaviors);
|
|
146
|
+
const behaviorSection = behavior
|
|
147
|
+
? `\n## Dynamic behaviors — observed by ACTIVE capture (not guesses)\n_Captured by driving the live page (scroll/click). These override the static heuristic above._\n${behavior}\n`
|
|
148
|
+
: '';
|
|
114
149
|
return `# ${section.name} — Clone Spec (${meta.bp})
|
|
115
150
|
|
|
116
151
|
> Auto-generated skeleton from sections.json by clone-spec.js. **This is a build contract.**
|
|
@@ -123,7 +158,7 @@ function renderSpec(section, meta) {
|
|
|
123
158
|
|
|
124
159
|
## Interaction model — ⚠ confirm before building
|
|
125
160
|
${interactionBlock(section.interaction)}
|
|
126
|
-
|
|
161
|
+
${behaviorSection}
|
|
127
162
|
## States to reproduce
|
|
128
163
|
_Harvested non-default state rules (hover/focus/active/checked/tab/aria/data-state). Implement each._
|
|
129
164
|
${statesBlock(section.states)}
|
|
@@ -162,6 +197,13 @@ function main() {
|
|
|
162
197
|
viewport: data.meta && data.meta.viewport,
|
|
163
198
|
bp: opts.section ? (data.meta && data.meta.bp) : (data.meta && data.meta.bp) || '?',
|
|
164
199
|
};
|
|
200
|
+
// behaviors.json (active interaction sweep) lives next to the source computed.json.
|
|
201
|
+
const behaviorsPath = opts.behaviors || path.join(path.dirname(sectionsPath), 'behaviors.json');
|
|
202
|
+
let behaviors = null;
|
|
203
|
+
if (fs.existsSync(behaviorsPath)) {
|
|
204
|
+
try { behaviors = JSON.parse(fs.readFileSync(behaviorsPath, 'utf8')); } catch { behaviors = null; }
|
|
205
|
+
}
|
|
206
|
+
|
|
165
207
|
let sections = data.sections || [];
|
|
166
208
|
if (opts.section) sections = sections.filter((s) => s.name === opts.section);
|
|
167
209
|
if (!sections.length) {
|
|
@@ -172,15 +214,24 @@ function main() {
|
|
|
172
214
|
fs.mkdirSync(opts.out, { recursive: true });
|
|
173
215
|
for (const section of sections) {
|
|
174
216
|
const file = path.join(opts.out, `${safeName(section.name)}.spec.md`);
|
|
175
|
-
fs.writeFileSync(file, renderSpec(section, meta));
|
|
217
|
+
fs.writeFileSync(file, renderSpec(section, meta, behaviors));
|
|
176
218
|
console.log(`[clone-spec] wrote ${file}`);
|
|
177
219
|
}
|
|
178
220
|
console.log(`[clone-spec] done → ${sections.length} spec(s) in ${opts.out}`);
|
|
179
221
|
}
|
|
180
222
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
223
|
+
const isMain = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('clone-spec.js');
|
|
224
|
+
if (isMain) {
|
|
225
|
+
if (!sectionsPath || !opts.out) {
|
|
226
|
+
console.error('Usage: node clone-spec.js <sections.json> --out=<dir> [--section=Name] [--feature=name] [--behaviors=path]');
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
try { main(); }
|
|
230
|
+
catch (e) {
|
|
231
|
+
console.error(`[clone-spec] FAIL: ${e.message}`);
|
|
232
|
+
if (process.env.DEBUG) console.error(e.stack);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
186
235
|
}
|
|
236
|
+
|
|
237
|
+
export { sectionHasNode, behaviorsBlock };
|
package/package.json
CHANGED
package/skills/clone/SKILL.md
CHANGED
|
@@ -41,6 +41,11 @@ The rendered DOM is the source of truth for markup. Screenshots are for pixel ve
|
|
|
41
41
|
|
|
42
42
|
6. Do NOT ship default-state-only. Implement every harvested state (hover/focus/active/open/
|
|
43
43
|
tab-switch) from states.json / the section spec.
|
|
44
|
+
|
|
45
|
+
7. Do NOT ignore behaviors.json. The ACTIVE interaction sweep (scroll-state diffs +
|
|
46
|
+
click-driven tab content-swap detection) catches JS-set state that static CSS harvesting
|
|
47
|
+
is blind to. When the spec's "Dynamic behaviors" block conflicts with the static
|
|
48
|
+
interaction heuristic, the active capture wins.
|
|
44
49
|
```
|
|
45
50
|
|
|
46
51
|
## Full Flow
|
|
@@ -119,6 +124,7 @@ node {{VIBE_PATH}}/hooks/scripts/clone-extract.js capture <URL> \
|
|
|
119
124
|
├── computed.json — per-element computed CSS + bounding box
|
|
120
125
|
├── screenshot.png — full-page screenshot
|
|
121
126
|
├── states.json — non-default state rules (hover/focus/active/checked/tab/aria/data-state)
|
|
127
|
+
├── behaviors.json — ACTIVE sweep: scroll-triggered header/nav diffs + click-driven tab groups
|
|
122
128
|
├── assets/
|
|
123
129
|
│ ├── images/ — all <img>, background-image, <picture> sources
|
|
124
130
|
│ └── fonts/ — @font-face srcs
|
|
@@ -136,8 +142,14 @@ node {{VIBE_PATH}}/hooks/scripts/clone-extract.js capture <URL> \
|
|
|
136
142
|
6. Rewrite asset URLs in rendered.html and computed.json to local paths
|
|
137
143
|
7. Strip inline analytics/tracking scripts before saving rendered.html
|
|
138
144
|
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
|
|
145
|
+
(deterministic: read :hover/:focus/:active/:checked/[aria-*]/[data-state]/.is-*/.active/
|
|
146
|
+
.scrolled/.sticky/.pinned/… declarations straight from CSS — NO scripted clicking)
|
|
147
|
+
9. ACTIVE interaction sweep → behaviors.json (runs after lazy-load, before freeze).
|
|
148
|
+
Drives the live page to capture JS-set state that CSS harvesting can't see:
|
|
149
|
+
- Scroll-state diff: tag sticky/fixed/top-bar headers+nav, snapshot computed CSS at
|
|
150
|
+
scroll 0, scroll past threshold, re-snapshot, diff → {prop: {from, to}, triggerScrollY}
|
|
151
|
+
- Tab groups: click tab-like sibling sets, detect whether content swaps on click
|
|
152
|
+
Disable with --no-interact (restores the old fully-deterministic, screenshot-stable capture).
|
|
141
153
|
```
|
|
142
154
|
|
|
143
155
|
---
|
|
@@ -256,7 +268,9 @@ node {{VIBE_PATH}}/hooks/scripts/clone-validate.js \
|
|
|
256
268
|
```
|
|
257
269
|
|
|
258
270
|
⛔ **No section is built without a completed spec.** Step 0 emits `_specs/{Section}.spec.md`
|
|
259
|
-
(interaction model + states + computed CSS + assets +
|
|
271
|
+
(interaction model + **active-capture Dynamic behaviors** + states + computed CSS + assets +
|
|
272
|
+
text + checklist). clone-spec.js auto-loads `behaviors.json` from the sections.json dir and
|
|
273
|
+
attaches matching scroll/tab behaviors per section. Before writing a
|
|
260
274
|
section's component, Claude reviews its spec and resolves every `TODO` (confirm interaction
|
|
261
275
|
model, list states to implement, choose tags, replace copyrighted text). The spec is the
|
|
262
276
|
contract AND the audit trail — it forces extraction rigor before any code is written.
|
|
@@ -407,4 +421,5 @@ Claude must:
|
|
|
407
421
|
| 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. |
|
|
408
422
|
| Pixel diff stuck > 0.05 after 5 rounds | Likely font fallback or anti-aliasing. Report metric, allow user to accept threshold. |
|
|
409
423
|
| 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.
|
|
424
|
+
| states.json empty but site has hover/tabs | States may be set via inline JS, not CSS rules. Check behaviors.json (active sweep captures scroll/tab JS state). If still missing, note in the spec and capture manually during Phase 5. |
|
|
425
|
+
| behaviors.json missing or empty | Active sweep was disabled (--no-interact), hit no sticky/tab elements, or errored (logged, non-fatal). Falls back to static states.json. Re-run without --no-interact to retry. |
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: vibe.clone
|
|
3
3
|
description: URL → 마크업 레벨 픽셀 완벽 클론 — 헤드리스 브라우저로 라이브 사이트 캡처 후 현재 프로젝트 스택에 맞게 스캐폴딩
|
|
4
|
-
argument-hint: "<url> [<url2>...] [--name=<feature>] [--mo-only] [--pc-only] [--ignore-robots]"
|
|
4
|
+
argument-hint: "<url> [<url2>...] [--name=<feature>] [--mo-only] [--pc-only] [--ignore-robots] [--no-interact]"
|
|
5
5
|
user-invocable: true
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -20,6 +20,7 @@ URL을 받아 **마크업 수준으로 정밀 복제**하고 현재 프로젝트
|
|
|
20
20
|
/vibe.clone <url> --name=stripe-clone # 기능 이름 지정 (기본: 호스트명 kebab-case)
|
|
21
21
|
/vibe.clone <url1> <url2> <url3> # 다중 페이지 클론 (같은 사이트의 여러 경로)
|
|
22
22
|
/vibe.clone <url> --ignore-robots # robots.txt 무시 (사이트 소유자 허가 있을 때만)
|
|
23
|
+
/vibe.clone <url> --no-interact # 능동 인터랙션 스윕 끄기 (완전 결정론적·재현 가능 캡처)
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
## Argument Routing
|
|
@@ -52,7 +53,9 @@ Phase 0: Setup
|
|
|
52
53
|
Phase 1: Capture (병렬 — scope에 따라 MO/PC 동시)
|
|
53
54
|
- node {{VIBE_PATH}}/hooks/scripts/clone-extract.js capture <url> \
|
|
54
55
|
--out=/tmp/{feature}/{bp}/ --viewport={WxH} --bp={mo|pc}
|
|
55
|
-
- 출력: rendered.html, computed.json, screenshot.png, states.json, asset-map.json, assets/
|
|
56
|
+
- 출력: rendered.html, computed.json, screenshot.png, states.json, behaviors.json, asset-map.json, assets/
|
|
57
|
+
- behaviors.json = 능동 인터랙션 스윕(스크롤 시 헤더/내비 변화 diff + 탭 클릭 콘텐츠 스왑 감지).
|
|
58
|
+
JS로 세팅되는 상태(정적 CSS로는 안 잡힘)를 포착 — 클론 정확도의 핵심. --no-interact 로 비활성화.
|
|
56
59
|
|
|
57
60
|
Phase 2: Refine (BP마다 독립)
|
|
58
61
|
- node {{VIBE_PATH}}/hooks/scripts/clone-refine.js \
|
|
@@ -88,7 +91,7 @@ Phase 5: Pixel Verification (P1=0까지 루프 — clone SKILL.md 규칙)
|
|
|
88
91
|
|
|
89
92
|
```
|
|
90
93
|
/tmp/{feature}/ # 작업 디렉토리 (산출물 원본)
|
|
91
|
-
├── mo/, pc/ # rendered.html, computed.json, screenshot.png, states.json, sections.json, assets/
|
|
94
|
+
├── mo/, pc/ # rendered.html, computed.json, screenshot.png, states.json, behaviors.json, sections.json, assets/
|
|
92
95
|
└── project-tokens.json # 기존 프로젝트 토큰 인덱스
|
|
93
96
|
|
|
94
97
|
./components/{feature}/ # Claude가 작성한 컴포넌트 (.tsx/.vue/.svelte/.html)
|