design-clone 1.1.1 → 2.1.0

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.
Files changed (70) hide show
  1. package/README.md +42 -20
  2. package/SKILL.md +74 -0
  3. package/bin/commands/clone-site.js +75 -10
  4. package/bin/commands/init.js +33 -1
  5. package/bin/commands/verify.js +5 -3
  6. package/bin/utils/validate.js +24 -8
  7. package/docs/cli-reference.md +224 -2
  8. package/docs/codebase-summary.md +309 -0
  9. package/docs/design-clone-architecture.md +290 -45
  10. package/docs/pixel-perfect.md +35 -4
  11. package/docs/project-roadmap.md +382 -0
  12. package/docs/troubleshooting.md +5 -4
  13. package/package.json +12 -6
  14. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  15. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  16. package/src/ai/analyze-structure.py +73 -3
  17. package/src/ai/extract-design-tokens.py +356 -13
  18. package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  19. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  20. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  21. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  22. package/src/ai/prompts/design_tokens.py +133 -0
  23. package/src/ai/prompts/structure_analysis.py +329 -10
  24. package/src/ai/prompts/ux_audit.py +198 -0
  25. package/src/ai/ux-audit.js +596 -0
  26. package/src/core/animation-extractor.js +526 -0
  27. package/src/core/app-state-snapshot.js +511 -0
  28. package/src/core/content-counter.js +342 -0
  29. package/src/core/cookie-handler.js +1 -1
  30. package/src/core/css-extractor.js +4 -4
  31. package/src/core/dimension-extractor.js +93 -21
  32. package/src/core/dimension-output.js +103 -6
  33. package/src/core/discover-pages.js +242 -14
  34. package/src/core/dom-tree-analyzer.js +298 -0
  35. package/src/core/extract-assets.js +1 -1
  36. package/src/core/framework-detector.js +538 -0
  37. package/src/core/html-extractor.js +45 -4
  38. package/src/core/lazy-loader.js +7 -7
  39. package/src/core/multi-page-screenshot.js +9 -6
  40. package/src/core/page-readiness.js +8 -8
  41. package/src/core/screenshot.js +311 -7
  42. package/src/core/section-cropper.js +209 -0
  43. package/src/core/section-detector.js +386 -0
  44. package/src/core/semantic-enhancer.js +492 -0
  45. package/src/core/state-capture.js +598 -0
  46. package/src/core/tests/test-section-cropper.js +177 -0
  47. package/src/core/tests/test-section-detector.js +55 -0
  48. package/src/core/video-capture.js +546 -0
  49. package/src/route-discoverers/angular-discoverer.js +157 -0
  50. package/src/route-discoverers/astro-discoverer.js +123 -0
  51. package/src/route-discoverers/base-discoverer.js +242 -0
  52. package/src/route-discoverers/index.js +106 -0
  53. package/src/route-discoverers/next-discoverer.js +130 -0
  54. package/src/route-discoverers/nuxt-discoverer.js +138 -0
  55. package/src/route-discoverers/react-discoverer.js +139 -0
  56. package/src/route-discoverers/svelte-discoverer.js +109 -0
  57. package/src/route-discoverers/universal-discoverer.js +227 -0
  58. package/src/route-discoverers/vue-discoverer.js +118 -0
  59. package/src/utils/__init__.py +1 -1
  60. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  61. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  62. package/src/utils/browser.js +11 -37
  63. package/src/utils/playwright.js +213 -0
  64. package/src/verification/generate-audit-report.js +398 -0
  65. package/src/verification/verify-footer.js +493 -0
  66. package/src/verification/verify-header.js +486 -0
  67. package/src/verification/verify-layout.js +2 -2
  68. package/src/verification/verify-menu.js +4 -20
  69. package/src/verification/verify-slider.js +533 -0
  70. package/src/utils/puppeteer.js +0 -281
@@ -16,7 +16,7 @@ import { getBrowser, getPage, disconnectBrowser } from '../utils/browser.js';
16
16
  import { captureViewport, VIEWPORTS, DEFAULT_SCROLL_DELAY } from './screenshot.js';
17
17
  import { waitForDomStable, waitForPageReady } from './page-readiness.js';
18
18
  import { dismissCookieBanner } from './cookie-handler.js';
19
- import { extractCleanHtml, JS_FRAMEWORK_PATTERNS, MAX_HTML_SIZE } from './html-extractor.js';
19
+ import { extractAndEnhanceHtml, JS_FRAMEWORK_PATTERNS, MAX_HTML_SIZE } from './html-extractor.js';
20
20
  import { extractAllCss, MAX_CSS_SIZE } from './css-extractor.js';
21
21
  import { filterCssFile } from './filter-css.js';
22
22
 
@@ -69,7 +69,7 @@ async function createOutputStructure(outputDir, viewports) {
69
69
 
70
70
  /**
71
71
  * Capture a single page (all viewports + HTML/CSS extraction)
72
- * @param {Page} page - Puppeteer page instance
72
+ * @param {Page} page - Playwright page instance
73
73
  * @param {Object} pageInfo - Page info { path, name, url }
74
74
  * @param {string} outputDir - Output directory
75
75
  * @param {Object} options - Capture options
@@ -91,7 +91,7 @@ async function captureSinglePage(page, pageInfo, outputDir, options) {
91
91
  try {
92
92
  // Navigate to page
93
93
  await page.goto(pageInfo.url, {
94
- waitUntil: ['load', 'networkidle0'],
94
+ waitUntil: 'networkidle',
95
95
  timeout: options.timeout
96
96
  });
97
97
 
@@ -104,10 +104,11 @@ async function captureSinglePage(page, pageInfo, outputDir, options) {
104
104
  // Extra stabilization
105
105
  await waitForDomStable(page, 300, 3000);
106
106
 
107
- // Extract HTML
107
+ // Extract HTML with semantic enhancement
108
108
  if (options.extractHtml) {
109
109
  try {
110
- const htmlResult = await extractCleanHtml(page, JS_FRAMEWORK_PATTERNS);
110
+ const enhanceSemantic = options.enhanceSemantic !== false;
111
+ const htmlResult = await extractAndEnhanceHtml(page, { enhanceSemantic });
111
112
  const htmlSize = Buffer.byteLength(htmlResult.html, 'utf-8');
112
113
 
113
114
  if (htmlSize > MAX_HTML_SIZE) {
@@ -118,7 +119,9 @@ async function captureSinglePage(page, pageInfo, outputDir, options) {
118
119
  result.html = {
119
120
  path: htmlPath,
120
121
  size: htmlSize,
121
- elementCount: htmlResult.elementCount
122
+ elementCount: htmlResult.elementCount,
123
+ semanticEnhanced: enhanceSemantic,
124
+ semanticStats: htmlResult.semanticStats || null
122
125
  };
123
126
  if (htmlResult.warnings.length > 0) {
124
127
  result.warnings.push(...htmlResult.warnings);
@@ -32,7 +32,7 @@ export const CONTENT_SELECTORS = [
32
32
 
33
33
  /**
34
34
  * Wait for fonts to finish loading
35
- * @param {Page} page - Puppeteer page
35
+ * @param {Page} page - Playwright page
36
36
  * @param {number} timeout - Max wait time in ms
37
37
  */
38
38
  export async function waitForFontsLoaded(page, timeout = FONT_LOAD_TIMEOUT) {
@@ -51,13 +51,13 @@ export async function waitForFontsLoaded(page, timeout = FONT_LOAD_TIMEOUT) {
51
51
 
52
52
  /**
53
53
  * Wait for styles to stabilize (no new style mutations)
54
- * @param {Page} page - Puppeteer page
54
+ * @param {Page} page - Playwright page
55
55
  * @param {number} stableMs - How long to wait without changes
56
56
  * @param {number} timeout - Max wait time in ms
57
57
  */
58
58
  export async function waitForStylesStable(page, stableMs = 500, timeout = 5000) {
59
59
  try {
60
- await page.evaluate(async (stable, max) => {
60
+ await page.evaluate(async ({ stable, max }) => {
61
61
  return new Promise((resolve) => {
62
62
  let lastChange = Date.now();
63
63
  let checkInterval;
@@ -88,7 +88,7 @@ export async function waitForStylesStable(page, stableMs = 500, timeout = 5000)
88
88
  }
89
89
  }, 100);
90
90
  });
91
- }, stableMs, timeout);
91
+ }, { stable: stableMs, max: timeout });
92
92
  } catch {
93
93
  // Error in style stability check, continue anyway
94
94
  }
@@ -96,7 +96,7 @@ export async function waitForStylesStable(page, stableMs = 500, timeout = 5000)
96
96
 
97
97
  /**
98
98
  * Wait for DOM to stabilize (element count unchanged)
99
- * @param {Page} page - Puppeteer page
99
+ * @param {Page} page - Playwright page
100
100
  * @param {number} threshold - Stability duration in ms
101
101
  * @param {number} timeout - Max wait time in ms
102
102
  */
@@ -116,7 +116,7 @@ export async function waitForDomStable(page, threshold = DOM_STABLE_THRESHOLD, t
116
116
 
117
117
  /**
118
118
  * Wait for page to be ready (loading complete, content visible)
119
- * @param {Page} page - Puppeteer page
119
+ * @param {Page} page - Playwright page
120
120
  * @param {number} timeout - Max wait time
121
121
  */
122
122
  export async function waitForPageReady(page, timeout = PAGE_READY_TIMEOUT) {
@@ -125,7 +125,7 @@ export async function waitForPageReady(page, timeout = PAGE_READY_TIMEOUT) {
125
125
  const minContentElements = Math.max(100, initialCount * 2);
126
126
 
127
127
  while (Date.now() - startTime < timeout) {
128
- const result = await page.evaluate((loadingSels, contentSels, minElements) => {
128
+ const result = await page.evaluate(({ loadingSels, contentSels, minElements }) => {
129
129
  const elementCount = document.querySelectorAll('*').length;
130
130
 
131
131
  const loadingGone = loadingSels.every(sel => {
@@ -148,7 +148,7 @@ export async function waitForPageReady(page, timeout = PAGE_READY_TIMEOUT) {
148
148
  loadingGone,
149
149
  contentExists
150
150
  };
151
- }, LOADING_SELECTORS, CONTENT_SELECTORS, minContentElements);
151
+ }, { loadingSels: LOADING_SELECTORS, contentSels: CONTENT_SELECTORS, minElements: minContentElements });
152
152
 
153
153
  if (result.ready) break;
154
154
  await new Promise(r => setTimeout(r, 200));
@@ -17,6 +17,12 @@
17
17
  * --extract-html Extract cleaned HTML (default: false)
18
18
  * --extract-css Extract all CSS from page (default: false)
19
19
  * --filter-unused Filter CSS to remove unused selectors (default: true)
20
+ * --capture-hover Capture hover state screenshots and CSS (default: false)
21
+ * --video Record scroll preview video (default: false)
22
+ * --video-format Video format: webm, mp4, gif (default: webm)
23
+ * --video-duration Video duration in ms (default: 12000)
24
+ * --section-mode Enable section-based capture for AI analysis (default: false)
25
+ * --no-semantic Disable WordPress semantic HTML enhancement (default: false)
20
26
  */
21
27
 
22
28
  import path from 'path';
@@ -30,10 +36,15 @@ import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, output
30
36
  import { waitForDomStable, waitForFontsLoaded, waitForStylesStable, waitForPageReady } from './page-readiness.js';
31
37
  import { dismissCookieBanner } from './cookie-handler.js';
32
38
  import { forceLazyImages, forceAnimatedElementsVisible, triggerLazyLoad, waitForAllImages, LAZY_LOAD_MAX_ITERATIONS } from './lazy-loader.js';
33
- import { extractCleanHtml, JS_FRAMEWORK_PATTERNS, MAX_HTML_SIZE } from './html-extractor.js';
39
+ import { extractCleanHtml, extractAndEnhanceHtml, JS_FRAMEWORK_PATTERNS, MAX_HTML_SIZE } from './html-extractor.js';
40
+ import { extractContentCounts, generateContentSummary } from './content-counter.js';
34
41
  import { extractAllCss, MAX_CSS_SIZE } from './css-extractor.js';
35
42
  import { extractComponentDimensions } from './dimension-extractor.js';
36
43
  import { buildDimensionsOutput, generateAISummary } from './dimension-output.js';
44
+ import { extractDOMHierarchy } from './dom-tree-analyzer.js';
45
+ import { extractAnimations, generateAnimationsCss, generateAnimationTokens } from './animation-extractor.js';
46
+ import { captureAllHoverStates, generateHoverCss } from './state-capture.js';
47
+ import { captureVideo, hasFfmpeg, FFMPEG_REQUIRED_FORMATS } from './video-capture.js';
37
48
 
38
49
  // Try to import Sharp for compression
39
50
  let sharp = null;
@@ -95,7 +106,7 @@ async function compressIfNeeded(filePath, maxSizeMB = 5) {
95
106
  * Capture screenshot for a single viewport
96
107
  */
97
108
  async function captureViewport(page, viewport, outputPath, fullPage = true, maxSize = 5, scrollDelay = DEFAULT_SCROLL_DELAY) {
98
- await page.setViewport(VIEWPORTS[viewport]);
109
+ await page.setViewportSize(VIEWPORTS[viewport]);
99
110
  await new Promise(r => setTimeout(r, VIEWPORT_SETTLE_DELAY));
100
111
  await waitForDomStable(page, 300, 5000);
101
112
  await waitForFontsLoaded(page, 3000);
@@ -103,13 +114,23 @@ async function captureViewport(page, viewport, outputPath, fullPage = true, maxS
103
114
 
104
115
  const componentDimensions = await extractComponentDimensions(page, viewport);
105
116
 
117
+ // Extract DOM hierarchy (desktop only for efficiency)
118
+ let domHierarchy = null;
119
+ if (viewport === 'desktop') {
120
+ try {
121
+ domHierarchy = await extractDOMHierarchy(page, { maxDepth: 8 });
122
+ } catch (err) {
123
+ console.error(`[WARN] DOM hierarchy extraction failed: ${err.message}`);
124
+ }
125
+ }
126
+
106
127
  const lazyStats = await forceLazyImages(page);
107
128
  const scrollInfo = await triggerLazyLoad(page, LAZY_LOAD_MAX_ITERATIONS, scrollDelay);
108
129
  await forceLazyImages(page);
109
130
  const imageStats = await waitForAllImages(page, 15000);
110
131
 
111
132
  try {
112
- await page.waitForNetworkIdle({ timeout: NETWORK_IDLE_TIMEOUT });
133
+ await page.waitForLoadState('networkidle', { timeout: NETWORK_IDLE_TIMEOUT });
113
134
  } catch {
114
135
  // Timeout ok
115
136
  }
@@ -135,6 +156,7 @@ async function captureViewport(page, viewport, outputPath, fullPage = true, maxS
135
156
  path: path.resolve(outputPath),
136
157
  dimensions: VIEWPORTS[viewport],
137
158
  componentDimensions,
159
+ domHierarchy, // DOM hierarchy (desktop only)
138
160
  scrollInfo,
139
161
  imageStats,
140
162
  size: compression.finalSize,
@@ -166,6 +188,13 @@ async function captureMultiViewport() {
166
188
  const extractHtml = args['extract-html'] === 'true';
167
189
  const extractCss = args['extract-css'] === 'true';
168
190
  const filterUnused = args['filter-unused'] !== 'false';
191
+ const captureHover = args['capture-hover'] === 'true';
192
+ const captureVideoFlag = args['video'] === 'true';
193
+ const videoFormat = args['video-format'] || 'webm';
194
+ const videoDuration = args['video-duration']
195
+ ? parseInt(args['video-duration'], 10)
196
+ : 12000;
197
+ const sectionMode = args['section-mode'] === 'true';
169
198
 
170
199
  for (const vp of requestedViewports) {
171
200
  if (!VIEWPORTS[vp]) {
@@ -201,8 +230,8 @@ async function captureMultiViewport() {
201
230
  currentHeadless = headless;
202
231
 
203
232
  if (navigateUrl) {
204
- await page.setViewport(VIEWPORTS.desktop);
205
- await page.goto(navigateUrl, { waitUntil: ['load', 'networkidle0'], timeout: 60000 });
233
+ await page.setViewportSize(VIEWPORTS.desktop);
234
+ await page.goto(navigateUrl, { waitUntil: 'domcontentloaded', timeout: 90000 });
206
235
  await new Promise(r => setTimeout(r, 3000));
207
236
  cookieResult = await dismissCookieBanner(page);
208
237
  await waitForPageReady(page);
@@ -221,9 +250,40 @@ async function captureMultiViewport() {
221
250
  if (extractHtml || extractCss) {
222
251
  extraction = { html: null, css: null, warnings: [] };
223
252
 
253
+ // Extract content counts BEFORE cleaning HTML (to count hidden items too)
224
254
  if (extractHtml) {
225
255
  try {
226
- const htmlResult = await extractCleanHtml(page, JS_FRAMEWORK_PATTERNS);
256
+ const contentCounts = await extractContentCounts(page);
257
+ const countsPath = path.join(args.output, 'content-counts.json');
258
+ await fs.writeFile(countsPath, JSON.stringify(contentCounts, null, 2), 'utf-8');
259
+
260
+ // Generate summary for structure analysis
261
+ const contentSummary = generateContentSummary(contentCounts);
262
+ const summaryPath = path.join(args.output, 'content-summary.md');
263
+ await fs.writeFile(summaryPath, contentSummary, 'utf-8');
264
+
265
+ extraction.contentCounts = {
266
+ path: path.resolve(countsPath),
267
+ summaryPath: path.resolve(summaryPath),
268
+ summary: contentCounts.summary
269
+ };
270
+
271
+ if (process.stderr.isTTY) {
272
+ console.error(`[INFO] Content counts: ${contentCounts.grids.total} grids, ${contentCounts.repeatedItems.total} items`);
273
+ }
274
+ } catch (error) {
275
+ extractionWarnings.push(`Content counting failed: ${error.message}`);
276
+ }
277
+ }
278
+
279
+ if (extractHtml) {
280
+ try {
281
+ // Use semantic enhancement unless --no-semantic flag is set
282
+ const enhanceSemantic = args['no-semantic'] !== 'true';
283
+ const htmlResult = enhanceSemantic
284
+ ? await extractAndEnhanceHtml(page, { enhanceSemantic: true })
285
+ : await extractCleanHtml(page, JS_FRAMEWORK_PATTERNS);
286
+
227
287
  const html = htmlResult.html;
228
288
  const htmlSize = Buffer.byteLength(html, 'utf-8');
229
289
 
@@ -233,7 +293,13 @@ async function captureMultiViewport() {
233
293
 
234
294
  const htmlPath = path.join(args.output, 'source.html');
235
295
  await fs.writeFile(htmlPath, html, 'utf-8');
236
- extraction.html = { path: path.resolve(htmlPath), size: htmlSize, elementCount: htmlResult.elementCount };
296
+ extraction.html = {
297
+ path: path.resolve(htmlPath),
298
+ size: htmlSize,
299
+ elementCount: htmlResult.elementCount,
300
+ semanticEnhanced: enhanceSemantic,
301
+ semanticStats: htmlResult.semanticStats || null
302
+ };
237
303
  if (htmlResult.warnings.length > 0) extractionWarnings.push(...htmlResult.warnings);
238
304
  } catch (error) {
239
305
  extraction.html = { error: error.message, failed: true };
@@ -294,12 +360,118 @@ async function captureMultiViewport() {
294
360
  }
295
361
  }
296
362
 
363
+ // Extract animations (enabled by default with CSS extraction)
364
+ const extractAnimationsFlag = args['extract-animations'] !== 'false';
365
+ if (extractCss && extractAnimationsFlag && extraction?.css?.path && !extraction.css.failed) {
366
+ try {
367
+ const rawCss = await fs.readFile(extraction.css.path, 'utf-8');
368
+ const animData = await extractAnimations(rawCss);
369
+
370
+ if (!animData.error) {
371
+ // Write animations.css
372
+ const animCss = generateAnimationsCss(animData);
373
+ const animPath = path.join(args.output, 'animations.css');
374
+ await fs.writeFile(animPath, animCss, 'utf-8');
375
+
376
+ // Generate animation tokens
377
+ const animTokens = generateAnimationTokens(animData);
378
+
379
+ // Write animation-tokens.json
380
+ const animTokensPath = path.join(args.output, 'animation-tokens.json');
381
+ await fs.writeFile(animTokensPath, JSON.stringify({
382
+ keyframes: animData.keyframes,
383
+ transitions: animData.transitions,
384
+ animatedElements: animData.animatedElements,
385
+ summary: animTokens
386
+ }, null, 2), 'utf-8');
387
+
388
+ extraction.animations = {
389
+ path: path.resolve(animPath),
390
+ tokensPath: path.resolve(animTokensPath),
391
+ keyframeCount: animTokens.keyframeCount,
392
+ transitionCount: animTokens.transitions,
393
+ animatedElementCount: animTokens.animatedElements,
394
+ tokens: animTokens
395
+ };
396
+
397
+ if (process.stderr.isTTY) {
398
+ console.error(`[INFO] Animations: ${animTokens.keyframeCount} keyframes, ${animTokens.transitions} transitions`);
399
+ }
400
+ } else {
401
+ extraction.animations = { error: animData.error, failed: true };
402
+ extractionWarnings.push(`Animation extraction failed: ${animData.error}`);
403
+ }
404
+ } catch (error) {
405
+ extraction.animations = { error: error.message, failed: true };
406
+ extractionWarnings.push(`Animation extraction failed: ${error.message}`);
407
+ }
408
+ }
409
+
297
410
  extraction.warnings = extractionWarnings;
298
411
  if (extractionWarnings.length > 0 && process.stderr.isTTY) {
299
412
  extractionWarnings.forEach(w => console.error(`[WARN] ${w}`));
300
413
  }
301
414
  }
302
415
 
416
+ // Capture hover states (requires headless mode per Playwright #5255)
417
+ let hoverResult = null;
418
+ if (captureHover) {
419
+ try {
420
+ // Try headed mode first, fallback to headless (per validation decision)
421
+ const wasHeadless = currentHeadless;
422
+ let hoverCaptureSuccess = false;
423
+
424
+ // Attempt headed mode first
425
+ if (!wasHeadless) {
426
+ try {
427
+ const cssContent = extraction?.css?.path
428
+ ? await fs.readFile(extraction.css.path, 'utf-8')
429
+ : null;
430
+ hoverResult = await captureAllHoverStates(page, cssContent, args.output);
431
+ hoverCaptureSuccess = hoverResult.captured > 0;
432
+ } catch (headedError) {
433
+ if (process.stderr.isTTY) {
434
+ console.error(`[WARN] Headed hover capture failed, switching to headless: ${headedError.message}`);
435
+ }
436
+ }
437
+ }
438
+
439
+ // Fallback to headless if headed failed or was already headless
440
+ if (!hoverCaptureSuccess) {
441
+ if (!currentHeadless) {
442
+ await initBrowser(true, args.url);
443
+ }
444
+
445
+ const cssContent = extraction?.css?.path
446
+ ? await fs.readFile(extraction.css.path, 'utf-8')
447
+ : null;
448
+ hoverResult = await captureAllHoverStates(page, cssContent, args.output);
449
+ }
450
+
451
+ // Generate hover.css from captured diffs
452
+ if (hoverResult && hoverResult.elements && hoverResult.captured > 0) {
453
+ const hoverCss = generateHoverCss(hoverResult.elements);
454
+ const hoverCssPath = path.join(args.output, 'hover.css');
455
+ await fs.writeFile(hoverCssPath, hoverCss, 'utf-8');
456
+ hoverResult.generatedCss = path.resolve(hoverCssPath);
457
+ }
458
+
459
+ if (process.stderr.isTTY && hoverResult) {
460
+ console.error(`[INFO] Hover states: ${hoverResult.captured}/${hoverResult.detected} captured`);
461
+ }
462
+
463
+ // Restore browser mode for viewport captures if needed
464
+ if (!wasHeadless && currentHeadless && requestedViewports.some(v => !getHeadlessForViewport(v))) {
465
+ await initBrowser(false, args.url);
466
+ }
467
+ } catch (error) {
468
+ if (process.stderr.isTTY) {
469
+ console.error(`[WARN] Hover capture failed: ${error.message}`);
470
+ }
471
+ hoverResult = { error: error.message, failed: true };
472
+ }
473
+ }
474
+
303
475
  // Capture viewports
304
476
  const screenshots = [];
305
477
  const browserRestarts = [];
@@ -316,6 +488,45 @@ async function captureMultiViewport() {
316
488
  screenshots.push(result);
317
489
  }
318
490
 
491
+ // Capture video (opt-in, after screenshots)
492
+ let videoResult = null;
493
+ if (captureVideoFlag) {
494
+ try {
495
+ // Check if ffmpeg is needed but not available
496
+ if (FFMPEG_REQUIRED_FORMATS.includes(videoFormat)) {
497
+ const hasFf = await hasFfmpeg();
498
+ if (!hasFf && process.stderr.isTTY) {
499
+ console.error(`[WARN] ffmpeg not found. Will output WebM instead of ${videoFormat}`);
500
+ console.error('[WARN] Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg');
501
+ }
502
+ }
503
+
504
+ // Use desktop viewport for video
505
+ await page.setViewportSize(VIEWPORTS.desktop);
506
+ await new Promise(r => setTimeout(r, 1000));
507
+
508
+ if (process.stderr.isTTY) {
509
+ console.error(`[INFO] Recording video (${videoDuration / 1000}s)...`);
510
+ }
511
+
512
+ videoResult = await captureVideo(page, args.output, {
513
+ format: videoFormat,
514
+ duration: videoDuration,
515
+ filename: 'preview'
516
+ });
517
+
518
+ if (process.stderr.isTTY) {
519
+ const outputFormat = videoResult.output.split('.').pop();
520
+ console.error(`[INFO] Video saved: ${outputFormat} (${(videoResult.duration / 1000).toFixed(1)}s)`);
521
+ }
522
+ } catch (error) {
523
+ if (process.stderr.isTTY) {
524
+ console.error(`[WARN] Video capture failed: ${error.message}`);
525
+ }
526
+ videoResult = { error: error.message, failed: true };
527
+ }
528
+ }
529
+
319
530
  // Build dimension output
320
531
  const allViewportDimensions = {};
321
532
  for (const screenshot of screenshots) {
@@ -332,6 +543,77 @@ async function captureMultiViewport() {
332
543
  const summaryPath = path.join(args.output, 'dimensions-summary.json');
333
544
  await fs.writeFile(summaryPath, JSON.stringify(aiSummary, null, 2));
334
545
 
546
+ // Write DOM hierarchy if available (from desktop capture)
547
+ const desktopScreenshot = screenshots.find(s => s.viewport === 'desktop');
548
+ let hierarchyPath = null;
549
+ if (desktopScreenshot?.domHierarchy) {
550
+ hierarchyPath = path.join(args.output, 'dom-hierarchy.json');
551
+ await fs.writeFile(hierarchyPath, JSON.stringify(desktopScreenshot.domHierarchy, null, 2));
552
+
553
+ if (process.stderr.isTTY) {
554
+ const stats = desktopScreenshot.domHierarchy.stats || {};
555
+ console.error(`[INFO] DOM hierarchy: ${stats.totalNodes || 0} nodes, ${stats.landmarkCount || 0} landmarks, ${stats.extractionTimeMs || 0}ms`);
556
+ }
557
+ }
558
+
559
+ // Section-based capture (for improved AI analysis)
560
+ let sectionResult = null;
561
+ if (sectionMode && desktopScreenshot) {
562
+ try {
563
+ // Lazy import section modules
564
+ const { detectSections } = await import('./section-detector.js');
565
+ const { cropSections } = await import('./section-cropper.js');
566
+
567
+ // Reset to desktop viewport for section detection
568
+ await page.setViewportSize(VIEWPORTS.desktop);
569
+ await new Promise(r => setTimeout(r, 500));
570
+
571
+ if (process.stderr.isTTY) {
572
+ console.error('[INFO] Detecting sections...');
573
+ }
574
+
575
+ const sections = await detectSections(page, {
576
+ padding: 40,
577
+ minSections: 3,
578
+ fallbackToViewport: true
579
+ });
580
+
581
+ if (process.stderr.isTTY) {
582
+ console.error(`[INFO] Found ${sections.length} sections, cropping...`);
583
+ }
584
+
585
+ const croppedResult = await cropSections(
586
+ desktopScreenshot.path,
587
+ sections,
588
+ args.output
589
+ );
590
+
591
+ sectionResult = {
592
+ enabled: true,
593
+ count: croppedResult.sections.length,
594
+ skipped: croppedResult.skipped.length,
595
+ sections: croppedResult.sections.map(s => ({
596
+ index: s.index,
597
+ name: s.name,
598
+ path: s.relativePath,
599
+ bounds: s.bounds,
600
+ role: s.role
601
+ })),
602
+ directory: croppedResult.directory,
603
+ summary: croppedResult.summary
604
+ };
605
+
606
+ if (process.stderr.isTTY) {
607
+ console.error(`[INFO] Sections: ${croppedResult.sections.length} cropped, ${croppedResult.skipped.length} skipped`);
608
+ }
609
+ } catch (err) {
610
+ sectionResult = { enabled: true, error: err.message };
611
+ if (process.stderr.isTTY) {
612
+ console.error(`[WARN] Section processing failed: ${err.message}`);
613
+ }
614
+ }
615
+ }
616
+
335
617
  const totalContainers = Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.containers?.length || 0), 0);
336
618
  const totalCards = Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.cards?.length || 0), 0);
337
619
  const totalGrids = Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.gridLayouts?.length || 0), 0);
@@ -346,6 +628,23 @@ async function captureMultiViewport() {
346
628
  outputDir: path.resolve(args.output),
347
629
  cookieHandling: cookieResult,
348
630
  extraction,
631
+ hoverStates: hoverResult && !hoverResult.failed ? {
632
+ directory: hoverResult.directory,
633
+ detected: hoverResult.detected,
634
+ captured: hoverResult.captured,
635
+ summaryPath: hoverResult.summaryPath,
636
+ generatedCss: hoverResult.generatedCss
637
+ } : (hoverResult?.error ? { error: hoverResult.error } : undefined),
638
+ video: videoResult && !videoResult.failed ? {
639
+ path: videoResult.output,
640
+ format: videoResult.output.split('.').pop(),
641
+ duration: videoResult.duration,
642
+ pageHeight: videoResult.pageHeight,
643
+ webm: videoResult.webm,
644
+ mp4: videoResult.mp4,
645
+ gif: videoResult.gif,
646
+ conversionError: videoResult.conversionError
647
+ } : (videoResult?.error ? { error: videoResult.error } : undefined),
349
648
  componentDimensions: {
350
649
  full: path.resolve(dimensionsPath),
351
650
  summary: path.resolve(summaryPath),
@@ -353,6 +652,11 @@ async function captureMultiViewport() {
353
652
  stats: { containers: totalContainers, cards: totalCards, gridLayouts: totalGrids,
354
653
  typography: Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.typography?.length || 0), 0) }
355
654
  },
655
+ domHierarchy: desktopScreenshot?.domHierarchy ? {
656
+ path: path.resolve(hierarchyPath),
657
+ stats: desktopScreenshot.domHierarchy.stats
658
+ } : undefined,
659
+ sections: sectionResult,
356
660
  screenshots,
357
661
  browserRestarts: browserRestarts.length > 0 ? browserRestarts : undefined,
358
662
  scrollDelay,