design-clone 1.0.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 (47) hide show
  1. package/.env.example +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -0
  4. package/SKILL.md +239 -0
  5. package/bin/cli.js +45 -0
  6. package/bin/commands/help.js +29 -0
  7. package/bin/commands/init.js +126 -0
  8. package/bin/commands/verify.js +99 -0
  9. package/bin/utils/copy.js +65 -0
  10. package/bin/utils/validate.js +122 -0
  11. package/docs/basic-clone.md +63 -0
  12. package/docs/cli-reference.md +94 -0
  13. package/docs/design-clone-architecture.md +247 -0
  14. package/docs/pixel-perfect.md +86 -0
  15. package/docs/troubleshooting.md +97 -0
  16. package/package.json +57 -0
  17. package/requirements.txt +5 -0
  18. package/src/ai/analyze-structure.py +305 -0
  19. package/src/ai/extract-design-tokens.py +439 -0
  20. package/src/ai/prompts/__init__.py +2 -0
  21. package/src/ai/prompts/design_tokens.py +183 -0
  22. package/src/ai/prompts/structure_analysis.py +273 -0
  23. package/src/core/cookie-handler.js +76 -0
  24. package/src/core/css-extractor.js +107 -0
  25. package/src/core/dimension-extractor.js +366 -0
  26. package/src/core/dimension-output.js +208 -0
  27. package/src/core/extract-assets.js +468 -0
  28. package/src/core/filter-css.js +499 -0
  29. package/src/core/html-extractor.js +102 -0
  30. package/src/core/lazy-loader.js +188 -0
  31. package/src/core/page-readiness.js +161 -0
  32. package/src/core/screenshot.js +380 -0
  33. package/src/post-process/enhance-assets.js +157 -0
  34. package/src/post-process/fetch-images.js +398 -0
  35. package/src/post-process/inject-icons.js +311 -0
  36. package/src/utils/__init__.py +16 -0
  37. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  38. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  39. package/src/utils/browser.js +103 -0
  40. package/src/utils/env.js +153 -0
  41. package/src/utils/env.py +134 -0
  42. package/src/utils/helpers.js +71 -0
  43. package/src/utils/puppeteer.js +281 -0
  44. package/src/verification/verify-layout.js +424 -0
  45. package/src/verification/verify-menu.js +422 -0
  46. package/templates/base.css +705 -0
  47. package/templates/base.html +293 -0
@@ -0,0 +1,439 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Extract design tokens from website screenshots using Gemini Vision API.
4
+
5
+ Usage:
6
+ python extract-design-tokens.py --screenshots ./analysis --output ./output
7
+ python extract-design-tokens.py -s ./analysis -o ./out --css source.css
8
+
9
+ Options:
10
+ --screenshots Directory containing desktop.png, tablet.png, mobile.png
11
+ --output Output directory for design-tokens.json and tokens.css
12
+ --css Path to filtered CSS file for exact token extraction (optional)
13
+ --model Gemini model (default: gemini-2.5-flash)
14
+ --verbose Enable verbose output
15
+
16
+ Output:
17
+ - design-tokens.json: Machine-readable tokens
18
+ - tokens.css: CSS custom properties
19
+
20
+ When CSS provided, extracts EXACT colors/fonts from source instead of estimating.
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import re
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any, Dict, Optional
30
+
31
+ # Add src directory to path for local imports
32
+ SCRIPT_DIR = Path(__file__).parent.resolve()
33
+ SRC_DIR = SCRIPT_DIR.parent
34
+ sys.path.insert(0, str(SRC_DIR))
35
+
36
+ # Import local env resolver (portable)
37
+ try:
38
+ from utils.env import resolve_env, load_env
39
+ load_env() # Load .env files on startup
40
+ except ImportError:
41
+ # Fallback: simple env getter
42
+ def resolve_env(key, default=None):
43
+ return os.environ.get(key, default)
44
+
45
+ # Check for google-genai dependency
46
+ try:
47
+ from google import genai
48
+ from google.genai import types
49
+ except ImportError:
50
+ print(json.dumps({
51
+ "success": False,
52
+ "error": "google-genai not installed",
53
+ "hint": "Run: pip install google-genai"
54
+ }, indent=2))
55
+ sys.exit(1)
56
+
57
+ # Import prompts from extracted module
58
+ from prompts.design_tokens import build_extraction_prompt
59
+
60
+
61
+ # Default tokens (fallback)
62
+ DEFAULT_TOKENS = {
63
+ "colors": {
64
+ "primary": "#2563eb",
65
+ "secondary": "#64748b",
66
+ "accent": "#f59e0b",
67
+ "background": "#ffffff",
68
+ "surface": "#f8fafc",
69
+ "text": {
70
+ "primary": "#0f172a",
71
+ "secondary": "#475569",
72
+ "muted": "#94a3b8"
73
+ },
74
+ "border": "#e2e8f0"
75
+ },
76
+ "typography": {
77
+ "fontFamily": {
78
+ "heading": "Inter, sans-serif",
79
+ "body": "Inter, sans-serif"
80
+ },
81
+ "fontSize": {
82
+ "xs": "12px",
83
+ "sm": "14px",
84
+ "base": "16px",
85
+ "lg": "18px",
86
+ "xl": "20px",
87
+ "2xl": "24px",
88
+ "3xl": "30px",
89
+ "4xl": "36px"
90
+ },
91
+ "fontWeight": {
92
+ "normal": 400,
93
+ "medium": 500,
94
+ "semibold": 600,
95
+ "bold": 700
96
+ },
97
+ "lineHeight": {
98
+ "tight": 1.25,
99
+ "normal": 1.5,
100
+ "relaxed": 1.75
101
+ }
102
+ },
103
+ "spacing": {
104
+ "1": "4px",
105
+ "2": "8px",
106
+ "3": "12px",
107
+ "4": "16px",
108
+ "6": "24px",
109
+ "8": "32px",
110
+ "12": "48px",
111
+ "16": "64px"
112
+ },
113
+ "borderRadius": {
114
+ "sm": "4px",
115
+ "md": "8px",
116
+ "lg": "16px",
117
+ "full": "9999px"
118
+ },
119
+ "shadows": {
120
+ "sm": "0 1px 2px rgba(0,0,0,0.05)",
121
+ "md": "0 4px 6px rgba(0,0,0,0.1)",
122
+ "lg": "0 10px 15px rgba(0,0,0,0.1)"
123
+ },
124
+ "notes": ["Using default tokens - extraction failed or was not performed"]
125
+ }
126
+
127
+
128
+ def get_api_key() -> Optional[str]:
129
+ """Get Gemini API key from environment (supports GEMINI_API_KEY or GOOGLE_API_KEY)."""
130
+ return resolve_env('GEMINI_API_KEY') or resolve_env('GOOGLE_API_KEY')
131
+
132
+
133
+ def validate_hex_color(color: str) -> bool:
134
+ """Validate hex color format."""
135
+ return bool(re.match(r'^#[0-9A-Fa-f]{6}$', color))
136
+
137
+
138
+ def validate_tokens(tokens: Dict[str, Any]) -> tuple[bool, list[str]]:
139
+ """Validate extracted tokens, return (is_valid, errors)."""
140
+ errors = []
141
+
142
+ # Check colors
143
+ if 'colors' in tokens:
144
+ colors = tokens['colors']
145
+ for key in ['primary', 'secondary', 'accent', 'background', 'surface', 'border']:
146
+ if key in colors and not validate_hex_color(colors[key]):
147
+ errors.append(f"Invalid hex color: colors.{key} = {colors[key]}")
148
+
149
+ if 'text' in colors:
150
+ for key in ['primary', 'secondary', 'muted']:
151
+ if key in colors['text'] and not validate_hex_color(colors['text'][key]):
152
+ errors.append(f"Invalid hex color: colors.text.{key} = {colors['text'][key]}")
153
+
154
+ return len(errors) == 0, errors
155
+
156
+
157
+ def merge_with_defaults(tokens: Dict[str, Any]) -> Dict[str, Any]:
158
+ """Merge extracted tokens with defaults for missing values."""
159
+ def deep_merge(base: dict, override: dict) -> dict:
160
+ result = base.copy()
161
+ for key, value in override.items():
162
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
163
+ result[key] = deep_merge(result[key], value)
164
+ else:
165
+ result[key] = value
166
+ return result
167
+
168
+ return deep_merge(DEFAULT_TOKENS.copy(), tokens)
169
+
170
+
171
+ def generate_tokens_css(tokens: Dict[str, Any]) -> str:
172
+ """Generate tokens.css from design tokens."""
173
+ lines = [
174
+ "/* Design Tokens - Auto-generated */",
175
+ "/* Edit values below to customize the design */",
176
+ "",
177
+ ":root {",
178
+ " /* Colors */",
179
+ ]
180
+
181
+ colors = tokens.get('colors', {})
182
+ lines.append(f" --color-primary: {colors.get('primary', '#2563eb')};")
183
+ lines.append(f" --color-secondary: {colors.get('secondary', '#64748b')};")
184
+ lines.append(f" --color-accent: {colors.get('accent', '#f59e0b')};")
185
+ lines.append(f" --color-background: {colors.get('background', '#ffffff')};")
186
+ lines.append(f" --color-surface: {colors.get('surface', '#f8fafc')};")
187
+
188
+ text_colors = colors.get('text', {})
189
+ lines.append(f" --color-text-primary: {text_colors.get('primary', '#0f172a')};")
190
+ lines.append(f" --color-text-secondary: {text_colors.get('secondary', '#475569')};")
191
+ lines.append(f" --color-text-muted: {text_colors.get('muted', '#94a3b8')};")
192
+ lines.append(f" --color-border: {colors.get('border', '#e2e8f0')};")
193
+
194
+ lines.append("")
195
+ lines.append(" /* Typography */")
196
+
197
+ typography = tokens.get('typography', {})
198
+ font_family = typography.get('fontFamily', {})
199
+ lines.append(f" --font-heading: {font_family.get('heading', 'Inter, sans-serif')};")
200
+ lines.append(f" --font-body: {font_family.get('body', 'Inter, sans-serif')};")
201
+
202
+ font_sizes = typography.get('fontSize', {})
203
+ for key in ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl']:
204
+ default = DEFAULT_TOKENS['typography']['fontSize'].get(key, '16px')
205
+ lines.append(f" --font-size-{key}: {font_sizes.get(key, default)};")
206
+
207
+ font_weights = typography.get('fontWeight', {})
208
+ for key in ['normal', 'medium', 'semibold', 'bold']:
209
+ default = DEFAULT_TOKENS['typography']['fontWeight'].get(key, 400)
210
+ lines.append(f" --font-weight-{key}: {font_weights.get(key, default)};")
211
+
212
+ line_heights = typography.get('lineHeight', {})
213
+ for key in ['tight', 'normal', 'relaxed']:
214
+ default = DEFAULT_TOKENS['typography']['lineHeight'].get(key, 1.5)
215
+ lines.append(f" --line-height-{key}: {line_heights.get(key, default)};")
216
+
217
+ lines.append("")
218
+ lines.append(" /* Spacing */")
219
+
220
+ spacing = tokens.get('spacing', {})
221
+ for key in ['1', '2', '3', '4', '6', '8', '12', '16']:
222
+ default = DEFAULT_TOKENS['spacing'].get(key, '16px')
223
+ lines.append(f" --space-{key}: {spacing.get(key, default)};")
224
+
225
+ lines.append("")
226
+ lines.append(" /* Border Radius */")
227
+
228
+ border_radius = tokens.get('borderRadius', {})
229
+ for key in ['sm', 'md', 'lg', 'full']:
230
+ default = DEFAULT_TOKENS['borderRadius'].get(key, '8px')
231
+ lines.append(f" --radius-{key}: {border_radius.get(key, default)};")
232
+
233
+ lines.append("")
234
+ lines.append(" /* Shadows */")
235
+
236
+ shadows = tokens.get('shadows', {})
237
+ for key in ['sm', 'md', 'lg']:
238
+ default = DEFAULT_TOKENS['shadows'].get(key, '0 1px 2px rgba(0,0,0,0.05)')
239
+ lines.append(f" --shadow-{key}: {shadows.get(key, default)};")
240
+
241
+ lines.append("}")
242
+ lines.append("")
243
+
244
+ return "\n".join(lines)
245
+
246
+
247
+ def extract_tokens(
248
+ screenshots_dir: str,
249
+ css_path: str = None,
250
+ model: str = "gemini-2.5-flash",
251
+ verbose: bool = False
252
+ ) -> Dict[str, Any]:
253
+ """Extract design tokens from screenshots using Gemini Vision.
254
+
255
+ Args:
256
+ screenshots_dir: Directory containing screenshots
257
+ css_path: Optional path to filtered CSS (improves accuracy)
258
+ model: Gemini model to use
259
+ verbose: Enable verbose output
260
+
261
+ Returns:
262
+ Design tokens dictionary
263
+ """
264
+
265
+ api_key = get_api_key()
266
+ if not api_key:
267
+ if verbose:
268
+ print("Warning: GEMINI_API_KEY not found, using default tokens")
269
+ return DEFAULT_TOKENS.copy()
270
+
271
+ # Load CSS if provided
272
+ css_content = None
273
+ if css_path and Path(css_path).exists():
274
+ with open(css_path, 'r', encoding='utf-8') as f:
275
+ css_content = f.read()
276
+ if verbose:
277
+ print(f"Loaded CSS: {len(css_content)} chars")
278
+
279
+ # Build prompt with context
280
+ prompt = build_extraction_prompt(css_content)
281
+
282
+ if verbose and css_content:
283
+ print("Using enhanced prompt with CSS context")
284
+
285
+ # Find screenshots
286
+ screenshots_path = Path(screenshots_dir)
287
+ desktop = screenshots_path / "desktop.png"
288
+ tablet = screenshots_path / "tablet.png"
289
+ mobile = screenshots_path / "mobile.png"
290
+
291
+ # Check which files exist
292
+ available_images = []
293
+ for img in [desktop, tablet, mobile]:
294
+ if img.exists():
295
+ available_images.append(img)
296
+ if verbose:
297
+ print(f"Found: {img}")
298
+
299
+ if not available_images:
300
+ if verbose:
301
+ print("Warning: No screenshots found, using default tokens")
302
+ return DEFAULT_TOKENS.copy()
303
+
304
+ try:
305
+ # Initialize client
306
+ client = genai.Client(api_key=api_key)
307
+
308
+ # Build content with images
309
+ content = [prompt]
310
+
311
+ for img_path in available_images:
312
+ with open(img_path, 'rb') as f:
313
+ img_bytes = f.read()
314
+ content.append(
315
+ types.Part.from_bytes(data=img_bytes, mime_type='image/png')
316
+ )
317
+
318
+ if verbose:
319
+ print(f"Sending {len(available_images)} images to {model}...")
320
+
321
+ # Request structured JSON output
322
+ config = types.GenerateContentConfig(
323
+ response_mime_type='application/json'
324
+ )
325
+
326
+ response = client.models.generate_content(
327
+ model=model,
328
+ contents=content,
329
+ config=config
330
+ )
331
+
332
+ # Parse response
333
+ if hasattr(response, 'text') and response.text:
334
+ tokens = json.loads(response.text)
335
+
336
+ # Validate
337
+ is_valid, errors = validate_tokens(tokens)
338
+ if not is_valid:
339
+ if verbose:
340
+ print(f"Validation warnings: {errors}")
341
+ tokens['notes'] = tokens.get('notes', []) + errors
342
+
343
+ # Merge with defaults for missing values
344
+ tokens = merge_with_defaults(tokens)
345
+
346
+ if verbose:
347
+ print("Tokens extracted successfully")
348
+
349
+ return tokens
350
+ else:
351
+ if verbose:
352
+ print("Warning: Empty response, using default tokens")
353
+ return DEFAULT_TOKENS.copy()
354
+
355
+ except Exception as e:
356
+ if verbose:
357
+ print(f"Error during extraction: {e}")
358
+
359
+ # Return defaults with error note
360
+ tokens = DEFAULT_TOKENS.copy()
361
+ tokens['notes'] = [f"Extraction failed: {str(e)}"]
362
+ return tokens
363
+
364
+
365
+ def main():
366
+ parser = argparse.ArgumentParser(
367
+ description="Extract design tokens from screenshots using Gemini Vision"
368
+ )
369
+ parser.add_argument(
370
+ '--screenshots', '-s',
371
+ required=True,
372
+ help='Directory containing screenshots (desktop.png, tablet.png, mobile.png)'
373
+ )
374
+ parser.add_argument(
375
+ '--output', '-o',
376
+ required=True,
377
+ help='Output directory for design-tokens.json and tokens.css'
378
+ )
379
+ parser.add_argument(
380
+ '--css',
381
+ default=None,
382
+ help='Path to filtered CSS file for exact token extraction (optional)'
383
+ )
384
+ parser.add_argument(
385
+ '--model', '-m',
386
+ default='gemini-2.5-flash',
387
+ help='Gemini model to use (default: gemini-2.5-flash)'
388
+ )
389
+ parser.add_argument(
390
+ '--verbose', '-v',
391
+ action='store_true',
392
+ help='Enable verbose output'
393
+ )
394
+
395
+ args = parser.parse_args()
396
+
397
+ # Create output directory
398
+ output_path = Path(args.output)
399
+ output_path.mkdir(parents=True, exist_ok=True)
400
+
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
+ )
408
+
409
+ # Save design-tokens.json
410
+ json_path = output_path / "design-tokens.json"
411
+ with open(json_path, 'w') as f:
412
+ json.dump(tokens, f, indent=2)
413
+
414
+ if args.verbose:
415
+ print(f"Saved: {json_path}")
416
+
417
+ # Generate and save tokens.css
418
+ css_content = generate_tokens_css(tokens)
419
+ css_path = output_path / "tokens.css"
420
+ with open(css_path, 'w') as f:
421
+ f.write(css_content)
422
+
423
+ if args.verbose:
424
+ print(f"Saved: {css_path}")
425
+
426
+ # Output result as JSON
427
+ result = {
428
+ "success": True,
429
+ "tokens_json": str(json_path),
430
+ "tokens_css": str(css_path),
431
+ "model": args.model,
432
+ "notes": tokens.get('notes', [])
433
+ }
434
+
435
+ print(json.dumps(result, indent=2))
436
+
437
+
438
+ if __name__ == '__main__':
439
+ main()
@@ -0,0 +1,2 @@
1
+ # Design Clone AI Prompts
2
+ # Extracted from main Python files for better maintainability
@@ -0,0 +1,183 @@
1
+ """
2
+ Design Token Extraction Prompts
3
+
4
+ Prompts for extracting design tokens from screenshots using Gemini Vision.
5
+ """
6
+
7
+ # Design token extraction prompt (basic - screenshot only)
8
+ EXTRACTION_PROMPT = """Analyze these website screenshots (desktop, tablet, mobile) and extract design tokens.
9
+
10
+ Return ONLY valid JSON in this exact format:
11
+
12
+ {
13
+ "colors": {
14
+ "primary": "#hex",
15
+ "secondary": "#hex",
16
+ "accent": "#hex",
17
+ "background": "#hex",
18
+ "surface": "#hex",
19
+ "text": {
20
+ "primary": "#hex",
21
+ "secondary": "#hex",
22
+ "muted": "#hex"
23
+ },
24
+ "border": "#hex"
25
+ },
26
+ "typography": {
27
+ "fontFamily": {
28
+ "heading": "Font Name, sans-serif",
29
+ "body": "Font Name, sans-serif"
30
+ },
31
+ "fontSize": {
32
+ "xs": "12px",
33
+ "sm": "14px",
34
+ "base": "16px",
35
+ "lg": "18px",
36
+ "xl": "20px",
37
+ "2xl": "24px",
38
+ "3xl": "30px",
39
+ "4xl": "36px"
40
+ },
41
+ "fontWeight": {
42
+ "normal": 400,
43
+ "medium": 500,
44
+ "semibold": 600,
45
+ "bold": 700
46
+ },
47
+ "lineHeight": {
48
+ "tight": 1.25,
49
+ "normal": 1.5,
50
+ "relaxed": 1.75
51
+ }
52
+ },
53
+ "spacing": {
54
+ "1": "4px",
55
+ "2": "8px",
56
+ "3": "12px",
57
+ "4": "16px",
58
+ "6": "24px",
59
+ "8": "32px",
60
+ "12": "48px",
61
+ "16": "64px"
62
+ },
63
+ "borderRadius": {
64
+ "sm": "4px",
65
+ "md": "8px",
66
+ "lg": "16px",
67
+ "full": "9999px"
68
+ },
69
+ "shadows": {
70
+ "sm": "0 1px 2px rgba(0,0,0,0.05)",
71
+ "md": "0 4px 6px rgba(0,0,0,0.1)",
72
+ "lg": "0 10px 15px rgba(0,0,0,0.1)"
73
+ },
74
+ "notes": []
75
+ }
76
+
77
+ RULES:
78
+ 1. Use exact 6-digit hex codes (#RRGGBB), not color names
79
+ 2. Identify Google Fonts: Inter, Roboto, Open Sans, Poppins, Montserrat, Lato, Nunito, Raleway, Playfair Display, Merriweather
80
+ 3. If font unknown, use reasonable fallback (sans-serif or serif)
81
+ 4. Extract observed values; use sensible defaults if unclear
82
+ 5. Detect spacing patterns (8px grid common)
83
+ 6. Add any observations or accessibility concerns to notes array"""
84
+
85
+
86
+ # Enhanced prompt when CSS context is available
87
+ EXTRACTION_PROMPT_WITH_CSS = """Extract design tokens from the provided CSS and screenshots.
88
+
89
+ You have access to:
90
+ 1. Screenshots showing the visual design
91
+ 2. The actual CSS used on the page
92
+
93
+ CRITICAL: Extract EXACT values from the CSS. Do not estimate colors or fonts.
94
+
95
+ ## Source CSS
96
+ ```css
97
+ {css_content}
98
+ ```
99
+
100
+ ---
101
+
102
+ Based on the CSS above, return ONLY valid JSON:
103
+
104
+ {{
105
+ "colors": {{
106
+ "primary": "[exact hex from CSS, look for primary/brand colors]",
107
+ "secondary": "[exact hex from CSS]",
108
+ "accent": "[exact hex for accent/highlight colors]",
109
+ "background": "[exact hex, usually body background]",
110
+ "surface": "[exact hex for cards/sections]",
111
+ "text": {{
112
+ "primary": "[exact hex, usually body color]",
113
+ "secondary": "[exact hex for muted text]",
114
+ "muted": "[exact hex for very light text]"
115
+ }},
116
+ "border": "[exact hex for borders]"
117
+ }},
118
+ "typography": {{
119
+ "fontFamily": {{
120
+ "heading": "[exact font-family from CSS h1-h6 rules]",
121
+ "body": "[exact font-family from CSS body rule]"
122
+ }},
123
+ "fontSize": {{
124
+ "xs": "[smallest font-size from CSS]",
125
+ "sm": "[small font-size]",
126
+ "base": "[body font-size]",
127
+ "lg": "[larger font-size]",
128
+ "xl": "[heading font-size]",
129
+ "2xl": "[h3 font-size]",
130
+ "3xl": "[h2 font-size]",
131
+ "4xl": "[h1 font-size]"
132
+ }},
133
+ "fontWeight": {{
134
+ "normal": [normal weight from CSS],
135
+ "medium": [medium weight],
136
+ "semibold": [semibold weight],
137
+ "bold": [bold weight from CSS]
138
+ }},
139
+ "lineHeight": {{
140
+ "tight": [tight line-height, usually 1.2-1.3],
141
+ "normal": [normal line-height, usually 1.5-1.6],
142
+ "relaxed": [relaxed line-height, usually 1.7-1.8]
143
+ }}
144
+ }},
145
+ "spacing": {{
146
+ "1": "[4px or smallest spacing]",
147
+ "2": "[8px]",
148
+ "3": "[12px]",
149
+ "4": "[16px - common padding]",
150
+ "6": "[24px]",
151
+ "8": "[32px]",
152
+ "12": "[48px - section padding]",
153
+ "16": "[64px - large section padding]"
154
+ }},
155
+ "borderRadius": {{
156
+ "sm": "[small radius from CSS]",
157
+ "md": "[medium radius]",
158
+ "lg": "[large radius]",
159
+ "full": "9999px"
160
+ }},
161
+ "shadows": {{
162
+ "sm": "[small shadow from CSS box-shadow]",
163
+ "md": "[medium shadow]",
164
+ "lg": "[large shadow]"
165
+ }},
166
+ "notes": ["List exact CSS custom properties/variables if found", "Note any @import URLs"]
167
+ }}
168
+
169
+ RULES:
170
+ 1. Extract EXACT hex codes from CSS, not approximate
171
+ 2. Copy font-family values exactly as written
172
+ 3. Extract actual px/rem values, convert rem to px if needed (1rem = 16px)
173
+ 4. Look for CSS custom properties (--color-*, --font-*, --space-*)
174
+ 5. If a value isn't in CSS, use screenshot to estimate"""
175
+
176
+
177
+ def build_extraction_prompt(css_content: str = None) -> str:
178
+ """Build prompt with or without CSS context."""
179
+ if css_content:
180
+ # Truncate if too long (15KB limit)
181
+ css_snippet = css_content[:15000] if len(css_content) > 15000 else css_content
182
+ return EXTRACTION_PROMPT_WITH_CSS.format(css_content=css_snippet)
183
+ return EXTRACTION_PROMPT