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.
- package/README.md +26 -12
- package/bin/commands/clone-site.js +75 -10
- package/bin/commands/init.js +33 -1
- package/bin/commands/verify.js +5 -3
- package/bin/utils/validate.js +24 -8
- package/docs/cli-reference.md +200 -2
- package/docs/codebase-summary.md +309 -0
- package/docs/design-clone-architecture.md +259 -42
- package/docs/pixel-perfect.md +35 -4
- package/docs/project-roadmap.md +382 -0
- package/docs/troubleshooting.md +5 -4
- package/package.json +10 -8
- package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
- package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
- package/src/ai/analyze-structure.py +73 -3
- package/src/ai/extract-design-tokens.py +356 -13
- package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
- package/src/ai/prompts/design_tokens.py +133 -0
- package/src/ai/prompts/structure_analysis.py +329 -10
- package/src/ai/prompts/ux_audit.py +198 -0
- package/src/ai/ux-audit.js +596 -0
- package/src/core/app-state-snapshot.js +511 -0
- package/src/core/content-counter.js +342 -0
- package/src/core/cookie-handler.js +1 -1
- package/src/core/css-extractor.js +4 -4
- package/src/core/dimension-extractor.js +93 -21
- package/src/core/dimension-output.js +103 -6
- package/src/core/discover-pages.js +242 -14
- package/src/core/dom-tree-analyzer.js +298 -0
- package/src/core/extract-assets.js +1 -1
- package/src/core/framework-detector.js +538 -0
- package/src/core/html-extractor.js +45 -4
- package/src/core/lazy-loader.js +7 -7
- package/src/core/multi-page-screenshot.js +9 -6
- package/src/core/page-readiness.js +8 -8
- package/src/core/screenshot.js +138 -9
- package/src/core/section-cropper.js +209 -0
- package/src/core/section-detector.js +386 -0
- package/src/core/semantic-enhancer.js +492 -0
- package/src/core/state-capture.js +18 -22
- package/src/core/tests/test-section-cropper.js +177 -0
- package/src/core/tests/test-section-detector.js +55 -0
- package/src/core/video-capture.js +152 -146
- package/src/route-discoverers/angular-discoverer.js +157 -0
- package/src/route-discoverers/astro-discoverer.js +123 -0
- package/src/route-discoverers/base-discoverer.js +242 -0
- package/src/route-discoverers/index.js +106 -0
- package/src/route-discoverers/next-discoverer.js +130 -0
- package/src/route-discoverers/nuxt-discoverer.js +138 -0
- package/src/route-discoverers/react-discoverer.js +139 -0
- package/src/route-discoverers/svelte-discoverer.js +109 -0
- package/src/route-discoverers/universal-discoverer.js +227 -0
- package/src/route-discoverers/vue-discoverer.js +118 -0
- package/src/utils/__init__.py +1 -1
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/browser.js +11 -37
- package/src/utils/playwright.js +213 -0
- package/src/verification/generate-audit-report.js +398 -0
- package/src/verification/verify-footer.js +493 -0
- package/src/verification/verify-header.js +486 -0
- package/src/verification/verify-layout.js +2 -2
- package/src/verification/verify-menu.js +4 -20
- package/src/verification/verify-slider.js +533 -0
- 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
|
-
#
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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)
|