prism-design 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/LICENSE +21 -0
  3. package/README.md +203 -0
  4. package/bin/clone-architect.mjs +476 -0
  5. package/bin/prism.mjs +467 -0
  6. package/catalog/index.json +1155 -0
  7. package/extractions/airbnb.com/DESIGN.md +1068 -0
  8. package/extractions/airbnb.com/tokens.json +507 -0
  9. package/extractions/attio.com/DESIGN.md +1295 -0
  10. package/extractions/attio.com/tokens.json +438 -0
  11. package/extractions/auroxdashboard.com/DESIGN.md +724 -0
  12. package/extractions/auroxdashboard.com/tokens.json +195 -0
  13. package/extractions/careerexplorer.com/DESIGN.md +1178 -0
  14. package/extractions/careerexplorer.com/tokens.json +141 -0
  15. package/extractions/chance.co/DESIGN.md +1209 -0
  16. package/extractions/chance.co/tokens.json +160 -0
  17. package/extractions/choisis-ton-avenir.com/DESIGN.md +1265 -0
  18. package/extractions/choisis-ton-avenir.com/tokens.json +227 -0
  19. package/extractions/example.com/DESIGN.md +436 -0
  20. package/extractions/example.com/tokens.json +91 -0
  21. package/extractions/getdesign.md/DESIGN.md +1009 -0
  22. package/extractions/getdesign.md/tokens.json +219 -0
  23. package/extractions/github.com/DESIGN.md +1130 -0
  24. package/extractions/github.com/tokens.json +2092 -0
  25. package/extractions/hello-charly.com/DESIGN.md +1146 -0
  26. package/extractions/hello-charly.com/tokens.json +322 -0
  27. package/extractions/hyperliquid.xyz/DESIGN.md +779 -0
  28. package/extractions/hyperliquid.xyz/tokens.json +598 -0
  29. package/extractions/instagram.com/DESIGN.md +996 -0
  30. package/extractions/instagram.com/tokens.json +1240 -0
  31. package/extractions/jobirl.com/DESIGN.md +1160 -0
  32. package/extractions/jobirl.com/tokens.json +139 -0
  33. package/extractions/life360.com/DESIGN.md +1133 -0
  34. package/extractions/life360.com/tokens.json +491 -0
  35. package/extractions/lifesum.com/DESIGN.md +965 -0
  36. package/extractions/lifesum.com/tokens.json +170 -0
  37. package/extractions/linear.app/DESIGN.md +1301 -0
  38. package/extractions/linear.app/tokens.json +732 -0
  39. package/extractions/mavoie.org/DESIGN.md +1148 -0
  40. package/extractions/mavoie.org/tokens.json +128 -0
  41. package/extractions/miro.com/DESIGN.md +1237 -0
  42. package/extractions/miro.com/tokens.json +401 -0
  43. package/extractions/notion.so/DESIGN.md +1319 -0
  44. package/extractions/notion.so/tokens.json +906 -0
  45. package/extractions/onetonline.org/DESIGN.md +909 -0
  46. package/extractions/onetonline.org/tokens.json +280 -0
  47. package/extractions/posthog.com/DESIGN.md +1024 -0
  48. package/extractions/posthog.com/tokens.json +197 -0
  49. package/extractions/revolut.com/DESIGN.md +1080 -0
  50. package/extractions/revolut.com/tokens.json +401 -0
  51. package/extractions/stripe.com/DESIGN.md +1272 -0
  52. package/extractions/stripe.com/tokens.json +794 -0
  53. package/extractions/switchcollective.com/DESIGN.md +1040 -0
  54. package/extractions/switchcollective.com/tokens.json +98 -0
  55. package/extractions/truity.com/DESIGN.md +970 -0
  56. package/extractions/truity.com/tokens.json +166 -0
  57. package/extractions/uniquekicks.be/DESIGN.md +1171 -0
  58. package/extractions/uniquekicks.be/tokens.json +237 -0
  59. package/package.json +122 -0
  60. package/scripts/analyze.ts +281 -0
  61. package/scripts/bank-register.ts +379 -0
  62. package/scripts/bank.ts +374 -0
  63. package/scripts/browser-stealth.ts +189 -0
  64. package/scripts/clone.ts +198 -0
  65. package/scripts/compare-vs-gd-final.ts +273 -0
  66. package/scripts/compare-vs-gd.ts +269 -0
  67. package/scripts/compare.ts +405 -0
  68. package/scripts/deploy-site.ts +181 -0
  69. package/scripts/diff-snapshots.ts +340 -0
  70. package/scripts/enrich-catalog.ts +212 -0
  71. package/scripts/extract.ts +2038 -0
  72. package/scripts/extractors/advanced.ts +524 -0
  73. package/scripts/extractors/widgets.ts +711 -0
  74. package/scripts/generate-design-md.ts +5775 -0
  75. package/scripts/generate-final-pdf.ts +274 -0
  76. package/scripts/generate-og-image.ts +87 -0
  77. package/scripts/generate-showcase.ts +1588 -0
  78. package/scripts/generate-site.ts +847 -0
  79. package/scripts/mass-extract.sh +91 -0
  80. package/scripts/post-process-all.sh +55 -0
  81. package/scripts/regen-catalog.ts +203 -0
  82. package/scripts/shared/cache.ts +149 -0
  83. package/scripts/shared/css-helpers.ts +263 -0
  84. package/scripts/shared/logger.ts +57 -0
  85. package/scripts/shared/named-colors.ts +355 -0
  86. package/scripts/shared/types.ts +220 -0
  87. package/scripts/sync-catalog.ts +105 -0
  88. package/scripts/tokenize.ts +988 -0
  89. package/templates/layout-template.md +52 -0
  90. package/templates/tokens-template.json +34 -0
@@ -0,0 +1,711 @@
1
+ /**
2
+ * extractors/widgets.ts — Phase 4.1 Widget & Structure Extractor
3
+ *
4
+ * Identifies structural UI patterns beyond raw CSS — gives LLM agents the
5
+ * "blueprint" they need to clone a page architecturally, not just visually.
6
+ *
7
+ * Detects:
8
+ * - Hero patterns (full-bleed, centered, split-screen)
9
+ * - Navigation (sticky topbar, sidebar, mega-menu signals)
10
+ * - Card grids (N-up, aspect ratio, content density)
11
+ * - Pricing tables (2-4 column comparison)
12
+ * - Testimonials (quote blocks)
13
+ * - FAQ accordions
14
+ * - CTA banners
15
+ * - Footer composition
16
+ *
17
+ * Strategy: heuristic DOM analysis using string-based page.evaluate (avoids
18
+ * tsx __name() compilation issues — same pattern as advanced.ts).
19
+ */
20
+
21
+ import type { Page } from 'playwright';
22
+
23
+ export interface WidgetExtraction {
24
+ hero: HeroPattern | null;
25
+ navigation: NavPattern | null;
26
+ cardGrid: CardGridPattern | null;
27
+ pricingTable: PricingPattern | null;
28
+ testimonials: TestimonialPattern | null;
29
+ faq: FaqPattern | null;
30
+ ctaBanner: CtaPattern | null;
31
+ footer: FooterPattern | null;
32
+ /** Total structural patterns detected (signal quality) */
33
+ detectedCount: number;
34
+ }
35
+
36
+ export interface HeroPattern {
37
+ composition: 'centered' | 'split-left-text' | 'split-right-text' | 'full-bleed-image' | 'background-pattern';
38
+ hasMedia: boolean;
39
+ mediaPosition: 'left' | 'right' | 'top' | 'bottom' | 'background' | 'none';
40
+ headingText: string;
41
+ headingFontSize: number;
42
+ subheadingText: string;
43
+ ctaCount: number;
44
+ ctaPrimaryText: string;
45
+ heightPx: number;
46
+ isFullViewport: boolean;
47
+ contentAlignment: 'left' | 'center' | 'right';
48
+ }
49
+
50
+ export interface NavPattern {
51
+ position: 'sticky' | 'fixed' | 'static';
52
+ hasLogo: boolean;
53
+ navItemCount: number;
54
+ ctaCount: number;
55
+ hasDarkMode: boolean; // toggle button detected?
56
+ hasSearchBar: boolean;
57
+ heightPx: number;
58
+ layout: 'logo-left-links-right' | 'logo-center-links-split' | 'logo-left-links-left' | 'unknown';
59
+ }
60
+
61
+ export interface CardGridPattern {
62
+ cardCount: number;
63
+ columnsDesktop: number;
64
+ cardAspectRatio: number; // width / height
65
+ cardHasImage: boolean;
66
+ cardHasIcon: boolean;
67
+ cardHasCta: boolean;
68
+ imagePosition: 'top' | 'left' | 'background' | 'none';
69
+ averageCardWidthPx: number;
70
+ averageCardHeightPx: number;
71
+ gapPx: number;
72
+ }
73
+
74
+ export interface PricingPattern {
75
+ tierCount: number;
76
+ hasPriceLabels: boolean;
77
+ hasFeatureList: boolean;
78
+ featuresPerTier: number;
79
+ highlightedTierIndex: number; // -1 if none
80
+ columnsDesktop: number;
81
+ layout: 'side-by-side' | 'vertical-stack';
82
+ }
83
+
84
+ export interface TestimonialPattern {
85
+ count: number;
86
+ hasAvatar: boolean;
87
+ hasCompanyLogo: boolean;
88
+ hasRating: boolean; // stars or score
89
+ layout: 'single-feature' | 'grid' | 'carousel' | 'list';
90
+ averageQuoteLength: number;
91
+ }
92
+
93
+ export interface FaqPattern {
94
+ itemCount: number;
95
+ isAccordion: boolean; // expand/collapse detected via <details> or aria-expanded
96
+ layout: 'single-column' | 'two-column';
97
+ averageQuestionLength: number;
98
+ }
99
+
100
+ export interface CtaPattern {
101
+ count: number;
102
+ hasContrastBg: boolean; // background distinct from body bg
103
+ hasGradient: boolean;
104
+ primaryButtonText: string;
105
+ position: 'mid-page' | 'bottom' | 'multiple';
106
+ }
107
+
108
+ export interface FooterPattern {
109
+ columnCount: number;
110
+ linkCount: number;
111
+ hasNewsletter: boolean;
112
+ hasSocialIcons: boolean;
113
+ hasLanguageSwitcher: boolean;
114
+ hasCopyrightLine: boolean;
115
+ heightPx: number;
116
+ }
117
+
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+
120
+ export async function extractWidgets(page: Page): Promise<WidgetExtraction> {
121
+ // String-based page.evaluate to avoid tsx __name() emission for named functions
122
+ return page.evaluate(`(function() {
123
+ // ── HELPERS ──────────────────────────────────────────────────────────
124
+ function visibleRect(el) {
125
+ var r = el.getBoundingClientRect();
126
+ if (r.width < 1 || r.height < 1) return null;
127
+ var cs = getComputedStyle(el);
128
+ if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) return null;
129
+ return { x: r.left, y: r.top + window.scrollY, width: r.width, height: r.height };
130
+ }
131
+
132
+ function getTextContent(el, maxLen) {
133
+ var txt = (el.textContent || '').replace(/\\s+/g, ' ').trim();
134
+ return maxLen ? txt.slice(0, maxLen) : txt;
135
+ }
136
+
137
+ function findCtaButtons(scope) {
138
+ var nodes = scope.querySelectorAll('a[href], button, [role="button"]');
139
+ var ctas = [];
140
+ for (var i = 0; i < nodes.length; i++) {
141
+ var n = nodes[i];
142
+ var rect = visibleRect(n);
143
+ if (!rect) continue;
144
+ var text = getTextContent(n, 60);
145
+ if (!text || text.length < 2) continue;
146
+ // Heuristic: CTAs have padding, are not tiny, are not pure icons
147
+ var cs = getComputedStyle(n);
148
+ var hasBg = cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent';
149
+ var hasPad = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) > 16;
150
+ var isLargeText = parseFloat(cs.fontSize) >= 12;
151
+ if (isLargeText && (hasBg || hasPad)) {
152
+ ctas.push({ text: text, rect: rect, hasBg: hasBg });
153
+ }
154
+ }
155
+ return ctas;
156
+ }
157
+
158
+ // ── HERO ─────────────────────────────────────────────────────────────
159
+ function detectHero() {
160
+ var viewportH = window.innerHeight;
161
+ // Look for largest h1 in top viewport
162
+ var headings = document.querySelectorAll('h1, [class*="hero"] h2, [class*="banner"] h2');
163
+ var bestH = null;
164
+ var bestSize = 0;
165
+ for (var i = 0; i < headings.length; i++) {
166
+ var h = headings[i];
167
+ var rect = visibleRect(h);
168
+ if (!rect) continue;
169
+ if (rect.y > viewportH * 1.5) continue;
170
+ var size = parseFloat(getComputedStyle(h).fontSize) || 0;
171
+ if (size > bestSize) {
172
+ bestSize = size;
173
+ bestH = h;
174
+ }
175
+ }
176
+ if (!bestH) return null;
177
+
178
+ // Find the hero container (climb until we hit a section-like parent)
179
+ var hero = bestH.closest('header, section, [class*="hero"], [class*="banner"], main > div, main > section');
180
+ if (!hero) hero = bestH.parentElement;
181
+ if (!hero) return null;
182
+ var heroRect = visibleRect(hero);
183
+ if (!heroRect) return null;
184
+
185
+ // Subheading: largest text below h1 in same container, font-size 14-24px
186
+ var subTxt = '';
187
+ var paras = hero.querySelectorAll('p, h2, h3, [class*="subtitle"], [class*="lead"]');
188
+ for (var i = 0; i < paras.length; i++) {
189
+ var p = paras[i];
190
+ if (p === bestH) continue;
191
+ var pRect = visibleRect(p);
192
+ if (!pRect) continue;
193
+ if (pRect.y < heroRect.y) continue;
194
+ var pSize = parseFloat(getComputedStyle(p).fontSize) || 0;
195
+ if (pSize < 12 || pSize > 32) continue;
196
+ var t = getTextContent(p, 200);
197
+ if (t.length > 20 && t.length > subTxt.length) {
198
+ subTxt = t;
199
+ if (subTxt.length > 60) break;
200
+ }
201
+ }
202
+
203
+ // CTAs
204
+ var heroCtas = findCtaButtons(hero);
205
+ var primaryCta = heroCtas.length > 0 ? heroCtas[0].text : '';
206
+
207
+ // Media detection
208
+ var imgs = hero.querySelectorAll('img, video, picture, svg, canvas, [class*="illustration"], [class*="hero-image"]');
209
+ var hasMedia = false;
210
+ var mediaPos = 'none';
211
+ var mediaX = 0;
212
+ for (var i = 0; i < imgs.length; i++) {
213
+ var img = imgs[i];
214
+ var iRect = visibleRect(img);
215
+ if (!iRect) continue;
216
+ if (iRect.width < 80 || iRect.height < 80) continue;
217
+ hasMedia = true;
218
+ mediaX = iRect.x + iRect.width / 2;
219
+ break;
220
+ }
221
+
222
+ var heroCenterX = heroRect.x + heroRect.width / 2;
223
+ var bgImage = getComputedStyle(hero).backgroundImage;
224
+ var hasBg = bgImage && bgImage !== 'none' && bgImage.indexOf('url(') !== -1;
225
+
226
+ var composition = 'centered';
227
+ if (hasMedia) {
228
+ if (mediaX < heroCenterX - 100) {
229
+ mediaPos = 'left';
230
+ composition = 'split-right-text';
231
+ } else if (mediaX > heroCenterX + 100) {
232
+ mediaPos = 'right';
233
+ composition = 'split-left-text';
234
+ } else {
235
+ mediaPos = 'top';
236
+ composition = 'centered';
237
+ }
238
+ } else if (hasBg) {
239
+ composition = 'background-pattern';
240
+ mediaPos = 'background';
241
+ }
242
+
243
+ // Content alignment from text-align
244
+ var headingCs = getComputedStyle(bestH);
245
+ var alignment = 'left';
246
+ if (headingCs.textAlign === 'center') alignment = 'center';
247
+ else if (headingCs.textAlign === 'right') alignment = 'right';
248
+
249
+ return {
250
+ composition: composition,
251
+ hasMedia: hasMedia,
252
+ mediaPosition: mediaPos,
253
+ headingText: getTextContent(bestH, 200),
254
+ headingFontSize: bestSize,
255
+ subheadingText: subTxt,
256
+ ctaCount: heroCtas.length,
257
+ ctaPrimaryText: primaryCta,
258
+ heightPx: Math.round(heroRect.height),
259
+ isFullViewport: heroRect.height >= viewportH * 0.7,
260
+ contentAlignment: alignment,
261
+ };
262
+ }
263
+
264
+ // ── NAVIGATION ───────────────────────────────────────────────────────
265
+ function detectNav() {
266
+ var navEl = document.querySelector('header > nav, header nav, body > nav, [role="banner"] nav, nav[class*="header"], nav[class*="top"], nav');
267
+ if (!navEl) {
268
+ // Fallback: top header element
269
+ navEl = document.querySelector('header, [role="banner"]');
270
+ }
271
+ if (!navEl) return null;
272
+ var rect = visibleRect(navEl);
273
+ if (!rect) return null;
274
+ if (rect.y > 300) return null; // not at top
275
+
276
+ var cs = getComputedStyle(navEl);
277
+ var pos = cs.position;
278
+ var stickyOrFixed = (pos === 'sticky' || pos === 'fixed') ? pos : 'static';
279
+
280
+ // Logo: <a> at left containing img or text near brand
281
+ var logoEl = navEl.querySelector('a[href="/"], a[class*="logo"], a[aria-label*="ogo" i]');
282
+ var hasLogo = !!logoEl;
283
+ if (!hasLogo) {
284
+ // Heuristic: leftmost link with img child
285
+ var leftLinks = navEl.querySelectorAll('a img, a svg');
286
+ if (leftLinks.length > 0) hasLogo = true;
287
+ }
288
+
289
+ // Nav items: links with text in nav
290
+ var navLinks = navEl.querySelectorAll('a');
291
+ var itemCount = 0;
292
+ var ctaCount = 0;
293
+ for (var i = 0; i < navLinks.length; i++) {
294
+ var l = navLinks[i];
295
+ var t = getTextContent(l, 50);
296
+ if (!t || t.length < 2) continue;
297
+ var lr = visibleRect(l);
298
+ if (!lr) continue;
299
+ // v2.9 A.6 — count ONLY links in the visible TOP BAR, not flattened mega-menu / dropdown
300
+ // items (which inflate the count to "50 items"). Header band ≈ nav top + ~72px.
301
+ if (lr.top > rect.y + 72) continue;
302
+ var lcs = getComputedStyle(l);
303
+ var hasBg = lcs.backgroundColor && lcs.backgroundColor !== 'rgba(0, 0, 0, 0)' && lcs.backgroundColor !== 'transparent';
304
+ var hasBorder = lcs.borderWidth && parseFloat(lcs.borderWidth) > 0;
305
+ if (hasBg || hasBorder) {
306
+ ctaCount++;
307
+ } else {
308
+ itemCount++;
309
+ }
310
+ }
311
+
312
+ var hasSearch = !!navEl.querySelector('input[type="search"], [role="searchbox"], [class*="search"]');
313
+ var hasDarkMode = !!navEl.querySelector('button[aria-label*="theme" i], button[aria-label*="mode" i], [class*="theme-toggle"], [class*="dark-mode"]');
314
+
315
+ // Layout heuristic from logo + links positions
316
+ var layout = 'unknown';
317
+ if (hasLogo && itemCount > 0) {
318
+ // Look at first nav link position vs logo
319
+ var firstLink = null;
320
+ for (var j = 0; j < navLinks.length; j++) {
321
+ var t2 = getTextContent(navLinks[j], 50);
322
+ if (t2 && t2.length > 2) {
323
+ firstLink = navLinks[j];
324
+ break;
325
+ }
326
+ }
327
+ if (firstLink && logoEl) {
328
+ var lRect = visibleRect(logoEl);
329
+ var fRect = visibleRect(firstLink);
330
+ if (lRect && fRect) {
331
+ var navCenterX = rect.x + rect.width / 2;
332
+ if (Math.abs(lRect.x + lRect.width / 2 - navCenterX) < 100) {
333
+ layout = 'logo-center-links-split';
334
+ } else if (fRect.x > rect.x + rect.width / 2) {
335
+ layout = 'logo-left-links-right';
336
+ } else {
337
+ layout = 'logo-left-links-left';
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ return {
344
+ position: stickyOrFixed,
345
+ hasLogo: hasLogo,
346
+ navItemCount: itemCount,
347
+ ctaCount: ctaCount,
348
+ hasDarkMode: hasDarkMode,
349
+ hasSearchBar: hasSearch,
350
+ heightPx: Math.round(rect.height),
351
+ layout: layout,
352
+ };
353
+ }
354
+
355
+ // ── CARD GRID ────────────────────────────────────────────────────────
356
+ function detectCardGrid() {
357
+ // Find containers with 3+ direct children of similar size
358
+ var containers = document.querySelectorAll('ul, ol, div, section, main > div');
359
+ var bestGrid = null;
360
+ var bestScore = 0;
361
+ for (var i = 0; i < containers.length; i++) {
362
+ var c = containers[i];
363
+ var children = c.children;
364
+ if (children.length < 3 || children.length > 30) continue;
365
+ // Sample first 4 children
366
+ var rects = [];
367
+ for (var j = 0; j < Math.min(children.length, 6); j++) {
368
+ var r = visibleRect(children[j]);
369
+ if (r) rects.push(r);
370
+ }
371
+ if (rects.length < 3) continue;
372
+ // Check homogeneity: similar widths AND similar heights
373
+ var widths = rects.map(function(r){ return r.width; });
374
+ var heights = rects.map(function(r){ return r.height; });
375
+ var avgW = widths.reduce(function(a,b){return a+b;}, 0) / widths.length;
376
+ var avgH = heights.reduce(function(a,b){return a+b;}, 0) / heights.length;
377
+ if (avgW < 120 || avgH < 80) continue;
378
+ var widthVar = widths.reduce(function(a,b){return a + Math.abs(b - avgW);}, 0) / widths.length;
379
+ var heightVar = heights.reduce(function(a,b){return a + Math.abs(b - avgH);}, 0) / heights.length;
380
+ if (widthVar / avgW > 0.2) continue; // not homogeneous widths
381
+ if (heightVar / avgH > 0.3) continue; // not homogeneous heights
382
+
383
+ var score = rects.length * (avgW * avgH / 10000);
384
+ if (score > bestScore) {
385
+ bestScore = score;
386
+ // Sample first card for content analysis
387
+ var firstChild = children[0];
388
+ var hasImg = !!firstChild.querySelector('img, picture, video');
389
+ var hasIcon = !hasImg && !!firstChild.querySelector('svg');
390
+ var hasCta = findCtaButtons(firstChild).length > 0;
391
+
392
+ var imgEl = firstChild.querySelector('img, picture, video');
393
+ var imgPos = 'none';
394
+ if (imgEl) {
395
+ var iRect = visibleRect(imgEl);
396
+ var cRect = visibleRect(firstChild);
397
+ if (iRect && cRect) {
398
+ if (iRect.y - cRect.y < 10) imgPos = 'top';
399
+ else if (iRect.x - cRect.x < 10 && iRect.width < cRect.width * 0.6) imgPos = 'left';
400
+ else imgPos = 'top';
401
+ }
402
+ }
403
+
404
+ // Columns: derived from how many cards fit in viewport width
405
+ var viewportW = window.innerWidth;
406
+ var colsDesktop = Math.max(1, Math.round(viewportW / avgW));
407
+
408
+ // Gap: distance between first 2 cards
409
+ var gap = 0;
410
+ if (rects.length >= 2) {
411
+ if (rects[0].y === rects[1].y) {
412
+ // horizontal row
413
+ gap = Math.max(0, rects[1].x - (rects[0].x + rects[0].width));
414
+ } else {
415
+ gap = Math.max(0, rects[1].y - (rects[0].y + rects[0].height));
416
+ }
417
+ }
418
+
419
+ bestGrid = {
420
+ cardCount: children.length,
421
+ columnsDesktop: colsDesktop,
422
+ cardAspectRatio: Math.round((avgW / avgH) * 100) / 100,
423
+ cardHasImage: hasImg,
424
+ cardHasIcon: hasIcon,
425
+ cardHasCta: hasCta,
426
+ imagePosition: imgPos,
427
+ averageCardWidthPx: Math.round(avgW),
428
+ averageCardHeightPx: Math.round(avgH),
429
+ gapPx: Math.round(gap),
430
+ };
431
+ }
432
+ }
433
+ return bestGrid;
434
+ }
435
+
436
+ // ── PRICING TABLE ────────────────────────────────────────────────────
437
+ function detectPricing() {
438
+ // Pricing = container with 2-5 children, each containing price-like text (currency + number)
439
+ var priceRegex = /[\\$€£¥][\\s]?\\d|\\d+[\\s]?\\/(month|mo|year|yr)|free/i;
440
+ var containers = document.querySelectorAll('section, div, ul');
441
+ for (var i = 0; i < containers.length; i++) {
442
+ var c = containers[i];
443
+ var children = c.children;
444
+ if (children.length < 2 || children.length > 5) continue;
445
+ var matches = 0;
446
+ var highlightIdx = -1;
447
+ var featuresPerTier = [];
448
+ for (var j = 0; j < children.length; j++) {
449
+ var ch = children[j];
450
+ var text = ch.textContent || '';
451
+ if (priceRegex.test(text)) {
452
+ matches++;
453
+ // Count features (li or check items)
454
+ var feats = ch.querySelectorAll('li, [class*="check"], [class*="feat"]');
455
+ featuresPerTier.push(feats.length);
456
+ // Highlighted: has unique border or background vs siblings
457
+ var cs = getComputedStyle(ch);
458
+ if (cs.borderWidth && parseFloat(cs.borderWidth) >= 2) highlightIdx = j;
459
+ }
460
+ }
461
+ if (matches >= 2 && matches === children.length) {
462
+ var avgFeat = featuresPerTier.length > 0 ? featuresPerTier.reduce(function(a,b){return a+b;}, 0) / featuresPerTier.length : 0;
463
+ var rect = visibleRect(c);
464
+ if (!rect) continue;
465
+ var firstChildRect = visibleRect(children[0]);
466
+ var secondChildRect = children.length > 1 ? visibleRect(children[1]) : null;
467
+ var isSideBySide = firstChildRect && secondChildRect && Math.abs(firstChildRect.y - secondChildRect.y) < 20;
468
+ return {
469
+ tierCount: matches,
470
+ hasPriceLabels: true,
471
+ hasFeatureList: avgFeat > 0,
472
+ featuresPerTier: Math.round(avgFeat),
473
+ highlightedTierIndex: highlightIdx,
474
+ columnsDesktop: matches,
475
+ layout: isSideBySide ? 'side-by-side' : 'vertical-stack',
476
+ };
477
+ }
478
+ }
479
+ return null;
480
+ }
481
+
482
+ // ── TESTIMONIALS ─────────────────────────────────────────────────────
483
+ function detectTestimonials() {
484
+ // Heuristic: <blockquote>, or class includes testimonial/review/quote
485
+ var nodes = document.querySelectorAll('blockquote, [class*="testimonial" i], [class*="review" i], [class*="quote" i]');
486
+ var visible = [];
487
+ for (var i = 0; i < nodes.length; i++) {
488
+ var n = nodes[i];
489
+ var r = visibleRect(n);
490
+ if (!r) continue;
491
+ var t = getTextContent(n, 500);
492
+ if (t.length < 20) continue;
493
+ visible.push({ node: n, text: t, rect: r });
494
+ }
495
+ if (visible.length === 0) return null;
496
+
497
+ var hasAvatar = false;
498
+ var hasLogo = false;
499
+ var hasRating = false;
500
+ var totalLen = 0;
501
+ for (var j = 0; j < visible.length; j++) {
502
+ var v = visible[j];
503
+ if (v.node.querySelector('img[class*="avatar" i], img[class*="profile" i]') ||
504
+ (v.node.querySelector('img') && v.node.querySelector('img').width < 80)) hasAvatar = true;
505
+ if (v.node.querySelector('img[class*="logo" i], svg[class*="logo" i]')) hasLogo = true;
506
+ if (v.node.querySelector('[class*="star" i], [class*="rating" i], [aria-label*="rating" i]') ||
507
+ /★|⭐|☆/.test(v.text)) hasRating = true;
508
+ totalLen += v.text.length;
509
+ }
510
+
511
+ var layout = 'single-feature';
512
+ if (visible.length >= 4) layout = 'grid';
513
+ else if (visible.length >= 2) layout = 'list';
514
+
515
+ return {
516
+ count: visible.length,
517
+ hasAvatar: hasAvatar,
518
+ hasCompanyLogo: hasLogo,
519
+ hasRating: hasRating,
520
+ layout: layout,
521
+ averageQuoteLength: Math.round(totalLen / visible.length),
522
+ };
523
+ }
524
+
525
+ // ── FAQ ──────────────────────────────────────────────────────────────
526
+ function detectFaq() {
527
+ // Look for <details>/<summary> or aria-expanded patterns or "FAQ" heading
528
+ var details = document.querySelectorAll('details > summary');
529
+ var expanded = document.querySelectorAll('[aria-expanded]');
530
+ var faqEls = [];
531
+
532
+ if (details.length >= 2) {
533
+ for (var i = 0; i < details.length; i++) {
534
+ var r = visibleRect(details[i]);
535
+ if (r) faqEls.push({ node: details[i], text: getTextContent(details[i], 200) });
536
+ }
537
+ } else if (expanded.length >= 3) {
538
+ // Filter to ones that look question-shaped (end with ? or have h-tag inside)
539
+ for (var i = 0; i < expanded.length; i++) {
540
+ var e = expanded[i];
541
+ var t = getTextContent(e, 200);
542
+ if (t.length > 10 && (t.indexOf('?') !== -1 || e.querySelector('h2, h3, h4'))) {
543
+ var r = visibleRect(e);
544
+ if (r) faqEls.push({ node: e, text: t });
545
+ }
546
+ }
547
+ }
548
+
549
+ if (faqEls.length < 2) return null;
550
+
551
+ var totalQuestionLen = 0;
552
+ for (var j = 0; j < faqEls.length; j++) {
553
+ totalQuestionLen += faqEls[j].text.length;
554
+ }
555
+ // Layout: 2-col if two columns of items detected
556
+ var firstY = visibleRect(faqEls[0].node);
557
+ var secondY = visibleRect(faqEls[1].node);
558
+ var twoCol = firstY && secondY && Math.abs(firstY.y - secondY.y) < 30;
559
+
560
+ return {
561
+ itemCount: faqEls.length,
562
+ isAccordion: details.length >= 2 || expanded.length >= 3,
563
+ layout: twoCol ? 'two-column' : 'single-column',
564
+ averageQuestionLength: Math.round(totalQuestionLen / faqEls.length),
565
+ };
566
+ }
567
+
568
+ // ── CTA BANNER ───────────────────────────────────────────────────────
569
+ function detectCtaBanner() {
570
+ // Heuristic: section with a prominent button and contrasting background, not the hero
571
+ var sections = document.querySelectorAll('section, [class*="cta" i], [class*="banner" i]');
572
+ var viewportH = window.innerHeight;
573
+ var bodyBg = getComputedStyle(document.body).backgroundColor;
574
+ var found = 0;
575
+ var primary = '';
576
+ var hasContrast = false;
577
+ var hasGrad = false;
578
+ var positions = [];
579
+
580
+ for (var i = 0; i < sections.length; i++) {
581
+ var s = sections[i];
582
+ var rect = visibleRect(s);
583
+ if (!rect) continue;
584
+ if (rect.y < viewportH * 0.5) continue; // skip hero zone
585
+ var ctas = findCtaButtons(s);
586
+ if (ctas.length === 0) continue;
587
+ var cs = getComputedStyle(s);
588
+ var sBg = cs.backgroundColor;
589
+ var contrasted = sBg && sBg !== bodyBg && sBg !== 'rgba(0, 0, 0, 0)';
590
+ var bgImage = cs.backgroundImage;
591
+ var grad = bgImage && bgImage.indexOf('gradient') !== -1;
592
+ if (!contrasted && !grad) continue;
593
+
594
+ found++;
595
+ if (!primary && ctas[0].text) primary = ctas[0].text;
596
+ if (contrasted) hasContrast = true;
597
+ if (grad) hasGrad = true;
598
+ positions.push(rect.y);
599
+ }
600
+
601
+ if (found === 0) return null;
602
+
603
+ var pos = 'mid-page';
604
+ if (found > 1) pos = 'multiple';
605
+ else if (positions[0] > (document.documentElement.scrollHeight - viewportH * 1.5)) pos = 'bottom';
606
+
607
+ return {
608
+ count: found,
609
+ hasContrastBg: hasContrast,
610
+ hasGradient: hasGrad,
611
+ primaryButtonText: primary,
612
+ position: pos,
613
+ };
614
+ }
615
+
616
+ // ── FOOTER ───────────────────────────────────────────────────────────
617
+ function detectFooter() {
618
+ var footer = document.querySelector('footer, [role="contentinfo"]');
619
+ if (!footer) {
620
+ // Fallback: last large section near bottom
621
+ var totalH = document.documentElement.scrollHeight;
622
+ var sections = document.querySelectorAll('section, div');
623
+ for (var i = sections.length - 1; i >= 0; i--) {
624
+ var r = visibleRect(sections[i]);
625
+ if (r && r.y > totalH - 800 && r.width > window.innerWidth * 0.5) {
626
+ footer = sections[i];
627
+ break;
628
+ }
629
+ }
630
+ }
631
+ if (!footer) return null;
632
+ var rect = visibleRect(footer);
633
+ if (!rect) return null;
634
+
635
+ // Columns: count direct flex/grid children, or count distinct X positions of headings
636
+ var directChildren = footer.children;
637
+ var xPositions = new Set();
638
+ for (var i = 0; i < directChildren.length; i++) {
639
+ var dr = visibleRect(directChildren[i]);
640
+ if (dr) xPositions.add(Math.round(dr.x / 50) * 50);
641
+ }
642
+ var columnCount = xPositions.size || 1;
643
+ if (columnCount === 1) {
644
+ // Try by header h3/h4 positions
645
+ var heads = footer.querySelectorAll('h3, h4, [class*="title"]');
646
+ var headX = new Set();
647
+ for (var i = 0; i < heads.length; i++) {
648
+ var hr = visibleRect(heads[i]);
649
+ if (hr) headX.add(Math.round(hr.x / 80) * 80);
650
+ }
651
+ if (headX.size > 1) columnCount = headX.size;
652
+ }
653
+ columnCount = Math.min(columnCount, 6);
654
+
655
+ var links = footer.querySelectorAll('a');
656
+ var visibleLinks = 0;
657
+ for (var i = 0; i < links.length; i++) {
658
+ if (visibleRect(links[i])) visibleLinks++;
659
+ }
660
+
661
+ var newsletter = !!footer.querySelector('input[type="email"], form[class*="newsletter" i]');
662
+ 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]');
663
+ var langSwitch = !!footer.querySelector('[class*="lang" i] select, [aria-label*="language" i]');
664
+ // Copyright: text containing ©, "all rights reserved", or year
665
+ var fText = footer.textContent || '';
666
+ var copyright = /©|all rights reserved|copyright/i.test(fText);
667
+
668
+ return {
669
+ columnCount: columnCount,
670
+ linkCount: visibleLinks,
671
+ hasNewsletter: newsletter,
672
+ hasSocialIcons: social,
673
+ hasLanguageSwitcher: langSwitch,
674
+ hasCopyrightLine: copyright,
675
+ heightPx: Math.round(rect.height),
676
+ };
677
+ }
678
+
679
+ // ── ASSEMBLE ─────────────────────────────────────────────────────────
680
+ var hero = detectHero();
681
+ var nav = detectNav();
682
+ var cardGrid = detectCardGrid();
683
+ var pricing = detectPricing();
684
+ var testimonials = detectTestimonials();
685
+ var faq = detectFaq();
686
+ var cta = detectCtaBanner();
687
+ var footer = detectFooter();
688
+
689
+ var detectedCount = 0;
690
+ if (hero) detectedCount++;
691
+ if (nav) detectedCount++;
692
+ if (cardGrid) detectedCount++;
693
+ if (pricing) detectedCount++;
694
+ if (testimonials) detectedCount++;
695
+ if (faq) detectedCount++;
696
+ if (cta) detectedCount++;
697
+ if (footer) detectedCount++;
698
+
699
+ return {
700
+ hero: hero,
701
+ navigation: nav,
702
+ cardGrid: cardGrid,
703
+ pricingTable: pricing,
704
+ testimonials: testimonials,
705
+ faq: faq,
706
+ ctaBanner: cta,
707
+ footer: footer,
708
+ detectedCount: detectedCount,
709
+ };
710
+ })()`);
711
+ }