@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 { cmd, url, opts } = parseArgs(process.argv);
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
- if (cmd !== 'capture') {
748
- console.error('Usage: node clone-extract.js capture <URL> --out=<dir> --viewport=WxH[@DPR] --bp=mo|pc [--stealth] [--ignore-robots] [--wait=ms]');
749
- process.exit(1);
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
- capture({ url, opts }).catch((err) => {
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
- 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);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@su-record/vibe",
3
- "version": "2.16.2",
3
+ "version": "2.16.3",
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",
@@ -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, so output stays reproducible)
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 + text + checklist). Before writing a
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. Note in the spec and capture them manually from the live site during Phase 5. |
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)