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
@@ -5,6 +5,7 @@ Extract design tokens from website screenshots using Gemini Vision API.
5
5
  Usage:
6
6
  python extract-design-tokens.py --screenshots ./analysis --output ./output
7
7
  python extract-design-tokens.py -s ./analysis -o ./out --css source.css
8
+ python extract-design-tokens.py -s ./analysis -o ./out --section-mode
8
9
 
9
10
  Options:
10
11
  --screenshots Directory containing desktop.png, tablet.png, mobile.png
@@ -12,12 +13,15 @@ Options:
12
13
  --css Path to filtered CSS file for exact token extraction (optional)
13
14
  --model Gemini model (default: gemini-2.5-flash)
14
15
  --verbose Enable verbose output
16
+ --section-mode Analyze sections instead of viewports (sections/*.png)
15
17
 
16
18
  Output:
17
19
  - design-tokens.json: Machine-readable tokens
18
20
  - tokens.css: CSS custom properties
21
+ - section-analysis/*.json: Per-section tokens (section-mode only)
19
22
 
20
23
  When CSS provided, extracts EXACT colors/fonts from source instead of estimating.
24
+ Section mode analyzes each section separately for better detail accuracy.
21
25
  """
22
26
 
23
27
  import argparse
@@ -25,8 +29,9 @@ import json
25
29
  import os
26
30
  import re
27
31
  import sys
32
+ import time
28
33
  from pathlib import Path
29
- from typing import Any, Dict, Optional
34
+ from typing import Any, Dict, List, Optional
30
35
 
31
36
  # Add src directory to path for local imports
32
37
  SCRIPT_DIR = Path(__file__).parent.resolve()
@@ -55,7 +60,7 @@ except ImportError:
55
60
  sys.exit(1)
56
61
 
57
62
  # Import prompts from extracted module
58
- from prompts.design_tokens import build_extraction_prompt
63
+ from prompts.design_tokens import build_extraction_prompt, build_section_prompt
59
64
 
60
65
 
61
66
  # Default tokens (fallback)
@@ -168,6 +173,235 @@ def merge_with_defaults(tokens: Dict[str, Any]) -> Dict[str, Any]:
168
173
  return deep_merge(DEFAULT_TOKENS.copy(), tokens)
169
174
 
170
175
 
176
+ def extract_section_tokens(
177
+ section_path: str,
178
+ css_content: Optional[str],
179
+ client,
180
+ model: str,
181
+ verbose: bool = False
182
+ ) -> Dict[str, Any]:
183
+ """Extract tokens from a single section image.
184
+
185
+ Args:
186
+ section_path: Path to section image
187
+ css_content: Optional CSS content for context
188
+ client: Gemini client instance
189
+ model: Model name to use
190
+ verbose: Enable verbose output
191
+
192
+ Returns:
193
+ Extracted tokens for this section
194
+ """
195
+ section_name = Path(section_path).stem # e.g., section-0-header
196
+
197
+ # Build section-specific prompt
198
+ prompt = build_section_prompt(section_name, css_content)
199
+
200
+ # Load image
201
+ with open(section_path, 'rb') as f:
202
+ img_bytes = f.read()
203
+
204
+ content = [
205
+ prompt,
206
+ types.Part.from_bytes(data=img_bytes, mime_type='image/png')
207
+ ]
208
+
209
+ try:
210
+ config = types.GenerateContentConfig(
211
+ response_mime_type='application/json'
212
+ )
213
+
214
+ response = client.models.generate_content(
215
+ model=model,
216
+ contents=content,
217
+ config=config
218
+ )
219
+
220
+ if hasattr(response, 'text') and response.text:
221
+ tokens = json.loads(response.text)
222
+ tokens['_section'] = section_name
223
+ return tokens
224
+ else:
225
+ return {'_section': section_name, 'error': 'Empty response'}
226
+
227
+ except Exception as e:
228
+ if verbose:
229
+ print(f"Error extracting {section_name}: {e}", file=sys.stderr)
230
+ return {'_section': section_name, 'error': str(e)}
231
+
232
+
233
+ def merge_section_tokens(section_tokens: List[Dict[str, Any]]) -> Dict[str, Any]:
234
+ """Merge tokens from multiple sections into unified set.
235
+
236
+ Strategy:
237
+ - Colors: First non-null occurrence wins (header colors take priority)
238
+ - Typography: Collect all unique values
239
+ - Spacing: Merge unique values
240
+ - Notes: Collect all
241
+
242
+ Args:
243
+ section_tokens: List of per-section token dicts
244
+
245
+ Returns:
246
+ Merged token dictionary
247
+ """
248
+ merged = {
249
+ 'colors': {
250
+ 'primary': None,
251
+ 'secondary': None,
252
+ 'accent': None,
253
+ 'background': None,
254
+ 'surface': None,
255
+ 'text': {
256
+ 'primary': None,
257
+ 'secondary': None,
258
+ 'muted': None
259
+ },
260
+ 'border': None
261
+ },
262
+ 'typography': {
263
+ 'fontFamily': {
264
+ 'heading': None,
265
+ 'body': None
266
+ },
267
+ 'fontSize': {},
268
+ 'fontWeight': {
269
+ 'normal': None,
270
+ 'medium': None,
271
+ 'semibold': None,
272
+ 'bold': None
273
+ },
274
+ 'lineHeight': {}
275
+ },
276
+ 'spacing': {},
277
+ 'borderRadius': {},
278
+ 'shadows': {},
279
+ 'notes': [],
280
+ '_sections': [],
281
+ '_sectionCount': len(section_tokens)
282
+ }
283
+
284
+ # Track seen font sizes for deduplication
285
+ seen_sizes = set()
286
+
287
+ for tokens in section_tokens:
288
+ if 'error' in tokens:
289
+ merged['notes'].append(f"Section {tokens.get('_section', 'unknown')} failed: {tokens['error']}")
290
+ continue
291
+
292
+ section_name = tokens.get('_section', 'unknown')
293
+ merged['_sections'].append(section_name)
294
+
295
+ # Merge colors (first occurrence wins)
296
+ if 'colors' in tokens:
297
+ colors = tokens['colors']
298
+
299
+ # Direct color mappings
300
+ color_mappings = [
301
+ ('background', 'background'),
302
+ ('text', 'text.primary'),
303
+ ('heading', 'text.secondary'),
304
+ ('accent', 'accent'),
305
+ ('border', 'border')
306
+ ]
307
+
308
+ for src_key, dest_key in color_mappings:
309
+ if src_key in colors and colors[src_key] and colors[src_key] != 'null':
310
+ value = colors[src_key]
311
+ if validate_hex_color(value):
312
+ if '.' in dest_key:
313
+ parent, child = dest_key.split('.')
314
+ if merged['colors'][parent][child] is None:
315
+ merged['colors'][parent][child] = value
316
+ else:
317
+ if merged['colors'][dest_key] is None:
318
+ merged['colors'][dest_key] = value
319
+
320
+ # Infer primary from accent if not set
321
+ if merged['colors']['primary'] is None and 'accent' in colors:
322
+ if colors['accent'] and validate_hex_color(colors['accent']):
323
+ merged['colors']['primary'] = colors['accent']
324
+
325
+ # Merge typography
326
+ if 'typography' in tokens:
327
+ typo = tokens['typography']
328
+
329
+ # Font family
330
+ if 'fontFamily' in typo and typo['fontFamily']:
331
+ font = typo['fontFamily']
332
+ if isinstance(font, str) and font != 'null':
333
+ if merged['typography']['fontFamily']['heading'] is None:
334
+ merged['typography']['fontFamily']['heading'] = font
335
+ if merged['typography']['fontFamily']['body'] is None:
336
+ merged['typography']['fontFamily']['body'] = font
337
+
338
+ # Font sizes - collect unique values
339
+ for key in ['headingSize', 'bodySize']:
340
+ if key in typo and typo[key] and typo[key] != 'null':
341
+ size = typo[key]
342
+ if size not in seen_sizes:
343
+ seen_sizes.add(size)
344
+ # Map to our size scale
345
+ if 'heading' in key.lower():
346
+ if '4xl' not in merged['typography']['fontSize']:
347
+ merged['typography']['fontSize']['4xl'] = size
348
+ else:
349
+ if 'base' not in merged['typography']['fontSize']:
350
+ merged['typography']['fontSize']['base'] = size
351
+
352
+ # Font weights
353
+ if 'fontWeight' in typo and isinstance(typo['fontWeight'], dict):
354
+ for key, val in typo['fontWeight'].items():
355
+ if val and val != 'null':
356
+ target_key = key.lower()
357
+ if target_key in merged['typography']['fontWeight']:
358
+ if merged['typography']['fontWeight'][target_key] is None:
359
+ merged['typography']['fontWeight'][target_key] = val
360
+
361
+ # Merge spacing
362
+ if 'spacing' in tokens:
363
+ spacing = tokens['spacing']
364
+ if isinstance(spacing, dict):
365
+ for key, val in spacing.items():
366
+ if val and val != 'null':
367
+ # Map section spacing to our scale
368
+ if 'section' in key.lower() or 'container' in key.lower():
369
+ if '16' not in merged['spacing']:
370
+ merged['spacing']['16'] = val
371
+ elif 'gap' in key.lower():
372
+ if '4' not in merged['spacing']:
373
+ merged['spacing']['4'] = val
374
+
375
+ # Merge border radius
376
+ if 'borderRadius' in tokens and tokens['borderRadius'] and tokens['borderRadius'] != 'null':
377
+ radius = tokens['borderRadius']
378
+ if 'md' not in merged['borderRadius']:
379
+ merged['borderRadius']['md'] = radius
380
+
381
+ # Merge shadows
382
+ if 'shadow' in tokens and tokens['shadow'] and tokens['shadow'] != 'null':
383
+ shadow = tokens['shadow']
384
+ if 'md' not in merged['shadows']:
385
+ merged['shadows']['md'] = shadow
386
+
387
+ # Collect notes
388
+ if 'notes' in tokens and isinstance(tokens['notes'], list):
389
+ merged['notes'].extend(tokens['notes'])
390
+
391
+ # Clean up None values
392
+ def clean_nones(d):
393
+ if isinstance(d, dict):
394
+ return {k: clean_nones(v) for k, v in d.items() if v is not None}
395
+ return d
396
+
397
+ # Don't clean top-level structure, just nested Nones
398
+ for key in ['colors', 'typography']:
399
+ if key in merged:
400
+ merged[key] = clean_nones(merged[key])
401
+
402
+ return merged
403
+
404
+
171
405
  def generate_tokens_css(tokens: Dict[str, Any]) -> str:
172
406
  """Generate tokens.css from design tokens."""
173
407
  lines = [
@@ -391,6 +625,17 @@ def main():
391
625
  action='store_true',
392
626
  help='Enable verbose output'
393
627
  )
628
+ parser.add_argument(
629
+ '--section-mode',
630
+ action='store_true',
631
+ help='Analyze sections instead of viewports (looks for sections/*.png)'
632
+ )
633
+ parser.add_argument(
634
+ '--delay',
635
+ type=float,
636
+ default=1.0,
637
+ help='Delay between API calls in seconds (default: 1.0)'
638
+ )
394
639
 
395
640
  args = parser.parse_args()
396
641
 
@@ -398,13 +643,105 @@ def main():
398
643
  output_path = Path(args.output)
399
644
  output_path.mkdir(parents=True, exist_ok=True)
400
645
 
401
- # Extract tokens
402
- tokens = extract_tokens(
403
- screenshots_dir=args.screenshots,
404
- css_path=args.css,
405
- model=args.model,
406
- verbose=args.verbose
407
- )
646
+ # Section mode: analyze each section separately
647
+ if args.section_mode:
648
+ sections_dir = Path(args.screenshots) / 'sections'
649
+ if not sections_dir.exists():
650
+ print(json.dumps({
651
+ "success": False,
652
+ "error": f"Sections directory not found: {sections_dir}",
653
+ "hint": "Run screenshot.js with --section-mode true first"
654
+ }, indent=2))
655
+ sys.exit(1)
656
+
657
+ section_files = sorted(sections_dir.glob('section-*.png'))
658
+ if not section_files:
659
+ print(json.dumps({
660
+ "success": False,
661
+ "error": "No section images found in sections/ directory"
662
+ }, indent=2))
663
+ sys.exit(1)
664
+
665
+ # Limit sections to avoid excessive API calls
666
+ MAX_SECTIONS = 15
667
+ if len(section_files) > MAX_SECTIONS:
668
+ if args.verbose:
669
+ print(f"Warning: Limiting to {MAX_SECTIONS} sections (found {len(section_files)})", file=sys.stderr)
670
+ section_files = section_files[:MAX_SECTIONS]
671
+
672
+ if args.verbose:
673
+ print(f"Found {len(section_files)} sections to analyze", file=sys.stderr)
674
+
675
+ # Check API key
676
+ api_key = get_api_key()
677
+ if not api_key:
678
+ print(json.dumps({
679
+ "success": False,
680
+ "error": "GEMINI_API_KEY not set",
681
+ "hint": "Set GEMINI_API_KEY environment variable"
682
+ }, indent=2))
683
+ sys.exit(1)
684
+
685
+ # Load CSS if provided
686
+ css_content = None
687
+ if args.css and Path(args.css).exists():
688
+ with open(args.css, 'r', encoding='utf-8') as f:
689
+ css_content = f.read()
690
+ if args.verbose:
691
+ print(f"Loaded CSS: {len(css_content)} chars", file=sys.stderr)
692
+
693
+ # Initialize client
694
+ client = genai.Client(api_key=api_key)
695
+
696
+ # Create section-analysis directory
697
+ section_output_dir = output_path / 'section-analysis'
698
+ section_output_dir.mkdir(exist_ok=True)
699
+
700
+ # Process each section
701
+ section_results = []
702
+ for i, section_path in enumerate(section_files):
703
+ if args.verbose:
704
+ print(f"[{i+1}/{len(section_files)}] Analyzing {section_path.name}...", file=sys.stderr)
705
+
706
+ tokens = extract_section_tokens(
707
+ str(section_path),
708
+ css_content,
709
+ client,
710
+ args.model,
711
+ args.verbose
712
+ )
713
+ section_results.append(tokens)
714
+
715
+ # Save individual section result
716
+ section_out_path = section_output_dir / f'{section_path.stem}-tokens.json'
717
+ with open(section_out_path, 'w') as f:
718
+ json.dump(tokens, f, indent=2)
719
+
720
+ # Rate limiting delay (except for last section)
721
+ if i < len(section_files) - 1:
722
+ time.sleep(args.delay)
723
+
724
+ if args.verbose:
725
+ print(f"Merging tokens from {len(section_results)} sections...", file=sys.stderr)
726
+
727
+ # Merge all section tokens
728
+ merged_tokens = merge_section_tokens(section_results)
729
+
730
+ # Merge with defaults for complete token set
731
+ tokens = merge_with_defaults(merged_tokens)
732
+ tokens['_mode'] = 'section'
733
+ tokens['_sections'] = merged_tokens.get('_sections', [])
734
+ tokens['_sectionCount'] = merged_tokens.get('_sectionCount', 0)
735
+
736
+ else:
737
+ # Standard mode: analyze viewport screenshots
738
+ tokens = extract_tokens(
739
+ screenshots_dir=args.screenshots,
740
+ css_path=args.css,
741
+ model=args.model,
742
+ verbose=args.verbose
743
+ )
744
+ tokens['_mode'] = 'viewport'
408
745
 
409
746
  # Save design-tokens.json
410
747
  json_path = output_path / "design-tokens.json"
@@ -412,16 +749,16 @@ def main():
412
749
  json.dump(tokens, f, indent=2)
413
750
 
414
751
  if args.verbose:
415
- print(f"Saved: {json_path}")
752
+ print(f"Saved: {json_path}", file=sys.stderr)
416
753
 
417
754
  # Generate and save tokens.css
418
- css_content = generate_tokens_css(tokens)
755
+ css_output = generate_tokens_css(tokens)
419
756
  css_path = output_path / "tokens.css"
420
757
  with open(css_path, 'w') as f:
421
- f.write(css_content)
758
+ f.write(css_output)
422
759
 
423
760
  if args.verbose:
424
- print(f"Saved: {css_path}")
761
+ print(f"Saved: {css_path}", file=sys.stderr)
425
762
 
426
763
  # Output result as JSON
427
764
  result = {
@@ -429,9 +766,15 @@ def main():
429
766
  "tokens_json": str(json_path),
430
767
  "tokens_css": str(css_path),
431
768
  "model": args.model,
769
+ "mode": tokens.get('_mode', 'viewport'),
432
770
  "notes": tokens.get('notes', [])
433
771
  }
434
772
 
773
+ # Add section info if in section mode
774
+ if args.section_mode:
775
+ result["section_analysis"] = str(section_output_dir)
776
+ result["sections_processed"] = len(section_results)
777
+
435
778
  print(json.dumps(result, indent=2))
436
779
 
437
780
 
@@ -181,3 +181,136 @@ def build_extraction_prompt(css_content: str = None) -> str:
181
181
  css_snippet = css_content[:15000] if len(css_content) > 15000 else css_content
182
182
  return EXTRACTION_PROMPT_WITH_CSS.format(css_content=css_snippet)
183
183
  return EXTRACTION_PROMPT
184
+
185
+
186
+ # Section-specific extraction prompt
187
+ SECTION_EXTRACTION_PROMPT = """Analyze this {section_type} section screenshot and extract design tokens.
188
+
189
+ Focus on elements visible in THIS section only:
190
+ - Background colors and gradients
191
+ - Text colors (headings, body, links)
192
+ - Typography (font family, sizes, weights)
193
+ - Spacing patterns (padding, margins, gaps)
194
+ - Border radius and shadows
195
+ - Any accent or highlight colors
196
+
197
+ Return ONLY valid JSON:
198
+
199
+ {{
200
+ "colors": {{
201
+ "background": "#hex or null if transparent",
202
+ "text": "#hex for main text",
203
+ "heading": "#hex for headings",
204
+ "accent": "#hex for buttons/links/highlights",
205
+ "border": "#hex if borders visible"
206
+ }},
207
+ "typography": {{
208
+ "fontFamily": "observed font or best guess",
209
+ "headingSize": "largest heading size in px",
210
+ "bodySize": "body text size in px",
211
+ "fontWeight": {{
212
+ "heading": 700,
213
+ "body": 400
214
+ }}
215
+ }},
216
+ "spacing": {{
217
+ "sectionPadding": "vertical padding estimate",
218
+ "elementGap": "gap between elements",
219
+ "containerPadding": "horizontal padding"
220
+ }},
221
+ "borderRadius": "observed radius or null",
222
+ "shadow": "observed shadow or null",
223
+ "notes": ["observations about this section"]
224
+ }}
225
+
226
+ RULES:
227
+ 1. Use exact 6-digit hex codes (#RRGGBB)
228
+ 2. If a value is not visible/applicable, use null
229
+ 3. Focus only on what's visible in this section image
230
+ 4. Add section-specific observations to notes"""
231
+
232
+
233
+ SECTION_EXTRACTION_PROMPT_WITH_CSS = """Analyze this {section_type} section screenshot with CSS context.
234
+
235
+ ## Source CSS (excerpt)
236
+ ```css
237
+ {css_content}
238
+ ```
239
+
240
+ ---
241
+
242
+ Extract design tokens visible in THIS section. Use EXACT values from CSS when possible.
243
+
244
+ Return ONLY valid JSON:
245
+
246
+ {{
247
+ "colors": {{
248
+ "background": "#exact-hex from CSS or screenshot",
249
+ "text": "#exact-hex for text color",
250
+ "heading": "#exact-hex for heading color",
251
+ "accent": "#exact-hex for accent/CTA",
252
+ "border": "#exact-hex if borders visible"
253
+ }},
254
+ "typography": {{
255
+ "fontFamily": "exact font-family from CSS",
256
+ "headingSize": "exact font-size for headings",
257
+ "bodySize": "exact body font-size",
258
+ "fontWeight": {{
259
+ "heading": "exact weight from CSS",
260
+ "body": "exact weight from CSS"
261
+ }}
262
+ }},
263
+ "spacing": {{
264
+ "sectionPadding": "exact padding from CSS",
265
+ "elementGap": "exact gap/margin",
266
+ "containerPadding": "exact container padding"
267
+ }},
268
+ "borderRadius": "exact radius from CSS or null",
269
+ "shadow": "exact box-shadow from CSS or null",
270
+ "notes": ["list any CSS custom properties found"]
271
+ }}
272
+
273
+ RULES:
274
+ 1. Extract EXACT hex codes from CSS
275
+ 2. Use null for values not visible in this section
276
+ 3. Note any CSS variables (--color-*, --space-*)"""
277
+
278
+
279
+ def build_section_prompt(section_name: str, css_content: str = None) -> str:
280
+ """Build prompt for single section analysis."""
281
+ # Extract section type from name (e.g., "section-0-header" -> "header")
282
+ parts = section_name.replace('.png', '').split('-')
283
+ section_type = parts[-1] if len(parts) > 2 else 'content'
284
+
285
+ # Map common section names to descriptive types
286
+ type_mapping = {
287
+ 'header': 'header/navigation',
288
+ 'footer': 'footer',
289
+ 'hero': 'hero/banner',
290
+ 'nav': 'navigation',
291
+ 'cta': 'call-to-action',
292
+ 'services': 'services/features',
293
+ 'features': 'features',
294
+ 'about': 'about',
295
+ 'contact': 'contact',
296
+ 'testimonials': 'testimonials',
297
+ 'pricing': 'pricing',
298
+ 'faq': 'FAQ',
299
+ 'viewport': 'page content'
300
+ }
301
+
302
+ # Get descriptive type
303
+ for key, desc in type_mapping.items():
304
+ if key in section_type.lower():
305
+ section_type = desc
306
+ break
307
+
308
+ if css_content:
309
+ # Truncate CSS for section prompt (5KB limit per section)
310
+ css_snippet = css_content[:5000] if len(css_content) > 5000 else css_content
311
+ return SECTION_EXTRACTION_PROMPT_WITH_CSS.format(
312
+ section_type=section_type,
313
+ css_content=css_snippet
314
+ )
315
+
316
+ return SECTION_EXTRACTION_PROMPT.format(section_type=section_type)