design-clone 1.2.0 → 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.
Files changed (66) hide show
  1. package/README.md +26 -12
  2. package/bin/commands/clone-site.js +75 -10
  3. package/bin/commands/init.js +33 -1
  4. package/bin/commands/verify.js +5 -3
  5. package/bin/utils/validate.js +24 -8
  6. package/docs/cli-reference.md +200 -2
  7. package/docs/codebase-summary.md +309 -0
  8. package/docs/design-clone-architecture.md +259 -42
  9. package/docs/pixel-perfect.md +35 -4
  10. package/docs/project-roadmap.md +382 -0
  11. package/docs/troubleshooting.md +5 -4
  12. package/package.json +10 -8
  13. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  14. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  15. package/src/ai/analyze-structure.py +73 -3
  16. package/src/ai/extract-design-tokens.py +356 -13
  17. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  18. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  19. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  20. package/src/ai/prompts/design_tokens.py +133 -0
  21. package/src/ai/prompts/structure_analysis.py +329 -10
  22. package/src/ai/prompts/ux_audit.py +198 -0
  23. package/src/ai/ux-audit.js +596 -0
  24. package/src/core/app-state-snapshot.js +511 -0
  25. package/src/core/content-counter.js +342 -0
  26. package/src/core/cookie-handler.js +1 -1
  27. package/src/core/css-extractor.js +4 -4
  28. package/src/core/dimension-extractor.js +93 -21
  29. package/src/core/dimension-output.js +103 -6
  30. package/src/core/discover-pages.js +242 -14
  31. package/src/core/dom-tree-analyzer.js +298 -0
  32. package/src/core/extract-assets.js +1 -1
  33. package/src/core/framework-detector.js +538 -0
  34. package/src/core/html-extractor.js +45 -4
  35. package/src/core/lazy-loader.js +7 -7
  36. package/src/core/multi-page-screenshot.js +9 -6
  37. package/src/core/page-readiness.js +8 -8
  38. package/src/core/screenshot.js +138 -9
  39. package/src/core/section-cropper.js +209 -0
  40. package/src/core/section-detector.js +386 -0
  41. package/src/core/semantic-enhancer.js +492 -0
  42. package/src/core/state-capture.js +18 -22
  43. package/src/core/tests/test-section-cropper.js +177 -0
  44. package/src/core/tests/test-section-detector.js +55 -0
  45. package/src/core/video-capture.js +152 -146
  46. package/src/route-discoverers/angular-discoverer.js +157 -0
  47. package/src/route-discoverers/astro-discoverer.js +123 -0
  48. package/src/route-discoverers/base-discoverer.js +242 -0
  49. package/src/route-discoverers/index.js +106 -0
  50. package/src/route-discoverers/next-discoverer.js +130 -0
  51. package/src/route-discoverers/nuxt-discoverer.js +138 -0
  52. package/src/route-discoverers/react-discoverer.js +139 -0
  53. package/src/route-discoverers/svelte-discoverer.js +109 -0
  54. package/src/route-discoverers/universal-discoverer.js +227 -0
  55. package/src/route-discoverers/vue-discoverer.js +118 -0
  56. package/src/utils/__init__.py +1 -1
  57. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  58. package/src/utils/browser.js +11 -37
  59. package/src/utils/playwright.js +213 -0
  60. package/src/verification/generate-audit-report.js +398 -0
  61. package/src/verification/verify-footer.js +493 -0
  62. package/src/verification/verify-header.js +486 -0
  63. package/src/verification/verify-layout.js +2 -2
  64. package/src/verification/verify-menu.js +4 -20
  65. package/src/verification/verify-slider.js +533 -0
  66. package/src/utils/puppeteer.js +0 -281
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Semantic HTML Enhancer
3
+ *
4
+ * Injects WordPress-compatible semantic IDs, classes, and ARIA roles
5
+ * into extracted HTML while preserving original styling.
6
+ *
7
+ * Key features:
8
+ * - Detects sections via semantic tags, ARIA roles, class patterns
9
+ * - Adds IDs only if none exist
10
+ * - Appends classes (never replaces)
11
+ * - Sets roles only if not present
12
+ * - Handles multiple navs with aria-labels
13
+ */
14
+
15
+ /**
16
+ * WordPress-compatible semantic mappings
17
+ */
18
+ export const SEMANTIC_MAPPINGS = {
19
+ header: {
20
+ id: 'site-header',
21
+ classes: ['site-header'],
22
+ role: 'banner'
23
+ },
24
+ nav: {
25
+ id: 'site-navigation',
26
+ classes: ['main-navigation', 'nav-menu'],
27
+ role: 'navigation'
28
+ },
29
+ main: {
30
+ id: 'main-content',
31
+ classes: ['site-main', 'content-area'],
32
+ role: 'main'
33
+ },
34
+ sidebar: {
35
+ id: 'primary-sidebar',
36
+ classes: ['widget-area', 'sidebar'],
37
+ role: 'complementary'
38
+ },
39
+ footer: {
40
+ id: 'site-footer',
41
+ classes: ['site-footer'],
42
+ role: 'contentinfo'
43
+ },
44
+ hero: {
45
+ id: 'hero-section',
46
+ classes: ['hero'],
47
+ role: null // No ARIA landmark role for hero
48
+ }
49
+ };
50
+
51
+ /**
52
+ * Class patterns for section detection (case-insensitive)
53
+ */
54
+ const CLASS_PATTERNS = {
55
+ header: ['header', 'masthead', 'site-header', 'page-header'],
56
+ nav: ['nav', 'menu', 'navigation'],
57
+ main: ['main', 'content', 'page-content'],
58
+ sidebar: ['sidebar', 'aside', 'widget-area'],
59
+ footer: ['footer', 'site-footer', 'page-footer'],
60
+ hero: ['hero', 'banner', 'jumbotron', 'splash']
61
+ };
62
+
63
+ /**
64
+ * Detect section type from element
65
+ *
66
+ * Priority:
67
+ * 1. Semantic HTML tags
68
+ * 2. ARIA role attributes
69
+ * 3. Class pattern matching
70
+ *
71
+ * @param {Element} element - DOM element to analyze
72
+ * @returns {string|null} Section type or null
73
+ */
74
+ export function detectSectionType(element) {
75
+ const tag = element.tagName?.toLowerCase();
76
+ const ariaRole = element.getAttribute?.('role');
77
+
78
+ // Priority 1: Semantic HTML tags
79
+ if (tag === 'header') return 'header';
80
+ if (tag === 'nav') return 'nav';
81
+ if (tag === 'main') return 'main';
82
+ if (tag === 'aside') return 'sidebar';
83
+ if (tag === 'footer') return 'footer';
84
+
85
+ // Priority 2: ARIA roles
86
+ if (ariaRole === 'banner') return 'header';
87
+ if (ariaRole === 'navigation') return 'nav';
88
+ if (ariaRole === 'main') return 'main';
89
+ if (ariaRole === 'complementary') return 'sidebar';
90
+ if (ariaRole === 'contentinfo') return 'footer';
91
+
92
+ // Priority 3: Class patterns
93
+ const className = (element.className || '').toString().toLowerCase();
94
+ if (!className) return null;
95
+
96
+ for (const [sectionType, patterns] of Object.entries(CLASS_PATTERNS)) {
97
+ if (patterns.some(pattern => className.includes(pattern))) {
98
+ // Avoid false positives: ensure it's a container element
99
+ if (tag === 'div' || tag === 'section' || tag === 'article') {
100
+ return sectionType;
101
+ }
102
+ }
103
+ }
104
+
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Apply semantic attributes to element
110
+ *
111
+ * Rules:
112
+ * - Add ID only if none exists
113
+ * - Append classes (preserve existing)
114
+ * - Set role only if none exists
115
+ *
116
+ * @param {Element} element - DOM element to enhance
117
+ * @param {string} sectionType - Type from SEMANTIC_MAPPINGS
118
+ * @param {Object} options - Configuration options
119
+ * @param {Set} options.usedIds - Track used IDs to avoid duplicates
120
+ * @param {number} options.navIndex - Index for multiple nav labeling
121
+ */
122
+ export function applySemanticAttributes(element, sectionType, options = {}) {
123
+ const mapping = SEMANTIC_MAPPINGS[sectionType];
124
+ if (!mapping) return;
125
+
126
+ const { usedIds = new Set(), navIndex = 0 } = options;
127
+
128
+ // Add ID only if not present and not already used
129
+ if (!element.id && mapping.id) {
130
+ let targetId = mapping.id;
131
+
132
+ // Handle multiple instances (e.g., footer-navigation for secondary nav)
133
+ if (usedIds.has(targetId)) {
134
+ targetId = `${mapping.id}-${navIndex + 1}`;
135
+ }
136
+
137
+ if (!usedIds.has(targetId)) {
138
+ element.id = targetId;
139
+ usedIds.add(targetId);
140
+ }
141
+ }
142
+
143
+ // Append classes (preserve existing)
144
+ if (mapping.classes && mapping.classes.length > 0) {
145
+ const existingClasses = element.className
146
+ ? element.className.toString().split(/\s+/).filter(Boolean)
147
+ : [];
148
+ const newClasses = mapping.classes.filter(c => !existingClasses.includes(c));
149
+
150
+ if (newClasses.length > 0) {
151
+ element.className = [...existingClasses, ...newClasses].join(' ').trim();
152
+ }
153
+ }
154
+
155
+ // Set role only if not present
156
+ if (mapping.role && !element.getAttribute('role')) {
157
+ element.setAttribute('role', mapping.role);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Handle multiple navigation elements with proper labeling
163
+ *
164
+ * @param {NodeList|Array} navElements - All nav elements
165
+ * @param {Set} usedIds - Track used IDs
166
+ */
167
+ export function handleMultipleNavs(navElements, usedIds = new Set()) {
168
+ const navs = Array.from(navElements);
169
+ if (navs.length === 0) return;
170
+
171
+ navs.forEach((nav, index) => {
172
+ const isInHeader = nav.closest?.('header') !== null;
173
+ const isInFooter = nav.closest?.('footer') !== null;
174
+
175
+ if (isInHeader && index === 0) {
176
+ // Primary navigation in header
177
+ applySemanticAttributes(nav, 'nav', { usedIds, navIndex: 0 });
178
+ if (!nav.getAttribute('aria-label')) {
179
+ nav.setAttribute('aria-label', 'Primary Menu');
180
+ }
181
+ } else if (isInFooter) {
182
+ // Footer navigation
183
+ if (!nav.id) {
184
+ nav.id = usedIds.has('footer-navigation')
185
+ ? `footer-navigation-${index}`
186
+ : 'footer-navigation';
187
+ usedIds.add(nav.id);
188
+ }
189
+ nav.setAttribute('role', 'navigation');
190
+ if (!nav.getAttribute('aria-label')) {
191
+ nav.setAttribute('aria-label', 'Footer Menu');
192
+ }
193
+ } else {
194
+ // Secondary/other navigation
195
+ applySemanticAttributes(nav, 'nav', { usedIds, navIndex: index });
196
+ if (!nav.getAttribute('aria-label')) {
197
+ nav.setAttribute('aria-label', `Navigation ${index + 1}`);
198
+ }
199
+ }
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Enhance HTML string with semantic attributes
205
+ *
206
+ * **IMPORTANT:** This function requires browser context (uses DOMParser).
207
+ * For Node.js/Playwright, use `enhanceSemanticHTMLInPage()` instead.
208
+ *
209
+ * @param {string} html - Original HTML string (must be valid HTML)
210
+ * @param {Object} [domHierarchy=null] - Optional DOM hierarchy from dom-tree-analyzer
211
+ * @returns {{html: string, stats: Object}} Enhanced HTML and stats
212
+ * @throws {Error} If html is empty or DOMParser is unavailable
213
+ *
214
+ * @example
215
+ * // In browser context:
216
+ * const result = enhanceSemanticHTML(htmlString);
217
+ * console.log(result.stats.sectionsEnhanced);
218
+ */
219
+ export function enhanceSemanticHTML(html, domHierarchy = null) {
220
+ // Validate input
221
+ if (!html || typeof html !== 'string') {
222
+ throw new Error('enhanceSemanticHTML requires a valid HTML string');
223
+ }
224
+
225
+ // Check for browser context
226
+ if (typeof DOMParser === 'undefined') {
227
+ throw new Error('enhanceSemanticHTML requires browser context (DOMParser). Use enhanceSemanticHTMLInPage() for Playwright.');
228
+ }
229
+
230
+ const stats = {
231
+ sectionsEnhanced: 0,
232
+ idsAdded: 0,
233
+ classesAdded: 0,
234
+ rolesAdded: 0,
235
+ warnings: []
236
+ };
237
+
238
+ // Parse HTML
239
+ const parser = new DOMParser();
240
+ const doc = parser.parseFromString(html, 'text/html');
241
+
242
+ const usedIds = new Set();
243
+
244
+ // Collect existing IDs to avoid duplicates
245
+ doc.querySelectorAll('[id]').forEach(el => {
246
+ usedIds.add(el.id);
247
+ });
248
+
249
+ // Optimized: Combined landmark selector (reduces querySelectorAll calls from 8 to 1)
250
+ const combinedLandmarkSelector = [
251
+ 'header:not(header header)', // Top-level headers only
252
+ 'footer:not(footer footer)', // Top-level footers only
253
+ 'main',
254
+ 'aside',
255
+ '[role="banner"]',
256
+ '[role="contentinfo"]',
257
+ '[role="main"]',
258
+ '[role="complementary"]'
259
+ ].join(', ');
260
+
261
+ const processedElements = new Set();
262
+
263
+ try {
264
+ doc.querySelectorAll(combinedLandmarkSelector).forEach(el => {
265
+ // Skip if already processed (avoid double-counting from overlapping selectors)
266
+ if (processedElements.has(el)) return;
267
+ processedElements.add(el);
268
+
269
+ const sectionType = detectSectionType(el);
270
+ if (sectionType) {
271
+ const hadId = !!el.id;
272
+ const hadRole = !!el.getAttribute('role');
273
+ const oldClasses = el.className;
274
+
275
+ applySemanticAttributes(el, sectionType, { usedIds });
276
+
277
+ if (!hadId && el.id) stats.idsAdded++;
278
+ if (!hadRole && el.getAttribute('role')) stats.rolesAdded++;
279
+ if (oldClasses !== el.className) stats.classesAdded++;
280
+ stats.sectionsEnhanced++;
281
+ }
282
+ });
283
+ } catch (err) {
284
+ stats.warnings.push(`Landmark selector error: ${err.message}`);
285
+ }
286
+
287
+ // Handle nav elements specially (multiple navs need labeling)
288
+ // Count only navs not already processed
289
+ const navElements = doc.querySelectorAll('nav, [role="navigation"]');
290
+ let newNavCount = 0;
291
+ navElements.forEach(nav => {
292
+ if (!processedElements.has(nav)) {
293
+ processedElements.add(nav);
294
+ newNavCount++;
295
+ }
296
+ });
297
+ if (navElements.length > 0) {
298
+ handleMultipleNavs(navElements, usedIds);
299
+ stats.sectionsEnhanced += newNavCount;
300
+ }
301
+
302
+ // Detect hero sections via class patterns
303
+ const heroSelectors = [
304
+ '.hero', '.banner', '.jumbotron', '.splash',
305
+ '[class*="hero"]', '[class*="banner"]'
306
+ ];
307
+ heroSelectors.forEach(selector => {
308
+ try {
309
+ doc.querySelectorAll(selector).forEach(el => {
310
+ // Only top-level hero elements
311
+ if (!el.closest('header') && !el.closest('footer')) {
312
+ const hadId = !!el.id;
313
+ applySemanticAttributes(el, 'hero', { usedIds });
314
+ if (!hadId && el.id) stats.idsAdded++;
315
+ stats.sectionsEnhanced++;
316
+ }
317
+ });
318
+ } catch (err) {
319
+ // Some selectors may not be valid in all contexts
320
+ }
321
+ });
322
+
323
+ // Serialize back to HTML
324
+ const enhancedHtml = '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;
325
+
326
+ return { html: enhancedHtml, stats };
327
+ }
328
+
329
+ /**
330
+ * Enhance HTML using page.evaluate (for Playwright integration)
331
+ *
332
+ * This is the recommended method for Node.js/Playwright usage.
333
+ *
334
+ * @param {import('playwright').Page} page - Playwright page
335
+ * @param {string} html - Original HTML string (must be valid HTML)
336
+ * @returns {Promise<{html: string, stats: Object}>}
337
+ * @throws {Error} If page is null or html is invalid
338
+ *
339
+ * @example
340
+ * const result = await enhanceSemanticHTMLInPage(page, extractedHtml);
341
+ * console.log(result.stats.sectionsEnhanced);
342
+ */
343
+ export async function enhanceSemanticHTMLInPage(page, html) {
344
+ // Validate inputs
345
+ if (!page || typeof page.evaluate !== 'function') {
346
+ throw new Error('enhanceSemanticHTMLInPage requires a valid Playwright page');
347
+ }
348
+ if (!html || typeof html !== 'string') {
349
+ throw new Error('enhanceSemanticHTMLInPage requires a valid HTML string');
350
+ }
351
+
352
+ return await page.evaluate((htmlStr) => {
353
+ // Re-define functions inside evaluate context
354
+ const SEMANTIC_MAPPINGS = {
355
+ header: { id: 'site-header', classes: ['site-header'], role: 'banner' },
356
+ nav: { id: 'site-navigation', classes: ['main-navigation', 'nav-menu'], role: 'navigation' },
357
+ main: { id: 'main-content', classes: ['site-main', 'content-area'], role: 'main' },
358
+ sidebar: { id: 'primary-sidebar', classes: ['widget-area', 'sidebar'], role: 'complementary' },
359
+ footer: { id: 'site-footer', classes: ['site-footer'], role: 'contentinfo' },
360
+ hero: { id: 'hero-section', classes: ['hero'], role: null }
361
+ };
362
+
363
+ const CLASS_PATTERNS = {
364
+ header: ['header', 'masthead', 'site-header', 'page-header'],
365
+ nav: ['nav', 'menu', 'navigation'],
366
+ sidebar: ['sidebar', 'aside', 'widget-area'],
367
+ footer: ['footer', 'site-footer', 'page-footer'],
368
+ hero: ['hero', 'banner', 'jumbotron', 'splash']
369
+ };
370
+
371
+ function detectSectionType(element) {
372
+ const tag = element.tagName?.toLowerCase();
373
+ const ariaRole = element.getAttribute?.('role');
374
+
375
+ if (tag === 'header') return 'header';
376
+ if (tag === 'nav') return 'nav';
377
+ if (tag === 'main') return 'main';
378
+ if (tag === 'aside') return 'sidebar';
379
+ if (tag === 'footer') return 'footer';
380
+
381
+ if (ariaRole === 'banner') return 'header';
382
+ if (ariaRole === 'navigation') return 'nav';
383
+ if (ariaRole === 'main') return 'main';
384
+ if (ariaRole === 'complementary') return 'sidebar';
385
+ if (ariaRole === 'contentinfo') return 'footer';
386
+
387
+ const className = (element.className || '').toString().toLowerCase();
388
+ if (!className) return null;
389
+
390
+ for (const [sectionType, patterns] of Object.entries(CLASS_PATTERNS)) {
391
+ if (patterns.some(pattern => className.includes(pattern))) {
392
+ if (['div', 'section', 'article'].includes(tag)) {
393
+ return sectionType;
394
+ }
395
+ }
396
+ }
397
+
398
+ return null;
399
+ }
400
+
401
+ function applySemanticAttributes(element, sectionType, usedIds, navIndex = 0) {
402
+ const mapping = SEMANTIC_MAPPINGS[sectionType];
403
+ if (!mapping) return;
404
+
405
+ if (!element.id && mapping.id) {
406
+ let targetId = mapping.id;
407
+ if (usedIds.has(targetId)) {
408
+ targetId = `${mapping.id}-${navIndex + 1}`;
409
+ }
410
+ if (!usedIds.has(targetId)) {
411
+ element.id = targetId;
412
+ usedIds.add(targetId);
413
+ }
414
+ }
415
+
416
+ if (mapping.classes && mapping.classes.length > 0) {
417
+ const existingClasses = element.className
418
+ ? element.className.toString().split(/\s+/).filter(Boolean)
419
+ : [];
420
+ const newClasses = mapping.classes.filter(c => !existingClasses.includes(c));
421
+ if (newClasses.length > 0) {
422
+ element.className = [...existingClasses, ...newClasses].join(' ').trim();
423
+ }
424
+ }
425
+
426
+ if (mapping.role && !element.getAttribute('role')) {
427
+ element.setAttribute('role', mapping.role);
428
+ }
429
+ }
430
+
431
+ const stats = { sectionsEnhanced: 0, idsAdded: 0, classesAdded: 0, rolesAdded: 0, warnings: [] };
432
+
433
+ const parser = new DOMParser();
434
+ const doc = parser.parseFromString(htmlStr, 'text/html');
435
+
436
+ const usedIds = new Set();
437
+ doc.querySelectorAll('[id]').forEach(el => usedIds.add(el.id));
438
+
439
+ // Process landmarks
440
+ ['header:not(header header)', 'footer:not(footer footer)', 'main', 'aside'].forEach(selector => {
441
+ try {
442
+ doc.querySelectorAll(selector).forEach(el => {
443
+ const sectionType = detectSectionType(el);
444
+ if (sectionType) {
445
+ const hadId = !!el.id;
446
+ const hadRole = !!el.getAttribute('role');
447
+ applySemanticAttributes(el, sectionType, usedIds);
448
+ if (!hadId && el.id) stats.idsAdded++;
449
+ if (!hadRole && el.getAttribute('role')) stats.rolesAdded++;
450
+ stats.sectionsEnhanced++;
451
+ }
452
+ });
453
+ } catch (err) {
454
+ stats.warnings.push(`Selector error: ${selector}`);
455
+ }
456
+ });
457
+
458
+ // Handle nav elements
459
+ const navElements = doc.querySelectorAll('nav, [role="navigation"]');
460
+ navElements.forEach((nav, index) => {
461
+ const isInHeader = nav.closest('header') !== null;
462
+ const isInFooter = nav.closest('footer') !== null;
463
+
464
+ if (isInHeader && index === 0) {
465
+ applySemanticAttributes(nav, 'nav', usedIds, 0);
466
+ if (!nav.getAttribute('aria-label')) {
467
+ nav.setAttribute('aria-label', 'Primary Menu');
468
+ }
469
+ } else if (isInFooter) {
470
+ if (!nav.id) {
471
+ nav.id = usedIds.has('footer-navigation') ? `footer-navigation-${index}` : 'footer-navigation';
472
+ usedIds.add(nav.id);
473
+ }
474
+ nav.setAttribute('role', 'navigation');
475
+ if (!nav.getAttribute('aria-label')) {
476
+ nav.setAttribute('aria-label', 'Footer Menu');
477
+ }
478
+ } else {
479
+ applySemanticAttributes(nav, 'nav', usedIds, index);
480
+ if (!nav.getAttribute('aria-label')) {
481
+ nav.setAttribute('aria-label', `Navigation ${index + 1}`);
482
+ }
483
+ }
484
+ stats.sectionsEnhanced++;
485
+ });
486
+
487
+ return {
488
+ html: '<!DOCTYPE html>\n' + doc.documentElement.outerHTML,
489
+ stats
490
+ };
491
+ }, html);
492
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * State Capture Module
3
3
  *
4
- * Capture hover states for interactive elements using Puppeteer.
4
+ * Capture hover states for interactive elements using Playwright.
5
5
  * Screenshots before/after, computes style differences, generates :hover CSS.
6
6
  *
7
7
  * Usage:
@@ -220,11 +220,11 @@ function extractHoverSelectorsFromCss(cssString) {
220
220
  * Detect interactive elements on page via DOM query.
221
221
  * Uses inline function to avoid new Function() for CSP compliance.
222
222
  *
223
- * @param {import('puppeteer').Page} page - Puppeteer page
223
+ * @param {import('playwright').Page} page - Playwright page
224
224
  * @returns {Promise<InteractiveElement[]>} Array of interactive elements
225
225
  */
226
226
  async function detectInteractiveElementsFromDom(page) {
227
- return await page.evaluate((selectors, maxScan, maxDepth) => {
227
+ return await page.evaluate(({ selectors, maxScan, maxDepth }) => {
228
228
  // Inline getUniqueSelector to avoid new Function()
229
229
  function getUniqueSelector(element) {
230
230
  if (element.id) return '#' + element.id;
@@ -316,7 +316,7 @@ async function detectInteractiveElementsFromDom(page) {
316
316
  }
317
317
 
318
318
  return results;
319
- }, INTERACTIVE_SELECTORS, MAX_DOM_SCAN, MAX_SELECTOR_DEPTH);
319
+ }, { selectors: INTERACTIVE_SELECTORS, maxScan: MAX_DOM_SCAN, maxDepth: MAX_SELECTOR_DEPTH });
320
320
  }
321
321
 
322
322
  // ============================================================================
@@ -326,7 +326,7 @@ async function detectInteractiveElementsFromDom(page) {
326
326
  /**
327
327
  * Detect interactive elements using CSS + DOM analysis.
328
328
  *
329
- * @param {import('puppeteer').Page} page - Puppeteer page
329
+ * @param {import('playwright').Page} page - Playwright page
330
330
  * @param {string|null} cssString - Raw CSS for :hover detection
331
331
  * @returns {Promise<{fromCss: string[], fromDom: InteractiveElement[], combined: string[]}>}
332
332
  */
@@ -370,12 +370,12 @@ export async function detectInteractiveElements(page, cssString) {
370
370
  /**
371
371
  * Capture computed styles for an element.
372
372
  *
373
- * @param {import('puppeteer').Page} page - Puppeteer page
373
+ * @param {import('playwright').Page} page - Playwright page
374
374
  * @param {string} selector - CSS selector
375
375
  * @returns {Promise<Object<string, string>|null>} Style object or null
376
376
  */
377
377
  async function captureElementStyles(page, selector) {
378
- return await page.evaluate((sel, props) => {
378
+ return await page.evaluate(({ sel, props }) => {
379
379
  const el = document.querySelector(sel);
380
380
  if (!el) return null;
381
381
 
@@ -385,13 +385,13 @@ async function captureElementStyles(page, selector) {
385
385
  result[prop] = style[prop];
386
386
  }
387
387
  return result;
388
- }, selector, STYLE_PROPERTIES);
388
+ }, { sel: selector, props: STYLE_PROPERTIES });
389
389
  }
390
390
 
391
391
  /**
392
392
  * Capture hover state for a single element.
393
393
  *
394
- * @param {import('puppeteer').Page} page - Puppeteer page
394
+ * @param {import('playwright').Page} page - Playwright page
395
395
  * @param {string} selector - CSS selector for element
396
396
  * @param {string} outputDir - Directory for screenshots
397
397
  * @param {number} index - Element index for filename
@@ -415,22 +415,18 @@ export async function captureHoverState(page, selector, outputDir, index) {
415
415
  }
416
416
 
417
417
  try {
418
- // Find element
419
- const element = await page.$(selector);
420
- if (!element) {
421
- result.error = 'Element not found';
422
- return result;
423
- }
418
+ // Use Playwright locator API for reliability
419
+ const locator = page.locator(selector);
424
420
 
425
- // Check visibility
426
- const isVisible = await element.isVisible().catch(() => false);
421
+ // Check visibility via locator
422
+ const isVisible = await locator.isVisible().catch(() => false);
427
423
  if (!isVisible) {
428
424
  result.error = 'Element not visible';
429
425
  return result;
430
426
  }
431
427
 
432
- // Get bounding box
433
- const box = await element.boundingBox();
428
+ // Get bounding box via locator
429
+ const box = await locator.boundingBox();
434
430
  if (!box) {
435
431
  result.error = 'No bounding box';
436
432
  return result;
@@ -450,8 +446,8 @@ export async function captureHoverState(page, selector, outputDir, index) {
450
446
  await page.screenshot({ path: normalPath, clip });
451
447
  result.normalScreenshot = normalPath;
452
448
 
453
- // Hover and wait for transition
454
- await page.hover(selector);
449
+ // Hover via locator (more reliable in Playwright)
450
+ await locator.hover();
455
451
  await new Promise(r => setTimeout(r, HOVER_SETTLE_DELAY));
456
452
 
457
453
  // Capture hover state using same helper
@@ -491,7 +487,7 @@ export async function captureHoverState(page, selector, outputDir, index) {
491
487
  /**
492
488
  * Capture all hover states for detected interactive elements.
493
489
  *
494
- * @param {import('puppeteer').Page} page - Puppeteer page
490
+ * @param {import('playwright').Page} page - Playwright page
495
491
  * @param {string|null} cssString - Raw CSS for detection
496
492
  * @param {string} outputDir - Base output directory
497
493
  * @returns {Promise<HoverCaptureOutput>}