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.
- package/README.md +42 -20
- package/SKILL.md +74 -0
- package/bin/commands/clone-site.js +75 -10
- package/bin/commands/init.js +33 -1
- package/bin/commands/verify.js +5 -3
- package/bin/utils/validate.js +24 -8
- package/docs/cli-reference.md +224 -2
- package/docs/codebase-summary.md +309 -0
- package/docs/design-clone-architecture.md +290 -45
- package/docs/pixel-perfect.md +35 -4
- package/docs/project-roadmap.md +382 -0
- package/docs/troubleshooting.md +5 -4
- package/package.json +12 -6
- package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
- package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
- package/src/ai/analyze-structure.py +73 -3
- package/src/ai/extract-design-tokens.py +356 -13
- package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
- package/src/ai/prompts/design_tokens.py +133 -0
- package/src/ai/prompts/structure_analysis.py +329 -10
- package/src/ai/prompts/ux_audit.py +198 -0
- package/src/ai/ux-audit.js +596 -0
- package/src/core/animation-extractor.js +526 -0
- package/src/core/app-state-snapshot.js +511 -0
- package/src/core/content-counter.js +342 -0
- package/src/core/cookie-handler.js +1 -1
- package/src/core/css-extractor.js +4 -4
- package/src/core/dimension-extractor.js +93 -21
- package/src/core/dimension-output.js +103 -6
- package/src/core/discover-pages.js +242 -14
- package/src/core/dom-tree-analyzer.js +298 -0
- package/src/core/extract-assets.js +1 -1
- package/src/core/framework-detector.js +538 -0
- package/src/core/html-extractor.js +45 -4
- package/src/core/lazy-loader.js +7 -7
- package/src/core/multi-page-screenshot.js +9 -6
- package/src/core/page-readiness.js +8 -8
- package/src/core/screenshot.js +311 -7
- package/src/core/section-cropper.js +209 -0
- package/src/core/section-detector.js +386 -0
- package/src/core/semantic-enhancer.js +492 -0
- package/src/core/state-capture.js +598 -0
- package/src/core/tests/test-section-cropper.js +177 -0
- package/src/core/tests/test-section-detector.js +55 -0
- package/src/core/video-capture.js +546 -0
- package/src/route-discoverers/angular-discoverer.js +157 -0
- package/src/route-discoverers/astro-discoverer.js +123 -0
- package/src/route-discoverers/base-discoverer.js +242 -0
- package/src/route-discoverers/index.js +106 -0
- package/src/route-discoverers/next-discoverer.js +130 -0
- package/src/route-discoverers/nuxt-discoverer.js +138 -0
- package/src/route-discoverers/react-discoverer.js +139 -0
- package/src/route-discoverers/svelte-discoverer.js +109 -0
- package/src/route-discoverers/universal-discoverer.js +227 -0
- package/src/route-discoverers/vue-discoverer.js +118 -0
- package/src/utils/__init__.py +1 -1
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
- package/src/utils/browser.js +11 -37
- package/src/utils/playwright.js +213 -0
- package/src/verification/generate-audit-report.js +398 -0
- package/src/verification/verify-footer.js +493 -0
- package/src/verification/verify-header.js +486 -0
- package/src/verification/verify-layout.js +2 -2
- package/src/verification/verify-menu.js +4 -20
- package/src/verification/verify-slider.js +533 -0
- 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 {
|
|
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 -
|
|
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:
|
|
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
|
|
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 -
|
|
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 -
|
|
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 -
|
|
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 -
|
|
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));
|
package/src/core/screenshot.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
205
|
-
await page.goto(navigateUrl, { waitUntil:
|
|
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
|
|
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 = {
|
|
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,
|