design-clone 1.2.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +26 -12
  2. package/bin/commands/clone-site.js +75 -10
  3. package/bin/commands/init.js +33 -1
  4. package/bin/commands/verify.js +5 -3
  5. package/bin/utils/validate.js +24 -8
  6. package/docs/cli-reference.md +200 -2
  7. package/docs/codebase-summary.md +309 -0
  8. package/docs/design-clone-architecture.md +259 -42
  9. package/docs/pixel-perfect.md +35 -4
  10. package/docs/project-roadmap.md +382 -0
  11. package/docs/troubleshooting.md +5 -4
  12. package/package.json +10 -8
  13. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  14. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  15. package/src/ai/analyze-structure.py +73 -3
  16. package/src/ai/extract-design-tokens.py +356 -13
  17. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  18. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  19. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  20. package/src/ai/prompts/design_tokens.py +133 -0
  21. package/src/ai/prompts/structure_analysis.py +329 -10
  22. package/src/ai/prompts/ux_audit.py +198 -0
  23. package/src/ai/ux-audit.js +596 -0
  24. package/src/core/app-state-snapshot.js +511 -0
  25. package/src/core/content-counter.js +342 -0
  26. package/src/core/cookie-handler.js +1 -1
  27. package/src/core/css-extractor.js +4 -4
  28. package/src/core/dimension-extractor.js +93 -21
  29. package/src/core/dimension-output.js +103 -6
  30. package/src/core/discover-pages.js +242 -14
  31. package/src/core/dom-tree-analyzer.js +298 -0
  32. package/src/core/extract-assets.js +1 -1
  33. package/src/core/framework-detector.js +538 -0
  34. package/src/core/html-extractor.js +45 -4
  35. package/src/core/lazy-loader.js +7 -7
  36. package/src/core/multi-page-screenshot.js +9 -6
  37. package/src/core/page-readiness.js +8 -8
  38. package/src/core/screenshot.js +138 -9
  39. package/src/core/section-cropper.js +209 -0
  40. package/src/core/section-detector.js +386 -0
  41. package/src/core/semantic-enhancer.js +492 -0
  42. package/src/core/state-capture.js +18 -22
  43. package/src/core/tests/test-section-cropper.js +177 -0
  44. package/src/core/tests/test-section-detector.js +55 -0
  45. package/src/core/video-capture.js +152 -146
  46. package/src/route-discoverers/angular-discoverer.js +157 -0
  47. package/src/route-discoverers/astro-discoverer.js +123 -0
  48. package/src/route-discoverers/base-discoverer.js +242 -0
  49. package/src/route-discoverers/index.js +106 -0
  50. package/src/route-discoverers/next-discoverer.js +130 -0
  51. package/src/route-discoverers/nuxt-discoverer.js +138 -0
  52. package/src/route-discoverers/react-discoverer.js +139 -0
  53. package/src/route-discoverers/svelte-discoverer.js +109 -0
  54. package/src/route-discoverers/universal-discoverer.js +227 -0
  55. package/src/route-discoverers/vue-discoverer.js +118 -0
  56. package/src/utils/__init__.py +1 -1
  57. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  58. package/src/utils/browser.js +11 -37
  59. package/src/utils/playwright.js +213 -0
  60. package/src/verification/generate-audit-report.js +398 -0
  61. package/src/verification/verify-footer.js +493 -0
  62. package/src/verification/verify-header.js +486 -0
  63. package/src/verification/verify-layout.js +2 -2
  64. package/src/verification/verify-menu.js +4 -20
  65. package/src/verification/verify-slider.js +533 -0
  66. package/src/utils/puppeteer.js +0 -281
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Content Counter
3
+ *
4
+ * Parse page DOM to extract exact content counts for:
5
+ * - Grid items, list items, cards
6
+ * - Navigation links
7
+ * - Sections/containers
8
+ * - Images, buttons, forms
9
+ *
10
+ * Outputs content-counts.json for use in structure analysis.
11
+ */
12
+
13
+ /**
14
+ * Count content items in page DOM
15
+ * @param {Page} page - Playwright page
16
+ * @returns {Promise<object>} Content counts
17
+ */
18
+ export async function extractContentCounts(page) {
19
+ return await page.evaluate(() => {
20
+ const counts = {
21
+ extractedAt: new Date().toISOString(),
22
+
23
+ // Section counts
24
+ sections: {
25
+ total: 0,
26
+ withBackground: 0,
27
+ details: []
28
+ },
29
+
30
+ // Grid/List containers
31
+ grids: {
32
+ total: 0,
33
+ details: []
34
+ },
35
+
36
+ // Repeated items (cards, list items)
37
+ repeatedItems: {
38
+ total: 0,
39
+ byType: {}
40
+ },
41
+
42
+ // Navigation
43
+ navigation: {
44
+ headerLinks: 0,
45
+ footerLinks: 0,
46
+ allLinks: 0
47
+ },
48
+
49
+ // Media
50
+ media: {
51
+ images: 0,
52
+ videos: 0,
53
+ svgIcons: 0
54
+ },
55
+
56
+ // Interactive
57
+ interactive: {
58
+ buttons: 0,
59
+ inputs: 0,
60
+ forms: 0
61
+ }
62
+ };
63
+
64
+ // Helper: check if element is visible (not hidden)
65
+ const isVisible = (el) => {
66
+ if (!el) return false;
67
+ const style = getComputedStyle(el);
68
+ return style.display !== 'none' &&
69
+ style.visibility !== 'hidden' &&
70
+ !el.hasAttribute('hidden');
71
+ };
72
+
73
+ // Helper: get meaningful class name for element
74
+ const getSelector = (el) => {
75
+ if (el.id) return `#${el.id}`;
76
+ const classes = [...el.classList].filter(c =>
77
+ !c.match(/^(js-|is-|has-|data-)/) &&
78
+ c.length > 2
79
+ ).slice(0, 3).join('.');
80
+ return classes ? `.${classes}` : el.tagName.toLowerCase();
81
+ };
82
+
83
+ // 1. Count sections (major containers with padding/background)
84
+ const sectionSelectors = [
85
+ 'section',
86
+ '[class*="section"]',
87
+ '[class*="py-lg"]', '[class*="py-xl"]', '[class*="py-2xl"]',
88
+ '[class*="py-md"]',
89
+ '[class*="bg-background"]',
90
+ '[class*="bg-white"]',
91
+ '[class*="bg-gray"]'
92
+ ];
93
+
94
+ const sectionElements = new Set();
95
+ const MAX_SECTION_DETAILS = 30;
96
+
97
+ sectionSelectors.forEach(sel => {
98
+ try {
99
+ document.querySelectorAll(sel).forEach(el => {
100
+ if (sectionElements.size >= MAX_SECTION_DETAILS) return;
101
+
102
+ // Check if element has significant height and width (major section)
103
+ const rect = el.getBoundingClientRect();
104
+ const isSignificant = rect.height > 100 && rect.width > 200;
105
+
106
+ // Count elements that are either:
107
+ // 1. Direct children of body/main/root
108
+ // 2. Have section-like characteristics (padding, bg, significant size)
109
+ const parent = el.parentElement;
110
+ const isTopLevel = parent?.tagName === 'BODY' ||
111
+ parent?.tagName === 'MAIN' ||
112
+ parent?.id === 'root' ||
113
+ parent?.id === '__next' ||
114
+ parent?.classList.contains('container');
115
+
116
+ if (isTopLevel || (isSignificant && isVisible(el))) {
117
+ sectionElements.add(el);
118
+ }
119
+ });
120
+ } catch (e) { /* invalid selector */ }
121
+ });
122
+
123
+ sectionElements.forEach(el => {
124
+ const style = getComputedStyle(el);
125
+ const hasBg = style.backgroundColor !== 'rgba(0, 0, 0, 0)' &&
126
+ style.backgroundColor !== 'transparent';
127
+ counts.sections.total++;
128
+ if (hasBg) counts.sections.withBackground++;
129
+
130
+ counts.sections.details.push({
131
+ selector: getSelector(el),
132
+ visible: isVisible(el),
133
+ hasBackground: hasBg,
134
+ childCount: el.children.length
135
+ });
136
+ });
137
+
138
+ // 2. Count grid/flex containers and their items
139
+ // Only target meaningful containers (not trivial wrappers)
140
+ const gridSelectors = [
141
+ '[class*="grid"]',
142
+ '[style*="display: grid"]'
143
+ ];
144
+
145
+ // Separate flex selectors - more selective
146
+ const flexSelectors = [
147
+ '[class*="flex"][class*="gap"]',
148
+ '[class*="flex"][class*="wrap"]',
149
+ '[class*="flex"][class*="col"]'
150
+ ];
151
+
152
+ const processedGrids = new Set();
153
+ const MIN_ITEMS_FOR_GRID = 2; // Only count containers with 2+ items
154
+ const MAX_GRID_DETAILS = 50; // Limit output size
155
+
156
+ [...gridSelectors, ...flexSelectors].forEach(sel => {
157
+ try {
158
+ document.querySelectorAll(sel).forEach(el => {
159
+ if (processedGrids.has(el)) return;
160
+ if (counts.grids.details.length >= MAX_GRID_DETAILS) return;
161
+
162
+ const style = getComputedStyle(el);
163
+ if (style.display === 'grid' || style.display === 'flex' ||
164
+ style.display === 'inline-grid' || style.display === 'inline-flex') {
165
+ processedGrids.add(el);
166
+
167
+ // Count direct children (grid items)
168
+ const items = [...el.children].filter(child =>
169
+ child.tagName !== 'SCRIPT' &&
170
+ child.tagName !== 'STYLE'
171
+ );
172
+
173
+ // Skip containers with fewer than MIN_ITEMS
174
+ if (items.length < MIN_ITEMS_FOR_GRID) return;
175
+
176
+ // Count visible vs hidden
177
+ const visibleItems = items.filter(isVisible);
178
+ const hiddenItems = items.filter(i => !isVisible(i));
179
+
180
+ // Only count if has meaningful visible items
181
+ if (visibleItems.length >= MIN_ITEMS_FOR_GRID) {
182
+ counts.grids.total++;
183
+ counts.grids.details.push({
184
+ selector: getSelector(el),
185
+ display: style.display,
186
+ totalItems: items.length,
187
+ visibleItems: visibleItems.length,
188
+ hiddenItems: hiddenItems.length,
189
+ gridCols: style.gridTemplateColumns || null,
190
+ visible: isVisible(el)
191
+ });
192
+ }
193
+ }
194
+ });
195
+ } catch (e) { /* invalid selector */ }
196
+ });
197
+
198
+ // 3. Count repeated items (cards, list items, etc.)
199
+ const repeatPatterns = [
200
+ { name: 'cards', selectors: ['[class*="card"]', '[class*="Card"]'] },
201
+ { name: 'listItems', selectors: ['li', '[class*="item"]', '[class*="Item"]'] },
202
+ { name: 'services', selectors: ['[class*="service"]', '[class*="Service"]'] },
203
+ { name: 'features', selectors: ['[class*="feature"]', '[class*="Feature"]'] },
204
+ { name: 'testimonials', selectors: ['[class*="testimonial"]', '[class*="review"]'] },
205
+ { name: 'teamMembers', selectors: ['[class*="team"]', '[class*="member"]', '[class*="person"]'] },
206
+ { name: 'faqItems', selectors: ['[class*="faq"]', '[class*="accordion"]', 'details'] },
207
+ { name: 'pricingCards', selectors: ['[class*="pricing"]', '[class*="plan"]'] },
208
+ { name: 'blogPosts', selectors: ['[class*="post"]', '[class*="article"]', 'article'] },
209
+ { name: 'products', selectors: ['[class*="product"]', '[class*="Product"]'] },
210
+ { name: 'categories', selectors: ['[class*="category"]', '[class*="Category"]'] }
211
+ ];
212
+
213
+ repeatPatterns.forEach(({ name, selectors }) => {
214
+ let totalCount = 0;
215
+ let visibleCount = 0;
216
+
217
+ selectors.forEach(sel => {
218
+ try {
219
+ const elements = document.querySelectorAll(sel);
220
+ elements.forEach(el => {
221
+ totalCount++;
222
+ if (isVisible(el)) visibleCount++;
223
+ });
224
+ } catch (e) { /* invalid selector */ }
225
+ });
226
+
227
+ if (totalCount > 0) {
228
+ counts.repeatedItems.byType[name] = {
229
+ total: totalCount,
230
+ visible: visibleCount,
231
+ hidden: totalCount - visibleCount
232
+ };
233
+ counts.repeatedItems.total += totalCount;
234
+ }
235
+ });
236
+
237
+ // 4. Count navigation links
238
+ const header = document.querySelector('header, [class*="header"], nav');
239
+ const footer = document.querySelector('footer, [class*="footer"]');
240
+
241
+ if (header) {
242
+ counts.navigation.headerLinks = header.querySelectorAll('a').length;
243
+ }
244
+ if (footer) {
245
+ counts.navigation.footerLinks = footer.querySelectorAll('a').length;
246
+ }
247
+ counts.navigation.allLinks = document.querySelectorAll('a').length;
248
+
249
+ // 5. Count media elements
250
+ counts.media.images = document.querySelectorAll('img, picture').length;
251
+ counts.media.videos = document.querySelectorAll('video, iframe[src*="youtube"], iframe[src*="vimeo"]').length;
252
+ counts.media.svgIcons = document.querySelectorAll('svg').length;
253
+
254
+ // 6. Count interactive elements
255
+ counts.interactive.buttons = document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]').length;
256
+ counts.interactive.inputs = document.querySelectorAll('input, textarea, select').length;
257
+ counts.interactive.forms = document.querySelectorAll('form').length;
258
+
259
+ // 7. Calculate summary
260
+ counts.summary = {
261
+ majorSections: counts.sections.total,
262
+ gridContainers: counts.grids.total,
263
+ totalRepeatedItems: counts.repeatedItems.total,
264
+ totalLinks: counts.navigation.allLinks,
265
+ totalImages: counts.media.images,
266
+ totalButtons: counts.interactive.buttons,
267
+
268
+ // Provide estimates for generation
269
+ recommendedItemCounts: {}
270
+ };
271
+
272
+ // Calculate recommended item counts per grid
273
+ counts.grids.details.forEach((grid, idx) => {
274
+ if (grid.visibleItems >= 3) {
275
+ counts.summary.recommendedItemCounts[grid.selector] = grid.visibleItems;
276
+ }
277
+ });
278
+
279
+ // Add repeated items recommendations
280
+ Object.entries(counts.repeatedItems.byType).forEach(([type, data]) => {
281
+ if (data.visible >= 2) {
282
+ counts.summary.recommendedItemCounts[type] = data.visible;
283
+ }
284
+ });
285
+
286
+ return counts;
287
+ });
288
+ }
289
+
290
+ /**
291
+ * Generate concise content summary for prompt injection
292
+ * @param {object} counts - Content counts from extractContentCounts
293
+ * @returns {string} Summary text
294
+ */
295
+ export function generateContentSummary(counts) {
296
+ const lines = [
297
+ '## EXACT CONTENT COUNTS (from DOM parsing)',
298
+ ''
299
+ ];
300
+
301
+ // Sections
302
+ lines.push(`### Sections: ${counts.sections.total} total`);
303
+ counts.sections.details.slice(0, 10).forEach(s => {
304
+ lines.push(`- ${s.selector}: ${s.childCount} children${s.visible ? '' : ' (hidden)'}`);
305
+ });
306
+ lines.push('');
307
+
308
+ // Grids with item counts
309
+ lines.push(`### Grid/Flex Containers: ${counts.grids.total} total`);
310
+ counts.grids.details.slice(0, 15).forEach(g => {
311
+ const visibilityNote = g.hiddenItems > 0 ? ` (+${g.hiddenItems} hidden)` : '';
312
+ lines.push(`- ${g.selector}: ${g.visibleItems} visible items${visibilityNote}`);
313
+ });
314
+ lines.push('');
315
+
316
+ // Repeated items
317
+ if (Object.keys(counts.repeatedItems.byType).length > 0) {
318
+ lines.push('### Repeated Items:');
319
+ Object.entries(counts.repeatedItems.byType).forEach(([type, data]) => {
320
+ const hiddenNote = data.hidden > 0 ? ` (+${data.hidden} hidden)` : '';
321
+ lines.push(`- ${type}: ${data.visible} visible${hiddenNote}`);
322
+ });
323
+ lines.push('');
324
+ }
325
+
326
+ // Links and media
327
+ lines.push('### Navigation & Media:');
328
+ lines.push(`- Header links: ${counts.navigation.headerLinks}`);
329
+ lines.push(`- Footer links: ${counts.navigation.footerLinks}`);
330
+ lines.push(`- Images: ${counts.media.images}`);
331
+ lines.push(`- SVG icons: ${counts.media.svgIcons}`);
332
+ lines.push('');
333
+
334
+ // Critical instruction
335
+ lines.push('### GENERATION INSTRUCTION:');
336
+ lines.push('When generating HTML, use EXACTLY these item counts:');
337
+ Object.entries(counts.summary.recommendedItemCounts).forEach(([selector, count]) => {
338
+ lines.push(`- ${selector}: ${count} items`);
339
+ });
340
+
341
+ return lines.join('\n');
342
+ }
@@ -38,7 +38,7 @@ export const COOKIE_REMOVE_SELECTORS = [
38
38
 
39
39
  /**
40
40
  * Dismiss cookie banners by clicking accept or removing elements
41
- * @param {Page} page - Puppeteer page
41
+ * @param {Page} page - Playwright page
42
42
  * @returns {Promise<{method: string, selector?: string, count?: number}>}
43
43
  */
44
44
  export async function dismissCookieBanner(page) {
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  // Size limits
9
- export const MAX_CSS_SIZE = 5 * 1024 * 1024; // 5MB limit
9
+ export const MAX_CSS_SIZE = 10 * 1024 * 1024; // 10MB limit
10
10
  export const MAX_CSS_RULES_WARN = 5000; // Warn on large stylesheets
11
11
 
12
12
  // Layout-critical properties for accurate cloning
@@ -32,12 +32,12 @@ export const ALL_PROPERTIES = Object.values(LAYOUT_PROPERTIES).flat();
32
32
 
33
33
  /**
34
34
  * Extract all CSS from page
35
- * @param {Page} page - Puppeteer page
35
+ * @param {Page} page - Playwright page
36
36
  * @param {string} baseUrl - Base URL for resolving relative paths
37
37
  * @returns {Promise<{cssBlocks: Array, corsBlocked: Array, computedStyles: Object, totalRules: number, warnings: Array}>}
38
38
  */
39
39
  export async function extractAllCss(page, baseUrl) {
40
- return await page.evaluate((url, allProps) => {
40
+ return await page.evaluate(({ url, allProps }) => {
41
41
  const cssBlocks = [];
42
42
  const corsBlocked = [];
43
43
  const warnings = [];
@@ -128,5 +128,5 @@ export async function extractAllCss(page, baseUrl) {
128
128
  totalRules,
129
129
  warnings
130
130
  };
131
- }, baseUrl, ALL_PROPERTIES);
131
+ }, { url: baseUrl, allProps: ALL_PROPERTIES });
132
132
  }
@@ -7,7 +7,7 @@
7
7
 
8
8
  /**
9
9
  * Extract component dimensions from page
10
- * @param {Page} page - Puppeteer page
10
+ * @param {Page} page - Playwright page
11
11
  * @param {string} viewportName - 'desktop', 'tablet', or 'mobile'
12
12
  * @returns {Promise<Object>} Dimension data for this viewport
13
13
  */
@@ -69,6 +69,59 @@ export async function extractComponentDimensions(page, viewportName) {
69
69
  );
70
70
  }
71
71
 
72
+ /**
73
+ * Section Detection Configuration
74
+ * These thresholds determine how elements are classified into page sections.
75
+ * Semantic tags (<header>, <footer>, etc.) always take priority over position.
76
+ * Position-based detection is used as fallback for non-semantic elements.
77
+ */
78
+ const HERO_THRESHOLD = 0.25; // Elements in top 25% with height >300px → hero
79
+ const FOOTER_THRESHOLD = 0.85; // Elements below 85% of page height → footer
80
+ const SIDEBAR_MAX_WIDTH = 400; // Max px width for fixed/sticky sidebar detection
81
+
82
+ // Page dimensions for section context
83
+ const pageHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
84
+ const pageWidth = document.documentElement.clientWidth;
85
+
86
+ /**
87
+ * Detect section context for an element.
88
+ *
89
+ * Priority order (per validation decision):
90
+ * 1. Semantic HTML tags: <header>, <footer>, <aside>, <nav> (highest priority)
91
+ * 2. Ancestor semantic tags: element inside <header>, <footer>, etc.
92
+ * 3. Position-based heuristics: hero (top 25%), footer (bottom 15%)
93
+ * 4. Layout-based: fixed/sticky narrow elements → sidebar
94
+ * 5. Default: 'content'
95
+ *
96
+ * @param {Element} el - DOM element to classify
97
+ * @returns {string} Section context: 'header' | 'hero' | 'content' | 'sidebar' | 'footer' | 'nav'
98
+ */
99
+ function detectSection(el) {
100
+ const rect = el.getBoundingClientRect();
101
+ const computed = window.getComputedStyle(el);
102
+ const yRatio = (rect.y + window.scrollY) / pageHeight;
103
+
104
+ // Semantic tags have priority (per validation decision)
105
+ const tag = el.tagName.toLowerCase();
106
+ if (tag === 'header' || el.closest('header')) return 'header';
107
+ if (tag === 'footer' || el.closest('footer')) return 'footer';
108
+ if (tag === 'aside' || el.closest('aside')) return 'sidebar';
109
+ if (tag === 'nav' || el.closest('nav')) return 'nav';
110
+
111
+ // Hero detection (large element in top 25%)
112
+ if (yRatio < HERO_THRESHOLD && rect.height > 300) return 'hero';
113
+
114
+ // Footer detection (bottom 15%)
115
+ if (yRatio > FOOTER_THRESHOLD) return 'footer';
116
+
117
+ // Sidebar detection (narrow fixed/sticky)
118
+ if ((computed.position === 'fixed' || computed.position === 'sticky') && rect.width < SIDEBAR_MAX_WIDTH) {
119
+ return 'sidebar';
120
+ }
121
+
122
+ return 'content';
123
+ }
124
+
72
125
  // 1. Extract containers
73
126
  const containerSelectors = [
74
127
  'section', 'main', 'article', 'header', 'footer',
@@ -98,6 +151,7 @@ export async function extractComponentDimensions(page, viewportName) {
98
151
  ? `.${el.className.split(' ').filter(c => c && !c.includes(':')).slice(0, 2).join('.')}`
99
152
  : el.tagName.toLowerCase();
100
153
  dims.childCount = children.length;
154
+ dims.section = detectSection(el); // Add section context
101
155
 
102
156
  if (children.length >= 2 && (dims.display === 'flex' || dims.display === 'grid')) {
103
157
  const firstRect = children[0].getBoundingClientRect();
@@ -118,35 +172,53 @@ export async function extractComponentDimensions(page, viewportName) {
118
172
  } catch (e) { /* ignore */ }
119
173
  });
120
174
 
121
- // 2. Extract typography
175
+ // 2. Extract typography (grouped by section context)
122
176
  const typographySelectors = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p'];
123
177
  typographySelectors.forEach(tag => {
124
178
  try {
125
179
  const elements = document.querySelectorAll(tag);
126
- if (elements.length > 0) {
127
- for (const el of elements) {
128
- const rect = el.getBoundingClientRect();
129
- if (rect.width > 50 && rect.height > 10) {
130
- const dims = extractDimensions(el);
131
- results.typography.push({
132
- selector: tag,
133
- fontSize: dims.fontSize,
134
- fontWeight: dims.fontWeight,
135
- lineHeight: dims.lineHeight,
136
- letterSpacing: dims.letterSpacing,
137
- color: dims.color,
138
- marginTop: dims.marginTop,
139
- marginBottom: dims.marginBottom,
140
- textSample: el.textContent?.trim().slice(0, 40),
141
- count: elements.length
142
- });
143
- break;
144
- }
180
+ if (elements.length === 0) return;
181
+
182
+ const bySection = {}; // Group by section
183
+
184
+ for (const el of elements) {
185
+ const rect = el.getBoundingClientRect();
186
+ if (rect.width < 50 || rect.height < 10) continue;
187
+
188
+ const section = detectSection(el);
189
+ const dims = extractDimensions(el);
190
+
191
+ // Create section group if not exists
192
+ if (!bySection[section]) bySection[section] = [];
193
+
194
+ // Add to section group (limit 2 per section per tag for token efficiency)
195
+ if (bySection[section].length < 2) {
196
+ bySection[section].push({
197
+ selector: tag,
198
+ section,
199
+ fontSize: dims.fontSize,
200
+ fontWeight: dims.fontWeight,
201
+ lineHeight: dims.lineHeight,
202
+ letterSpacing: dims.letterSpacing,
203
+ color: dims.color,
204
+ marginTop: dims.marginTop,
205
+ marginBottom: dims.marginBottom,
206
+ textSample: el.textContent?.trim().slice(0, 40),
207
+ y: Math.round(rect.y + window.scrollY)
208
+ });
145
209
  }
146
210
  }
211
+
212
+ // Flatten section groups into typography array
213
+ for (const items of Object.values(bySection)) {
214
+ results.typography.push(...items);
215
+ }
147
216
  } catch (e) { /* ignore */ }
148
217
  });
149
218
 
219
+ // Sort typography by position for consistent output
220
+ results.typography.sort((a, b) => a.y - b.y);
221
+
150
222
  // 3. Extract buttons
151
223
  const buttonSelectors = [
152
224
  'button', 'a[class*="btn"]', 'a[class*="button"]',