design-clone 2.1.0 → 2.3.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 (177) hide show
  1. package/README.md +13 -34
  2. package/SKILL.md +69 -45
  3. package/bin/cli.js +22 -4
  4. package/bin/commands/clone-site.js +31 -171
  5. package/bin/commands/help.js +19 -6
  6. package/bin/commands/init.js +9 -86
  7. package/bin/commands/uninstall.js +105 -0
  8. package/bin/commands/update.js +70 -0
  9. package/bin/commands/verify.js +7 -14
  10. package/bin/utils/paths.js +28 -0
  11. package/bin/utils/validate.js +2 -22
  12. package/bin/utils/version.js +23 -0
  13. package/docs/code-standards.md +789 -0
  14. package/docs/codebase-summary.md +533 -286
  15. package/docs/index.md +74 -0
  16. package/docs/project-overview-pdr.md +797 -0
  17. package/docs/system-architecture.md +718 -0
  18. package/package.json +14 -17
  19. package/src/ai/prompts/design-tokens/basic.md +80 -0
  20. package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
  21. package/src/ai/prompts/design-tokens/section.md +48 -0
  22. package/src/ai/prompts/design-tokens/with-css.md +87 -0
  23. package/src/ai/prompts/structure-analysis/basic.md +55 -0
  24. package/src/ai/prompts/structure-analysis/with-context.md +59 -0
  25. package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
  26. package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
  27. package/src/ai/prompts/ux-audit/aggregation.md +42 -0
  28. package/src/ai/prompts/ux-audit/desktop.md +92 -0
  29. package/src/ai/prompts/ux-audit/mobile.md +93 -0
  30. package/src/ai/prompts/ux-audit/tablet.md +92 -0
  31. package/src/core/animation/animation-extractor-ast.js +183 -0
  32. package/src/core/animation/animation-extractor-output.js +152 -0
  33. package/src/core/animation/animation-extractor.js +178 -0
  34. package/src/core/animation/state-capture-detection.js +200 -0
  35. package/src/core/animation/state-capture.js +193 -0
  36. package/src/core/capture/browser-context-pool.js +96 -0
  37. package/src/core/capture/multi-page-screenshot-page.js +110 -0
  38. package/src/core/capture/multi-page-screenshot.js +208 -0
  39. package/src/core/capture/screenshot-extraction.js +186 -0
  40. package/src/core/capture/screenshot-helpers.js +175 -0
  41. package/src/core/capture/screenshot-orchestrator.js +174 -0
  42. package/src/core/capture/screenshot-viewport.js +93 -0
  43. package/src/core/capture/screenshot.js +192 -0
  44. package/src/core/content/content-counter-dom.js +191 -0
  45. package/src/core/content/content-counter.js +76 -0
  46. package/src/core/css/breakpoint-detector.js +66 -0
  47. package/src/core/css/chromium-defaults.json +23 -0
  48. package/src/core/css/computed-style-extractor.js +102 -0
  49. package/src/core/css/css-chunker.js +103 -0
  50. package/src/core/css/filter-css-dead-code.js +120 -0
  51. package/src/core/css/filter-css-html-analyzer.js +110 -0
  52. package/src/core/css/filter-css-selector-matcher.js +172 -0
  53. package/src/core/css/filter-css.js +206 -0
  54. package/src/core/css/merge-css-atrule-processor.js +158 -0
  55. package/src/core/css/merge-css-file-io.js +68 -0
  56. package/src/core/css/merge-css.js +148 -0
  57. package/src/core/detection/framework-detector-routing.js +68 -0
  58. package/src/core/detection/framework-detector-signals.js +65 -0
  59. package/src/core/detection/framework-detector.js +198 -0
  60. package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
  61. package/src/core/dimension/dimension-extractor.js +317 -0
  62. package/src/core/dimension/dimension-output-ai-summary.js +111 -0
  63. package/src/core/dimension/dimension-output.js +173 -0
  64. package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
  65. package/src/core/dimension/dom-tree-analyzer.js +191 -0
  66. package/src/core/discovery/app-state-snapshot-capture.js +195 -0
  67. package/src/core/discovery/app-state-snapshot-utils.js +178 -0
  68. package/src/core/discovery/app-state-snapshot.js +131 -0
  69. package/src/core/discovery/discover-pages-routes.js +84 -0
  70. package/src/core/discovery/discover-pages-utils.js +177 -0
  71. package/src/core/discovery/discover-pages.js +191 -0
  72. package/src/core/html/html-extractor-inline-styler.js +70 -0
  73. package/src/core/html/html-extractor.js +147 -0
  74. package/src/core/html/semantic-enhancer-mappings.js +200 -0
  75. package/src/core/html/semantic-enhancer-page.js +148 -0
  76. package/src/core/html/semantic-enhancer.js +135 -0
  77. package/src/core/links/rewrite-links-css-rewriter.js +53 -0
  78. package/src/core/links/rewrite-links.js +173 -0
  79. package/src/core/media/asset-validator.js +118 -0
  80. package/src/core/media/extract-assets-downloader.js +187 -0
  81. package/src/core/media/extract-assets-page-scraper.js +115 -0
  82. package/src/core/media/extract-assets.js +159 -0
  83. package/src/core/media/video-capture-convert.js +200 -0
  84. package/src/core/media/video-capture.js +201 -0
  85. package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
  86. package/src/core/section/section-cropper-helpers.js +43 -0
  87. package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
  88. package/src/core/section/section-detector-strategies.js +139 -0
  89. package/src/core/section/section-detector-utils.js +100 -0
  90. package/src/core/section/section-detector.js +88 -0
  91. package/src/core/tests/test-section-cropper.js +2 -2
  92. package/src/core/tests/test-section-detector.js +2 -2
  93. package/src/post-process/enhance-assets.js +29 -4
  94. package/src/post-process/fetch-images-unsplash-client.js +123 -0
  95. package/src/post-process/fetch-images.js +60 -263
  96. package/src/post-process/inject-gosnap.js +88 -0
  97. package/src/post-process/inject-icons-svg-replacer.js +76 -0
  98. package/src/post-process/inject-icons.js +47 -200
  99. package/src/route-discoverers/base-discoverer-utils.js +137 -0
  100. package/src/route-discoverers/base-discoverer.js +29 -118
  101. package/src/route-discoverers/index.js +1 -1
  102. package/src/shared/config.js +38 -0
  103. package/src/shared/error-codes.js +31 -0
  104. package/src/shared/viewports.js +46 -0
  105. package/src/utils/browser.js +0 -7
  106. package/src/utils/helpers.js +4 -0
  107. package/src/utils/log.js +12 -0
  108. package/src/utils/playwright-loader.js +76 -0
  109. package/src/utils/playwright.js +3 -69
  110. package/src/utils/progress.js +32 -0
  111. package/src/verification/generate-audit-report-css-fixes.js +52 -0
  112. package/src/verification/generate-audit-report-sections.js +158 -0
  113. package/src/verification/generate-audit-report.js +5 -281
  114. package/src/verification/quality-scorer.js +92 -0
  115. package/src/verification/verify-footer-checks.js +103 -0
  116. package/src/verification/verify-footer-helpers.js +178 -0
  117. package/src/verification/verify-footer.js +23 -381
  118. package/src/verification/verify-header-checks.js +104 -0
  119. package/src/verification/verify-header-helpers.js +156 -0
  120. package/src/verification/verify-header.js +23 -365
  121. package/src/verification/verify-layout-report.js +101 -0
  122. package/src/verification/verify-layout.js +13 -259
  123. package/src/verification/verify-menu-checks.js +104 -0
  124. package/src/verification/verify-menu-helpers.js +112 -0
  125. package/src/verification/verify-menu.js +17 -285
  126. package/src/verification/verify-slider-checks.js +115 -0
  127. package/src/verification/verify-slider-constants.js +65 -0
  128. package/src/verification/verify-slider-helpers.js +164 -0
  129. package/src/verification/verify-slider.js +23 -414
  130. package/.env.example +0 -14
  131. package/docs/basic-clone.md +0 -63
  132. package/docs/cli-reference.md +0 -316
  133. package/docs/design-clone-architecture.md +0 -492
  134. package/docs/pixel-perfect.md +0 -117
  135. package/docs/project-roadmap.md +0 -382
  136. package/docs/troubleshooting.md +0 -170
  137. package/requirements.txt +0 -5
  138. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  139. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  140. package/src/ai/analyze-structure.py +0 -375
  141. package/src/ai/extract-design-tokens.py +0 -782
  142. package/src/ai/prompts/__init__.py +0 -2
  143. package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  144. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  145. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  146. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  147. package/src/ai/prompts/design_tokens.py +0 -316
  148. package/src/ai/prompts/structure_analysis.py +0 -592
  149. package/src/ai/prompts/ux_audit.py +0 -198
  150. package/src/ai/ux-audit.js +0 -596
  151. package/src/core/animation-extractor.js +0 -526
  152. package/src/core/app-state-snapshot.js +0 -511
  153. package/src/core/content-counter.js +0 -342
  154. package/src/core/design-tokens.js +0 -103
  155. package/src/core/dimension-extractor.js +0 -438
  156. package/src/core/dimension-output.js +0 -305
  157. package/src/core/discover-pages.js +0 -542
  158. package/src/core/dom-tree-analyzer.js +0 -298
  159. package/src/core/extract-assets.js +0 -468
  160. package/src/core/filter-css.js +0 -499
  161. package/src/core/framework-detector.js +0 -538
  162. package/src/core/html-extractor.js +0 -212
  163. package/src/core/merge-css.js +0 -407
  164. package/src/core/multi-page-screenshot.js +0 -380
  165. package/src/core/rewrite-links.js +0 -226
  166. package/src/core/screenshot.js +0 -701
  167. package/src/core/section-detector.js +0 -386
  168. package/src/core/semantic-enhancer.js +0 -492
  169. package/src/core/state-capture.js +0 -598
  170. package/src/core/video-capture.js +0 -546
  171. package/src/utils/__init__.py +0 -16
  172. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  173. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  174. package/src/utils/env.py +0 -134
  175. /package/src/core/{css-extractor.js → css/css-extractor.js} +0 -0
  176. /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
  177. /package/src/core/{page-readiness.js → page-prep/page-readiness.js} +0 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Animation Extractor
3
+ *
4
+ * Extract @keyframes definitions, animation properties, and transition values
5
+ * from CSS using css-tree AST walking.
6
+ *
7
+ * Usage:
8
+ * import { extractAnimations, generateAnimationsCss } from './animation-extractor.js';
9
+ * const animations = await extractAnimations(cssString);
10
+ * const animCss = generateAnimationsCss(animations);
11
+ *
12
+ * @module animation-extractor
13
+ */
14
+
15
+ import { extractAllFromAst, processKeyframeRule, processStyleRule, TRANSITION_PROPERTIES, ANIMATION_PROPERTIES } from './animation-extractor-ast.js';
16
+ import { generateAnimationsCss, generateAnimationTokens, extractTimingFromShorthand } from './animation-extractor-output.js';
17
+
18
+ // Re-export for backward compatibility
19
+ export { generateAnimationsCss, generateAnimationTokens };
20
+
21
+ // ============================================================================
22
+ // Type Definitions (JSDoc)
23
+ // ============================================================================
24
+
25
+ /**
26
+ * @typedef {Object} KeyframeFrame
27
+ * @property {string} offset - Keyframe selector (e.g., "0%", "50%", "100%", "from", "to")
28
+ * @property {Object<string, string>} properties - CSS properties and their values
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} KeyframeData
33
+ * @property {KeyframeFrame[]} frames - Array of keyframe frames
34
+ * @property {string} raw - Original CSS text for regeneration
35
+ * @property {boolean} vendorPrefixed - True if @-webkit-keyframes
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} ExtractionResult
40
+ * @property {Object<string, KeyframeData>} keyframes - Map of keyframe name to data
41
+ * @property {Array} transitions - Array of transition rules
42
+ * @property {Array} animatedElements - Array of animated element rules
43
+ * @property {string} [error] - Error message if extraction failed
44
+ */
45
+
46
+ /**
47
+ * @typedef {Object} AnimationTokens
48
+ * @property {string[]} keyframes - List of keyframe names
49
+ * @property {number} keyframeCount - Total number of keyframes
50
+ * @property {number} transitions - Total number of transition rules
51
+ * @property {number} animatedElements - Total number of animated elements
52
+ * @property {string[]} durations - Unique duration values found
53
+ * @property {string[]} timingFunctions - Unique timing functions found
54
+ */
55
+
56
+ // ============================================================================
57
+ // Dependency Management
58
+ // ============================================================================
59
+
60
+ /**
61
+ * css-tree module reference - loaded dynamically to handle missing dependency
62
+ * @type {Object|null}
63
+ */
64
+ let csstree = null;
65
+
66
+ try {
67
+ csstree = await import('css-tree');
68
+ } catch (importError) {
69
+ const errorDetails = importError.code === 'ERR_MODULE_NOT_FOUND'
70
+ ? 'Module not found in node_modules'
71
+ : importError.message;
72
+
73
+ console.error(
74
+ '[animation-extractor] Failed to load css-tree dependency.\n' +
75
+ ` Error: ${errorDetails}\n` +
76
+ ' Fix: Run "npm install css-tree" to install the required dependency.\n' +
77
+ ' Note: Animation extraction will be disabled until css-tree is available.'
78
+ );
79
+ }
80
+
81
+ // ============================================================================
82
+ // Legacy Individual Extractors (Kept for Testing/Backwards Compatibility)
83
+ // ============================================================================
84
+
85
+ /**
86
+ * Extract @keyframes from CSS AST
87
+ * @param {Object} cssAst - css-tree AST
88
+ * @returns {Object<string, KeyframeData>}
89
+ */
90
+ function extractKeyframes(cssAst) {
91
+ if (!csstree) return {};
92
+ const { keyframes } = extractAllFromAst(csstree, cssAst);
93
+ return keyframes;
94
+ }
95
+
96
+ /**
97
+ * Extract transition properties from CSS rules
98
+ * @param {Object} cssAst - css-tree AST
99
+ * @returns {Array}
100
+ */
101
+ function extractTransitions(cssAst) {
102
+ if (!csstree) return [];
103
+ const { transitions } = extractAllFromAst(csstree, cssAst);
104
+ return transitions;
105
+ }
106
+
107
+ /**
108
+ * Extract animation-* properties from CSS rules
109
+ * @param {Object} cssAst - css-tree AST
110
+ * @returns {Array}
111
+ */
112
+ function extractAnimationProps(cssAst) {
113
+ if (!csstree) return [];
114
+ const { animatedElements } = extractAllFromAst(csstree, cssAst);
115
+ return animatedElements;
116
+ }
117
+
118
+ // ============================================================================
119
+ // Main Extraction Function
120
+ // ============================================================================
121
+
122
+ /**
123
+ * Main extraction function - extract all animation-related CSS data.
124
+ *
125
+ * Uses single-pass AST walking for optimal performance.
126
+ * Falls back to lenient parsing if strict parsing fails.
127
+ *
128
+ * @param {string} cssString - Raw CSS string to parse
129
+ * @returns {Promise<ExtractionResult>}
130
+ *
131
+ * @example
132
+ * const css = '@keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } }';
133
+ * const result = await extractAnimations(css);
134
+ * console.log(result.keyframes.fadeIn.frames.length); // 2
135
+ */
136
+ export async function extractAnimations(cssString) {
137
+ if (!cssString || typeof cssString !== 'string') {
138
+ return { keyframes: {}, transitions: [], animatedElements: [] };
139
+ }
140
+
141
+ if (!csstree) {
142
+ return {
143
+ keyframes: {},
144
+ transitions: [],
145
+ animatedElements: [],
146
+ error: 'css-tree dependency not available. Run: npm install css-tree'
147
+ };
148
+ }
149
+
150
+ let ast;
151
+ try {
152
+ ast = csstree.parse(cssString, {
153
+ parseRulePrelude: true,
154
+ parseValue: true,
155
+ parseAtrulePrelude: true
156
+ });
157
+ } catch (parseError) {
158
+ try {
159
+ ast = csstree.parse(cssString, {
160
+ parseRulePrelude: true,
161
+ parseValue: false,
162
+ parseAtrulePrelude: false
163
+ });
164
+ } catch (lenientError) {
165
+ return {
166
+ keyframes: {},
167
+ transitions: [],
168
+ animatedElements: [],
169
+ error: `CSS parse error: ${parseError.message}. Lenient parse also failed: ${lenientError.message}`
170
+ };
171
+ }
172
+ }
173
+
174
+ return extractAllFromAst(csstree, ast);
175
+ }
176
+
177
+ // Export individual functions for testing and advanced use
178
+ export { extractKeyframes, extractTransitions, extractAnimationProps };
@@ -0,0 +1,200 @@
1
+ /**
2
+ * State Capture Detection
3
+ *
4
+ * CSS-based and DOM-based detection of interactive elements that have
5
+ * hover states. Used by state-capture.js to find elements before
6
+ * screenshot capture.
7
+ *
8
+ * @module state-capture-detection
9
+ */
10
+
11
+ import { logError } from '../../utils/log.js';
12
+
13
+ /** Maximum number of elements to capture (performance limit) */
14
+ export const MAX_ELEMENTS = 50;
15
+
16
+ /** Maximum elements to scan in DOM for transitions (performance limit) */
17
+ export const MAX_DOM_SCAN = 200;
18
+
19
+ /** Maximum selector depth when generating unique selectors */
20
+ export const MAX_SELECTOR_DEPTH = 3;
21
+
22
+ /** Interactive element selectors for DOM query */
23
+ export const INTERACTIVE_SELECTORS = [
24
+ 'button:not(:disabled)',
25
+ 'a[href]',
26
+ '[role="button"]',
27
+ '[role="link"]',
28
+ 'input[type="submit"]',
29
+ 'input[type="button"]',
30
+ '.btn',
31
+ '.button',
32
+ '.card',
33
+ '.nav-link'
34
+ ];
35
+
36
+ let csstree = null;
37
+ try {
38
+ csstree = await import('css-tree');
39
+ } catch {
40
+ console.error(
41
+ '[state-capture] css-tree not available. CSS-based hover detection disabled.\n' +
42
+ ' Fix: Run "npm install css-tree"'
43
+ );
44
+ }
45
+
46
+ /** Strip :hover from selector text (handles compound selectors). */
47
+ export function extractBaseSelector(selectorText) {
48
+ return selectorText.replace(/:hover/g, '').replace(/\s+/g, ' ').trim();
49
+ }
50
+
51
+ /** Basic CSS selector validation (non-empty, no HTML chars, ≤500 chars). */
52
+ export function isValidSelector(selector) {
53
+ if (!selector || typeof selector !== 'string') return false;
54
+ const trimmed = selector.trim();
55
+ if (!trimmed || trimmed.length > 500) return false;
56
+ if (/[<>{}]/.test(trimmed)) return false;
57
+ return true;
58
+ }
59
+
60
+ /** Extract base selectors that have :hover rules from a CSS string via AST. */
61
+ export function extractHoverSelectorsFromCss(cssString) {
62
+ const hoverSelectors = new Set();
63
+
64
+ if (!csstree || !cssString || typeof cssString !== 'string') {
65
+ return hoverSelectors;
66
+ }
67
+
68
+ try {
69
+ const ast = csstree.parse(cssString, { parseRulePrelude: true });
70
+
71
+ csstree.walk(ast, {
72
+ visit: 'Rule',
73
+ enter(node) {
74
+ if (!node.prelude) return;
75
+
76
+ const selectorText = csstree.generate(node.prelude);
77
+ if (selectorText.includes(':hover')) {
78
+ const baseSelector = extractBaseSelector(selectorText);
79
+ if (baseSelector && isValidSelector(baseSelector)) {
80
+ hoverSelectors.add(baseSelector);
81
+ }
82
+ }
83
+ }
84
+ });
85
+ } catch (e) {
86
+ logError(`[state-capture] CSS parse error: ${e.message}`);
87
+ }
88
+
89
+ return hoverSelectors;
90
+ }
91
+
92
+ /**
93
+ * Detect interactive elements via DOM query + transition scan (CSP-safe, no new Function).
94
+ * @param {import('playwright').Page} page
95
+ * @returns {Promise<Array<{selector, tag, text?, hasTransition?}>>}
96
+ */
97
+ export async function detectInteractiveElementsFromDom(page) {
98
+ return await page.evaluate(({ selectors, maxScan, maxDepth }) => {
99
+ function getUniqueSelector(element) {
100
+ if (element.id) return '#' + element.id;
101
+
102
+ const pathArr = [];
103
+ let current = element;
104
+
105
+ while (current && current.nodeType === 1 && pathArr.length < maxDepth) {
106
+ let selector = current.tagName.toLowerCase();
107
+
108
+ if (current.className && typeof current.className === 'string') {
109
+ const classes = current.className.trim().split(/\s+/).slice(0, 2).filter(c => c);
110
+ if (classes.length) selector += '.' + classes.join('.');
111
+ }
112
+
113
+ const siblings = current.parentNode?.children || [];
114
+ const sameTagSiblings = Array.from(siblings).filter(s => s.tagName === current.tagName);
115
+ if (sameTagSiblings.length > 1) {
116
+ const index = sameTagSiblings.indexOf(current) + 1;
117
+ selector += ':nth-of-type(' + index + ')';
118
+ }
119
+
120
+ pathArr.unshift(selector);
121
+ current = current.parentElement;
122
+ }
123
+
124
+ return pathArr.join(' > ');
125
+ }
126
+
127
+ const results = [];
128
+ const seen = new Set();
129
+ let totalScanned = 0;
130
+
131
+ for (const sel of selectors) {
132
+ if (totalScanned >= maxScan) break;
133
+
134
+ try {
135
+ const elements = document.querySelectorAll(sel);
136
+ for (const el of elements) {
137
+ if (totalScanned >= maxScan) break;
138
+ totalScanned++;
139
+
140
+ if (!el.offsetParent && el.tagName !== 'BODY') continue;
141
+
142
+ const uniqueSel = getUniqueSelector(el);
143
+ if (!seen.has(uniqueSel)) {
144
+ seen.add(uniqueSel);
145
+ results.push({
146
+ selector: uniqueSel,
147
+ tag: el.tagName.toLowerCase(),
148
+ text: el.textContent?.slice(0, 30)?.trim() || ''
149
+ });
150
+ }
151
+ }
152
+ } catch {
153
+ // Invalid selector, skip
154
+ }
155
+ }
156
+
157
+ const allElements = document.querySelectorAll('*');
158
+ for (const el of allElements) {
159
+ if (totalScanned >= maxScan) break;
160
+ totalScanned++;
161
+
162
+ if (!el.offsetParent && el.tagName !== 'BODY') continue;
163
+
164
+ const style = getComputedStyle(el);
165
+ const hasTransition = style.transition &&
166
+ style.transition !== 'all 0s ease 0s' &&
167
+ style.transition !== 'none' &&
168
+ !style.transition.startsWith('none');
169
+
170
+ if (hasTransition) {
171
+ const uniqueSel = getUniqueSelector(el);
172
+ if (!seen.has(uniqueSel)) {
173
+ seen.add(uniqueSel);
174
+ results.push({
175
+ selector: uniqueSel,
176
+ tag: el.tagName.toLowerCase(),
177
+ hasTransition: true
178
+ });
179
+ }
180
+ }
181
+ }
182
+
183
+ return results;
184
+ }, { selectors: INTERACTIVE_SELECTORS, maxScan: MAX_DOM_SCAN, maxDepth: MAX_SELECTOR_DEPTH });
185
+ }
186
+
187
+ /**
188
+ * Detect interactive elements via CSS + DOM, deduped and limited to MAX_ELEMENTS.
189
+ * @param {import('playwright').Page} page
190
+ * @param {string|null} cssString
191
+ * @returns {Promise<{fromCss: string[], fromDom: Array, combined: string[]}>}
192
+ */
193
+ export async function detectInteractiveElements(page, cssString) {
194
+ if (!page) throw new Error('Page parameter is required');
195
+ const hoverSelectors = extractHoverSelectorsFromCss(cssString);
196
+ const domInteractive = await detectInteractiveElementsFromDom(page);
197
+ const validDomSelectors = domInteractive.map(e => e.selector).filter(s => isValidSelector(s));
198
+ const combined = Array.from(new Set([...hoverSelectors, ...validDomSelectors])).slice(0, MAX_ELEMENTS);
199
+ return { fromCss: Array.from(hoverSelectors), fromDom: domInteractive, combined };
200
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * State Capture Module
3
+ *
4
+ * Capture hover states for interactive elements using Playwright.
5
+ * Screenshots before/after, computes style differences, generates :hover CSS.
6
+ *
7
+ * Usage:
8
+ * import { captureAllHoverStates, generateHoverCss } from './state-capture.js';
9
+ * const result = await captureAllHoverStates(page, cssString, outputDir);
10
+ * const hoverCss = generateHoverCss(result.elements);
11
+ *
12
+ * @module state-capture
13
+ */
14
+
15
+ import path from 'path';
16
+ import fs from 'fs/promises';
17
+ import { logInfo } from '../../utils/log.js';
18
+
19
+ import {
20
+ detectInteractiveElements,
21
+ extractHoverSelectorsFromCss,
22
+ detectInteractiveElementsFromDom,
23
+ isValidSelector
24
+ } from './state-capture-detection.js';
25
+
26
+ /** Delay after hover for CSS transitions to complete (ms) */
27
+ const HOVER_SETTLE_DELAY = 100;
28
+
29
+ /** Delay after mouse reset for state to clear (ms) */
30
+ const MOUSE_RESET_DELAY = 50;
31
+
32
+ /** Padding around element for screenshots (px) */
33
+ const SCREENSHOT_PADDING = 20;
34
+
35
+ /** CSS properties to capture for style diff */
36
+ const STYLE_PROPERTIES = [
37
+ 'backgroundColor',
38
+ 'color',
39
+ 'transform',
40
+ 'boxShadow',
41
+ 'borderColor',
42
+ 'opacity',
43
+ 'scale',
44
+ 'filter',
45
+ 'textDecoration',
46
+ 'outline'
47
+ ];
48
+
49
+ /** Convert camelCase to kebab-case (e.g. backgroundColor → background-color). */
50
+ function toKebabCase(str) {
51
+ return str.replace(/([A-Z])/g, '-$1').toLowerCase();
52
+ }
53
+
54
+ // Logging via centralized utils/log.js
55
+
56
+ /** Capture computed styles for STYLE_PROPERTIES on a given selector. */
57
+ async function captureElementStyles(page, selector) {
58
+ return await page.evaluate(({ sel, props }) => {
59
+ const el = document.querySelector(sel);
60
+ if (!el) return null;
61
+
62
+ const style = getComputedStyle(el);
63
+ const result = {};
64
+ for (const prop of props) {
65
+ result[prop] = style[prop];
66
+ }
67
+ return result;
68
+ }, { sel: selector, props: STYLE_PROPERTIES });
69
+ }
70
+
71
+ /**
72
+ * Capture hover state for a single element (normal + hover screenshots + style diff).
73
+ * @param {import('playwright').Page} page
74
+ * @param {string} selector
75
+ * @param {string} outputDir
76
+ * @param {number} index - used in output filename
77
+ * @returns {Promise<{selector, success, normalScreenshot, hoverScreenshot, styleDiff, error?}>}
78
+ */
79
+ export async function captureHoverState(page, selector, outputDir, index) {
80
+ const result = {
81
+ selector,
82
+ success: false,
83
+ normalScreenshot: null,
84
+ hoverScreenshot: null,
85
+ normalStyles: null,
86
+ hoverStyles: null,
87
+ styleDiff: {}
88
+ };
89
+
90
+ if (!isValidSelector(selector)) {
91
+ result.error = 'Invalid selector';
92
+ return result;
93
+ }
94
+
95
+ try {
96
+ const locator = page.locator(selector);
97
+ const isVisible = await locator.isVisible().catch(() => false);
98
+ if (!isVisible) { result.error = 'Element not visible'; return result; }
99
+ const box = await locator.boundingBox();
100
+ if (!box) { result.error = 'No bounding box'; return result; }
101
+
102
+ const clip = {
103
+ x: Math.max(0, box.x - SCREENSHOT_PADDING), y: Math.max(0, box.y - SCREENSHOT_PADDING),
104
+ width: box.width + SCREENSHOT_PADDING * 2, height: box.height + SCREENSHOT_PADDING * 2
105
+ };
106
+
107
+ result.normalStyles = await captureElementStyles(page, selector);
108
+ const normalPath = path.join(outputDir, `hover-${index}-normal.png`);
109
+ await page.screenshot({ path: normalPath, clip });
110
+ result.normalScreenshot = normalPath;
111
+
112
+ await locator.hover();
113
+ await new Promise(r => setTimeout(r, HOVER_SETTLE_DELAY));
114
+
115
+ result.hoverStyles = await captureElementStyles(page, selector);
116
+ const hoverPath = path.join(outputDir, `hover-${index}-hover.png`);
117
+ await page.screenshot({ path: hoverPath, clip });
118
+ result.hoverScreenshot = hoverPath;
119
+
120
+ if (result.normalStyles && result.hoverStyles) {
121
+ for (const [prop, normalVal] of Object.entries(result.normalStyles)) {
122
+ const hoverVal = result.hoverStyles[prop];
123
+ if (hoverVal !== normalVal) result.styleDiff[prop] = { from: normalVal, to: hoverVal };
124
+ }
125
+ }
126
+ await page.mouse.move(0, 0);
127
+ await new Promise(r => setTimeout(r, MOUSE_RESET_DELAY));
128
+ result.success = Object.keys(result.styleDiff).length > 0;
129
+ } catch (e) {
130
+ result.error = e.message;
131
+ }
132
+
133
+ return result;
134
+ }
135
+
136
+ /**
137
+ * Capture hover states for all detected interactive elements.
138
+ * Writes screenshots to hover-states/ subdir and hover-diff.json summary.
139
+ * @param {import('playwright').Page} page
140
+ * @param {string|null} cssString - Raw CSS for :hover detection
141
+ * @param {string} outputDir
142
+ * @returns {Promise<{directory, detected, captured, summaryPath, elements}>}
143
+ */
144
+ export async function captureAllHoverStates(page, cssString, outputDir) {
145
+ if (!page) throw new Error('Page parameter is required');
146
+ if (!outputDir || typeof outputDir !== 'string') throw new Error('Output directory parameter is required');
147
+
148
+ const hoverDir = path.join(outputDir, 'hover-states');
149
+ await fs.mkdir(hoverDir, { recursive: true });
150
+ const interactive = await detectInteractiveElements(page, cssString);
151
+ const elements = [];
152
+ let capturedCount = 0;
153
+
154
+ for (let i = 0; i < interactive.combined.length; i++) {
155
+ const selector = interactive.combined[i];
156
+ const result = await captureHoverState(page, selector, hoverDir, i);
157
+ elements.push(result);
158
+ if (result.success) { capturedCount++; logInfo(`[hover] ${capturedCount}: ${selector}`); }
159
+ }
160
+
161
+ const summaryPath = path.join(hoverDir, 'hover-diff.json');
162
+ await fs.writeFile(summaryPath, JSON.stringify({
163
+ detected: interactive.combined.length, captured: capturedCount,
164
+ fromCss: interactive.fromCss.length, fromDom: interactive.fromDom.length,
165
+ elements: elements.filter(e => e.success).map(r => ({
166
+ selector: r.selector, styleDiff: r.styleDiff,
167
+ normalScreenshot: r.normalScreenshot ? path.basename(r.normalScreenshot) : null,
168
+ hoverScreenshot: r.hoverScreenshot ? path.basename(r.hoverScreenshot) : null
169
+ }))
170
+ }, null, 2), 'utf-8');
171
+
172
+ return { directory: hoverDir, detected: interactive.combined.length, captured: capturedCount, summaryPath, elements };
173
+ }
174
+
175
+ /** Generate :hover CSS rules from captured style diffs. */
176
+ export function generateHoverCss(results) {
177
+ if (!results || !Array.isArray(results)) return '/* No hover style changes detected */\n';
178
+ const successful = results.filter(r => r.success && Object.keys(r.styleDiff).length > 0);
179
+ if (successful.length === 0) return '/* No hover style changes detected */\n';
180
+
181
+ const lines = ['/**', ' * Generated :hover Styles', ' * Captured by design-clone state-capture', ' */\n'];
182
+ for (const result of successful) {
183
+ lines.push(`/* Element: ${result.selector} */`, `${result.selector}:hover {`);
184
+ for (const [prop, diff] of Object.entries(result.styleDiff)) {
185
+ lines.push(` ${toKebabCase(prop)}: ${diff.to};`);
186
+ }
187
+ lines.push('}\n');
188
+ }
189
+ return lines.join('\n');
190
+ }
191
+
192
+ // Re-exports for backward compatibility
193
+ export { detectInteractiveElements, extractHoverSelectorsFromCss, detectInteractiveElementsFromDom };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Browser Context Pool
3
+ *
4
+ * Manages a pool of Playwright browser contexts for parallel page capture.
5
+ * Includes memory guard to prevent OOM and queue for overflow requests.
6
+ */
7
+
8
+ import os from 'os';
9
+
10
+ const DEFAULT_MAX_CONTEXTS = 3;
11
+ const MIN_FREE_MEMORY_MB = 500;
12
+ const DEFAULT_ACQUIRE_TIMEOUT = 30000;
13
+
14
+ export class BrowserContextPool {
15
+ #browser;
16
+ #maxContexts;
17
+ #acquireTimeout;
18
+ #active = new Set();
19
+ #waitQueue = [];
20
+
21
+ /**
22
+ * @param {import('playwright').Browser} browser
23
+ * @param {{ maxContexts?: number, acquireTimeout?: number }} options
24
+ */
25
+ constructor(browser, options = {}) {
26
+ this.#browser = browser;
27
+ this.#maxContexts = options.maxContexts || DEFAULT_MAX_CONTEXTS;
28
+ this.#acquireTimeout = options.acquireTimeout ?? DEFAULT_ACQUIRE_TIMEOUT;
29
+ }
30
+
31
+ /**
32
+ * Acquire a browser context + page from the pool.
33
+ * Waits if pool is full or memory is low.
34
+ * Rejects with an error if acquireTimeout is exceeded.
35
+ * @returns {Promise<{context: import('playwright').BrowserContext, page: import('playwright').Page}>}
36
+ */
37
+ async acquire() {
38
+ // Memory guard: wait for a release if free memory is low
39
+ const freeMB = os.freemem() / (1024 * 1024);
40
+ if (freeMB < MIN_FREE_MEMORY_MB && this.#active.size > 0) {
41
+ return this.#waitForRelease();
42
+ }
43
+
44
+ if (this.#active.size >= this.#maxContexts) {
45
+ return this.#waitForRelease();
46
+ }
47
+
48
+ const context = await this.#browser.newContext();
49
+ const page = await context.newPage();
50
+ this.#active.add(context);
51
+ return { context, page };
52
+ }
53
+
54
+ /**
55
+ * Release a context back to the pool (closes it).
56
+ * @param {import('playwright').BrowserContext} context
57
+ */
58
+ async release(context) {
59
+ await context.close().catch(() => {});
60
+ this.#active.delete(context);
61
+ if (this.#waitQueue.length > 0) {
62
+ const resolve = this.#waitQueue.shift();
63
+ resolve(this.acquire());
64
+ }
65
+ }
66
+
67
+ /** @returns {Promise<{context, page}>} */
68
+ #waitForRelease() {
69
+ const timeout = this.#acquireTimeout;
70
+ return new Promise((resolve, reject) => {
71
+ let timer;
72
+ const entry = (result) => {
73
+ clearTimeout(timer);
74
+ resolve(result);
75
+ };
76
+ this.#waitQueue.push(entry);
77
+ if (timeout > 0) {
78
+ timer = setTimeout(() => {
79
+ const idx = this.#waitQueue.indexOf(entry);
80
+ if (idx !== -1) this.#waitQueue.splice(idx, 1);
81
+ reject(new Error(`Context pool acquire timed out after ${timeout}ms`));
82
+ }, timeout);
83
+ }
84
+ });
85
+ }
86
+
87
+ /** Close all active contexts */
88
+ async drain() {
89
+ const contexts = [...this.#active];
90
+ await Promise.all(contexts.map(c => c.close().catch(() => {})));
91
+ this.#active.clear();
92
+ this.#waitQueue = [];
93
+ }
94
+
95
+ get activeCount() { return this.#active.size; }
96
+ }