/** * extractors/widgets.ts — Phase 4.1 Widget & Structure Extractor * * Identifies structural UI patterns beyond raw CSS — gives LLM agents the * "blueprint" they need to clone a page architecturally, not just visually. * * Detects: * - Hero patterns (full-bleed, centered, split-screen) * - Navigation (sticky topbar, sidebar, mega-menu signals) * - Card grids (N-up, aspect ratio, content density) * - Pricing tables (2-4 column comparison) * - Testimonials (quote blocks) * - FAQ accordions * - CTA banners * - Footer composition * * Strategy: heuristic DOM analysis using string-based page.evaluate (avoids * tsx __name() compilation issues — same pattern as advanced.ts). */ import type { Page } from 'playwright'; export interface WidgetExtraction { hero: HeroPattern | null; navigation: NavPattern | null; cardGrid: CardGridPattern | null; pricingTable: PricingPattern | null; testimonials: TestimonialPattern | null; faq: FaqPattern | null; ctaBanner: CtaPattern | null; footer: FooterPattern | null; /** Total structural patterns detected (signal quality) */ detectedCount: number; } export interface HeroPattern { composition: 'centered' | 'split-left-text' | 'split-right-text' | 'full-bleed-image' | 'background-pattern'; hasMedia: boolean; mediaPosition: 'left' | 'right' | 'top' | 'bottom' | 'background' | 'none'; headingText: string; headingFontSize: number; subheadingText: string; ctaCount: number; ctaPrimaryText: string; heightPx: number; isFullViewport: boolean; contentAlignment: 'left' | 'center' | 'right'; } export interface NavPattern { position: 'sticky' | 'fixed' | 'static'; hasLogo: boolean; navItemCount: number; ctaCount: number; hasDarkMode: boolean; // toggle button detected? hasSearchBar: boolean; heightPx: number; layout: 'logo-left-links-right' | 'logo-center-links-split' | 'logo-left-links-left' | 'unknown'; } export interface CardGridPattern { cardCount: number; columnsDesktop: number; cardAspectRatio: number; // width / height cardHasImage: boolean; cardHasIcon: boolean; cardHasCta: boolean; imagePosition: 'top' | 'left' | 'background' | 'none'; averageCardWidthPx: number; averageCardHeightPx: number; gapPx: number; } export interface PricingPattern { tierCount: number; hasPriceLabels: boolean; hasFeatureList: boolean; featuresPerTier: number; highlightedTierIndex: number; // -1 if none columnsDesktop: number; layout: 'side-by-side' | 'vertical-stack'; } export interface TestimonialPattern { count: number; hasAvatar: boolean; hasCompanyLogo: boolean; hasRating: boolean; // stars or score layout: 'single-feature' | 'grid' | 'carousel' | 'list'; averageQuoteLength: number; } export interface FaqPattern { itemCount: number; isAccordion: boolean; // expand/collapse detected via
or aria-expanded layout: 'single-column' | 'two-column'; averageQuestionLength: number; } export interface CtaPattern { count: number; hasContrastBg: boolean; // background distinct from body bg hasGradient: boolean; primaryButtonText: string; position: 'mid-page' | 'bottom' | 'multiple'; } export interface FooterPattern { columnCount: number; linkCount: number; hasNewsletter: boolean; hasSocialIcons: boolean; hasLanguageSwitcher: boolean; hasCopyrightLine: boolean; heightPx: number; } // ───────────────────────────────────────────────────────────────────────────── export async function extractWidgets(page: Page): Promise { // String-based page.evaluate to avoid tsx __name() emission for named functions return page.evaluate(`(function() { // ── HELPERS ────────────────────────────────────────────────────────── function visibleRect(el) { var r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return null; var cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) return null; return { x: r.left, y: r.top + window.scrollY, width: r.width, height: r.height }; } function getTextContent(el, maxLen) { var txt = (el.textContent || '').replace(/\\s+/g, ' ').trim(); return maxLen ? txt.slice(0, maxLen) : txt; } function findCtaButtons(scope) { var nodes = scope.querySelectorAll('a[href], button, [role="button"]'); var ctas = []; for (var i = 0; i < nodes.length; i++) { var n = nodes[i]; var rect = visibleRect(n); if (!rect) continue; var text = getTextContent(n, 60); if (!text || text.length < 2) continue; // Heuristic: CTAs have padding, are not tiny, are not pure icons var cs = getComputedStyle(n); var hasBg = cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent'; var hasPad = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) > 16; var isLargeText = parseFloat(cs.fontSize) >= 12; if (isLargeText && (hasBg || hasPad)) { ctas.push({ text: text, rect: rect, hasBg: hasBg }); } } return ctas; } // ── HERO ───────────────────────────────────────────────────────────── function detectHero() { var viewportH = window.innerHeight; // Look for largest h1 in top viewport var headings = document.querySelectorAll('h1, [class*="hero"] h2, [class*="banner"] h2'); var bestH = null; var bestSize = 0; for (var i = 0; i < headings.length; i++) { var h = headings[i]; var rect = visibleRect(h); if (!rect) continue; if (rect.y > viewportH * 1.5) continue; var size = parseFloat(getComputedStyle(h).fontSize) || 0; if (size > bestSize) { bestSize = size; bestH = h; } } if (!bestH) return null; // Find the hero container (climb until we hit a section-like parent) var hero = bestH.closest('header, section, [class*="hero"], [class*="banner"], main > div, main > section'); if (!hero) hero = bestH.parentElement; if (!hero) return null; var heroRect = visibleRect(hero); if (!heroRect) return null; // Subheading: largest text below h1 in same container, font-size 14-24px var subTxt = ''; var paras = hero.querySelectorAll('p, h2, h3, [class*="subtitle"], [class*="lead"]'); for (var i = 0; i < paras.length; i++) { var p = paras[i]; if (p === bestH) continue; var pRect = visibleRect(p); if (!pRect) continue; if (pRect.y < heroRect.y) continue; var pSize = parseFloat(getComputedStyle(p).fontSize) || 0; if (pSize < 12 || pSize > 32) continue; var t = getTextContent(p, 200); if (t.length > 20 && t.length > subTxt.length) { subTxt = t; if (subTxt.length > 60) break; } } // CTAs var heroCtas = findCtaButtons(hero); var primaryCta = heroCtas.length > 0 ? heroCtas[0].text : ''; // Media detection var imgs = hero.querySelectorAll('img, video, picture, svg, canvas, [class*="illustration"], [class*="hero-image"]'); var hasMedia = false; var mediaPos = 'none'; var mediaX = 0; for (var i = 0; i < imgs.length; i++) { var img = imgs[i]; var iRect = visibleRect(img); if (!iRect) continue; if (iRect.width < 80 || iRect.height < 80) continue; hasMedia = true; mediaX = iRect.x + iRect.width / 2; break; } var heroCenterX = heroRect.x + heroRect.width / 2; var bgImage = getComputedStyle(hero).backgroundImage; var hasBg = bgImage && bgImage !== 'none' && bgImage.indexOf('url(') !== -1; var composition = 'centered'; if (hasMedia) { if (mediaX < heroCenterX - 100) { mediaPos = 'left'; composition = 'split-right-text'; } else if (mediaX > heroCenterX + 100) { mediaPos = 'right'; composition = 'split-left-text'; } else { mediaPos = 'top'; composition = 'centered'; } } else if (hasBg) { composition = 'background-pattern'; mediaPos = 'background'; } // Content alignment from text-align var headingCs = getComputedStyle(bestH); var alignment = 'left'; if (headingCs.textAlign === 'center') alignment = 'center'; else if (headingCs.textAlign === 'right') alignment = 'right'; return { composition: composition, hasMedia: hasMedia, mediaPosition: mediaPos, headingText: getTextContent(bestH, 200), headingFontSize: bestSize, subheadingText: subTxt, ctaCount: heroCtas.length, ctaPrimaryText: primaryCta, heightPx: Math.round(heroRect.height), isFullViewport: heroRect.height >= viewportH * 0.7, contentAlignment: alignment, }; } // ── NAVIGATION ─────────────────────────────────────────────────────── function detectNav() { var navEl = document.querySelector('header > nav, header nav, body > nav, [role="banner"] nav, nav[class*="header"], nav[class*="top"], nav'); if (!navEl) { // Fallback: top header element navEl = document.querySelector('header, [role="banner"]'); } if (!navEl) return null; var rect = visibleRect(navEl); if (!rect) return null; if (rect.y > 300) return null; // not at top var cs = getComputedStyle(navEl); var pos = cs.position; var stickyOrFixed = (pos === 'sticky' || pos === 'fixed') ? pos : 'static'; // Logo: at left containing img or text near brand var logoEl = navEl.querySelector('a[href="/"], a[class*="logo"], a[aria-label*="ogo" i]'); var hasLogo = !!logoEl; if (!hasLogo) { // Heuristic: leftmost link with img child var leftLinks = navEl.querySelectorAll('a img, a svg'); if (leftLinks.length > 0) hasLogo = true; } // Nav items: links with text in nav var navLinks = navEl.querySelectorAll('a'); var itemCount = 0; var ctaCount = 0; for (var i = 0; i < navLinks.length; i++) { var l = navLinks[i]; var t = getTextContent(l, 50); if (!t || t.length < 2) continue; var lr = visibleRect(l); if (!lr) continue; var lcs = getComputedStyle(l); var hasBg = lcs.backgroundColor && lcs.backgroundColor !== 'rgba(0, 0, 0, 0)' && lcs.backgroundColor !== 'transparent'; var hasBorder = lcs.borderWidth && parseFloat(lcs.borderWidth) > 0; if (hasBg || hasBorder) { ctaCount++; } else { itemCount++; } } var hasSearch = !!navEl.querySelector('input[type="search"], [role="searchbox"], [class*="search"]'); var hasDarkMode = !!navEl.querySelector('button[aria-label*="theme" i], button[aria-label*="mode" i], [class*="theme-toggle"], [class*="dark-mode"]'); // Layout heuristic from logo + links positions var layout = 'unknown'; if (hasLogo && itemCount > 0) { // Look at first nav link position vs logo var firstLink = null; for (var j = 0; j < navLinks.length; j++) { var t2 = getTextContent(navLinks[j], 50); if (t2 && t2.length > 2) { firstLink = navLinks[j]; break; } } if (firstLink && logoEl) { var lRect = visibleRect(logoEl); var fRect = visibleRect(firstLink); if (lRect && fRect) { var navCenterX = rect.x + rect.width / 2; if (Math.abs(lRect.x + lRect.width / 2 - navCenterX) < 100) { layout = 'logo-center-links-split'; } else if (fRect.x > rect.x + rect.width / 2) { layout = 'logo-left-links-right'; } else { layout = 'logo-left-links-left'; } } } } return { position: stickyOrFixed, hasLogo: hasLogo, navItemCount: itemCount, ctaCount: ctaCount, hasDarkMode: hasDarkMode, hasSearchBar: hasSearch, heightPx: Math.round(rect.height), layout: layout, }; } // ── CARD GRID ──────────────────────────────────────────────────────── function detectCardGrid() { // Find containers with 3+ direct children of similar size var containers = document.querySelectorAll('ul, ol, div, section, main > div'); var bestGrid = null; var bestScore = 0; for (var i = 0; i < containers.length; i++) { var c = containers[i]; var children = c.children; if (children.length < 3 || children.length > 30) continue; // Sample first 4 children var rects = []; for (var j = 0; j < Math.min(children.length, 6); j++) { var r = visibleRect(children[j]); if (r) rects.push(r); } if (rects.length < 3) continue; // Check homogeneity: similar widths AND similar heights var widths = rects.map(function(r){ return r.width; }); var heights = rects.map(function(r){ return r.height; }); var avgW = widths.reduce(function(a,b){return a+b;}, 0) / widths.length; var avgH = heights.reduce(function(a,b){return a+b;}, 0) / heights.length; if (avgW < 120 || avgH < 80) continue; var widthVar = widths.reduce(function(a,b){return a + Math.abs(b - avgW);}, 0) / widths.length; var heightVar = heights.reduce(function(a,b){return a + Math.abs(b - avgH);}, 0) / heights.length; if (widthVar / avgW > 0.2) continue; // not homogeneous widths if (heightVar / avgH > 0.3) continue; // not homogeneous heights var score = rects.length * (avgW * avgH / 10000); if (score > bestScore) { bestScore = score; // Sample first card for content analysis var firstChild = children[0]; var hasImg = !!firstChild.querySelector('img, picture, video'); var hasIcon = !hasImg && !!firstChild.querySelector('svg'); var hasCta = findCtaButtons(firstChild).length > 0; var imgEl = firstChild.querySelector('img, picture, video'); var imgPos = 'none'; if (imgEl) { var iRect = visibleRect(imgEl); var cRect = visibleRect(firstChild); if (iRect && cRect) { if (iRect.y - cRect.y < 10) imgPos = 'top'; else if (iRect.x - cRect.x < 10 && iRect.width < cRect.width * 0.6) imgPos = 'left'; else imgPos = 'top'; } } // Columns: derived from how many cards fit in viewport width var viewportW = window.innerWidth; var colsDesktop = Math.max(1, Math.round(viewportW / avgW)); // Gap: distance between first 2 cards var gap = 0; if (rects.length >= 2) { if (rects[0].y === rects[1].y) { // horizontal row gap = Math.max(0, rects[1].x - (rects[0].x + rects[0].width)); } else { gap = Math.max(0, rects[1].y - (rects[0].y + rects[0].height)); } } bestGrid = { cardCount: children.length, columnsDesktop: colsDesktop, cardAspectRatio: Math.round((avgW / avgH) * 100) / 100, cardHasImage: hasImg, cardHasIcon: hasIcon, cardHasCta: hasCta, imagePosition: imgPos, averageCardWidthPx: Math.round(avgW), averageCardHeightPx: Math.round(avgH), gapPx: Math.round(gap), }; } } return bestGrid; } // ── PRICING TABLE ──────────────────────────────────────────────────── function detectPricing() { // Pricing = container with 2-5 children, each containing price-like text (currency + number) var priceRegex = /[\\$€£¥][\\s]?\\d|\\d+[\\s]?\\/(month|mo|year|yr)|free/i; var containers = document.querySelectorAll('section, div, ul'); for (var i = 0; i < containers.length; i++) { var c = containers[i]; var children = c.children; if (children.length < 2 || children.length > 5) continue; var matches = 0; var highlightIdx = -1; var featuresPerTier = []; for (var j = 0; j < children.length; j++) { var ch = children[j]; var text = ch.textContent || ''; if (priceRegex.test(text)) { matches++; // Count features (li or check items) var feats = ch.querySelectorAll('li, [class*="check"], [class*="feat"]'); featuresPerTier.push(feats.length); // Highlighted: has unique border or background vs siblings var cs = getComputedStyle(ch); if (cs.borderWidth && parseFloat(cs.borderWidth) >= 2) highlightIdx = j; } } if (matches >= 2 && matches === children.length) { var avgFeat = featuresPerTier.length > 0 ? featuresPerTier.reduce(function(a,b){return a+b;}, 0) / featuresPerTier.length : 0; var rect = visibleRect(c); if (!rect) continue; var firstChildRect = visibleRect(children[0]); var secondChildRect = children.length > 1 ? visibleRect(children[1]) : null; var isSideBySide = firstChildRect && secondChildRect && Math.abs(firstChildRect.y - secondChildRect.y) < 20; return { tierCount: matches, hasPriceLabels: true, hasFeatureList: avgFeat > 0, featuresPerTier: Math.round(avgFeat), highlightedTierIndex: highlightIdx, columnsDesktop: matches, layout: isSideBySide ? 'side-by-side' : 'vertical-stack', }; } } return null; } // ── TESTIMONIALS ───────────────────────────────────────────────────── function detectTestimonials() { // Heuristic:
, or class includes testimonial/review/quote var nodes = document.querySelectorAll('blockquote, [class*="testimonial" i], [class*="review" i], [class*="quote" i]'); var visible = []; for (var i = 0; i < nodes.length; i++) { var n = nodes[i]; var r = visibleRect(n); if (!r) continue; var t = getTextContent(n, 500); if (t.length < 20) continue; visible.push({ node: n, text: t, rect: r }); } if (visible.length === 0) return null; var hasAvatar = false; var hasLogo = false; var hasRating = false; var totalLen = 0; for (var j = 0; j < visible.length; j++) { var v = visible[j]; if (v.node.querySelector('img[class*="avatar" i], img[class*="profile" i]') || (v.node.querySelector('img') && v.node.querySelector('img').width < 80)) hasAvatar = true; if (v.node.querySelector('img[class*="logo" i], svg[class*="logo" i]')) hasLogo = true; if (v.node.querySelector('[class*="star" i], [class*="rating" i], [aria-label*="rating" i]') || /★|⭐|☆/.test(v.text)) hasRating = true; totalLen += v.text.length; } var layout = 'single-feature'; if (visible.length >= 4) layout = 'grid'; else if (visible.length >= 2) layout = 'list'; return { count: visible.length, hasAvatar: hasAvatar, hasCompanyLogo: hasLogo, hasRating: hasRating, layout: layout, averageQuoteLength: Math.round(totalLen / visible.length), }; } // ── FAQ ────────────────────────────────────────────────────────────── function detectFaq() { // Look for
/ or aria-expanded patterns or "FAQ" heading var details = document.querySelectorAll('details > summary'); var expanded = document.querySelectorAll('[aria-expanded]'); var faqEls = []; if (details.length >= 2) { for (var i = 0; i < details.length; i++) { var r = visibleRect(details[i]); if (r) faqEls.push({ node: details[i], text: getTextContent(details[i], 200) }); } } else if (expanded.length >= 3) { // Filter to ones that look question-shaped (end with ? or have h-tag inside) for (var i = 0; i < expanded.length; i++) { var e = expanded[i]; var t = getTextContent(e, 200); if (t.length > 10 && (t.indexOf('?') !== -1 || e.querySelector('h2, h3, h4'))) { var r = visibleRect(e); if (r) faqEls.push({ node: e, text: t }); } } } if (faqEls.length < 2) return null; var totalQuestionLen = 0; for (var j = 0; j < faqEls.length; j++) { totalQuestionLen += faqEls[j].text.length; } // Layout: 2-col if two columns of items detected var firstY = visibleRect(faqEls[0].node); var secondY = visibleRect(faqEls[1].node); var twoCol = firstY && secondY && Math.abs(firstY.y - secondY.y) < 30; return { itemCount: faqEls.length, isAccordion: details.length >= 2 || expanded.length >= 3, layout: twoCol ? 'two-column' : 'single-column', averageQuestionLength: Math.round(totalQuestionLen / faqEls.length), }; } // ── CTA BANNER ─────────────────────────────────────────────────────── function detectCtaBanner() { // Heuristic: section with a prominent button and contrasting background, not the hero var sections = document.querySelectorAll('section, [class*="cta" i], [class*="banner" i]'); var viewportH = window.innerHeight; var bodyBg = getComputedStyle(document.body).backgroundColor; var found = 0; var primary = ''; var hasContrast = false; var hasGrad = false; var positions = []; for (var i = 0; i < sections.length; i++) { var s = sections[i]; var rect = visibleRect(s); if (!rect) continue; if (rect.y < viewportH * 0.5) continue; // skip hero zone var ctas = findCtaButtons(s); if (ctas.length === 0) continue; var cs = getComputedStyle(s); var sBg = cs.backgroundColor; var contrasted = sBg && sBg !== bodyBg && sBg !== 'rgba(0, 0, 0, 0)'; var bgImage = cs.backgroundImage; var grad = bgImage && bgImage.indexOf('gradient') !== -1; if (!contrasted && !grad) continue; found++; if (!primary && ctas[0].text) primary = ctas[0].text; if (contrasted) hasContrast = true; if (grad) hasGrad = true; positions.push(rect.y); } if (found === 0) return null; var pos = 'mid-page'; if (found > 1) pos = 'multiple'; else if (positions[0] > (document.documentElement.scrollHeight - viewportH * 1.5)) pos = 'bottom'; return { count: found, hasContrastBg: hasContrast, hasGradient: hasGrad, primaryButtonText: primary, position: pos, }; } // ── FOOTER ─────────────────────────────────────────────────────────── function detectFooter() { var footer = document.querySelector('footer, [role="contentinfo"]'); if (!footer) { // Fallback: last large section near bottom var totalH = document.documentElement.scrollHeight; var sections = document.querySelectorAll('section, div'); for (var i = sections.length - 1; i >= 0; i--) { var r = visibleRect(sections[i]); if (r && r.y > totalH - 800 && r.width > window.innerWidth * 0.5) { footer = sections[i]; break; } } } if (!footer) return null; var rect = visibleRect(footer); if (!rect) return null; // Columns: count direct flex/grid children, or count distinct X positions of headings var directChildren = footer.children; var xPositions = new Set(); for (var i = 0; i < directChildren.length; i++) { var dr = visibleRect(directChildren[i]); if (dr) xPositions.add(Math.round(dr.x / 50) * 50); } var columnCount = xPositions.size || 1; if (columnCount === 1) { // Try by header h3/h4 positions var heads = footer.querySelectorAll('h3, h4, [class*="title"]'); var headX = new Set(); for (var i = 0; i < heads.length; i++) { var hr = visibleRect(heads[i]); if (hr) headX.add(Math.round(hr.x / 80) * 80); } if (headX.size > 1) columnCount = headX.size; } columnCount = Math.min(columnCount, 6); var links = footer.querySelectorAll('a'); var visibleLinks = 0; for (var i = 0; i < links.length; i++) { if (visibleRect(links[i])) visibleLinks++; } var newsletter = !!footer.querySelector('input[type="email"], form[class*="newsletter" i]'); var social = !!footer.querySelector('a[href*="twitter"], a[href*="x.com"], a[href*="facebook"], a[href*="instagram"], a[href*="linkedin"], a[href*="github"], a[aria-label*="ocial" i]'); var langSwitch = !!footer.querySelector('[class*="lang" i] select, [aria-label*="language" i]'); // Copyright: text containing ©, "all rights reserved", or year var fText = footer.textContent || ''; var copyright = /©|all rights reserved|copyright/i.test(fText); return { columnCount: columnCount, linkCount: visibleLinks, hasNewsletter: newsletter, hasSocialIcons: social, hasLanguageSwitcher: langSwitch, hasCopyrightLine: copyright, heightPx: Math.round(rect.height), }; } // ── ASSEMBLE ───────────────────────────────────────────────────────── var hero = detectHero(); var nav = detectNav(); var cardGrid = detectCardGrid(); var pricing = detectPricing(); var testimonials = detectTestimonials(); var faq = detectFaq(); var cta = detectCtaBanner(); var footer = detectFooter(); var detectedCount = 0; if (hero) detectedCount++; if (nav) detectedCount++; if (cardGrid) detectedCount++; if (pricing) detectedCount++; if (testimonials) detectedCount++; if (faq) detectedCount++; if (cta) detectedCount++; if (footer) detectedCount++; return { hero: hero, navigation: nav, cardGrid: cardGrid, pricingTable: pricing, testimonials: testimonials, faq: faq, ctaBanner: cta, footer: footer, detectedCount: detectedCount, }; })()`); }