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.
@@ -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),