design-clone 1.1.1 → 1.2.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 +16 -8
- package/SKILL.md +74 -0
- package/docs/cli-reference.md +25 -1
- package/docs/design-clone-architecture.md +47 -19
- package/package.json +5 -1
- 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/core/animation-extractor.js +526 -0
- package/src/core/screenshot.js +175 -0
- package/src/core/state-capture.js +602 -0
- package/src/core/video-capture.js +540 -0
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
package/src/core/screenshot.js
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
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)
|
|
20
24
|
*/
|
|
21
25
|
|
|
22
26
|
import path from 'path';
|
|
@@ -34,6 +38,9 @@ import { extractCleanHtml, JS_FRAMEWORK_PATTERNS, MAX_HTML_SIZE } from './html-e
|
|
|
34
38
|
import { extractAllCss, MAX_CSS_SIZE } from './css-extractor.js';
|
|
35
39
|
import { extractComponentDimensions } from './dimension-extractor.js';
|
|
36
40
|
import { buildDimensionsOutput, generateAISummary } from './dimension-output.js';
|
|
41
|
+
import { extractAnimations, generateAnimationsCss, generateAnimationTokens } from './animation-extractor.js';
|
|
42
|
+
import { captureAllHoverStates, generateHoverCss } from './state-capture.js';
|
|
43
|
+
import { captureVideo, hasFfmpeg, FFMPEG_REQUIRED_FORMATS } from './video-capture.js';
|
|
37
44
|
|
|
38
45
|
// Try to import Sharp for compression
|
|
39
46
|
let sharp = null;
|
|
@@ -166,6 +173,12 @@ async function captureMultiViewport() {
|
|
|
166
173
|
const extractHtml = args['extract-html'] === 'true';
|
|
167
174
|
const extractCss = args['extract-css'] === 'true';
|
|
168
175
|
const filterUnused = args['filter-unused'] !== 'false';
|
|
176
|
+
const captureHover = args['capture-hover'] === 'true';
|
|
177
|
+
const captureVideoFlag = args['video'] === 'true';
|
|
178
|
+
const videoFormat = args['video-format'] || 'webm';
|
|
179
|
+
const videoDuration = args['video-duration']
|
|
180
|
+
? parseInt(args['video-duration'], 10)
|
|
181
|
+
: 12000;
|
|
169
182
|
|
|
170
183
|
for (const vp of requestedViewports) {
|
|
171
184
|
if (!VIEWPORTS[vp]) {
|
|
@@ -294,12 +307,118 @@ async function captureMultiViewport() {
|
|
|
294
307
|
}
|
|
295
308
|
}
|
|
296
309
|
|
|
310
|
+
// Extract animations (enabled by default with CSS extraction)
|
|
311
|
+
const extractAnimationsFlag = args['extract-animations'] !== 'false';
|
|
312
|
+
if (extractCss && extractAnimationsFlag && extraction?.css?.path && !extraction.css.failed) {
|
|
313
|
+
try {
|
|
314
|
+
const rawCss = await fs.readFile(extraction.css.path, 'utf-8');
|
|
315
|
+
const animData = await extractAnimations(rawCss);
|
|
316
|
+
|
|
317
|
+
if (!animData.error) {
|
|
318
|
+
// Write animations.css
|
|
319
|
+
const animCss = generateAnimationsCss(animData);
|
|
320
|
+
const animPath = path.join(args.output, 'animations.css');
|
|
321
|
+
await fs.writeFile(animPath, animCss, 'utf-8');
|
|
322
|
+
|
|
323
|
+
// Generate animation tokens
|
|
324
|
+
const animTokens = generateAnimationTokens(animData);
|
|
325
|
+
|
|
326
|
+
// Write animation-tokens.json
|
|
327
|
+
const animTokensPath = path.join(args.output, 'animation-tokens.json');
|
|
328
|
+
await fs.writeFile(animTokensPath, JSON.stringify({
|
|
329
|
+
keyframes: animData.keyframes,
|
|
330
|
+
transitions: animData.transitions,
|
|
331
|
+
animatedElements: animData.animatedElements,
|
|
332
|
+
summary: animTokens
|
|
333
|
+
}, null, 2), 'utf-8');
|
|
334
|
+
|
|
335
|
+
extraction.animations = {
|
|
336
|
+
path: path.resolve(animPath),
|
|
337
|
+
tokensPath: path.resolve(animTokensPath),
|
|
338
|
+
keyframeCount: animTokens.keyframeCount,
|
|
339
|
+
transitionCount: animTokens.transitions,
|
|
340
|
+
animatedElementCount: animTokens.animatedElements,
|
|
341
|
+
tokens: animTokens
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
if (process.stderr.isTTY) {
|
|
345
|
+
console.error(`[INFO] Animations: ${animTokens.keyframeCount} keyframes, ${animTokens.transitions} transitions`);
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
extraction.animations = { error: animData.error, failed: true };
|
|
349
|
+
extractionWarnings.push(`Animation extraction failed: ${animData.error}`);
|
|
350
|
+
}
|
|
351
|
+
} catch (error) {
|
|
352
|
+
extraction.animations = { error: error.message, failed: true };
|
|
353
|
+
extractionWarnings.push(`Animation extraction failed: ${error.message}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
297
357
|
extraction.warnings = extractionWarnings;
|
|
298
358
|
if (extractionWarnings.length > 0 && process.stderr.isTTY) {
|
|
299
359
|
extractionWarnings.forEach(w => console.error(`[WARN] ${w}`));
|
|
300
360
|
}
|
|
301
361
|
}
|
|
302
362
|
|
|
363
|
+
// Capture hover states (requires headless mode per Puppeteer #5255)
|
|
364
|
+
let hoverResult = null;
|
|
365
|
+
if (captureHover) {
|
|
366
|
+
try {
|
|
367
|
+
// Try headed mode first, fallback to headless (per validation decision)
|
|
368
|
+
const wasHeadless = currentHeadless;
|
|
369
|
+
let hoverCaptureSuccess = false;
|
|
370
|
+
|
|
371
|
+
// Attempt headed mode first
|
|
372
|
+
if (!wasHeadless) {
|
|
373
|
+
try {
|
|
374
|
+
const cssContent = extraction?.css?.path
|
|
375
|
+
? await fs.readFile(extraction.css.path, 'utf-8')
|
|
376
|
+
: null;
|
|
377
|
+
hoverResult = await captureAllHoverStates(page, cssContent, args.output);
|
|
378
|
+
hoverCaptureSuccess = hoverResult.captured > 0;
|
|
379
|
+
} catch (headedError) {
|
|
380
|
+
if (process.stderr.isTTY) {
|
|
381
|
+
console.error(`[WARN] Headed hover capture failed, switching to headless: ${headedError.message}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Fallback to headless if headed failed or was already headless
|
|
387
|
+
if (!hoverCaptureSuccess) {
|
|
388
|
+
if (!currentHeadless) {
|
|
389
|
+
await initBrowser(true, args.url);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const cssContent = extraction?.css?.path
|
|
393
|
+
? await fs.readFile(extraction.css.path, 'utf-8')
|
|
394
|
+
: null;
|
|
395
|
+
hoverResult = await captureAllHoverStates(page, cssContent, args.output);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Generate hover.css from captured diffs
|
|
399
|
+
if (hoverResult && hoverResult.elements && hoverResult.captured > 0) {
|
|
400
|
+
const hoverCss = generateHoverCss(hoverResult.elements);
|
|
401
|
+
const hoverCssPath = path.join(args.output, 'hover.css');
|
|
402
|
+
await fs.writeFile(hoverCssPath, hoverCss, 'utf-8');
|
|
403
|
+
hoverResult.generatedCss = path.resolve(hoverCssPath);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (process.stderr.isTTY && hoverResult) {
|
|
407
|
+
console.error(`[INFO] Hover states: ${hoverResult.captured}/${hoverResult.detected} captured`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Restore browser mode for viewport captures if needed
|
|
411
|
+
if (!wasHeadless && currentHeadless && requestedViewports.some(v => !getHeadlessForViewport(v))) {
|
|
412
|
+
await initBrowser(false, args.url);
|
|
413
|
+
}
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (process.stderr.isTTY) {
|
|
416
|
+
console.error(`[WARN] Hover capture failed: ${error.message}`);
|
|
417
|
+
}
|
|
418
|
+
hoverResult = { error: error.message, failed: true };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
303
422
|
// Capture viewports
|
|
304
423
|
const screenshots = [];
|
|
305
424
|
const browserRestarts = [];
|
|
@@ -316,6 +435,45 @@ async function captureMultiViewport() {
|
|
|
316
435
|
screenshots.push(result);
|
|
317
436
|
}
|
|
318
437
|
|
|
438
|
+
// Capture video (opt-in, after screenshots)
|
|
439
|
+
let videoResult = null;
|
|
440
|
+
if (captureVideoFlag) {
|
|
441
|
+
try {
|
|
442
|
+
// Check if ffmpeg is needed but not available
|
|
443
|
+
if (FFMPEG_REQUIRED_FORMATS.includes(videoFormat)) {
|
|
444
|
+
const hasFf = await hasFfmpeg();
|
|
445
|
+
if (!hasFf && process.stderr.isTTY) {
|
|
446
|
+
console.error(`[WARN] ffmpeg not found. Will output WebM instead of ${videoFormat}`);
|
|
447
|
+
console.error('[WARN] Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Use desktop viewport for video
|
|
452
|
+
await page.setViewport(VIEWPORTS.desktop);
|
|
453
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
454
|
+
|
|
455
|
+
if (process.stderr.isTTY) {
|
|
456
|
+
console.error(`[INFO] Recording video (${videoDuration / 1000}s)...`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
videoResult = await captureVideo(page, args.output, {
|
|
460
|
+
format: videoFormat,
|
|
461
|
+
duration: videoDuration,
|
|
462
|
+
filename: 'preview'
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
if (process.stderr.isTTY) {
|
|
466
|
+
const outputFormat = videoResult.output.split('.').pop();
|
|
467
|
+
console.error(`[INFO] Video saved: ${outputFormat} (${(videoResult.duration / 1000).toFixed(1)}s)`);
|
|
468
|
+
}
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (process.stderr.isTTY) {
|
|
471
|
+
console.error(`[WARN] Video capture failed: ${error.message}`);
|
|
472
|
+
}
|
|
473
|
+
videoResult = { error: error.message, failed: true };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
319
477
|
// Build dimension output
|
|
320
478
|
const allViewportDimensions = {};
|
|
321
479
|
for (const screenshot of screenshots) {
|
|
@@ -346,6 +504,23 @@ async function captureMultiViewport() {
|
|
|
346
504
|
outputDir: path.resolve(args.output),
|
|
347
505
|
cookieHandling: cookieResult,
|
|
348
506
|
extraction,
|
|
507
|
+
hoverStates: hoverResult && !hoverResult.failed ? {
|
|
508
|
+
directory: hoverResult.directory,
|
|
509
|
+
detected: hoverResult.detected,
|
|
510
|
+
captured: hoverResult.captured,
|
|
511
|
+
summaryPath: hoverResult.summaryPath,
|
|
512
|
+
generatedCss: hoverResult.generatedCss
|
|
513
|
+
} : (hoverResult?.error ? { error: hoverResult.error } : undefined),
|
|
514
|
+
video: videoResult && !videoResult.failed ? {
|
|
515
|
+
path: videoResult.output,
|
|
516
|
+
format: videoResult.output.split('.').pop(),
|
|
517
|
+
duration: videoResult.duration,
|
|
518
|
+
pageHeight: videoResult.pageHeight,
|
|
519
|
+
webm: videoResult.webm,
|
|
520
|
+
mp4: videoResult.mp4,
|
|
521
|
+
gif: videoResult.gif,
|
|
522
|
+
conversionError: videoResult.conversionError
|
|
523
|
+
} : (videoResult?.error ? { error: videoResult.error } : undefined),
|
|
349
524
|
componentDimensions: {
|
|
350
525
|
full: path.resolve(dimensionsPath),
|
|
351
526
|
summary: path.resolve(summaryPath),
|