design-clone 1.2.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.
- package/README.md +32 -39
- package/SKILL.md +69 -45
- package/bin/cli.js +22 -4
- package/bin/commands/clone-site.js +31 -106
- package/bin/commands/help.js +19 -6
- package/bin/commands/init.js +11 -56
- package/bin/commands/uninstall.js +105 -0
- package/bin/commands/update.js +70 -0
- package/bin/commands/verify.js +11 -16
- package/bin/utils/paths.js +28 -0
- package/bin/utils/validate.js +24 -28
- package/bin/utils/version.js +23 -0
- package/docs/code-standards.md +789 -0
- package/docs/codebase-summary.md +556 -0
- package/docs/index.md +74 -0
- package/docs/project-overview-pdr.md +797 -0
- package/docs/system-architecture.md +718 -0
- package/package.json +20 -21
- package/src/ai/prompts/design-tokens/basic.md +80 -0
- package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
- package/src/ai/prompts/design-tokens/section.md +48 -0
- package/src/ai/prompts/design-tokens/with-css.md +87 -0
- package/src/ai/prompts/structure-analysis/basic.md +55 -0
- package/src/ai/prompts/structure-analysis/with-context.md +59 -0
- package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
- package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
- package/src/ai/prompts/ux-audit/aggregation.md +42 -0
- package/src/ai/prompts/ux-audit/desktop.md +92 -0
- package/src/ai/prompts/ux-audit/mobile.md +93 -0
- package/src/ai/prompts/ux-audit/tablet.md +92 -0
- package/src/core/animation/animation-extractor-ast.js +183 -0
- package/src/core/animation/animation-extractor-output.js +152 -0
- package/src/core/animation/animation-extractor.js +178 -0
- package/src/core/animation/state-capture-detection.js +200 -0
- package/src/core/animation/state-capture.js +193 -0
- package/src/core/capture/browser-context-pool.js +96 -0
- package/src/core/capture/multi-page-screenshot-page.js +110 -0
- package/src/core/capture/multi-page-screenshot.js +208 -0
- package/src/core/capture/screenshot-extraction.js +186 -0
- package/src/core/capture/screenshot-helpers.js +175 -0
- package/src/core/capture/screenshot-orchestrator.js +174 -0
- package/src/core/capture/screenshot-viewport.js +93 -0
- package/src/core/capture/screenshot.js +192 -0
- package/src/core/content/content-counter-dom.js +191 -0
- package/src/core/content/content-counter.js +76 -0
- package/src/core/css/breakpoint-detector.js +66 -0
- package/src/core/css/chromium-defaults.json +23 -0
- package/src/core/css/computed-style-extractor.js +102 -0
- package/src/core/css/css-chunker.js +103 -0
- package/src/core/{css-extractor.js → css/css-extractor.js} +4 -4
- package/src/core/css/filter-css-dead-code.js +120 -0
- package/src/core/css/filter-css-html-analyzer.js +110 -0
- package/src/core/css/filter-css-selector-matcher.js +172 -0
- package/src/core/css/filter-css.js +206 -0
- package/src/core/css/merge-css-atrule-processor.js +158 -0
- package/src/core/css/merge-css-file-io.js +68 -0
- package/src/core/css/merge-css.js +148 -0
- package/src/core/detection/framework-detector-routing.js +68 -0
- package/src/core/detection/framework-detector-signals.js +65 -0
- package/src/core/detection/framework-detector.js +198 -0
- package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
- package/src/core/dimension/dimension-extractor.js +317 -0
- package/src/core/dimension/dimension-output-ai-summary.js +111 -0
- package/src/core/dimension/dimension-output.js +173 -0
- package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
- package/src/core/dimension/dom-tree-analyzer.js +191 -0
- package/src/core/discovery/app-state-snapshot-capture.js +195 -0
- package/src/core/discovery/app-state-snapshot-utils.js +178 -0
- package/src/core/discovery/app-state-snapshot.js +131 -0
- package/src/core/discovery/discover-pages-routes.js +84 -0
- package/src/core/discovery/discover-pages-utils.js +177 -0
- package/src/core/discovery/discover-pages.js +191 -0
- package/src/core/html/html-extractor-inline-styler.js +70 -0
- package/src/core/html/html-extractor.js +147 -0
- package/src/core/html/semantic-enhancer-mappings.js +200 -0
- package/src/core/html/semantic-enhancer-page.js +148 -0
- package/src/core/html/semantic-enhancer.js +135 -0
- package/src/core/links/rewrite-links-css-rewriter.js +53 -0
- package/src/core/links/rewrite-links.js +173 -0
- package/src/core/media/asset-validator.js +118 -0
- package/src/core/media/extract-assets-downloader.js +187 -0
- package/src/core/media/extract-assets-page-scraper.js +115 -0
- package/src/core/media/extract-assets.js +159 -0
- package/src/core/media/video-capture-convert.js +200 -0
- package/src/core/media/video-capture.js +201 -0
- package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +1 -1
- package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +44 -46
- package/src/core/{page-readiness.js → page-prep/page-readiness.js} +8 -8
- package/src/core/section/section-cropper-helpers.js +43 -0
- package/src/core/section/section-cropper.js +132 -0
- package/src/core/section/section-detector-strategies.js +139 -0
- package/src/core/section/section-detector-utils.js +100 -0
- package/src/core/section/section-detector.js +88 -0
- package/src/core/tests/test-section-cropper.js +177 -0
- package/src/core/tests/test-section-detector.js +55 -0
- package/src/post-process/enhance-assets.js +29 -4
- package/src/post-process/fetch-images-unsplash-client.js +123 -0
- package/src/post-process/fetch-images.js +60 -263
- package/src/post-process/inject-gosnap.js +88 -0
- package/src/post-process/inject-icons-svg-replacer.js +76 -0
- package/src/post-process/inject-icons.js +47 -200
- package/src/route-discoverers/angular-discoverer.js +157 -0
- package/src/route-discoverers/astro-discoverer.js +123 -0
- package/src/route-discoverers/base-discoverer-utils.js +137 -0
- package/src/route-discoverers/base-discoverer.js +153 -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/shared/config.js +38 -0
- package/src/shared/error-codes.js +31 -0
- package/src/shared/viewports.js +46 -0
- package/src/utils/browser.js +11 -44
- package/src/utils/helpers.js +4 -0
- package/src/utils/log.js +12 -0
- package/src/utils/playwright-loader.js +76 -0
- package/src/utils/playwright.js +147 -0
- package/src/utils/progress.js +32 -0
- package/src/verification/generate-audit-report-css-fixes.js +52 -0
- package/src/verification/generate-audit-report-sections.js +158 -0
- package/src/verification/generate-audit-report.js +122 -0
- package/src/verification/quality-scorer.js +92 -0
- package/src/verification/verify-footer-checks.js +103 -0
- package/src/verification/verify-footer-helpers.js +178 -0
- package/src/verification/verify-footer.js +135 -0
- package/src/verification/verify-header-checks.js +104 -0
- package/src/verification/verify-header-helpers.js +156 -0
- package/src/verification/verify-header.js +144 -0
- package/src/verification/verify-layout-report.js +101 -0
- package/src/verification/verify-layout.js +14 -260
- package/src/verification/verify-menu-checks.js +104 -0
- package/src/verification/verify-menu-helpers.js +112 -0
- package/src/verification/verify-menu.js +18 -302
- package/src/verification/verify-slider-checks.js +115 -0
- package/src/verification/verify-slider-constants.js +65 -0
- package/src/verification/verify-slider-helpers.js +164 -0
- package/src/verification/verify-slider.js +142 -0
- package/.env.example +0 -14
- package/docs/basic-clone.md +0 -63
- package/docs/cli-reference.md +0 -118
- package/docs/design-clone-architecture.md +0 -275
- package/docs/pixel-perfect.md +0 -86
- package/docs/troubleshooting.md +0 -169
- package/requirements.txt +0 -5
- package/src/ai/analyze-structure.py +0 -305
- package/src/ai/extract-design-tokens.py +0 -439
- package/src/ai/prompts/__init__.py +0 -2
- 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/design_tokens.py +0 -183
- package/src/ai/prompts/structure_analysis.py +0 -273
- package/src/core/animation-extractor.js +0 -526
- package/src/core/design-tokens.js +0 -103
- package/src/core/dimension-extractor.js +0 -366
- package/src/core/dimension-output.js +0 -208
- package/src/core/discover-pages.js +0 -314
- package/src/core/extract-assets.js +0 -468
- package/src/core/filter-css.js +0 -499
- package/src/core/html-extractor.js +0 -171
- package/src/core/merge-css.js +0 -407
- package/src/core/multi-page-screenshot.js +0 -377
- package/src/core/rewrite-links.js +0 -226
- package/src/core/screenshot.js +0 -572
- package/src/core/state-capture.js +0 -602
- package/src/core/video-capture.js +0 -540
- package/src/utils/__init__.py +0 -16
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
- package/src/utils/env.py +0 -134
- package/src/utils/puppeteer.js +0 -281
|
@@ -1,602 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* State Capture Module
|
|
3
|
-
*
|
|
4
|
-
* Capture hover states for interactive elements using Puppeteer.
|
|
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
|
-
|
|
18
|
-
// ============================================================================
|
|
19
|
-
// Constants
|
|
20
|
-
// ============================================================================
|
|
21
|
-
|
|
22
|
-
/** Delay after hover for CSS transitions to complete (ms) */
|
|
23
|
-
const HOVER_SETTLE_DELAY = 100;
|
|
24
|
-
|
|
25
|
-
/** Delay after mouse reset for state to clear (ms) */
|
|
26
|
-
const MOUSE_RESET_DELAY = 50;
|
|
27
|
-
|
|
28
|
-
/** Padding around element for screenshots (px) */
|
|
29
|
-
const SCREENSHOT_PADDING = 20;
|
|
30
|
-
|
|
31
|
-
/** Maximum number of elements to capture (performance limit) */
|
|
32
|
-
const MAX_ELEMENTS = 50;
|
|
33
|
-
|
|
34
|
-
/** Maximum elements to scan in DOM for transitions (performance limit) */
|
|
35
|
-
const MAX_DOM_SCAN = 200;
|
|
36
|
-
|
|
37
|
-
/** Maximum selector depth when generating unique selectors */
|
|
38
|
-
const MAX_SELECTOR_DEPTH = 3;
|
|
39
|
-
|
|
40
|
-
/** Interactive element selectors for DOM query */
|
|
41
|
-
const INTERACTIVE_SELECTORS = [
|
|
42
|
-
'button:not(:disabled)',
|
|
43
|
-
'a[href]',
|
|
44
|
-
'[role="button"]',
|
|
45
|
-
'[role="link"]',
|
|
46
|
-
'input[type="submit"]',
|
|
47
|
-
'input[type="button"]',
|
|
48
|
-
'.btn',
|
|
49
|
-
'.button',
|
|
50
|
-
'.card',
|
|
51
|
-
'.nav-link'
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
/** CSS properties to capture for style diff */
|
|
55
|
-
const STYLE_PROPERTIES = [
|
|
56
|
-
'backgroundColor',
|
|
57
|
-
'color',
|
|
58
|
-
'transform',
|
|
59
|
-
'boxShadow',
|
|
60
|
-
'borderColor',
|
|
61
|
-
'opacity',
|
|
62
|
-
'scale',
|
|
63
|
-
'filter',
|
|
64
|
-
'textDecoration',
|
|
65
|
-
'outline'
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
// ============================================================================
|
|
69
|
-
// Dependency Management
|
|
70
|
-
// ============================================================================
|
|
71
|
-
|
|
72
|
-
let csstree = null;
|
|
73
|
-
try {
|
|
74
|
-
csstree = await import('css-tree');
|
|
75
|
-
} catch {
|
|
76
|
-
console.error(
|
|
77
|
-
'[state-capture] css-tree not available. CSS-based hover detection disabled.\n' +
|
|
78
|
-
' Fix: Run "npm install css-tree"'
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ============================================================================
|
|
83
|
-
// Type Definitions (JSDoc)
|
|
84
|
-
// ============================================================================
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* @typedef {Object} InteractiveElement
|
|
88
|
-
* @property {string} selector - Unique CSS selector
|
|
89
|
-
* @property {string} tag - HTML tag name
|
|
90
|
-
* @property {string} [text] - First 30 chars of text content
|
|
91
|
-
* @property {boolean} [hasTransition] - True if element has CSS transitions
|
|
92
|
-
*/
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* @typedef {Object} StyleDiff
|
|
96
|
-
* @property {string} from - Value in normal state
|
|
97
|
-
* @property {string} to - Value in hover state
|
|
98
|
-
*/
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* @typedef {Object} HoverCaptureResult
|
|
102
|
-
* @property {string} selector - CSS selector for the element
|
|
103
|
-
* @property {boolean} success - True if hover state differs from normal
|
|
104
|
-
* @property {string|null} normalScreenshot - Path to normal state screenshot
|
|
105
|
-
* @property {string|null} hoverScreenshot - Path to hover state screenshot
|
|
106
|
-
* @property {Object<string, StyleDiff>} styleDiff - Style differences
|
|
107
|
-
* @property {string} [error] - Error message if capture failed
|
|
108
|
-
*/
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* @typedef {Object} HoverCaptureOutput
|
|
112
|
-
* @property {string} directory - Output directory path
|
|
113
|
-
* @property {number} detected - Number of detected interactive elements
|
|
114
|
-
* @property {number} captured - Number of successfully captured elements
|
|
115
|
-
* @property {string} summaryPath - Path to hover-diff.json
|
|
116
|
-
* @property {HoverCaptureResult[]} elements - Captured element results
|
|
117
|
-
*/
|
|
118
|
-
|
|
119
|
-
// ============================================================================
|
|
120
|
-
// Utility Functions
|
|
121
|
-
// ============================================================================
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Extract base selector from :hover selector (remove :hover pseudo-class).
|
|
125
|
-
* Handles patterns like ".btn:hover", ".card:hover .title", "button:hover, button:focus"
|
|
126
|
-
*
|
|
127
|
-
* @param {string} selectorText - Full selector text with :hover
|
|
128
|
-
* @returns {string} Base selector without :hover
|
|
129
|
-
*/
|
|
130
|
-
function extractBaseSelector(selectorText) {
|
|
131
|
-
return selectorText.replace(/:hover/g, '').replace(/\s+/g, ' ').trim();
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Convert camelCase to kebab-case.
|
|
136
|
-
* Example: backgroundColor -> background-color
|
|
137
|
-
*
|
|
138
|
-
* @param {string} str - camelCase string
|
|
139
|
-
* @returns {string} kebab-case string
|
|
140
|
-
*/
|
|
141
|
-
function toKebabCase(str) {
|
|
142
|
-
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Validate that a selector is valid CSS syntax.
|
|
147
|
-
*
|
|
148
|
-
* @param {string} selector - CSS selector to validate
|
|
149
|
-
* @returns {boolean} True if selector appears valid
|
|
150
|
-
*/
|
|
151
|
-
function isValidSelector(selector) {
|
|
152
|
-
if (!selector || typeof selector !== 'string') return false;
|
|
153
|
-
// Basic validation: not empty, not just whitespace, has content after trimming
|
|
154
|
-
const trimmed = selector.trim();
|
|
155
|
-
if (!trimmed || trimmed.length > 500) return false;
|
|
156
|
-
// Check for obviously invalid patterns
|
|
157
|
-
if (/[<>{}]/.test(trimmed)) return false;
|
|
158
|
-
return true;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Log message if running in TTY mode.
|
|
163
|
-
*
|
|
164
|
-
* @param {string} level - Log level (error, warn, info)
|
|
165
|
-
* @param {string} message - Message to log
|
|
166
|
-
*/
|
|
167
|
-
function log(level, message) {
|
|
168
|
-
if (process.stderr.isTTY) {
|
|
169
|
-
const prefix = level === 'error' ? '[ERROR]' : level === 'warn' ? '[WARN]' : '[INFO]';
|
|
170
|
-
console.error(`${prefix} ${message}`);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ============================================================================
|
|
175
|
-
// CSS-Based Detection
|
|
176
|
-
// ============================================================================
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Extract selectors with :hover from CSS using AST.
|
|
180
|
-
*
|
|
181
|
-
* @param {string|null|undefined} cssString - Raw CSS string
|
|
182
|
-
* @returns {Set<string>} Set of base selectors that have :hover rules
|
|
183
|
-
*/
|
|
184
|
-
function extractHoverSelectorsFromCss(cssString) {
|
|
185
|
-
const hoverSelectors = new Set();
|
|
186
|
-
|
|
187
|
-
if (!csstree || !cssString || typeof cssString !== 'string') {
|
|
188
|
-
return hoverSelectors;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
const ast = csstree.parse(cssString, { parseRulePrelude: true });
|
|
193
|
-
|
|
194
|
-
csstree.walk(ast, {
|
|
195
|
-
visit: 'Rule',
|
|
196
|
-
enter(node) {
|
|
197
|
-
if (!node.prelude) return;
|
|
198
|
-
|
|
199
|
-
const selectorText = csstree.generate(node.prelude);
|
|
200
|
-
if (selectorText.includes(':hover')) {
|
|
201
|
-
const baseSelector = extractBaseSelector(selectorText);
|
|
202
|
-
if (baseSelector && isValidSelector(baseSelector)) {
|
|
203
|
-
hoverSelectors.add(baseSelector);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
} catch (e) {
|
|
209
|
-
log('error', `[state-capture] CSS parse error: ${e.message}`);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return hoverSelectors;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// ============================================================================
|
|
216
|
-
// DOM-Based Detection
|
|
217
|
-
// ============================================================================
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Detect interactive elements on page via DOM query.
|
|
221
|
-
* Uses inline function to avoid new Function() for CSP compliance.
|
|
222
|
-
*
|
|
223
|
-
* @param {import('puppeteer').Page} page - Puppeteer page
|
|
224
|
-
* @returns {Promise<InteractiveElement[]>} Array of interactive elements
|
|
225
|
-
*/
|
|
226
|
-
async function detectInteractiveElementsFromDom(page) {
|
|
227
|
-
return await page.evaluate((selectors, maxScan, maxDepth) => {
|
|
228
|
-
// Inline getUniqueSelector to avoid new Function()
|
|
229
|
-
function getUniqueSelector(element) {
|
|
230
|
-
if (element.id) return '#' + element.id;
|
|
231
|
-
|
|
232
|
-
const pathArr = [];
|
|
233
|
-
let current = element;
|
|
234
|
-
|
|
235
|
-
while (current && current.nodeType === 1 && pathArr.length < maxDepth) {
|
|
236
|
-
let selector = current.tagName.toLowerCase();
|
|
237
|
-
|
|
238
|
-
// Add first 2 class names for specificity
|
|
239
|
-
if (current.className && typeof current.className === 'string') {
|
|
240
|
-
const classes = current.className.trim().split(/\s+/).slice(0, 2).filter(c => c);
|
|
241
|
-
if (classes.length) selector += '.' + classes.join('.');
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Add nth-of-type if siblings exist
|
|
245
|
-
const siblings = current.parentNode?.children || [];
|
|
246
|
-
const sameTagSiblings = Array.from(siblings).filter(s => s.tagName === current.tagName);
|
|
247
|
-
if (sameTagSiblings.length > 1) {
|
|
248
|
-
const index = sameTagSiblings.indexOf(current) + 1;
|
|
249
|
-
selector += ':nth-of-type(' + index + ')';
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
pathArr.unshift(selector);
|
|
253
|
-
current = current.parentElement;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return pathArr.join(' > ');
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const results = [];
|
|
260
|
-
const seen = new Set();
|
|
261
|
-
let totalScanned = 0;
|
|
262
|
-
|
|
263
|
-
// Query by interactive selectors
|
|
264
|
-
for (const sel of selectors) {
|
|
265
|
-
if (totalScanned >= maxScan) break;
|
|
266
|
-
|
|
267
|
-
try {
|
|
268
|
-
const elements = document.querySelectorAll(sel);
|
|
269
|
-
for (const el of elements) {
|
|
270
|
-
if (totalScanned >= maxScan) break;
|
|
271
|
-
totalScanned++;
|
|
272
|
-
|
|
273
|
-
// Skip hidden elements
|
|
274
|
-
if (!el.offsetParent && el.tagName !== 'BODY') continue;
|
|
275
|
-
|
|
276
|
-
const uniqueSel = getUniqueSelector(el);
|
|
277
|
-
if (!seen.has(uniqueSel)) {
|
|
278
|
-
seen.add(uniqueSel);
|
|
279
|
-
results.push({
|
|
280
|
-
selector: uniqueSel,
|
|
281
|
-
tag: el.tagName.toLowerCase(),
|
|
282
|
-
text: el.textContent?.slice(0, 30)?.trim() || ''
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
} catch {
|
|
287
|
-
// Invalid selector, skip
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Also detect elements with CSS transitions
|
|
292
|
-
const allElements = document.querySelectorAll('*');
|
|
293
|
-
for (const el of allElements) {
|
|
294
|
-
if (totalScanned >= maxScan) break;
|
|
295
|
-
totalScanned++;
|
|
296
|
-
|
|
297
|
-
if (!el.offsetParent && el.tagName !== 'BODY') continue;
|
|
298
|
-
|
|
299
|
-
const style = getComputedStyle(el);
|
|
300
|
-
const hasTransition = style.transition &&
|
|
301
|
-
style.transition !== 'all 0s ease 0s' &&
|
|
302
|
-
style.transition !== 'none' &&
|
|
303
|
-
!style.transition.startsWith('none');
|
|
304
|
-
|
|
305
|
-
if (hasTransition) {
|
|
306
|
-
const uniqueSel = getUniqueSelector(el);
|
|
307
|
-
if (!seen.has(uniqueSel)) {
|
|
308
|
-
seen.add(uniqueSel);
|
|
309
|
-
results.push({
|
|
310
|
-
selector: uniqueSel,
|
|
311
|
-
tag: el.tagName.toLowerCase(),
|
|
312
|
-
hasTransition: true
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return results;
|
|
319
|
-
}, INTERACTIVE_SELECTORS, MAX_DOM_SCAN, MAX_SELECTOR_DEPTH);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// ============================================================================
|
|
323
|
-
// Main Detection Function
|
|
324
|
-
// ============================================================================
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Detect interactive elements using CSS + DOM analysis.
|
|
328
|
-
*
|
|
329
|
-
* @param {import('puppeteer').Page} page - Puppeteer page
|
|
330
|
-
* @param {string|null} cssString - Raw CSS for :hover detection
|
|
331
|
-
* @returns {Promise<{fromCss: string[], fromDom: InteractiveElement[], combined: string[]}>}
|
|
332
|
-
*/
|
|
333
|
-
export async function detectInteractiveElements(page, cssString) {
|
|
334
|
-
// Validate input
|
|
335
|
-
if (!page) {
|
|
336
|
-
throw new Error('Page parameter is required');
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Method 1: CSS-based detection (faster, more accurate for :hover)
|
|
340
|
-
const hoverSelectors = extractHoverSelectorsFromCss(cssString);
|
|
341
|
-
|
|
342
|
-
// Method 2: DOM-based detection
|
|
343
|
-
const domInteractive = await detectInteractiveElementsFromDom(page);
|
|
344
|
-
|
|
345
|
-
// Merge and dedupe, prioritizing CSS selectors
|
|
346
|
-
// Filter invalid selectors before merging
|
|
347
|
-
const validDomSelectors = domInteractive
|
|
348
|
-
.map(e => e.selector)
|
|
349
|
-
.filter(s => isValidSelector(s));
|
|
350
|
-
|
|
351
|
-
const allSelectors = new Set([
|
|
352
|
-
...hoverSelectors,
|
|
353
|
-
...validDomSelectors
|
|
354
|
-
]);
|
|
355
|
-
|
|
356
|
-
// Limit to MAX_ELEMENTS
|
|
357
|
-
const combined = Array.from(allSelectors).slice(0, MAX_ELEMENTS);
|
|
358
|
-
|
|
359
|
-
return {
|
|
360
|
-
fromCss: Array.from(hoverSelectors),
|
|
361
|
-
fromDom: domInteractive,
|
|
362
|
-
combined
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// ============================================================================
|
|
367
|
-
// Hover State Capture
|
|
368
|
-
// ============================================================================
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Capture computed styles for an element.
|
|
372
|
-
*
|
|
373
|
-
* @param {import('puppeteer').Page} page - Puppeteer page
|
|
374
|
-
* @param {string} selector - CSS selector
|
|
375
|
-
* @returns {Promise<Object<string, string>|null>} Style object or null
|
|
376
|
-
*/
|
|
377
|
-
async function captureElementStyles(page, selector) {
|
|
378
|
-
return await page.evaluate((sel, props) => {
|
|
379
|
-
const el = document.querySelector(sel);
|
|
380
|
-
if (!el) return null;
|
|
381
|
-
|
|
382
|
-
const style = getComputedStyle(el);
|
|
383
|
-
const result = {};
|
|
384
|
-
for (const prop of props) {
|
|
385
|
-
result[prop] = style[prop];
|
|
386
|
-
}
|
|
387
|
-
return result;
|
|
388
|
-
}, selector, STYLE_PROPERTIES);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Capture hover state for a single element.
|
|
393
|
-
*
|
|
394
|
-
* @param {import('puppeteer').Page} page - Puppeteer page
|
|
395
|
-
* @param {string} selector - CSS selector for element
|
|
396
|
-
* @param {string} outputDir - Directory for screenshots
|
|
397
|
-
* @param {number} index - Element index for filename
|
|
398
|
-
* @returns {Promise<HoverCaptureResult>}
|
|
399
|
-
*/
|
|
400
|
-
export async function captureHoverState(page, selector, outputDir, index) {
|
|
401
|
-
const result = {
|
|
402
|
-
selector,
|
|
403
|
-
success: false,
|
|
404
|
-
normalScreenshot: null,
|
|
405
|
-
hoverScreenshot: null,
|
|
406
|
-
normalStyles: null,
|
|
407
|
-
hoverStyles: null,
|
|
408
|
-
styleDiff: {}
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
// Validate selector before attempting capture
|
|
412
|
-
if (!isValidSelector(selector)) {
|
|
413
|
-
result.error = 'Invalid selector';
|
|
414
|
-
return result;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
try {
|
|
418
|
-
// Find element
|
|
419
|
-
const element = await page.$(selector);
|
|
420
|
-
if (!element) {
|
|
421
|
-
result.error = 'Element not found';
|
|
422
|
-
return result;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Check visibility
|
|
426
|
-
const isVisible = await element.isVisible().catch(() => false);
|
|
427
|
-
if (!isVisible) {
|
|
428
|
-
result.error = 'Element not visible';
|
|
429
|
-
return result;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Get bounding box
|
|
433
|
-
const box = await element.boundingBox();
|
|
434
|
-
if (!box) {
|
|
435
|
-
result.error = 'No bounding box';
|
|
436
|
-
return result;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// Calculate clip area with padding
|
|
440
|
-
const clip = {
|
|
441
|
-
x: Math.max(0, box.x - SCREENSHOT_PADDING),
|
|
442
|
-
y: Math.max(0, box.y - SCREENSHOT_PADDING),
|
|
443
|
-
width: box.width + SCREENSHOT_PADDING * 2,
|
|
444
|
-
height: box.height + SCREENSHOT_PADDING * 2
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
// Capture normal state using helper
|
|
448
|
-
result.normalStyles = await captureElementStyles(page, selector);
|
|
449
|
-
const normalPath = path.join(outputDir, `hover-${index}-normal.png`);
|
|
450
|
-
await page.screenshot({ path: normalPath, clip });
|
|
451
|
-
result.normalScreenshot = normalPath;
|
|
452
|
-
|
|
453
|
-
// Hover and wait for transition
|
|
454
|
-
await page.hover(selector);
|
|
455
|
-
await new Promise(r => setTimeout(r, HOVER_SETTLE_DELAY));
|
|
456
|
-
|
|
457
|
-
// Capture hover state using same helper
|
|
458
|
-
result.hoverStyles = await captureElementStyles(page, selector);
|
|
459
|
-
const hoverPath = path.join(outputDir, `hover-${index}-hover.png`);
|
|
460
|
-
await page.screenshot({ path: hoverPath, clip });
|
|
461
|
-
result.hoverScreenshot = hoverPath;
|
|
462
|
-
|
|
463
|
-
// Compute style diff
|
|
464
|
-
if (result.normalStyles && result.hoverStyles) {
|
|
465
|
-
for (const [prop, normalVal] of Object.entries(result.normalStyles)) {
|
|
466
|
-
const hoverVal = result.hoverStyles[prop];
|
|
467
|
-
if (hoverVal !== normalVal) {
|
|
468
|
-
result.styleDiff[prop] = { from: normalVal, to: hoverVal };
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// Reset mouse position
|
|
474
|
-
await page.mouse.move(0, 0);
|
|
475
|
-
await new Promise(r => setTimeout(r, MOUSE_RESET_DELAY));
|
|
476
|
-
|
|
477
|
-
// Success if any style changed
|
|
478
|
-
result.success = Object.keys(result.styleDiff).length > 0;
|
|
479
|
-
|
|
480
|
-
} catch (e) {
|
|
481
|
-
result.error = e.message;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
return result;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// ============================================================================
|
|
488
|
-
// Batch Capture
|
|
489
|
-
// ============================================================================
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Capture all hover states for detected interactive elements.
|
|
493
|
-
*
|
|
494
|
-
* @param {import('puppeteer').Page} page - Puppeteer page
|
|
495
|
-
* @param {string|null} cssString - Raw CSS for detection
|
|
496
|
-
* @param {string} outputDir - Base output directory
|
|
497
|
-
* @returns {Promise<HoverCaptureOutput>}
|
|
498
|
-
*/
|
|
499
|
-
export async function captureAllHoverStates(page, cssString, outputDir) {
|
|
500
|
-
// Validate inputs
|
|
501
|
-
if (!page) {
|
|
502
|
-
throw new Error('Page parameter is required');
|
|
503
|
-
}
|
|
504
|
-
if (!outputDir || typeof outputDir !== 'string') {
|
|
505
|
-
throw new Error('Output directory parameter is required');
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// Create hover-states directory
|
|
509
|
-
const hoverDir = path.join(outputDir, 'hover-states');
|
|
510
|
-
await fs.mkdir(hoverDir, { recursive: true });
|
|
511
|
-
|
|
512
|
-
// Detect interactive elements
|
|
513
|
-
const interactive = await detectInteractiveElements(page, cssString);
|
|
514
|
-
const elements = [];
|
|
515
|
-
let capturedCount = 0;
|
|
516
|
-
|
|
517
|
-
// Capture each element
|
|
518
|
-
for (let i = 0; i < interactive.combined.length; i++) {
|
|
519
|
-
const selector = interactive.combined[i];
|
|
520
|
-
|
|
521
|
-
const result = await captureHoverState(page, selector, hoverDir, i);
|
|
522
|
-
elements.push(result);
|
|
523
|
-
|
|
524
|
-
if (result.success) {
|
|
525
|
-
capturedCount++;
|
|
526
|
-
log('info', `[hover] ${capturedCount}: ${selector}`);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// Write summary JSON
|
|
531
|
-
const summaryPath = path.join(hoverDir, 'hover-diff.json');
|
|
532
|
-
await fs.writeFile(summaryPath, JSON.stringify({
|
|
533
|
-
detected: interactive.combined.length,
|
|
534
|
-
captured: capturedCount,
|
|
535
|
-
fromCss: interactive.fromCss.length,
|
|
536
|
-
fromDom: interactive.fromDom.length,
|
|
537
|
-
elements: elements.filter(e => e.success).map(r => ({
|
|
538
|
-
selector: r.selector,
|
|
539
|
-
styleDiff: r.styleDiff,
|
|
540
|
-
normalScreenshot: r.normalScreenshot ? path.basename(r.normalScreenshot) : null,
|
|
541
|
-
hoverScreenshot: r.hoverScreenshot ? path.basename(r.hoverScreenshot) : null
|
|
542
|
-
}))
|
|
543
|
-
}, null, 2), 'utf-8');
|
|
544
|
-
|
|
545
|
-
return {
|
|
546
|
-
directory: hoverDir,
|
|
547
|
-
detected: interactive.combined.length,
|
|
548
|
-
captured: capturedCount,
|
|
549
|
-
summaryPath,
|
|
550
|
-
elements
|
|
551
|
-
};
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// ============================================================================
|
|
555
|
-
// CSS Generation
|
|
556
|
-
// ============================================================================
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Generate :hover CSS from captured style diffs.
|
|
560
|
-
*
|
|
561
|
-
* @param {HoverCaptureResult[]} results - Array of capture results
|
|
562
|
-
* @returns {string} Generated CSS string
|
|
563
|
-
*/
|
|
564
|
-
export function generateHoverCss(results) {
|
|
565
|
-
// Validate input
|
|
566
|
-
if (!results || !Array.isArray(results)) {
|
|
567
|
-
return '/* No hover style changes detected */\n';
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
const lines = [
|
|
571
|
-
'/**',
|
|
572
|
-
' * Generated :hover Styles',
|
|
573
|
-
' * Captured by design-clone state-capture',
|
|
574
|
-
' */\n'
|
|
575
|
-
];
|
|
576
|
-
|
|
577
|
-
const successfulResults = results.filter(r => r.success && Object.keys(r.styleDiff).length > 0);
|
|
578
|
-
|
|
579
|
-
if (successfulResults.length === 0) {
|
|
580
|
-
return '/* No hover style changes detected */\n';
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
for (const result of successfulResults) {
|
|
584
|
-
lines.push(`/* Element: ${result.selector} */`);
|
|
585
|
-
lines.push(`${result.selector}:hover {`);
|
|
586
|
-
|
|
587
|
-
for (const [prop, diff] of Object.entries(result.styleDiff)) {
|
|
588
|
-
const cssProp = toKebabCase(prop);
|
|
589
|
-
lines.push(` ${cssProp}: ${diff.to};`);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
lines.push('}\n');
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
return lines.join('\n');
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// ============================================================================
|
|
599
|
-
// Exports
|
|
600
|
-
// ============================================================================
|
|
601
|
-
|
|
602
|
-
export { extractHoverSelectorsFromCss, detectInteractiveElementsFromDom };
|