Setting the file. One moment.
Extract Interactions · Wix Headless Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 28.10
Workflow
This file
Number 28.20
Position 20 of 89
Type JavaScript
Size 83 KB
Lines 1,903 scripts/ extract-interactions.mjs
JavaScript · 1,903 lines · 83 KB
from
"./lib/html-extract.mjs"
;
14 import { loadPlaywrightFromContext, resolveBrowserToolingContext } from "./lib/browser-tooling.mjs" ;
15 import {
16 DEFAULT_INTERACTION_TIMELINE_MS,
17 compactInteractionTimeline,
18 deriveCarouselInvariants,
19 deriveScrollInvariants,
20 } from "./lib/interaction-timeline.mjs" ;
21
22 const GENERIC_SELECTORS = new Set ([ "*" , "body" , "html" , ":root" , "main" ]);
23 const CSS_SIGNAL_PROPS = [
24 "transition" ,
25 "transition-property" ,
26 "transition-duration" ,
27 "animation" ,
28 "animation-name" ,
29 "animation-duration" ,
30 "transform" ,
31 "opacity" ,
32 "filter" ,
33 "will-change" ,
34 "scroll-behavior" ,
35 ];
36 const SNAPSHOT_STYLE_KEYS = [
37 "opacity" ,
38 "transform" ,
39 "backgroundColor" ,
40 "color" ,
41 "boxShadow" ,
42 "filter" ,
43 "width" ,
44 "height" ,
45 "borderColor" ,
46 "borderTopColor" ,
47 "borderRightColor" ,
48 "borderBottomColor" ,
49 "borderLeftColor" ,
50 "borderTopWidth" ,
51 "borderRightWidth" ,
52 "borderBottomWidth" ,
53 "borderLeftWidth" ,
54 "textDecorationLine" ,
55 "textDecorationColor" ,
56 "textDecorationThickness" ,
57 "textUnderlineOffset" ,
58 "outlineColor" ,
59 "outlineStyle" ,
60 "outlineWidth" ,
61 "outlineOffset" ,
62 "fill" ,
63 "stroke" ,
64 "translate" ,
65 "scale" ,
66 "cursor" ,
67 "transitionProperty" ,
68 "transitionDuration" ,
69 "transitionTimingFunction" ,
70 ];
71
72 async function main () {
73 const args = parseArgs ();
74 const sourceUrl = normalizeUrl (args._[ 0 ] || args.url). toString ();
75 const outputDir = resolveOutputDir (sourceUrl, args.out);
76 const docs = docsDir (outputDir);
77 const pagesDir = path. join (docs, "pages" );
78 const pages = [];
79 if (args.page) {
80 pages. push ( await readJson (path. resolve (args.page)));
81 } else {
82 try {
83 const defaultPage = path. join (pagesDir, "home-home.json" );
84 pages. push ( await readJson (defaultPage));
85 } catch {
86 // Page records are optional when running the extractor standalone.
87 }
88 }
89 const interactionMap = await extractInteractions (sourceUrl, {
90 outputDir,
91 pages,
92 browserTooling: args[ "project-root" ]
93 ? await resolveBrowserToolingContext ({ startDir: path. resolve (args[ "project-root" ]) })
94 : undefined ,
95 });
96 await writeJson (path. join (docs, "interaction-map.json" ), interactionMap);
97 if (pages. length ) {
98 for ( const page of interactionMap.pages || []) {
99 await writeJson (path. join (pagesDir, `${ safePageName ( page ) }.json` ), page);
100 }
101 }
102 if (args.json) process.stdout. write ( `${ JSON . stringify ( interactionMap , null , 2 ) } \n ` );
103 }
104
105 export async function extractInteractions ( sourceUrl , { outputDir , pages = [], browserTooling } = {}) {
106 const url = normalizeUrl (sourceUrl). toString ();
107 await writeProgress (outputDir, "fetching_html" );
108 const html = await fetchText (url);
109 await writeProgress (outputDir, "loading_stylesheets" );
110 const stylesheets = await loadStylesheets (html, url);
111 await writeProgress (outputDir, "building_static_inventory" );
112 const inlineStyles = extractInlineStyles (html, url);
113 const staticInventory = buildStaticInventory ({ stylesheets, inlineStyles });
114 await writeProgress (outputDir, "starting_dynamic_probe" , {
115 candidateCount: staticInventory.candidates. length ,
116 });
117 const runtime = await runDynamicProbe ({
118 sourceUrl: url,
119 outputDir,
120 staticInventory,
121 browserTooling,
122 });
123 await writeProgress (outputDir, "assembling_interaction_map" , {
124 captureCount: runtime.captures. length ,
125 });
126 const interactions = [ ... runtime.captures, ... (runtime.specializedCaptures || [])]. map (( capture , index ) => ({
127 id: capture.id || `interaction-${ String ( index + 1 ). padStart ( 3 , "0" ) }` ,
128 kind: classifyCapture (capture),
129 trigger: normalizeTrigger (capture.trigger),
130 importance: classifyImportance (capture),
131 sources: capture.sources || [],
132 changedProperties: (capture.diff?.changedProperties || []). map (( item ) => item.property),
133 textChanged: Boolean (capture.diff?.textChanged),
134 states: summarizeStates (capture),
135 implementationHint: implementationHintFor (capture),
136 evidence: capture.screenshot ? { screenshot: capture.screenshot } : {},
137 label: capture.label || "" ,
138 ... (capture.probeId ? { controlId: capture.probeId } : {}),
139 ... (capture.controlRole ? { controlRole: capture.controlRole } : {}),
140 ... (capture.controlScope ? { controlScope: capture.controlScope } : {}),
141 ... (capture.scope ? { scope: capture.scope } : {}),
142 ... (capture.timeline ? { timeline: capture.timeline } : {}),
143 ... (capture.invariants ? { invariants: capture.invariants } : {}),
144 }));
145 const enrichedPages = pages. map (( page ) => enrichPageRecord (page, { generatedAt: new Date (). toISOString (), staticInventory, runtime }));
146 bindInteractionsToSections (interactions, enrichedPages);
147 const summaryKinds = interactions. reduce (( acc , item ) => {
148 acc[item.kind] = (acc[item.kind] || 0 ) + 1 ;
149 return acc;
150 }, {});
151 return {
152 sourceUrl: url,
153 generatedAt: new Date (). toISOString (),
154 staticSummary: staticInventory.summary,
155 summary: {
156 interactionCount: interactions. length ,
157 kinds: summaryKinds,
158 targetCount: runtime.summary.targetCount,
159 meaningfulCaptureCount: runtime.summary.meaningfulCaptureCount,
160 },
161 interactions,
162 structural: runtime.structural,
163 probeDiagnostics: runtime.probeDiagnostics || [],
164 pages: enrichedPages,
165 };
166 }
167
168 async function writeProgress ( outputDir , stage , details = {}) {
169 if ( ! outputDir) return ;
170 await writeJson (path. join ( docsDir (outputDir), "interaction-progress.json" ), {
171 stage,
172 updatedAt: new Date (). toISOString (),
173 ... details,
174 });
175 }
176
177 async function loadStylesheets ( html , sourceUrl ) {
178 const urls = extractStylesheetUrls (html, sourceUrl);
179 const stylesheets = [];
180 for ( const stylesheetUrl of urls. slice ( 0 , 24 )) {
181 try {
182 const cssText = await fetchText (stylesheetUrl, {
183 headers: { accept: "text/css,*/*;q=0.1" },
184 });
185 stylesheets. push ({ url: stylesheetUrl, cssText, sourceType: "linked-stylesheet" });
186 } catch (error) {
187 stylesheets. push ({ url: stylesheetUrl, cssText: "" , sourceType: "linked-stylesheet" , warning: error.message });
188 }
189 }
190 return stylesheets;
191 }
192
193 function extractInlineStyles ( html , sourceUrl ) {
194 const out = [];
195 const pattern = /<style \b [ ^ >] * >( [\s\S] *? )< \/ style>/ gi ;
196 let match;
197 let index = 0 ;
198 while ((match = pattern. exec (html))) {
199 const cssText = String (match[ 1 ] || "" ). trim ();
200 if ( ! cssText) continue ;
201 index += 1 ;
202 out. push ({
203 url: `${ sourceUrl }#inline-style-${ index }` ,
204 cssText,
205 sourceType: "inline-style" ,
206 });
207 }
208 return out;
209 }
210
211 function buildStaticInventory ({ stylesheets , inlineStyles }) {
212 const sources = [ ... stylesheets, ... inlineStyles];
213 const keyframes = [];
214 const rules = [];
215 const candidateMap = new Map ();
216 for ( const source of sources) {
217 const cssText = source.cssText || "" ;
218 if ( ! cssText) continue ;
219 for ( const match of cssText. matchAll ( /@keyframes \s + ( [ ^ {\s] + ) \s * \{ / g )) {
220 keyframes. push ({ name: match[ 1 ], sourceUrl: source.url });
221 }
222 for ( const rule of parseCssRules (cssText)) {
223 const signals = extractSignals (rule.declarations);
224 if ( ! signals. length ) continue ;
225 const pseudos = extractPseudos (rule.selector);
226 const baseSelector = normalizeSelector (rule.selector);
227 rules. push ({ selector: rule.selector, baseSelector, sourceUrl: source.url, pseudos, signals });
228 if ( ! baseSelector) continue ;
229 const key = `${ baseSelector } \u0000 ${ pseudos . sort (). join ( "," ) }` ;
230 const existing = candidateMap. get (key) || {
231 baseSelector,
232 selectors: [],
233 pseudos: new Set (),
234 signals: new Set (),
235 };
236 existing.selectors. push (rule.selector);
237 for ( const pseudo of pseudos) existing.pseudos. add (pseudo);
238 for ( const signal of signals) existing.signals. add (signal);
239 candidateMap. set (key, existing);
240 }
241 }
242 const candidates = Array. from (candidateMap. values ())
243 . map (( candidate ) => ({
244 baseSelector: candidate.baseSelector,
245 selectors: candidate.selectors. slice ( 0 , 6 ),
246 pseudos: Array. from (candidate.pseudos),
247 signals: Array. from (candidate.signals),
248 likelyTriggers: inferTriggers (Array. from (candidate.pseudos), Array. from (candidate.signals)),
249 }))
250 . filter (( candidate ) => candidate.baseSelector && ! GENERIC_SELECTORS . has (candidate.baseSelector))
251 . slice ( 0 , 120 );
252 return {
253 sourceCount: sources. length ,
254 stylesheetCount: stylesheets. length ,
255 inlineStyleCount: inlineStyles. length ,
256 keyframes,
257 rules,
258 candidates,
259 summary: {
260 keyframeCount: keyframes. length ,
261 ruleCount: rules. length ,
262 candidateCount: candidates. length ,
263 },
264 };
265 }
266
267 function parseCssRules ( cssText ) {
268 const rules = [];
269 const pattern = /( [ ^ {}@][ ^ {}] * ) \{ ( [ ^ {}] + ) \} / g ;
270 let match;
271 while ((match = pattern. exec (cssText))) {
272 const selector = String (match[ 1 ] || "" ). trim ();
273 const declarations = String (match[ 2 ] || "" ). trim ();
274 if ( ! selector || ! declarations) continue ;
275 rules. push ({ selector, declarations });
276 }
277 return rules;
278 }
279
280 function extractSignals ( declarations ) {
281 const found = [];
282 const lowered = declarations. toLowerCase ();
283 for ( const prop of CSS_SIGNAL_PROPS ) {
284 if (lowered. includes ( `${ prop }:` )) found. push (prop);
285 }
286 return Array. from ( new Set (found));
287 }
288
289 function extractPseudos ( selector ) {
290 return Array. from ( new Set ((selector. match ( /:(hover | focus-visible | focus-within | focus | active | target | checked)/ g ) || [])
291 . map (( value ) => value. slice ( 1 ))));
292 }
293
294 function normalizeSelector ( selector ) {
295 const cleaned = selector
296 . replace ( /:: ? [\w-] + (?: \( [ ^ )] * \) ) ? / g , "" )
297 . replace ( / \[ [ ^ \] ] + \] / g , "" )
298 . replace ( / \s + / g , " " )
299 . split ( "," )[ 0 ]
300 . trim ();
301 if ( ! cleaned || cleaned. startsWith ( "@" )) return "" ;
302 return cleaned;
303 }
304
305 function inferTriggers ( pseudos , signals ) {
306 const triggers = new Set ();
307 for ( const pseudo of pseudos) {
308 if (pseudo === "hover" ) triggers. add ( "hover" );
309 if (pseudo === "focus" || pseudo === "focus-visible" || pseudo === "focus-within" ) triggers. add ( "focus" );
310 if (pseudo === "active" ) triggers. add ( "press" );
311 if (pseudo === "checked" || pseudo === "target" ) triggers. add ( "click" );
312 }
313 if (signals. includes ( "animation" ) || signals. includes ( "animation-name" )) triggers. add ( "load" );
314 return Array. from (triggers);
315 }
316
317 function redactProbeUrl ( value ) {
318 try {
319 const url = new URL (value);
320 for ( const key of [ ... url.searchParams. keys ()]) {
321 if ( /token | key | secret | auth | password | session | email | code/ i . test (key)) url.searchParams. set (key, "[redacted]" );
322 }
323 return url. toString ();
324 } catch {
325 return "[unparseable-url]" ;
326 }
327 }
328
329 async function runDynamicProbe ({ sourceUrl , outputDir , staticInventory , browserTooling }) {
330 await writeProgress (outputDir, "resolving_browser_tooling" );
331 const toolingContext = browserTooling || await resolveBrowserToolingContext ({ startDir: process. cwd () });
332 await writeProgress (outputDir, "loading_playwright" , { projectRoot: toolingContext.projectRoot });
333 const playwright = await loadPlaywrightFromContext (toolingContext);
334 await writeProgress (outputDir, "launching_browser" );
335 const browser = await withTimeout (
336 playwright.chromium. launch ({ headless: true }),
337 30_000 ,
338 "Chromium launch" ,
339 );
340 await writeProgress (outputDir, "creating_browser_page" );
341 const page = await withTimeout (
342 browser. newPage ({ viewport: { width: 1440 , height: 900 } }),
343 30_000 ,
344 "Chromium page creation" ,
345 );
346 await writeProgress (outputDir, "browser_page_ready" );
347 const screenshotsDir = path. join ( docsDir (outputDir), "screenshots" );
348 await ensureDir (screenshotsDir);
349 const blockedRequests = [];
350 const safeProbeRoute = async ( route ) => {
351 const request = route. request ();
352 const method = request. method (). toUpperCase ();
353 if (request. isNavigationRequest () || ! [ "GET" , "HEAD" , "OPTIONS" ]. includes (method)) {
354 blockedRequests. push ({ method, url: redactProbeUrl (request. url ()), reason: request. isNavigationRequest () ? "navigation-blocked" : "mutating-request-blocked" });
355 await route. abort ( "blockedbyclient" );
356 return ;
357 }
358 await route. continue ();
359 };
360 try {
361 await writeProgress (outputDir, "loading_source_page" );
362 await page. goto (sourceUrl, { waitUntil: "domcontentloaded" , timeout: 45000 });
363 await writeProgress (outputDir, "capturing_dynamic_interactions" );
364 await page. waitForTimeout ( 2600 );
365 await page. route ( "**/*" , safeProbeRoute);
366 const ignoredSurfaces = await markIgnoredConsentSurfaces (page);
367 const liveAnimationSnapshot = await page. evaluate (() =>
368 document. getAnimations (). map (( animation ) => ({
369 playState: animation.playState,
370 currentTime: animation.currentTime,
371 effectTarget: animation.effect?.target?.tagName?. toLowerCase () || "" ,
372 })),
373 );
374 const targets = await tagProbeTargets (page, staticInventory.candidates);
375 const captures = [];
376 const probeDiagnostics = [];
377 for ( const [ targetIndex , target ] of targets. slice ( 0 , 28 ). entries ()) {
378 const triggerOrder = target.triggers. includes ( "hover" )
379 ? [ "hover" , ... target.triggers. filter (( item ) => item !== "hover" )]
380 : target.triggers;
381 for ( const trigger of triggerOrder. slice ( 0 , 4 )) {
382 await writeProgress (outputDir, "capturing_dynamic_interaction" , {
383 targetIndex: targetIndex + 1 ,
384 targetCount: Math. min (targets. length , 28 ),
385 probeId: target.probeId,
386 trigger,
387 });
388 const capture = await captureInteractionBounded (page, target, trigger, screenshotsDir);
389 probeDiagnostics. push ({ probeId: target.probeId, label: target.text, role: target.role, scope: target.scope, trigger, meaningful: Boolean (capture.meaningful), error: capture.error || null , changedProperties: (capture.diff?.changedProperties || []). map (( item ) => item.property) });
390 if (capture.meaningful) captures. push (capture);
391 }
392 }
393 // Generic probes scroll and mutate controls. Reset before scene probes so initial
394 // header, tab, and carousel states are not contaminated by probe order.
395 await page. evaluate (() => {
396 if ( "scrollRestoration" in history) history.scrollRestoration = "manual" ;
397 window. scrollTo ( 0 , 0 );
398 });
399 await page. unroute ( "**/*" , safeProbeRoute);
400 await page. reload ({ waitUntil: "domcontentloaded" , timeout: 45000 });
401 await page. route ( "**/*" , safeProbeRoute);
402 await page. waitForTimeout ( 1800 );
403 await page. evaluate (() => window. scrollTo ( 0 , 0 ));
404 await page. waitForTimeout ( 400 );
405 await markIgnoredConsentSurfaces (page);
406 const headerScroll = await probeHeaderScrollState (page, screenshotsDir);
407 const scrollTabs = await probeScrollTabsState (page, screenshotsDir);
408 const carousels = await probeCarouselStates (page, screenshotsDir);
409 const specialized = {
410 headerScroll,
411 scrollTabs,
412 carousel: carousels[ 0 ] || { missing: true },
413 carousels,
414 };
415 const structural = await page. evaluate (() => {
416 const visible = ( node ) => {
417 if ( ! (node instanceof Element )) return false ;
418 if (node. closest ( "[data-rp-ignored-surface]" )) return false ;
419 const rect = node. getBoundingClientRect ();
420 const style = getComputedStyle (node);
421 return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" ;
422 };
423 const canonicalMediaSource = ( value ) => {
424 const raw = String (value || "" ). trim ();
425 if ( ! raw) return raw;
426 try {
427 const url = new URL (raw, window.location.href);
428 url.search = url.search. replace ( / \? ( [ ^ ?] * ) \? / g , "?$1&" );
429 return url. toString ();
430 } catch {
431 return raw;
432 }
433 };
434 const tabs = Array. from (document. querySelectorAll ( "[role='tab'], [aria-controls], [data-tab], [data-panel]" ))
435 . filter (visible)
436 . map (( node ) => node.textContent?. replace ( / \s + / g , " " ). trim ())
437 . filter (Boolean);
438 const media = Array. from (document. querySelectorAll ( "iframe, video, canvas, [data-lottie], [class*='lottie']" ))
439 . filter (visible)
440 . map (( node , index ) => {
441 const src = canonicalMediaSource (node.currentSrc || node. getAttribute ( "src" ) || node. getAttribute ( "data-src" ) || "" );
442 let provider = "" ;
443 let playback = {};
444 try {
445 const url = new URL (src, window.location.href);
446 provider = /vimeo \. com/ i . test (url.hostname) ? "vimeo" : /youtube \. com | youtu \. be/ i . test (url.hostname) ? "youtube" : url.hostname;
447 playback = {
448 autoplay: url.searchParams. get ( "autoplay" ) === "1" ,
449 loop: url.searchParams. get ( "loop" ) === "1" ,
450 muted: url.searchParams. get ( "muted" ) === "1" ,
451 background: url.searchParams. get ( "background" ) === "1" ,
452 };
453 } catch {
454 // A relative or deferred source is still useful to the implementation agent.
455 }
456 const parent = node.parentElement;
457 const owner = node. closest ( "section,header,footer,main" ) || parent;
458 const classTokens = String (node.className || "" ). split ( / \s + / ). filter (Boolean);
459 const mediaStyle = getComputedStyle (node);
460 let fallbackSource = canonicalMediaSource (node. getAttribute ( "poster" ) || node. getAttribute ( "data-poster" ) || "" );
461 let fallbackOrigin = fallbackSource ? "media-attribute" : "" ;
462 if ( ! fallbackSource) {
463 for ( let current = parent; current && current !== owner?.parentElement; current = current.parentElement) {
464 const backgroundImage = getComputedStyle (current).backgroundImage || "" ;
465 const match = backgroundImage. match ( /url \( ["'] ? ( [ ^ "')] + ) ["'] ? \) / i );
466 if (match?.[ 1 ]) {
467 fallbackSource = canonicalMediaSource (match[ 1 ]);
468 fallbackOrigin = "ancestor-background" ;
469 break ;
470 }
471 if (current === owner) break ;
472 }
473 }
474 const visiblyComposite = Number (mediaStyle.opacity || 1 ) < 0.99 || (mediaStyle.mixBlendMode && mediaStyle.mixBlendMode !== "normal" );
475 return {
476 id: `media-${ String ( index + 1 ). padStart ( 3 , "0" ) }` ,
477 tag: node.tagName. toLowerCase (),
478 src,
479 provider,
480 playback,
481 role: playback.background || /hero | background | scene | scroll | cover/ i . test ( `${ classTokens . join ( " " ) } ${ parent ?. className || ""}` ) ? "background" : node.tagName. toLowerCase () === "canvas" ? "runtime-surface" : "inline" ,
482 presentation: {
483 opacity: mediaStyle.opacity,
484 mixBlendMode: mediaStyle.mixBlendMode,
485 },
486 ... (fallbackSource ? {
487 fallback: {
488 src: fallbackSource,
489 origin: fallbackOrigin,
490 policy: visiblyComposite ? "composite" : "fallback-only" ,
491 },
492 } : {}),
493 domRef: {
494 id: node.id || "" ,
495 classTokens: classTokens. slice ( 0 , 8 ),
496 parentClassTokens: String (parent?.className || "" ). split ( / \s + / ). filter (Boolean). slice ( 0 , 8 ),
497 sectionClassTokens: String (owner?.className || "" ). split ( / \s + / ). filter (Boolean). slice ( 0 , 12 ),
498 sectionTextFingerprint: String (owner?.textContent || "" ). replace ( / \s + / g , " " ). trim (). slice ( 0 , 180 ),
499 },
500 };
501 });
502 return {
503 tabLabels: tabs. slice ( 0 , 12 ),
504 embedCount: media. length ,
505 media,
506 };
507 });
508 structural.ignoredSurfaces = ignoredSurfaces;
509 const specializedCaptures = deriveSpecializedCaptures ({ ... specialized, media: structural.media });
510 return {
511 liveAnimationSnapshot,
512 targets,
513 captures,
514 probeDiagnostics,
515 safeProbeAudit: {
516 policy: "public-presentation-controls-only" ,
517 blockedRequests,
518 actionBudget: { targetLimit: 28 , triggerLimitPerTarget: 4 , probeTimeoutMs: 8000 },
519 },
520 specialized,
521 specializedCaptures,
522 structural,
523 summary: {
524 targetCount: targets. length ,
525 meaningfulCaptureCount: captures. length + specializedCaptures. length ,
526 liveAnimationCount: liveAnimationSnapshot. length ,
527 },
528 };
529 } finally {
530 await browser. close ();
531 }
532 }
533
534 async function captureInteractionBounded ( page , target , trigger , screenshotsDir ) {
535 try {
536 return await withTimeout (
537 captureInteraction (page, target, trigger, screenshotsDir),
538 8_000 ,
539 `Interaction probe ${ target . probeId } (${ trigger })` ,
540 );
541 } catch (error) {
542 return {
543 probeId: target.probeId,
544 label: target.text || target.className || target.tag,
545 trigger,
546 sources: target.sources,
547 meaningful: false ,
548 error: error.message,
549 };
550 }
551 }
552
553 async function withTimeout ( operation , timeoutMs , label ) {
554 let timeout;
555 try {
556 return await Promise . race ([
557 operation,
558 new Promise (( _ , reject ) => {
559 timeout = setTimeout (() => reject ( new Error ( `${ label } timed out after ${ timeoutMs / 1000 }s.` )), timeoutMs);
560 }),
561 ]);
562 } finally {
563 clearTimeout (timeout);
564 }
565 }
566
567 async function tagProbeTargets ( page , staticCandidates ) {
568 return page. evaluate (( staticCandidates ) => {
569 const visible = ( node ) => {
570 if ( ! (node instanceof Element )) return false ;
571 if (node. closest ( "[data-rp-ignored-surface]" )) return false ;
572 const rect = node. getBoundingClientRect ();
573 const style = getComputedStyle (node);
574 return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" ;
575 };
576 const clean = ( value ) => String (value || "" ). replace ( / \s + / g , " " ). trim ();
577 const semanticSelectors = [
578 { selector: "header a" , limit: 10 , priority: 100 },
579 { selector: "footer a" , limit: 10 , priority: 96 },
580 { selector: "a[class*='button' i],a[class*='cta' i],a[role='button']" , limit: 8 , priority: 98 },
581 { selector: "button" , limit: 8 , priority: 94 },
582 { selector: "details > summary" , limit: 12 , priority: 93 },
583 { selector: "[aria-expanded='true'],[aria-expanded='false']" , limit: 12 , priority: 93 },
584 { selector: "[role='tab']" , limit: 6 , priority: 92 },
585 { selector: "[aria-controls]" , limit: 4 , priority: 90 },
586 { selector: "[data-tab]" , limit: 4 , priority: 88 },
587 { selector: "[data-carousel-card]" , limit: 4 , priority: 82 },
588 { selector: "[role='listitem']" , limit: 2 , priority: 50 },
589 { selector: "article" , limit: 2 , priority: 30 },
590 { selector: "iframe" , limit: 2 , priority: 20 },
591 { selector: "video" , limit: 2 , priority: 20 },
592 ];
593 const targetMap = new Map ();
594 let sequence = 0 ;
595 const addTarget = ( element , source , triggers , priority = 40 ) => {
596 if ( ! element || ! visible (element)) return ;
597 const textClone = element. cloneNode ( true );
598 textClone. querySelectorAll ?.( "svg,script,style" ). forEach (( node ) => node. remove ());
599 const text = clean (textClone.textContent || element. getAttribute ( "aria-label" ) || element. getAttribute ( "title" ) || element.textContent). slice ( 0 , 120 );
600 if ( /window \. | document \. | function \s * \( | nreum/ i . test (text)) return ;
601 const key = element;
602 if (targetMap. has (key)) {
603 const existing = targetMap. get (key);
604 for ( const trigger of triggers) existing.triggers. add (trigger);
605 existing.sources. add (source);
606 existing.priority = Math. max (existing.priority || 0 , priority);
607 return ;
608 }
609 sequence += 1 ;
610 const probeId = `probe-${ sequence }` ;
611 element. setAttribute ( "data-probe-id" , probeId);
612 targetMap. set (key, {
613 probeId,
614 text,
615 tag: element.tagName. toLowerCase (),
616 className: clean (element.className || "" ). slice ( 0 , 140 ),
617 sources: new Set ([source]),
618 triggers: new Set (triggers),
619 priority,
620 role: element. getAttribute ( "role" ) || (element. matches ( "a[href]" ) ? "link" : element. matches ( "button" ) ? "button" : element.tagName. toLowerCase ()),
621 scope: element. closest ( "header" ) ? "header" : element. closest ( "footer" ) ? "footer" : element. closest ( "nav" ) ? "navigation" : "content" ,
622 });
623 };
624 for ( const candidate of staticCandidates. slice ( 0 , 80 )) {
625 if ( ! candidate.baseSelector) continue ;
626 let matches = [];
627 try {
628 matches = Array. from (document. querySelectorAll (candidate.baseSelector)). filter (visible). slice ( 0 , 2 );
629 } catch {
630 matches = [];
631 }
632 for ( const match of matches) {
633 addTarget (match, `css:${ candidate . baseSelector }` , candidate.likelyTriggers. length ? candidate.likelyTriggers : [ "hover" ], 45 );
634 }
635 }
636 for ( const entry of semanticSelectors) {
637 let matches = [];
638 try {
639 matches = Array. from (document. querySelectorAll (entry.selector)). filter (visible). sort (( left , right ) => {
640 const readable = ( node ) => {
641 const clone = node. cloneNode ( true );
642 clone. querySelectorAll ?.( "svg,script,style" ). forEach (( child ) => child. remove ());
643 return clean (clone.textContent). length >= 2 ? 1 : 0 ;
644 };
645 return readable (right) - readable (left);
646 }). slice ( 0 , entry.limit);
647 } catch {
648 matches = [];
649 }
650 for ( const match of matches) {
651 const triggers = match. matches ( "button,[role='tab'],[aria-controls],summary,[aria-expanded]" )
652 ? [ "hover" , "focus" , "press" , "click" ]
653 : match. matches ( "a[href]" ) ? [ "hover" , "focus" , "press" ] : [ "hover" , "focus" ];
654 addTarget (match, `semantic:${ entry . selector }` , triggers, entry.priority);
655 }
656 }
657 return Array. from (targetMap. values ()). sort (( left , right ) => (right.priority || 0 ) - (left.priority || 0 )). map (( target ) => ({
658 ... target,
659 sources: Array. from (target.sources),
660 triggers: Array. from (target.triggers),
661 }));
662 }, staticCandidates);
663 }
664
665 async function markIgnoredConsentSurfaces ( page ) {
666 return page. evaluate (() => {
667 const clean = ( value ) => String (value || "" ). replace ( / \s + / g , " " ). trim ();
668 const visible = ( node ) => {
669 if ( ! (node instanceof Element )) return false ;
670 const rect = node. getBoundingClientRect ();
671 const style = getComputedStyle (node);
672 return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" && Number (style.opacity || 1 ) > 0 ;
673 };
674 const selector = [
675 "#onetrust-banner-sdk" ,
676 "#onetrust-consent-sdk" ,
677 "[class*='onetrust' i]" ,
678 "[id*='cookie-banner' i]" ,
679 "[class*='cookie-banner' i]" ,
680 "[id*='consent-banner' i]" ,
681 "[class*='consent-banner' i]" ,
682 "[role='dialog']" ,
683 ]. join ( "," );
684 const candidates = Array. from (document. querySelectorAll (selector)). filter (visible). filter (( node ) => {
685 const text = clean (node.textContent). toLowerCase ();
686 const identity = `${ node . id || ""} ${ node . className || ""} ${ node . getAttribute ( "aria-label" ) || ""}` . toLowerCase ();
687 const controls = Array. from (node. querySelectorAll ( "button,a,[role='button']" )). map (( control ) => clean (control.textContent)). join ( " " ). toLowerCase ();
688 const style = getComputedStyle (node);
689 const rect = node. getBoundingClientRect ();
690 const consentSignal = /cookie | consent | privacy preferences | tracking preferences/ . test ( `${ identity } ${ text }` );
691 const actionSignal = /accept | reject | decline | customi [sz] e | preferences | allow all/ . test (controls);
692 const overlaySignal = style.position === "fixed" || style.position === "sticky" || node. getAttribute ( "role" ) === "dialog" || rect.width >= window.innerWidth * 0.55 ;
693 return /onetrust | cookiebot | quantcast/ . test (identity) || consentSignal && actionSignal && overlaySignal;
694 });
695 const roots = candidates. filter (( node , index , all ) => ! all. some (( candidate , candidateIndex ) => candidateIndex !== index && candidate. contains (node)));
696 return roots. map (( node , index ) => {
697 node. setAttribute ( "data-rp-ignored-surface" , "consent-management" );
698 const rect = node. getBoundingClientRect ();
699 const identity = `${ node . id || ""} ${ node . className || ""}` . toLowerCase ();
700 return {
701 id: `ignored-surface-${ String ( index + 1 ). padStart ( 3 , "0" ) }` ,
702 kind: "consent-management" ,
703 provider: /onetrust | ot-sdk/ . test (identity) ? "onetrust" : /cookiebot/ . test (identity) ? "cookiebot" : /quantcast/ . test (identity) ? "quantcast" : "unknown" ,
704 creationPolicy: "ignore" ,
705 reason: "Destination consent must be implemented as functional infrastructure, not cloned page content." ,
706 textFingerprint: clean (node.textContent). slice ( 0 , 240 ),
707 rect: { top: Math. round (rect.top), left: Math. round (rect.left), width: Math. round (rect.width), height: Math. round (rect.height) },
708 domRef: {
709 tag: node.tagName. toLowerCase (),
710 id: node.id || "" ,
711 classTokens: String (node.className || "" ). split ( / \s + / ). filter (Boolean). slice ( 0 , 10 ),
712 },
713 };
714 });
715 });
716 }
717
718 async function captureInteraction ( page , target , trigger , screenshotsDir ) {
719 const selector = `[data-probe-id="${ target . probeId }"]` ;
720 const locator = page. locator (selector). first ();
721 try {
722 const before = await readProbeSnapshot (page, target.probeId);
723 if ( ! before) return { probeId: target.probeId, trigger, meaningful: false };
724 await locator. scrollIntoViewIfNeeded ();
725 await page. waitForTimeout ( 80 );
726 if (trigger === "hover" ) await locator. hover ({ force: true });
727 else if (trigger === "focus" ) await locator. focus ();
728 else if (trigger === "press" ) {
729 await locator. hover ({ force: true });
730 await page.mouse. down ();
731 }
732 else if (trigger === "click" ) {
733 const safety = await locator. evaluate (( node ) => {
734 const identity = `${ node . tagName } ${ node . getAttribute ( "role" ) || ""} ${ node . getAttribute ( "aria-label" ) || ""} ${ node . id || ""} ${ node . className || ""} ${ node . textContent || ""}` . toLowerCase ();
735 const href = node. closest ( "a[href]" )?. getAttribute ( "href" ) || "" ;
736 const formOwner = node. closest ( "form" );
737 const unsafe = Boolean (formOwner)
738 || /submit | login | sign . ? in | register | checkout | cart | purchase | buy | book | upload | delete | remove | admin | account | password/ . test (identity)
739 || Boolean (href && ! href. startsWith ( "#" ));
740 const presentation = /tab | accordion | disclosure | carousel | slider | gallery | menu | navigation/ . test (identity)
741 || node. hasAttribute ( "aria-controls" )
742 || node. hasAttribute ( "aria-expanded" )
743 || node. getAttribute ( "role" ) === "tab" ;
744 return { safe: ! unsafe && presentation, reason: unsafe ? "unsafe-or-business-action" : presentation ? "allowlisted-presentation-control" : "unclassified-control" };
745 });
746 if ( ! safety.safe) {
747 return {
748 probeId: target.probeId,
749 label: target.text || target.className || target.tag,
750 tag: target.tag,
751 trigger,
752 sources: target.sources,
753 meaningful: false ,
754 blocked: true ,
755 error: `Safe probing blocked click: ${ safety . reason }` ,
756 };
757 }
758 await locator. dispatchEvent ( "click" );
759 }
760 if (trigger === "load" ) await page. waitForTimeout ( 120 );
761 if (trigger === "hover" || trigger === "focus" || trigger === "click" ) await page. waitForTimeout ( 220 );
762 if (trigger === "press" ) await page. waitForTimeout ( 80 );
763 const after = await readProbeSnapshot (page, target.probeId);
764 const diff = diffSnapshots (before, after);
765 const liveAnimations = await page. evaluate (( probeId ) => {
766 const node = document. querySelector ( `[data-probe-id="${ probeId }"]` );
767 return document. getAnimations (). filter (( animation ) => {
768 const effectTarget = animation.effect?.target;
769 return effectTarget instanceof Element && (effectTarget === node || node?. contains (effectTarget) || effectTarget. contains ?.(node));
770 }). map (( animation ) => ({
771 playState: animation.playState,
772 currentTime: animation.currentTime,
773 }));
774 }, target.probeId);
775 const meaningful = diff.changedProperties. length > 0 || diff.textChanged || diff.attributeChanges. length > 0 || liveAnimations. length > 0 ;
776 let screenshot = null ;
777 if (meaningful) {
778 screenshot = path. join (screenshotsDir, `${ target . probeId }-${ trigger }.png` );
779 try {
780 await locator. screenshot ({ path: screenshot });
781 } catch {
782 screenshot = null ;
783 }
784 }
785 if (trigger === "press" ) {
786 await page.mouse. move ( 2 , 2 );
787 await page.mouse. up ();
788 }
789 if (trigger === "hover" ) await page.mouse. move ( 2 , 2 );
790 return {
791 probeId: target.probeId,
792 label: target.text || target.className || target.tag,
793 tag: target.tag,
794 controlRole: target.role,
795 controlScope: target.scope,
796 trigger,
797 sources: target.sources,
798 meaningful,
799 before,
800 after,
801 diff,
802 liveAnimations,
803 screenshot,
804 };
805 } catch (error) {
806 if (trigger === "press" ) {
807 await page.mouse. move ( 2 , 2 ). catch (() => {});
808 await page.mouse. up (). catch (() => {});
809 }
810 return {
811 probeId: target.probeId,
812 label: target.text || target.className || target.tag,
813 tag: target.tag,
814 trigger,
815 sources: target.sources,
816 meaningful: false ,
817 error: error.message,
818 };
819 }
820 }
821
822 async function readProbeSnapshot ( page , probeId ) {
823 return page. evaluate (({ probeId , styleKeys }) => {
824 const clean = ( value ) => String (value || "" ). replace ( / \s + / g , " " ). trim ();
825 const node = document. querySelector ( `[data-probe-id="${ probeId }"]` );
826 if ( ! (node instanceof Element )) return null ;
827 const style = getComputedStyle (node);
828 const rect = node. getBoundingClientRect ();
829 const section = node. closest ( "section,header,main,article,nav,footer" );
830 const sectionText = section ? clean (section.textContent). slice ( 0 , 420 ) : "" ;
831 const text = clean (node.textContent). slice ( 0 , 180 );
832 const styleRecord = ( target , pseudo = null ) => {
833 const computed = getComputedStyle (target, pseudo);
834 const styles = {};
835 for ( const key of styleKeys) styles[key] = computed[key];
836 return styles;
837 };
838 const visualChildren = Array. from ( new Set ([
839 ... Array. from (node.children),
840 ... Array. from (node. querySelectorAll ( "svg,use,path,i,[class*='icon' i],[aria-hidden='true']" )),
841 ]))
842 . filter (( child ) => child instanceof Element )
843 . slice ( 0 , 12 )
844 . map (( child , index ) => ({
845 key: `${ child . tagName . toLowerCase () }:${ index }` ,
846 tag: child.tagName. toLowerCase (),
847 className: clean (child.className?.baseVal || child.className || "" ),
848 styles: styleRecord (child),
849 pseudo: {
850 before: styleRecord (child, "::before" ),
851 after: styleRecord (child, "::after" ),
852 },
853 }));
854 return {
855 text,
856 sectionText,
857 rect: {
858 width: Math. round (rect.width),
859 height: Math. round (rect.height),
860 top: Math. round (rect.top),
861 left: Math. round (rect.left),
862 },
863 styles: styleRecord (node),
864 pseudo: {
865 before: styleRecord (node, "::before" ),
866 after: styleRecord (node, "::after" ),
867 },
868 visualChildren,
869 className: clean (node.className || "" ),
870 ariaSelected: node. getAttribute ( "aria-selected" ) || "" ,
871 ariaExpanded: node. getAttribute ( "aria-expanded" ) || "" ,
872 ariaCurrent: node. getAttribute ( "aria-current" ) || "" ,
873 ariaPressed: node. getAttribute ( "aria-pressed" ) || "" ,
874 disabled: node. matches ( ":disabled,[aria-disabled='true']" ),
875 };
876 }, { probeId, styleKeys: SNAPSHOT_STYLE_KEYS });
877 }
878
879 function diffSnapshots ( before , after ) {
880 if ( ! before || ! after) return { changedProperties: [], textChanged: false , attributeChanges: [] };
881 const changedProperties = [];
882 for ( const key of SNAPSHOT_STYLE_KEYS ) {
883 if ( String (before.styles[key]) !== String (after.styles[key])) {
884 changedProperties. push ({ property: key, before: before.styles[key], after: after.styles[key] });
885 }
886 }
887 for ( const pseudo of [ "before" , "after" ]) {
888 for ( const key of SNAPSHOT_STYLE_KEYS ) {
889 if ( String (before.pseudo?.[pseudo]?.[key]) !== String (after.pseudo?.[pseudo]?.[key])) {
890 changedProperties. push ({ property: `::${ pseudo }.${ key }` , before: before.pseudo?.[pseudo]?.[key], after: after.pseudo?.[pseudo]?.[key] });
891 }
892 }
893 }
894 const childKeys = new Set ([ ... (before.visualChildren || []). map (( child ) => child.key), ... (after.visualChildren || []). map (( child ) => child.key)]);
895 for ( const childKey of childKeys) {
896 const beforeChild = before.visualChildren?. find (( child ) => child.key === childKey);
897 const afterChild = after.visualChildren?. find (( child ) => child.key === childKey);
898 for ( const key of SNAPSHOT_STYLE_KEYS ) {
899 if ( String (beforeChild?.styles?.[key]) !== String (afterChild?.styles?.[key])) {
900 changedProperties. push ({ property: `child:${ childKey }.${ key }` , before: beforeChild?.styles?.[key], after: afterChild?.styles?.[key] });
901 }
902 }
903 for ( const pseudo of [ "before" , "after" ]) {
904 for ( const key of SNAPSHOT_STYLE_KEYS ) {
905 if ( String (beforeChild?.pseudo?.[pseudo]?.[key]) !== String (afterChild?.pseudo?.[pseudo]?.[key])) {
906 changedProperties. push ({ property: `child:${ childKey }::${ pseudo }.${ key }` , before: beforeChild?.pseudo?.[pseudo]?.[key], after: afterChild?.pseudo?.[pseudo]?.[key] });
907 }
908 }
909 }
910 }
911 const attributeChanges = [];
912 for ( const key of [ "className" , "ariaSelected" , "ariaExpanded" , "ariaCurrent" , "ariaPressed" , "disabled" ]) {
913 if ( String (before[key]) !== String (after[key])) {
914 attributeChanges. push ({ property: key, before: before[key], after: after[key] });
915 }
916 }
917 return {
918 changedProperties,
919 textChanged: before.text !== after.text || before.sectionText !== after.sectionText,
920 attributeChanges,
921 };
922 }
923
924 async function probeHeaderScrollState ( page , screenshotsDir ) {
925 const result = await page. evaluate ( async () => {
926 const visible = ( node ) => {
927 if ( ! (node instanceof Element )) return false ;
928 const rect = node. getBoundingClientRect ();
929 const style = getComputedStyle (node);
930 return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" ;
931 };
932 const clean = ( value ) => String (value || "" ). replace ( / \s + / g , " " ). trim ();
933 const header = document. querySelector ( "header,[role='banner'],#header,.header,.site-header,.main-header,.masthead,.sticky-header" );
934 if ( ! visible (header)) return { missing: true };
935 const snapshot = ( name ) => {
936 const style = getComputedStyle (header);
937 const rect = header. getBoundingClientRect ();
938 return {
939 name,
940 className: clean (header.className || "" ),
941 height: Math. round (rect.height),
942 position: style.position,
943 backgroundColor: style.backgroundColor,
944 backdropFilter: style.backdropFilter || style.webkitBackdropFilter || "" ,
945 };
946 };
947 const initial = snapshot ( "initial" );
948 const scrollTarget = Math. max ( 220 , Math. min (document.documentElement.scrollHeight - window.innerHeight, Math. round (window.innerHeight * 0.75 )));
949 window. scrollTo ( 0 , scrollTarget);
950 await new Promise (( resolve ) => setTimeout (resolve, 400 ));
951 const scrolled = snapshot ( "scrolled" );
952 window. scrollTo ( 0 , 0 );
953 await new Promise (( resolve ) => setTimeout (resolve, 200 ));
954 return { initial, scrolled };
955 });
956 if ( ! result || result.missing) return { missing: true };
957 const changedProperties = [];
958 for ( const key of [ "className" , "height" , "position" , "backgroundColor" , "backdropFilter" ]) {
959 if ( String (result.initial[key]) !== String (result.scrolled[key])) {
960 changedProperties. push ({ property: key, before: result.initial[key], after: result.scrolled[key] });
961 }
962 }
963 return {
964 initial: result.initial,
965 scrolled: result.scrolled,
966 changedProperties,
967 screenshot: await captureStateScreenshots (
968 page,
969 "header,[role='banner'],#header,.header,.site-header,.main-header,.masthead,.sticky-header" ,
970 screenshotsDir,
971 "header" ,
972 async () => page. evaluate (() => {
973 const scrollTarget = Math. max ( 220 , Math. min (document.documentElement.scrollHeight - window.innerHeight, Math. round (window.innerHeight * 0.75 )));
974 window. scrollTo ( 0 , scrollTarget);
975 }),
976 ),
977 meaningful: changedProperties. length > 0 ,
978 };
979 }
980
981 async function probeScrollTabsState ( page , screenshotsDir ) {
982 const result = await page. evaluate ( async () => {
983 const visible = ( node ) => {
984 if ( ! (node instanceof Element )) return false ;
985 const rect = node. getBoundingClientRect ();
986 const style = getComputedStyle (node);
987 return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" ;
988 };
989 const clean = ( value ) => String (value || "" ). replace ( / \s + / g , " " ). trim ();
990 const roots = Array. from (document. querySelectorAll ( "main section, [data-scroll-section], [data-scroll-scene], [data-animation-scene]" ));
991 const root = roots. filter (visible). map (( node ) => ({ node, score: (node. querySelector ( "iframe,video,canvas" ) ? 4 : 0 ) + (node. querySelector ( "[role='tab'],[aria-controls],[data-tab],button" ) ? 2 : 0 ) + (node. getBoundingClientRect ().height > innerHeight ? 1 : 0 ) })). sort (( a , b ) => b.score - a.score)[ 0 ]?.node;
992 if ( ! visible (root)) return { missing: true };
993 root. setAttribute ( "data-rp-scroll-probe" , "true" );
994 const visual = root. querySelector ( "iframe, video, canvas, img, [data-scene-visual], [data-scroll-visual]" );
995 const copy = root. querySelector ( "[data-scene-copy], [data-scroll-copy], [role='tabpanel'], h1,h2,h3" ) || root;
996 const snapshot = ( name ) => {
997 const rootRect = root. getBoundingClientRect ();
998 const visualRect = visual?. getBoundingClientRect () || null ;
999 const copyRect = copy?. getBoundingClientRect () || null ;
1000 const copyStyle = copy ? getComputedStyle (copy) : null ;
1001 const visualChain = [];
1002 for ( let node = visual; node && node !== root.parentElement && visualChain. length < 5 ; node = node.parentElement) {
1003 const style = getComputedStyle (node);
1004 const rect = node. getBoundingClientRect ();
1005 visualChain. push ({
1006 tag: node.tagName. toLowerCase (),
1007 className: clean (node.className || "" ),
1008 position: style.position,
1009 overflow: style.overflow,
1010 transform: style.transform,
1011 opacity: style.opacity,
1012 clipPath: style.clipPath,
1013 borderRadius: style.borderRadius,
1014 width: Math. round (rect.width),
1015 height: Math. round (rect.height),
1016 left: Math. round (rect.left),
1017 top: Math. round (rect.top),
1018 });
1019 if (node === root) break ;
1020 }
1021 const sceneLayers = Array. from (root. querySelectorAll ( "*" ))
1022 . map (( node ) => ({ node, style: getComputedStyle (node), rect: node. getBoundingClientRect () }))
1023 . filter (({ node , style , rect }) => rect.height >= 80 && style.display !== "none" && style.visibility !== "hidden" && ( /curtain | mask | overlay | scrim | scroll-over__bg | scroll-over__frame/ i . test ( String (node.className || "" )) || [ "absolute" , "fixed" , "sticky" ]. includes (style.position)))
1024 . slice ( 0 , 18 )
1025 . map (({ node , style , rect }) => ({
1026 tag: node.tagName. toLowerCase (),
1027 className: clean (node.className || "" ),
1028 role: /curtain | mask | scrim/ i . test ( String (node.className || "" )) ? "curtain" : / \b bg \b| background/ i . test ( String (node.className || "" )) ? "background" : "layer" ,
1029 position: style.position,
1030 transform: style.transform,
1031 opacity: style.opacity,
1032 clipPath: style.clipPath,
1033 backgroundColor: style.backgroundColor,
1034 width: Math. round (rect.width),
1035 height: Math. round (rect.height),
1036 left: Math. round (rect.left),
1037 top: Math. round (rect.top),
1038 }));
1039 return {
1040 name,
1041 scrollY: Math. round (window.scrollY),
1042 rootClassName: clean (root.className || "" ),
1043 visualClassName: clean (visual?.className || "" ),
1044 copyClassName: clean (copy?.className || "" ),
1045 rootTop: Math. round (rootRect.top),
1046 rootWidth: Math. round (rootRect.width),
1047 rootHeight: Math. round (rootRect.height),
1048 visual: visualRect ? { width: Math. round (visualRect.width), height: Math. round (visualRect.height), top: Math. round (visualRect.top) } : null ,
1049 copy: copyRect ? {
1050 width: Math. round (copyRect.width),
1051 height: Math. round (copyRect.height),
1052 top: Math. round (copyRect.top),
1053 left: Math. round (copyRect.left),
1054 position: copyStyle?.position || "" ,
1055 transform: copyStyle?.transform || "none" ,
1056 opacity: copyStyle?.opacity || "1" ,
1057 fontSize: copyStyle?.fontSize || "" ,
1058 lineHeight: copyStyle?.lineHeight || "" ,
1059 } : null ,
1060 visualChain,
1061 sceneLayers,
1062 };
1063 };
1064 const rootStart = window.scrollY + root. getBoundingClientRect ().top;
1065 const travel = Math. min (root. getBoundingClientRect ().height * 0.82 , window.innerHeight * 2.5 );
1066 const phases = [];
1067 const samples = [
1068 { progress: - 2 , name: "entry-offscreen" , scrollY: Math. max ( 0 , rootStart - window.innerHeight * 2 ) },
1069 { progress: - 1 , name: "entry-below" , scrollY: Math. max ( 0 , rootStart - window.innerHeight) },
1070 { progress: - 0.5 , name: "entry-midpoint" , scrollY: Math. max ( 0 , rootStart - window.innerHeight * 0.5 ) },
1071 ... [ 0 , 0.2 , 0.45 , 0.7 , 1 ]. map (( progress ) => ({
1072 progress,
1073 name: `scroll-${ Math . round ( progress * 100 ) }` ,
1074 scrollY: rootStart + travel * progress,
1075 })),
1076 ];
1077 for ( const sample of samples) {
1078 window. scrollTo ( 0 , Math. round (sample.scrollY));
1079 await new Promise (( resolve ) => setTimeout (resolve, 260 ));
1080 phases. push ({ progress: sample.progress, stage: sample.progress < 0 ? "entry" : "scene" , ... snapshot (sample.name) });
1081 }
1082 const beforeScroll = phases[ 0 ];
1083 const afterScroll = phases[phases. length - 1 ];
1084 const buttonNodes = Array. from (root. querySelectorAll ( "[role='tab'], [aria-controls], [data-tab], button" )). filter (visible). slice ( 0 , 8 );
1085 const states = [];
1086 for ( const button of buttonNodes) {
1087 const label = clean (button.textContent);
1088 button. dispatchEvent ( new MouseEvent ( "click" , { bubbles: true , cancelable: true }));
1089 await new Promise (( resolve ) => setTimeout (resolve, 280 ));
1090 const visibleText = Array. from (root. querySelectorAll ( "h2,h3,p" ))
1091 . filter (visible)
1092 . map (( node ) => clean (node.textContent))
1093 . filter (Boolean)
1094 . slice ( 0 , 8 );
1095 const activeTab = buttonNodes. find (( node ) => node. getAttribute ( "aria-selected" ) === "true" || / \b active \b / i . test ( String (node.className || "" )));
1096 states. push ({
1097 label,
1098 activeTab: clean (activeTab?.textContent || "" ),
1099 text: visibleText,
1100 buttonClassName: clean (button.className || "" ),
1101 ariaSelected: button. getAttribute ( "aria-selected" ) || "" ,
1102 });
1103 }
1104 window. scrollTo ( 0 , 0 );
1105 return { beforeScroll, afterScroll, phases, states };
1106 });
1107 if ( ! result || result.missing) return { missing: true };
1108 const wheelPhases = await probeWheelScrollScene (page);
1109 const phases = [ ... (result.phases || []), ... wheelPhases];
1110 const scrollChanges = [];
1111 for ( const key of [ "rootClassName" , "visualClassName" , "copyClassName" , "rootTop" , "rootHeight" ]) {
1112 if ( String (result.beforeScroll[key]) !== String (result.afterScroll[key])) {
1113 scrollChanges. push ({ property: key, before: result.beforeScroll[key], after: result.afterScroll[key] });
1114 }
1115 }
1116 for ( const key of [ "visual" , "copy" ]) {
1117 if ( JSON . stringify (result.beforeScroll[key]) !== JSON . stringify (result.afterScroll[key])) {
1118 scrollChanges. push ({ property: key, before: result.beforeScroll[key], after: result.afterScroll[key] });
1119 }
1120 }
1121 if ( JSON . stringify (result.beforeScroll.visualChain) !== JSON . stringify (result.afterScroll.visualChain)) {
1122 scrollChanges. push ({ property: "visualChain" , before: result.beforeScroll.visualChain, after: result.afterScroll.visualChain });
1123 }
1124 const uniqueStateTexts = Array. from ( new Set (result.states. map (( state ) => state.text. join ( " | " ))));
1125 return {
1126 beforeScroll: result.beforeScroll,
1127 afterScroll: result.afterScroll,
1128 scrollChanges,
1129 states: result.states,
1130 uniqueStateCount: uniqueStateTexts. length ,
1131 phases,
1132 invariants: deriveScrollInvariants (phases),
1133 screenshot: await captureSingleScreenshot (page, "[data-rp-scroll-probe='true']" , screenshotsDir, "interaction-section" ),
1134 meaningful: scrollChanges. length > 0 || uniqueStateTexts. length > 1 ,
1135 };
1136 }
1137
1138 async function probeWheelScrollScene ( page ) {
1139 await page. evaluate (() => window. scrollTo ( 0 , 0 ));
1140 await page. waitForTimeout ( 320 );
1141 const meta = await page. evaluate (() => {
1142 const root = document. querySelector ( "[data-rp-scroll-probe='true']" );
1143 if ( ! (root instanceof Element )) return null ;
1144 return {
1145 rootStart: window.scrollY + root. getBoundingClientRect ().top,
1146 travel: Math. max ( 1 , root. getBoundingClientRect ().height - window.innerHeight),
1147 viewportWidth: window.innerWidth,
1148 viewportHeight: window.innerHeight,
1149 };
1150 });
1151 if ( ! meta) return [];
1152 const frames = [ await readWheelScrollFrame (page, meta, 0 )];
1153 await page.mouse. move (Math. round (meta.viewportWidth * 0.85 ), Math. round (meta.viewportHeight * 0.75 ));
1154 for ( let index = 1 ; index <= 10 ; index += 1 ) {
1155 await page.mouse. wheel ( 0 , Math. round (meta.viewportHeight * 0.32 ));
1156 await page. waitForTimeout ( 220 );
1157 frames. push ( await readWheelScrollFrame (page, meta, index));
1158 }
1159 return frames. filter (Boolean);
1160 }
1161
1162 async function readWheelScrollFrame ( page , meta , index ) {
1163 return page. evaluate (({ meta , index }) => {
1164 const clean = ( value ) => String (value || "" ). replace ( / \s + / g , " " ). trim ();
1165 const root = document. querySelector ( "[data-rp-scroll-probe='true']" );
1166 if ( ! (root instanceof Element )) return null ;
1167 const visual = root. querySelector ( "iframe, video, canvas, img, [data-scene-visual], [data-scroll-visual]" );
1168 const copy = root. querySelector ( "[data-scene-copy], [data-scroll-copy], [role='tabpanel'], h1,h2,h3" ) || root;
1169 const rootRect = root. getBoundingClientRect ();
1170 const visualRect = visual?. getBoundingClientRect () || null ;
1171 const copyRect = copy?. getBoundingClientRect () || null ;
1172 const copyStyle = copy ? getComputedStyle (copy) : null ;
1173 const visualChain = [];
1174 for ( let node = visual; node && node !== root.parentElement && visualChain. length < 6 ; node = node.parentElement) {
1175 const style = getComputedStyle (node);
1176 const rect = node. getBoundingClientRect ();
1177 visualChain. push ({
1178 tag: node.tagName. toLowerCase (),
1179 className: clean (node.className || "" ),
1180 position: style.position,
1181 overflow: style.overflow,
1182 transform: style.transform,
1183 opacity: style.opacity,
1184 clipPath: style.clipPath,
1185 borderRadius: style.borderRadius,
1186 width: Math. round (rect.width),
1187 height: Math. round (rect.height),
1188 left: Math. round (rect.left),
1189 top: Math. round (rect.top),
1190 });
1191 if (node === root) break ;
1192 }
1193 const sceneLayers = Array. from (root. querySelectorAll ( "*" ))
1194 . map (( node ) => ({ node, style: getComputedStyle (node), rect: node. getBoundingClientRect () }))
1195 . filter (({ node , style , rect }) => rect.height >= 80 && style.display !== "none" && style.visibility !== "hidden" && ( /curtain | mask | overlay | scrim | scroll-over__bg | scroll-over__frame/ i . test ( String (node.className || "" )) || [ "absolute" , "fixed" , "sticky" ]. includes (style.position)))
1196 . slice ( 0 , 18 )
1197 . map (({ node , style , rect }) => ({
1198 tag: node.tagName. toLowerCase (),
1199 className: clean (node.className || "" ),
1200 role: /curtain | mask | scrim/ i . test ( String (node.className || "" )) ? "curtain" : / \b bg \b| background/ i . test ( String (node.className || "" )) ? "background" : "layer" ,
1201 position: style.position,
1202 transform: style.transform,
1203 opacity: style.opacity,
1204 clipPath: style.clipPath,
1205 backgroundColor: style.backgroundColor,
1206 width: Math. round (rect.width),
1207 height: Math. round (rect.height),
1208 left: Math. round (rect.left),
1209 top: Math. round (rect.top),
1210 }));
1211 const progress = (window.scrollY - meta.rootStart) / meta.travel;
1212 return {
1213 progress: Math. round (progress * 1000 ) / 1000 ,
1214 stage: "wheel" ,
1215 name: `wheel-${ index }` ,
1216 scrollY: Math. round (window.scrollY),
1217 rootClassName: clean (root.className || "" ),
1218 visualClassName: clean (visual?.className || "" ),
1219 copyClassName: clean (copy?.className || "" ),
1220 rootTop: Math. round (rootRect.top),
1221 rootWidth: Math. round (rootRect.width),
1222 rootHeight: Math. round (rootRect.height),
1223 visual: visualRect ? { width: Math. round (visualRect.width), height: Math. round (visualRect.height), top: Math. round (visualRect.top) } : null ,
1224 copy: copyRect ? {
1225 width: Math. round (copyRect.width),
1226 height: Math. round (copyRect.height),
1227 top: Math. round (copyRect.top),
1228 left: Math. round (copyRect.left),
1229 position: copyStyle?.position || "" ,
1230 transform: copyStyle?.transform || "none" ,
1231 opacity: copyStyle?.opacity || "1" ,
1232 fontSize: copyStyle?.fontSize || "" ,
1233 lineHeight: copyStyle?.lineHeight || "" ,
1234 } : null ,
1235 visualChain,
1236 sceneLayers,
1237 };
1238 }, { meta, index });
1239 }
1240
1241 async function probeCarouselStates ( page , screenshotsDir ) {
1242 const candidates = await page. evaluate (() => {
1243 const clean = ( value ) => String (value || "" ). replace ( / \s + / g , " " ). trim ();
1244 const hasActiveToken = ( value ) => clean (value). split ( " " ). some (( token ) => /(?: ^| [-_:] )(active | selected | expanded) $|^ (active | selected | expanded) $ / i . test (token));
1245 const visible = ( node ) => {
1246 if ( ! (node instanceof Element )) return false ;
1247 const rect = node. getBoundingClientRect ();
1248 const style = getComputedStyle (node);
1249 return rect.width >= 80 && rect.height >= 60 && style.display !== "none" && style.visibility !== "hidden" ;
1250 };
1251 const classSignature = ( node ) => clean (node.className). split ( " " ). filter (Boolean). slice ( 0 , 3 ). join ( "." );
1252 const roots = Array. from ( new Set (document. querySelectorAll (
1253 "[data-carousel], [aria-roledescription='carousel'], [class*='carousel'], [class*='slider'], [class*='rail'], [class*='swiper'], [class*='splide'], [class*='embla']" ,
1254 ))). filter (visible);
1255 const byTrack = new Map ();
1256 for ( const hintedRoot of roots. slice ( 0 , 80 )) {
1257 const scope = hintedRoot. closest ( "section,main,article,[role='region']" ) || hintedRoot;
1258 const nodes = [hintedRoot, ... Array. from (hintedRoot. querySelectorAll ( "*" )). slice ( 0 , 240 )];
1259 for ( const track of nodes) {
1260 if ( ! visible (track)) continue ;
1261 const directChildren = Array. from (track.children). filter (visible);
1262 if (directChildren. length < 3 || directChildren. length > 40 ) continue ;
1263 const signatureCounts = new Map ();
1264 for ( const child of directChildren) {
1265 const key = classSignature (child) || child.tagName. toLowerCase ();
1266 signatureCounts. set (key, (signatureCounts. get (key) || 0 ) + 1 );
1267 }
1268 const dominant = Array. from (signatureCounts. entries ()). sort (( left , right ) => right[ 1 ] - left[ 1 ])[ 0 ];
1269 const items = dominant && dominant[ 1 ] >= 3
1270 ? directChildren. filter (( child ) => ( classSignature (child) || child.tagName. toLowerCase ()) === dominant[ 0 ])
1271 : directChildren;
1272 if (items. length < 3 ) continue ;
1273 const rects = items. map (( item ) => item. getBoundingClientRect ());
1274 const horizontalSpan = Math. max ( ... rects. map (( rect ) => rect.right)) - Math. min ( ... rects. map (( rect ) => rect.left));
1275 const verticalSpan = Math. max ( ... rects. map (( rect ) => rect.bottom)) - Math. min ( ... rects. map (( rect ) => rect.top));
1276 const trackRect = track. getBoundingClientRect ();
1277 const horizontal = horizontalSpan > verticalSpan * 1.15 || horizontalSpan > trackRect.width * 1.15 || track.scrollWidth > track.clientWidth * 1.05 ;
1278 if ( ! horizontal) continue ;
1279 const classText = `${ clean ( hintedRoot . className ) } ${ clean ( track . className ) } ${ clean ( scope . className ) }` . toLowerCase ();
1280 let score = items. length * 2 ;
1281 if (track.scrollWidth > track.clientWidth * 1.05 ) score += 35 ;
1282 if ( /carousel | slider | rail | track | slides | swiper | splide | embla/ . test (classText)) score += 30 ;
1283 if ( getComputedStyle (track).display === "flex" || getComputedStyle (track).display === "grid" ) score += 15 ;
1284 if (scope. querySelectorAll ( "button" ). length >= 2 ) score += 12 ;
1285 const existing = byTrack. get (track);
1286 if ( ! existing || existing.score < score) byTrack. set (track, { hintedRoot, scope, track, items, score });
1287 }
1288 }
1289 const ranked = Array. from (byTrack. values ())
1290 . sort (( left , right ) => right.score - left.score)
1291 . filter (( candidate , index , all ) => ! all. slice ( 0 , index). some (( earlier ) => earlier.track. contains (candidate.track) || candidate.track. contains (earlier.track)))
1292 . slice ( 0 , 4 );
1293 return ranked. map (( candidate , probeIndex ) => {
1294 candidate.scope. setAttribute ( "data-rp-carousel-probe" , String (probeIndex));
1295 candidate.track. setAttribute ( "data-rp-carousel-track" , String (probeIndex));
1296 candidate.items. forEach (( item , itemIndex ) => item. setAttribute ( "data-rp-carousel-item" , `${ probeIndex }:${ itemIndex }` ));
1297 const activeIndex = candidate.items. findIndex (( item ) => hasActiveToken (item.className) || item. getAttribute ( "aria-selected" ) === "true" );
1298 const targetIndex = candidate.items. length > 1 && activeIndex !== 1 ? 1 : activeIndex !== 0 ? 0 : Math. min ( 2 , candidate.items. length - 1 );
1299 const controls = Array. from (candidate.scope. querySelectorAll ( "button,[role='button']" )). slice ( 0 , 8 ). map (( button ) => ({
1300 label: clean (button. getAttribute ( "aria-label" ) || button.textContent). slice ( 0 , 100 ),
1301 classTokens: clean (button.className). split ( " " ). filter (Boolean). slice ( 0 , 8 ),
1302 }));
1303 return {
1304 probeIndex,
1305 score: candidate.score,
1306 targetIndex,
1307 itemCount: candidate.items. length ,
1308 scope: {
1309 tag: candidate.scope.tagName. toLowerCase (),
1310 id: candidate.scope.id || "" ,
1311 classTokens: clean (candidate.scope.className). split ( " " ). filter (Boolean). slice ( 0 , 12 ),
1312 textFingerprint: clean (candidate.scope.textContent). slice ( 0 , 180 ),
1313 },
1314 track: {
1315 classTokens: clean (candidate.track.className). split ( " " ). filter (Boolean). slice ( 0 , 12 ),
1316 },
1317 controls,
1318 };
1319 });
1320 });
1321
1322 const results = [];
1323 for ( const candidate of candidates) {
1324 const probeIndex = candidate.probeIndex;
1325 const itemSelector = `[data-rp-carousel-item="${ probeIndex }:${ candidate . targetIndex }"]` ;
1326 const frames = [];
1327 try {
1328 const target = page. locator (itemSelector). first ();
1329 await target. scrollIntoViewIfNeeded ();
1330 await page. waitForTimeout ( 160 );
1331 frames. push ( await readCarouselTimelineFrame (page, probeIndex, - 1 ));
1332 const safeCarouselTarget = await target. evaluate (( node ) => {
1333 const anchor = node. closest ( "a[href]" );
1334 const identity = `${ node . getAttribute ( "role" ) || ""} ${ node . className || ""} ${ node . getAttribute ( "aria-label" ) || ""}` . toLowerCase ();
1335 return ! anchor && ! node. closest ( "form" ) && ! /cart | checkout | buy | book | delete | account/ . test (identity);
1336 });
1337 if ( ! safeCarouselTarget) {
1338 results. push ({
1339 id: `carousel-${ probeIndex + 1 }` ,
1340 missing: false ,
1341 changed: false ,
1342 blocked: true ,
1343 reason: "Safe probing refused a carousel item that could navigate or perform a business action." ,
1344 scope: candidate.scope,
1345 target: { itemIndex: candidate.targetIndex },
1346 controls: candidate.controls,
1347 });
1348 continue ;
1349 }
1350 await target. click ({ force: true });
1351 let elapsed = 0 ;
1352 for ( const atMs of DEFAULT_INTERACTION_TIMELINE_MS ) {
1353 if (atMs > elapsed) await page. waitForTimeout (atMs - elapsed);
1354 elapsed = atMs;
1355 frames. push ( await readCarouselTimelineFrame (page, probeIndex, atMs));
1356 }
1357 const timeline = compactInteractionTimeline (frames);
1358 const invariants = deriveCarouselInvariants ({ frames, timeline });
1359 const firstItems = itemFrame (frames[ 0 ]);
1360 const finalItems = itemFrame (frames[frames. length - 1 ]);
1361 const changed = JSON . stringify (firstItems) !== JSON . stringify (finalItems) || timeline.changedNodeCount > 0 ;
1362 const prefix = probeIndex === 0 ? "interaction-carousel" : `interaction-carousel-${ probeIndex + 1 }` ;
1363 results. push ({
1364 id: `carousel-${ probeIndex + 1 }` ,
1365 missing: false ,
1366 changed,
1367 scope: candidate.scope,
1368 target: { itemIndex: candidate.targetIndex },
1369 controls: candidate.controls,
1370 initial: legacyCarouselState (frames[ 0 ]),
1371 after: legacyCarouselState (frames[frames. length - 1 ]),
1372 timeline,
1373 invariants,
1374 screenshot: await captureSingleScreenshot (page, `[data-rp-carousel-probe="${ probeIndex }"]` , screenshotsDir, prefix),
1375 });
1376 } catch (error) {
1377 results. push ({
1378 id: `carousel-${ probeIndex + 1 }` ,
1379 missing: false ,
1380 changed: false ,
1381 scope: candidate.scope,
1382 target: { itemIndex: candidate.targetIndex },
1383 controls: candidate.controls,
1384 frames,
1385 error: error.message,
1386 });
1387 }
1388 }
1389 return results;
1390 }
1391
1392 async function readCarouselTimelineFrame ( page , probeIndex , atMs ) {
1393 return page. evaluate (({ probeIndex , atMs }) => {
1394 const clean = ( value ) => String (value || "" ). replace ( / \s + / g , " " ). trim ();
1395 const hasActiveToken = ( value ) => clean (value). split ( " " ). some (( token ) => /(?: ^| [-_:] )(active | selected | expanded) $|^ (active | selected | expanded) $ / i . test (token));
1396 const root = document. querySelector ( `[data-rp-carousel-probe="${ probeIndex }"]` );
1397 const track = document. querySelector ( `[data-rp-carousel-track="${ probeIndex }"]` );
1398 const items = Array. from (document. querySelectorAll ( `[data-rp-carousel-item^="${ probeIndex }:"]` ));
1399 if ( ! (root instanceof Element ) || ! (track instanceof Element ) || items. length < 2 ) return null ;
1400 const pathWithin = ( node , stop ) => {
1401 const parts = [];
1402 for ( let current = node; current && current !== stop && parts. length < 5 ; current = current.parentElement) {
1403 const parent = current.parentElement;
1404 const index = parent ? Array. from (parent.children). indexOf (current) : 0 ;
1405 parts. unshift ( `${ current . tagName . toLowerCase () }:${ index }` );
1406 }
1407 return parts. join ( "/" );
1408 };
1409 const snapshot = ( node , path , role ) => {
1410 const rect = node. getBoundingClientRect ();
1411 const style = getComputedStyle (node);
1412 const className = clean (node.className);
1413 return {
1414 path,
1415 role,
1416 text: clean (node.textContent). slice ( 0 , 160 ),
1417 className,
1418 active: className. split ( / \s + / ). some (( token ) => /(?: ^| [-_:] )(active | selected | expanded) $|^ (active | selected | expanded) $ / i . test (token)),
1419 ariaSelected: node. getAttribute ( "aria-selected" ) || "" ,
1420 ariaExpanded: node. getAttribute ( "aria-expanded" ) || "" ,
1421 hidden: node. hasAttribute ( "hidden" ),
1422 rect: {
1423 left: Math. round (rect.left * 100 ) / 100 ,
1424 top: Math. round (rect.top * 100 ) / 100 ,
1425 width: Math. round (rect.width * 100 ) / 100 ,
1426 height: Math. round (rect.height * 100 ) / 100 ,
1427 },
1428 display: style.display,
1429 visibility: style.visibility,
1430 opacity: style.opacity,
1431 transform: style.transform,
1432 width: style.width,
1433 height: style.height,
1434 flexBasis: style.flexBasis,
1435 gridTemplateColumns: style.gridTemplateColumns,
1436 overflow: style.overflow,
1437 clipPath: style.clipPath,
1438 gap: style.gap,
1439 rowGap: style.rowGap,
1440 columnGap: style.columnGap,
1441 marginLeft: style.marginLeft,
1442 marginRight: style.marginRight,
1443 paddingLeft: style.paddingLeft,
1444 paddingRight: style.paddingRight,
1445 borderLeftWidth: style.borderLeftWidth,
1446 borderRightWidth: style.borderRightWidth,
1447 borderLeftColor: style.borderLeftColor,
1448 borderRightColor: style.borderRightColor,
1449 transitionProperty: style.transitionProperty,
1450 transitionDuration: style.transitionDuration,
1451 transitionTimingFunction: style.transitionTimingFunction,
1452 clientWidth: node.clientWidth,
1453 scrollWidth: node.scrollWidth,
1454 };
1455 };
1456 const nodes = [ snapshot (root, "scope" , "scope" ), snapshot (track, "track" , "track" )];
1457 items. slice ( 0 , 12 ). forEach (( item , index ) => nodes. push ( snapshot (item, `item:${ index }` , "item" )));
1458 const activeOrTarget = items. find (( item ) => hasActiveToken (item.className)) || items[ 0 ];
1459 const descendants = Array. from (activeOrTarget. querySelectorAll ( "*" )). filter (( node ) => {
1460 const rect = node. getBoundingClientRect ();
1461 const style = getComputedStyle (node);
1462 const className = clean (node.className);
1463 return rect.width > 0 && rect.height > 0 && style.display !== "none" && (className || node. matches ( "img,picture,video,iframe,button,a,[aria-hidden],[aria-expanded]" ));
1464 }). slice ( 0 , 48 );
1465 descendants. forEach (( node ) => nodes. push ( snapshot (node, `active/${ pathWithin ( node , activeOrTarget ) }` , "active-descendant" )));
1466 const animations = typeof document.getAnimations === "function"
1467 ? document. getAnimations (). filter (( animation ) => {
1468 const target = animation.effect?.target;
1469 return target instanceof Node && root. contains (target);
1470 }). slice ( 0 , 24 ). map (( animation ) => {
1471 const target = animation.effect?.target;
1472 const timing = animation.effect?. getTiming ?.() || {};
1473 return {
1474 path: target instanceof Element ? pathWithin (target, root) : "" ,
1475 name: animation.animationName || "" ,
1476 playState: animation.playState,
1477 currentTime: Math. round ( Number (animation.currentTime) || 0 ),
1478 duration: timing.duration,
1479 delay: timing.delay,
1480 easing: timing.easing,
1481 fill: timing.fill,
1482 };
1483 })
1484 : [];
1485 return { atMs, nodes, animations };
1486 }, { probeIndex, atMs });
1487 }
1488
1489 function itemFrame ( frame ) {
1490 return (frame?.nodes || []). filter (( node ) => node.role === "item" ). map (( node ) => ({
1491 className: node.className,
1492 rect: node.rect,
1493 ariaSelected: node.ariaSelected,
1494 ariaExpanded: node.ariaExpanded,
1495 }));
1496 }
1497
1498 function legacyCarouselState ( frame ) {
1499 const track = frame?.nodes?. find (( node ) => node.role === "track" );
1500 return {
1501 clientWidth: track?.clientWidth || 0 ,
1502 scrollWidth: track?.scrollWidth || 0 ,
1503 cards: (frame?.nodes || []). filter (( node ) => node.role === "item" ). map (( node , index ) => ({
1504 index,
1505 className: node.className,
1506 width: node.rect?.width || 0 ,
1507 height: node.rect?.height || 0 ,
1508 left: node.rect?.left || 0 ,
1509 })),
1510 };
1511 }
1512
1513 async function captureStateScreenshots ( page , selector , screenshotsDir , prefix , moveToState ) {
1514 let initial = null ;
1515 let scrolled = null ;
1516 try {
1517 initial = path. join (screenshotsDir, `${ prefix }-initial.png` );
1518 await page. locator (selector). first (). screenshot ({ path: initial });
1519 await moveToState ();
1520 await page. waitForTimeout ( 400 );
1521 scrolled = path. join (screenshotsDir, `${ prefix }-scrolled.png` );
1522 await page. locator (selector). first (). screenshot ({ path: scrolled });
1523 await page. evaluate (() => window. scrollTo ( 0 , 0 ));
1524 await page. waitForTimeout ( 120 );
1525 } catch {
1526 // Best effort only.
1527 }
1528 return { initial, scrolled };
1529 }
1530
1531 async function captureSingleScreenshot ( page , selector , screenshotsDir , prefix ) {
1532 try {
1533 await page. locator (selector). first (). scrollIntoViewIfNeeded ();
1534 await page. waitForTimeout ( 120 );
1535 const screenshot = path. join (screenshotsDir, `${ prefix }.png` );
1536 await page. locator (selector). first (). screenshot ({ path: screenshot });
1537 return screenshot;
1538 } catch {
1539 return null ;
1540 }
1541 }
1542
1543 function deriveSpecializedCaptures ( specialized ) {
1544 const captures = [];
1545 if (specialized.headerScroll?.meaningful) {
1546 captures. push ({
1547 id: "header-scroll" ,
1548 kind: "sticky-transform" ,
1549 label: "Header scroll state" ,
1550 tag: "header" ,
1551 trigger: "scroll" ,
1552 sources: [ "specialized:header-scroll" ],
1553 meaningful: true ,
1554 before: specialized.headerScroll.initial,
1555 after: specialized.headerScroll.scrolled,
1556 diff: {
1557 changedProperties: specialized.headerScroll.changedProperties,
1558 textChanged: false ,
1559 attributeChanges: [],
1560 },
1561 liveAnimations: [],
1562 screenshot: specialized.headerScroll.screenshot?.scrolled || specialized.headerScroll.screenshot?.initial || null ,
1563 });
1564 }
1565 if (specialized.scrollTabs?.meaningful) {
1566 if (specialized.scrollTabs.scrollChanges. length ) {
1567 captures. push ({
1568 id: "section-scroll-state" ,
1569 kind: "scroll-state" ,
1570 label: "Section scroll-expanded state" ,
1571 tag: "section" ,
1572 trigger: "scroll" ,
1573 sources: [ "specialized:scroll-scene" ],
1574 meaningful: true ,
1575 before: specialized.scrollTabs.beforeScroll,
1576 after: specialized.scrollTabs.afterScroll,
1577 diff: {
1578 changedProperties: specialized.scrollTabs.scrollChanges,
1579 textChanged: false ,
1580 attributeChanges: [],
1581 },
1582 liveAnimations: [],
1583 screenshot: specialized.scrollTabs.screenshot || null ,
1584 states: specialized.scrollTabs.phases || [],
1585 invariants: specialized.scrollTabs.invariants || {},
1586 });
1587 }
1588 for ( const state of specialized.scrollTabs.states. slice ( 0 , 4 )) {
1589 captures. push ({
1590 id: `tab-${ slugify ( state . label ) }` ,
1591 kind: "tabs" ,
1592 label: state.label,
1593 tag: "button" ,
1594 trigger: "click" ,
1595 sources: [ "specialized:scroll-scene-tabs" ],
1596 meaningful: true ,
1597 before: null ,
1598 after: {
1599 ariaSelected: state.ariaSelected,
1600 activeTab: state.activeTab,
1601 text: state.text. join ( " | " ),
1602 },
1603 diff: {
1604 changedProperties: [],
1605 textChanged: specialized.scrollTabs.uniqueStateCount > 1 ,
1606 attributeChanges: state.ariaSelected ? [{ property: "ariaSelected" , before: "false" , after: state.ariaSelected }] : [],
1607 },
1608 liveAnimations: [],
1609 screenshot: specialized.scrollTabs.screenshot || null ,
1610 });
1611 }
1612 }
1613 const carouselStates = specialized.carousels?. length ? specialized.carousels : specialized.carousel ? [specialized.carousel] : [];
1614 for ( const carousel of carouselStates. filter (( item ) => item?.changed)) {
1615 captures. push ({
1616 id: `${ carousel . id || "carousel"}-card-expansion` ,
1617 kind: "carousel" ,
1618 label: "Carousel card expansion and horizontal rail" ,
1619 tag: "section" ,
1620 trigger: "click" ,
1621 sources: [ "specialized:carousel-state" ],
1622 meaningful: true ,
1623 before: carousel.initial,
1624 after: carousel.after,
1625 diff: { changedProperties: [{ property: "cardGeometry" , before: carousel.initial.cards, after: carousel.after.cards }], textChanged: false , attributeChanges: [] },
1626 liveAnimations: [],
1627 screenshot: carousel.screenshot || null ,
1628 scope: carousel.scope,
1629 timeline: carousel.timeline,
1630 invariants: carousel.invariants,
1631 });
1632 }
1633 for ( const media of specialized.media || []) {
1634 captures. push ({
1635 id: media.id,
1636 kind: "embedded-media" ,
1637 label: `${ media . role } ${ media . provider || media . tag } media` ,
1638 tag: media.tag,
1639 trigger: "load" ,
1640 sources: [ "structural:media" ],
1641 meaningful: true ,
1642 after: media,
1643 diff: { changedProperties: [], textChanged: false , attributeChanges: [] },
1644 liveAnimations: [],
1645 screenshot: null ,
1646 scope: {
1647 tag: "section" ,
1648 classTokens: media.domRef?.sectionClassTokens || [],
1649 textFingerprint: media.domRef?.sectionTextFingerprint || "" ,
1650 },
1651 });
1652 }
1653 return captures;
1654 }
1655
1656 function enrichPageRecord ( page , experiment ) {
1657 const allCaptures = [ ... experiment.runtime.captures, ... (experiment.runtime.specializedCaptures || [])];
1658 const captures = allCaptures. map (( capture ) => ({
1659 id: capture.id || "" ,
1660 kind: classifyCapture (capture),
1661 label: capture.label,
1662 trigger: capture.trigger,
1663 sources: capture.sources,
1664 changedProperties: (capture.diff?.changedProperties || []). map (( item ) => item.property),
1665 textChanged: Boolean (capture.diff?.textChanged),
1666 screenshot: capture.screenshot,
1667 importance: classifyImportance (capture),
1668 ... (capture.scope ? { scope: capture.scope } : {}),
1669 }));
1670 const sectionInteractions = (page.sections || []). map (( section ) => {
1671 const heading = String (section.heading || "" ). toLowerCase ();
1672 const className = `${ section . className || ""} ${ ( section . domRef ?. classTokens || []). join ( " " ) }` . toLowerCase ();
1673 const relevant = captures. filter (( capture ) => {
1674 const label = String (capture.label || "" ). toLowerCase ();
1675 if (capture.scope && scopeMatchesSection (capture.scope, section)) return true ;
1676 if ( ! capture.scope && (className. includes ( "hero-video" ) || section.kind === "hero" ) && capture.kind === "embedded-media" ) return true ;
1677 if ( /carousel | slider | rail | gallery/ . test (className) && (capture.kind === "hover-card" || capture.kind === "carousel-control" || capture.kind === "carousel" )) return true ;
1678 if ( /scroll | scene | sticky | pinned/ . test (className)) {
1679 return capture.kind === "tabs" || capture.kind === "sticky-transform" || capture.kind === "scroll-state" ;
1680 }
1681 return label && heading && label. includes (heading. slice ( 0 , 12 ));
1682 });
1683 return {
1684 sectionId: section.id,
1685 interactions: dedupeSectionInteractions (relevant). slice ( 0 , 12 ),
1686 };
1687 });
1688 return {
1689 ... page,
1690 interactionDiscovery: {
1691 generatedAt: experiment.generatedAt,
1692 staticSummary: experiment.staticInventory.summary,
1693 runtimeSummary: experiment.runtime.summary,
1694 captures,
1695 sectionInteractions,
1696 structural: experiment.runtime.structural,
1697 media: experiment.runtime.structural.media || [],
1698 specialized: summarizeSpecializedRuntime (experiment.runtime.specialized),
1699 requiredBehaviors: [
1700 experiment.runtime.structural.embedCount ? "preserve-embedded-media" : null ,
1701 experiment.runtime.structural.tabLabels. length ? "preserve-content-switching-tabs" : null ,
1702 experiment.runtime.specialized?.headerScroll?.meaningful ? "preserve-header-scroll-state" : null ,
1703 experiment.runtime.specialized?.scrollTabs?.scrollChanges?. length ? "preserve-scroll-expanded-section-state" : null ,
1704 (experiment.runtime.specialized?.carousels || [experiment.runtime.specialized?.carousel]). some (( carousel ) => carousel?.changed) ? "preserve-carousel-card-expansion" : null ,
1705 ]. filter (Boolean),
1706 },
1707 };
1708 }
1709
1710 function summarizeSpecializedRuntime ( specialized = {}) {
1711 return {
1712 headerScroll: specialized.headerScroll ? {
1713 meaningful: Boolean (specialized.headerScroll.meaningful),
1714 changedProperties: specialized.headerScroll.changedProperties || [],
1715 screenshot: specialized.headerScroll.screenshot || null ,
1716 } : { missing: true },
1717 scrollTabs: specialized.scrollTabs ? {
1718 meaningful: Boolean (specialized.scrollTabs.meaningful),
1719 uniqueStateCount: specialized.scrollTabs.uniqueStateCount || 0 ,
1720 phaseCount: specialized.scrollTabs.phases?. length || 0 ,
1721 invariants: specialized.scrollTabs.invariants || {},
1722 screenshot: specialized.scrollTabs.screenshot || null ,
1723 } : { missing: true },
1724 carousels: (specialized.carousels || []). map (( carousel ) => ({
1725 id: carousel.id,
1726 changed: Boolean (carousel.changed),
1727 scope: carousel.scope,
1728 invariants: carousel.invariants,
1729 screenshot: carousel.screenshot || null ,
1730 ... (carousel.error ? { error: carousel.error } : {}),
1731 })),
1732 };
1733 }
1734
1735 function scopeMatchesSection ( scope , section ) {
1736 const identityToken = ( token ) => {
1737 const value = String (token || "" ). toLowerCase ();
1738 return value && ! / ^ (relative | absolute | fixed | sticky | static | block | inline | flex | grid | container | hidden | visible | w- | h- | min- | max- | p [trblxy] ? - | m [trblxy] ? - | bg- | text- | font- | z- | overflow | object- | top- | left- | right- | bottom- | translate- | scale- | rotate- | opacity- | rounded- | border- | shadow- | transition | duration- | ease- | sm: | md: | lg: | xl: | 2xl:)/ . test (value);
1739 };
1740 const scopeTokens = new Set ((scope.classTokens || []). map (( token ) => String (token). toLowerCase ()). filter (identityToken));
1741 const sectionTokens = [
1742 section.className,
1743 ... (section.domRef?.classTokens || []),
1744 ]. flatMap (( value ) => String (value || "" ). toLowerCase (). split ( / \s + / )). filter (identityToken);
1745 if (sectionTokens. some (( token ) => scopeTokens. has (token))) return true ;
1746 const scopeText = String (scope.textFingerprint || "" ). toLowerCase ();
1747 const heading = String (section.heading || "" ). toLowerCase (). trim ();
1748 return Boolean (heading. length >= 8 && scopeText. includes (heading. slice ( 0 , 32 )));
1749 }
1750
1751 export function bindInteractionsToSections ( interactions , pages ) {
1752 for ( const interaction of interactions) {
1753 const sectionIds = [];
1754 for ( const page of pages || []) {
1755 for ( const entry of page.interactionDiscovery?.sectionInteractions || []) {
1756 const matched = (entry.interactions || []). some (( summary ) => {
1757 if (summary.id) return summary.id === interaction.id;
1758 return summary.kind === interaction.kind &&
1759 summary.label === interaction.label &&
1760 normalizeTrigger (summary.trigger).type === normalizeTrigger (interaction.trigger).type;
1761 });
1762 if (matched) sectionIds. push (entry.sectionId);
1763 }
1764 }
1765 interaction.sectionIds = Array. from ( new Set (sectionIds));
1766 }
1767 }
1768
1769 function dedupeSectionInteractions ( items ) {
1770 const seen = new Set ();
1771 return items. filter (( item ) => {
1772 const key = `${ item . kind } \u0000 ${ item . label } \u0000 ${ item . trigger }` ;
1773 if (seen. has (key)) return false ;
1774 seen. add (key);
1775 return true ;
1776 });
1777 }
1778
1779 function classifyCapture ( capture ) {
1780 if (capture.kind) return capture.kind;
1781 const label = `${ capture . label || ""} ${ capture . sources ?. join ( " " ) || ""}` . toLowerCase ();
1782 if (capture.tag === "iframe" || capture.tag === "video" ) return "embedded-media" ;
1783 if (capture.tag === "summary" || capture.tag === "details" || capture.before?.ariaExpanded || capture.after?.ariaExpanded || label. includes ( "accordion" ) || label. includes ( "disclosure" )) return "accordion" ;
1784 if (capture.controlRole) return "control-state" ;
1785 if (label. includes ( "tab" ) || label. includes ( ".panel__btn" ) || capture.after?.ariaSelected) return "tabs" ;
1786 if (capture.kind === "carousel" || capture.sources?. some (( source ) => source. includes ( "carousel-state" ))) return "carousel" ;
1787 if (label. includes ( "story-card" ) || label. includes ( "carousel" )) return "hover-card" ;
1788 if (label. includes ( "header" ) || capture.sources?. some (( source ) => source. includes ( "header a" ))) return "sticky-transform" ;
1789 if (label. includes ( "button" ) || label. includes ( "next" ) || label. includes ( "prev" )) return "carousel-control" ;
1790 return "micro-interaction" ;
1791 }
1792
1793 function classifyImportance ( capture ) {
1794 const kind = classifyCapture (capture);
1795 if (kind === "tabs" || kind === "accordion" || kind === "embedded-media" || kind === "sticky-transform" || kind === "scroll-state" || kind === "carousel" ) return "core" ;
1796 if (kind === "hover-card" || kind === "carousel-control" ) return "supportive" ;
1797 if (kind === "control-state" ) return "supportive" ;
1798 return "decorative" ;
1799 }
1800
1801 function normalizeTrigger ( trigger ) {
1802 return typeof trigger === "string" ? { type: trigger } : trigger || { type: "unknown" };
1803 }
1804
1805 function summarizeStates ( capture ) {
1806 if (Array. isArray (capture.states) && capture.states. length ) return capture.states;
1807 if (capture.controlRole && (capture.before || capture.after)) {
1808 const properties = (capture.diff?.changedProperties || []). map (( item ) => item.property);
1809 return [
1810 capture.before ? compactControlSnapshot (capture.before, properties, { includeBase: true }) : null ,
1811 capture.after ? compactControlSnapshot (capture.after, properties) : null ,
1812 ]. filter (Boolean);
1813 }
1814 if (capture.before || capture.after) return [capture.before, capture.after]. filter (Boolean);
1815 return [];
1816 }
1817
1818 function compactControlSnapshot ( snapshot , properties , { includeBase = false } = {}) {
1819 const baseKeys = [
1820 "color" , "backgroundColor" , "borderColor" , "borderTopWidth" , "borderRightWidth" , "borderBottomWidth" , "borderLeftWidth" ,
1821 "textDecorationLine" , "textDecorationColor" , "textDecorationThickness" , "textUnderlineOffset" ,
1822 "outlineColor" , "outlineStyle" , "outlineWidth" , "outlineOffset" , "cursor" ,
1823 "transitionProperty" , "transitionDuration" , "transitionTimingFunction" ,
1824 ];
1825 const targetKeys = new Set (includeBase ? baseKeys : []);
1826 const pseudoKeys = { before: new Set (), after: new Set () };
1827 const childKeys = new Map ();
1828 for ( const property of properties || []) {
1829 const child = String (property). match ( / ^ child:( . +? )(?:::(before | after)) ? \. ( [ ^ .] + ) $ / );
1830 if (child) {
1831 const record = childKeys. get (child[ 1 ]) || { styles: new Set (), before: new Set (), after: new Set () };
1832 (child[ 2 ] ? record[child[ 2 ]] : record.styles). add (child[ 3 ]);
1833 childKeys. set (child[ 1 ], record);
1834 continue ;
1835 }
1836 const pseudo = String (property). match ( / ^ ::(before | after) \. ( [ ^ .] + ) $ / );
1837 if (pseudo) {
1838 pseudoKeys[pseudo[ 1 ]]. add (pseudo[ 2 ]);
1839 continue ;
1840 }
1841 if (snapshot.styles && property in snapshot.styles) targetKeys. add (property);
1842 }
1843 if (includeBase) {
1844 const timingKeys = [ "transitionProperty" , "transitionDuration" , "transitionTimingFunction" ];
1845 for ( const fields of childKeys. values ()) timingKeys. forEach (( key ) => fields.styles. add (key));
1846 for ( const pseudo of [ "before" , "after" ]) {
1847 if (pseudoKeys[pseudo].size) timingKeys. forEach (( key ) => pseudoKeys[pseudo]. add (key));
1848 }
1849 }
1850 const pick = ( source , keys ) => Object. fromEntries (Array. from (keys). map (( key ) => [key, source?.[key]]));
1851 return {
1852 styles: pick (snapshot.styles, targetKeys),
1853 pseudo: {
1854 before: pick (snapshot.pseudo?.before, pseudoKeys.before),
1855 after: pick (snapshot.pseudo?.after, pseudoKeys.after),
1856 },
1857 visualChildren: Array. from (childKeys. entries ()). map (([ key , fields ]) => {
1858 const child = (snapshot.visualChildren || []). find (( item ) => item.key === key) || {};
1859 return {
1860 key,
1861 styles: pick (child.styles, fields.styles),
1862 pseudo: { before: pick (child.pseudo?.before, fields.before), after: pick (child.pseudo?.after, fields.after) },
1863 };
1864 }),
1865 className: snapshot.className || "" ,
1866 ariaSelected: snapshot.ariaSelected || "" ,
1867 ariaExpanded: snapshot.ariaExpanded || "" ,
1868 ariaCurrent: snapshot.ariaCurrent || "" ,
1869 ariaPressed: snapshot.ariaPressed || "" ,
1870 disabled: Boolean (snapshot.disabled),
1871 };
1872 }
1873
1874 function implementationHintFor ( capture ) {
1875 const kind = classifyCapture (capture);
1876 if (kind === "sticky-transform" ) return "Preserve the source shared-chrome scroll state rather than collapsing it to one static header." ;
1877 if (kind === "tabs" ) return "Implement as an explicit content switcher keyed to the visible tab labels." ;
1878 if (kind === "accordion" ) return "Build a CMS-backed accordion repeater; preserve every closed panel's complete structure, the captured initial expansion, and the observed single/multiple-open behavior." ;
1879 if (kind === "scroll-state" ) return "Preserve the source section's scroll-driven visual and copy relationship rather than flattening it into a static block." ;
1880 if (kind === "carousel" ) return "Implement an overflowing horizontal rail with the captured active-card expansion and controls; do not flatten it into a grid." ;
1881 if (kind === "embedded-media" ) return "Keep the media interactive instead of replacing it with a static poster image." ;
1882 if (kind === "hover-card" || kind === "carousel-control" ) return "Preserve the hover/control affordance closely enough to maintain scanability and emphasis." ;
1883 if (kind === "control-state" ) return "Reproduce the captured state delta on its owning target, pseudo-element, or nested icon; preserve focus visibility and source timing." ;
1884 return "Preserve this behavior when it materially affects comprehension, navigation, or brand feel." ;
1885 }
1886
1887 function slugify ( value ) {
1888 return String (value || "interaction" )
1889 . toLowerCase ()
1890 . replace ( / [ ^ a-z0-9] + / g , "-" )
1891 . replace ( / ^ - +| - +$ / g , "" ) || "interaction" ;
1892 }
1893
1894 function safePageName ( page ) {
1895 return `${ page . area }-${ String ( page . path || "home" ). replace ( / [ ^ a-z0-9] + / gi , "-" ). replace ( / ^ - +| - +$ / g , "" ) || "home"}` ;
1896 }
1897
1898 if ( import . meta .url === `file://${ process . argv [ 1 ] }` ) {
1899 main (). catch (( error ) => {
1900 console. error (error.stack || error.message);
1901 process. exit ( 1 );
1902 });
1903 }