riazmo commited on
Commit
ebc119a
·
verified ·
1 Parent(s): 9a47966

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1838
app.py DELETED
@@ -1,1838 +0,0 @@
1
- """
2
- Design System Extractor v2 — Main Application
3
- ==============================================
4
-
5
- Flow:
6
- 1. User enters URL
7
- 2. Agent 1 discovers pages → User confirms
8
- 3. Agent 1 extracts tokens (Desktop + Mobile)
9
- 4. Agent 2 normalizes tokens
10
- 5. Stage 1 UI: User reviews tokens (accept/reject, Desktop↔Mobile toggle)
11
- 6. Agent 3 proposes upgrades
12
- 7. Stage 2 UI: User selects options with live preview
13
- 8. Agent 4 generates JSON
14
- 9. Stage 3 UI: User exports
15
- """
16
-
17
- import os
18
- import asyncio
19
- import json
20
- import gradio as gr
21
- from datetime import datetime
22
- from typing import Optional
23
-
24
- # Get HF token from environment
25
- HF_TOKEN_FROM_ENV = os.getenv("HF_TOKEN", "")
26
-
27
- # =============================================================================
28
- # GLOBAL STATE
29
- # =============================================================================
30
-
31
- class AppState:
32
- """Global application state."""
33
- def __init__(self):
34
- self.reset()
35
-
36
- def reset(self):
37
- self.discovered_pages = []
38
- self.base_url = ""
39
- self.desktop_raw = None # ExtractedTokens
40
- self.mobile_raw = None # ExtractedTokens
41
- self.desktop_normalized = None # NormalizedTokens
42
- self.mobile_normalized = None # NormalizedTokens
43
- self.upgrade_recommendations = None # UpgradeRecommendations
44
- self.selected_upgrades = {} # User selections
45
- self.logs = []
46
-
47
- def log(self, message: str):
48
- timestamp = datetime.now().strftime("%H:%M:%S")
49
- self.logs.append(f"[{timestamp}] {message}")
50
- if len(self.logs) > 100:
51
- self.logs.pop(0)
52
-
53
- def get_logs(self) -> str:
54
- return "\n".join(self.logs)
55
-
56
- state = AppState()
57
-
58
-
59
- # =============================================================================
60
- # LAZY IMPORTS
61
- # =============================================================================
62
-
63
- def get_crawler():
64
- import agents.crawler
65
- return agents.crawler
66
-
67
- def get_extractor():
68
- import agents.extractor
69
- return agents.extractor
70
-
71
- def get_normalizer():
72
- import agents.normalizer
73
- return agents.normalizer
74
-
75
- def get_advisor():
76
- import agents.advisor
77
- return agents.advisor
78
-
79
- def get_schema():
80
- import core.token_schema
81
- return core.token_schema
82
-
83
-
84
- # =============================================================================
85
- # PHASE 1: DISCOVER PAGES
86
- # =============================================================================
87
-
88
- async def discover_pages(url: str, progress=gr.Progress()):
89
- """Discover pages from URL."""
90
- state.reset()
91
-
92
- if not url or not url.startswith(("http://", "https://")):
93
- return "❌ Please enter a valid URL", "", None
94
-
95
- state.log(f"🚀 Starting discovery for: {url}")
96
- progress(0.1, desc="🔍 Discovering pages...")
97
-
98
- try:
99
- crawler = get_crawler()
100
- discoverer = crawler.PageDiscoverer()
101
-
102
- pages = await discoverer.discover(url)
103
-
104
- state.discovered_pages = pages
105
- state.base_url = url
106
-
107
- state.log(f"✅ Found {len(pages)} pages")
108
-
109
- # Format for display
110
- pages_data = []
111
- for page in pages:
112
- pages_data.append([
113
- True, # Selected by default
114
- page.url,
115
- page.title if page.title else "(No title)",
116
- page.page_type.value,
117
- "✓" if not page.error else f"⚠ {page.error}"
118
- ])
119
-
120
- progress(1.0, desc="✅ Discovery complete!")
121
-
122
- status = f"✅ Found {len(pages)} pages. Review and click 'Extract Tokens' to continue."
123
-
124
- return status, state.get_logs(), pages_data
125
-
126
- except Exception as e:
127
- import traceback
128
- state.log(f"❌ Error: {str(e)}")
129
- return f"❌ Error: {str(e)}", state.get_logs(), None
130
-
131
-
132
- # =============================================================================
133
- # PHASE 2: EXTRACT TOKENS
134
- # =============================================================================
135
-
136
- async def extract_tokens(pages_data, progress=gr.Progress()):
137
- """Extract tokens from selected pages (both viewports)."""
138
-
139
- state.log(f"📥 Received pages_data type: {type(pages_data)}")
140
-
141
- if pages_data is None:
142
- return "❌ Please discover pages first", state.get_logs(), None, None
143
-
144
- # Get selected URLs - handle pandas DataFrame
145
- selected_urls = []
146
-
147
- try:
148
- # Check if it's a pandas DataFrame
149
- if hasattr(pages_data, 'iterrows'):
150
- state.log(f"📥 DataFrame with {len(pages_data)} rows, columns: {list(pages_data.columns)}")
151
-
152
- for idx, row in pages_data.iterrows():
153
- # Get values by column name or position
154
- try:
155
- # Try column names first
156
- is_selected = row.get('Select', row.iloc[0] if len(row) > 0 else False)
157
- url = row.get('URL', row.iloc[1] if len(row) > 1 else '')
158
- except:
159
- # Fallback to positional
160
- is_selected = row.iloc[0] if len(row) > 0 else False
161
- url = row.iloc[1] if len(row) > 1 else ''
162
-
163
- if is_selected and url:
164
- selected_urls.append(url)
165
-
166
- # If it's a dict (Gradio sometimes sends this)
167
- elif isinstance(pages_data, dict):
168
- state.log(f"📥 Dict with keys: {list(pages_data.keys())}")
169
- data = pages_data.get('data', [])
170
- for row in data:
171
- if isinstance(row, (list, tuple)) and len(row) >= 2 and row[0]:
172
- selected_urls.append(row[1])
173
-
174
- # If it's a list
175
- elif isinstance(pages_data, (list, tuple)):
176
- state.log(f"📥 List with {len(pages_data)} items")
177
- for row in pages_data:
178
- if isinstance(row, (list, tuple)) and len(row) >= 2 and row[0]:
179
- selected_urls.append(row[1])
180
-
181
- except Exception as e:
182
- state.log(f"❌ Error parsing pages_data: {str(e)}")
183
- import traceback
184
- state.log(traceback.format_exc())
185
-
186
- state.log(f"📋 Found {len(selected_urls)} selected URLs")
187
-
188
- # If still no URLs, try using stored discovered pages
189
- if not selected_urls and state.discovered_pages:
190
- state.log("⚠️ No URLs from table, using all discovered pages")
191
- selected_urls = [p.url for p in state.discovered_pages if not p.error][:10]
192
-
193
- if not selected_urls:
194
- return "❌ No pages selected. Please select pages or rediscover.", state.get_logs(), None, None
195
-
196
- # Limit to 10 pages for performance
197
- selected_urls = selected_urls[:10]
198
-
199
- state.log(f"📋 Extracting from {len(selected_urls)} pages:")
200
- for url in selected_urls[:3]:
201
- state.log(f" • {url}")
202
- if len(selected_urls) > 3:
203
- state.log(f" ... and {len(selected_urls) - 3} more")
204
-
205
- progress(0.05, desc="🚀 Starting extraction...")
206
-
207
- try:
208
- schema = get_schema()
209
- extractor_mod = get_extractor()
210
- normalizer_mod = get_normalizer()
211
-
212
- # === DESKTOP EXTRACTION ===
213
- state.log("")
214
- state.log("🖥️ DESKTOP EXTRACTION (1440px)")
215
- progress(0.1, desc="🖥️ Extracting desktop tokens...")
216
-
217
- desktop_extractor = extractor_mod.TokenExtractor(viewport=schema.Viewport.DESKTOP)
218
-
219
- def desktop_progress(p):
220
- progress(0.1 + (p * 0.35), desc=f"🖥️ Desktop... {int(p*100)}%")
221
-
222
- state.desktop_raw = await desktop_extractor.extract(selected_urls, progress_callback=desktop_progress)
223
-
224
- state.log(f" Raw: {len(state.desktop_raw.colors)} colors, {len(state.desktop_raw.typography)} typography, {len(state.desktop_raw.spacing)} spacing")
225
-
226
- # Normalize desktop
227
- state.log(" Normalizing...")
228
- state.desktop_normalized = normalizer_mod.normalize_tokens(state.desktop_raw)
229
- state.log(f" Normalized: {len(state.desktop_normalized.colors)} colors, {len(state.desktop_normalized.typography)} typography, {len(state.desktop_normalized.spacing)} spacing")
230
-
231
- # === MOBILE EXTRACTION ===
232
- state.log("")
233
- state.log("📱 MOBILE EXTRACTION (375px)")
234
- progress(0.5, desc="📱 Extracting mobile tokens...")
235
-
236
- mobile_extractor = extractor_mod.TokenExtractor(viewport=schema.Viewport.MOBILE)
237
-
238
- def mobile_progress(p):
239
- progress(0.5 + (p * 0.35), desc=f"📱 Mobile... {int(p*100)}%")
240
-
241
- state.mobile_raw = await mobile_extractor.extract(selected_urls, progress_callback=mobile_progress)
242
-
243
- state.log(f" Raw: {len(state.mobile_raw.colors)} colors, {len(state.mobile_raw.typography)} typography, {len(state.mobile_raw.spacing)} spacing")
244
-
245
- # Normalize mobile
246
- state.log(" Normalizing...")
247
- state.mobile_normalized = normalizer_mod.normalize_tokens(state.mobile_raw)
248
- state.log(f" Normalized: {len(state.mobile_normalized.colors)} colors, {len(state.mobile_normalized.typography)} typography, {len(state.mobile_normalized.spacing)} spacing")
249
-
250
- progress(0.95, desc="📊 Preparing results...")
251
-
252
- # Format results for Stage 1 UI
253
- desktop_data = format_tokens_for_display(state.desktop_normalized)
254
- mobile_data = format_tokens_for_display(state.mobile_normalized)
255
-
256
- # Generate visual previews
257
- state.log("")
258
- state.log("🎨 Generating visual previews...")
259
-
260
- from core.preview_generator import generate_typography_preview_html, generate_color_ramps_preview_html
261
-
262
- # Get detected font
263
- fonts = get_detected_fonts()
264
- primary_font = fonts.get("primary", "Open Sans")
265
-
266
- # Convert typography tokens to dict format for preview
267
- typo_dict = {}
268
- for name, t in state.desktop_normalized.typography.items():
269
- typo_dict[name] = {
270
- "font_size": t.font_size,
271
- "font_weight": t.font_weight,
272
- "line_height": t.line_height or "1.5",
273
- "letter_spacing": "0",
274
- }
275
-
276
- # Convert color tokens to dict format for preview
277
- color_dict = {}
278
- for name, c in state.desktop_normalized.colors.items():
279
- color_dict[name] = {"value": c.value}
280
-
281
- typography_preview_html = generate_typography_preview_html(
282
- typography_tokens=typo_dict,
283
- font_family=primary_font,
284
- sample_text="The quick brown fox jumps over the lazy dog",
285
- )
286
-
287
- color_ramps_preview_html = generate_color_ramps_preview_html(
288
- color_tokens=color_dict,
289
- )
290
-
291
- state.log(" ✅ Typography preview generated")
292
- state.log(" ✅ Color ramps preview generated")
293
-
294
- state.log("")
295
- state.log("=" * 50)
296
- state.log("✅ EXTRACTION COMPLETE!")
297
- state.log("=" * 50)
298
-
299
- progress(1.0, desc="✅ Complete!")
300
-
301
- status = f"""## ✅ Extraction Complete!
302
-
303
- | Viewport | Colors | Typography | Spacing |
304
- |----------|--------|------------|---------|
305
- | Desktop | {len(state.desktop_normalized.colors)} | {len(state.desktop_normalized.typography)} | {len(state.desktop_normalized.spacing)} |
306
- | Mobile | {len(state.mobile_normalized.colors)} | {len(state.mobile_normalized.typography)} | {len(state.mobile_normalized.spacing)} |
307
-
308
- **Primary Font:** {primary_font}
309
-
310
- **Next:** Review the tokens below. Accept or reject, then proceed to Stage 2.
311
- """
312
-
313
- return status, state.get_logs(), desktop_data, mobile_data, typography_preview_html, color_ramps_preview_html
314
-
315
- except Exception as e:
316
- import traceback
317
- state.log(f"❌ Error: {str(e)}")
318
- state.log(traceback.format_exc())
319
- return f"❌ Error: {str(e)}", state.get_logs(), None, None, "", ""
320
-
321
-
322
- def format_tokens_for_display(normalized) -> dict:
323
- """Format normalized tokens for Gradio display."""
324
- if normalized is None:
325
- return {"colors": [], "typography": [], "spacing": []}
326
-
327
- # Colors are now a dict
328
- colors = []
329
- color_items = list(normalized.colors.values()) if isinstance(normalized.colors, dict) else normalized.colors
330
- for c in sorted(color_items, key=lambda x: -x.frequency)[:50]:
331
- colors.append([
332
- True, # Accept checkbox
333
- c.value,
334
- c.suggested_name or "",
335
- c.frequency,
336
- c.confidence.value if c.confidence else "medium",
337
- f"{c.contrast_white:.1f}:1" if c.contrast_white else "N/A",
338
- "✓" if c.wcag_aa_small_text else "✗",
339
- ", ".join(c.contexts[:2]) if c.contexts else "",
340
- ])
341
-
342
- # Typography
343
- typography = []
344
- typo_items = list(normalized.typography.values()) if isinstance(normalized.typography, dict) else normalized.typography
345
- for t in sorted(typo_items, key=lambda x: -x.frequency)[:30]:
346
- typography.append([
347
- True, # Accept checkbox
348
- t.font_family,
349
- t.font_size,
350
- str(t.font_weight),
351
- t.line_height or "",
352
- t.suggested_name or "",
353
- t.frequency,
354
- t.confidence.value if t.confidence else "medium",
355
- ])
356
-
357
- # Spacing
358
- spacing = []
359
- spacing_items = list(normalized.spacing.values()) if isinstance(normalized.spacing, dict) else normalized.spacing
360
- for s in sorted(spacing_items, key=lambda x: x.value_px)[:20]:
361
- spacing.append([
362
- True, # Accept checkbox
363
- s.value,
364
- f"{s.value_px}px",
365
- s.suggested_name or "",
366
- s.frequency,
367
- "✓" if s.fits_base_8 else "",
368
- s.confidence.value if s.confidence else "medium",
369
- ])
370
-
371
- return {
372
- "colors": colors,
373
- "typography": typography,
374
- "spacing": spacing,
375
- }
376
-
377
-
378
- def switch_viewport(viewport: str):
379
- """Switch between desktop and mobile view."""
380
- if viewport == "Desktop (1440px)":
381
- data = format_tokens_for_display(state.desktop_normalized)
382
- else:
383
- data = format_tokens_for_display(state.mobile_normalized)
384
-
385
- return data["colors"], data["typography"], data["spacing"]
386
-
387
-
388
- # =============================================================================
389
- # STAGE 2: AI ANALYSIS (Multi-Agent)
390
- # =============================================================================
391
-
392
- async def run_stage2_analysis(competitors_str: str = "", progress=gr.Progress()):
393
- """Run multi-agent analysis on extracted tokens."""
394
-
395
- if not state.desktop_normalized or not state.mobile_normalized:
396
- return ("❌ Please complete Stage 1 first", "", "", "", None, None, None, "", "", "", "")
397
-
398
- # Parse competitors from input
399
- default_competitors = [
400
- "Material Design 3",
401
- "Apple Human Interface Guidelines",
402
- "Shopify Polaris",
403
- "IBM Carbon",
404
- "Atlassian Design System"
405
- ]
406
-
407
- if competitors_str and competitors_str.strip():
408
- competitors = [c.strip() for c in competitors_str.split(",") if c.strip()]
409
- else:
410
- competitors = default_competitors
411
-
412
- progress(0.05, desc="🤖 Initializing multi-agent analysis...")
413
-
414
- try:
415
- # Import the multi-agent workflow
416
- from agents.stage2_graph import run_stage2_multi_agent
417
-
418
- # Convert normalized tokens to dict for the workflow
419
- desktop_dict = normalized_to_dict(state.desktop_normalized)
420
- mobile_dict = normalized_to_dict(state.mobile_normalized)
421
-
422
- # Run multi-agent analysis
423
- progress(0.1, desc="🚀 Running parallel LLM analysis...")
424
-
425
- result = await run_stage2_multi_agent(
426
- desktop_tokens=desktop_dict,
427
- mobile_tokens=mobile_dict,
428
- competitors=competitors,
429
- log_callback=state.log,
430
- )
431
-
432
- progress(0.8, desc="📊 Processing results...")
433
-
434
- # Extract results
435
- final_recs = result.get("final_recommendations", {})
436
- llm1_analysis = result.get("llm1_analysis", {})
437
- llm2_analysis = result.get("llm2_analysis", {})
438
- rule_calculations = result.get("rule_calculations", {})
439
- cost_tracking = result.get("cost_tracking", {})
440
-
441
- # Store for later use
442
- state.upgrade_recommendations = final_recs
443
- state.multi_agent_result = result
444
-
445
- # Get font info
446
- fonts = get_detected_fonts()
447
- base_size = get_base_font_size()
448
-
449
- progress(0.9, desc="📊 Formatting results...")
450
-
451
- # Build status markdown
452
- status = build_analysis_status(final_recs, cost_tracking, result.get("errors", []))
453
-
454
- # Format brand/competitor comparison from LLM analyses
455
- brand_md = format_multi_agent_comparison(llm1_analysis, llm2_analysis, final_recs)
456
-
457
- # Format font families display
458
- font_families_md = format_font_families_display(fonts)
459
-
460
- # Format typography with BOTH desktop and mobile
461
- typography_desktop_data = format_typography_comparison_viewport(
462
- state.desktop_normalized, base_size, "desktop"
463
- )
464
- typography_mobile_data = format_typography_comparison_viewport(
465
- state.mobile_normalized, base_size, "mobile"
466
- )
467
-
468
- # Format spacing comparison table
469
- spacing_data = format_spacing_comparison_from_rules(rule_calculations)
470
-
471
- # Format color display: BASE colors + ramps separately
472
- base_colors_md = format_base_colors()
473
- color_ramps_md = format_color_ramps_from_rules(rule_calculations)
474
-
475
- # Format radius display (with token suggestions)
476
- radius_md = format_radius_with_tokens()
477
-
478
- # Format shadows display (with token suggestions)
479
- shadows_md = format_shadows_with_tokens()
480
-
481
- # Generate visual previews for Stage 2
482
- state.log("")
483
- state.log("🎨 Generating visual previews...")
484
-
485
- from core.preview_generator import generate_typography_preview_html, generate_color_ramps_preview_html
486
-
487
- primary_font = fonts.get("primary", "Open Sans")
488
-
489
- # Convert typography tokens to dict format for preview
490
- typo_dict = {}
491
- for name, t in state.desktop_normalized.typography.items():
492
- typo_dict[name] = {
493
- "font_size": t.font_size,
494
- "font_weight": t.font_weight,
495
- "line_height": t.line_height or "1.5",
496
- "letter_spacing": "0",
497
- }
498
-
499
- # Convert color tokens to dict format for preview
500
- color_dict = {}
501
- for name, c in state.desktop_normalized.colors.items():
502
- color_dict[name] = {"value": c.value}
503
-
504
- typography_preview_html = generate_typography_preview_html(
505
- typography_tokens=typo_dict,
506
- font_family=primary_font,
507
- sample_text="The quick brown fox jumps over the lazy dog",
508
- )
509
-
510
- color_ramps_preview_html = generate_color_ramps_preview_html(
511
- color_tokens=color_dict,
512
- )
513
-
514
- state.log(" ✅ Visual previews generated")
515
-
516
- progress(1.0, desc="✅ Analysis complete!")
517
-
518
- return (status, state.get_logs(), brand_md, font_families_md,
519
- typography_desktop_data, typography_mobile_data, spacing_data,
520
- base_colors_md, color_ramps_md, radius_md, shadows_md,
521
- typography_preview_html, color_ramps_preview_html)
522
-
523
- except Exception as e:
524
- import traceback
525
- state.log(f"❌ Error: {str(e)}")
526
- state.log(traceback.format_exc())
527
- return (f"❌ Analysis failed: {str(e)}", state.get_logs(), "", "", None, None, None, "", "", "", "", "", "")
528
-
529
-
530
- def normalized_to_dict(normalized) -> dict:
531
- """Convert NormalizedTokens to dict for workflow."""
532
- if not normalized:
533
- return {}
534
-
535
- result = {
536
- "colors": {},
537
- "typography": {},
538
- "spacing": {},
539
- "radius": {},
540
- "shadows": {},
541
- }
542
-
543
- # Colors
544
- for name, c in normalized.colors.items():
545
- result["colors"][name] = {
546
- "value": c.value,
547
- "frequency": c.frequency,
548
- "suggested_name": c.suggested_name,
549
- "contrast_white": c.contrast_white,
550
- "contrast_black": c.contrast_black,
551
- }
552
-
553
- # Typography
554
- for name, t in normalized.typography.items():
555
- result["typography"][name] = {
556
- "font_family": t.font_family,
557
- "font_size": t.font_size,
558
- "font_weight": t.font_weight,
559
- "line_height": t.line_height,
560
- "frequency": t.frequency,
561
- }
562
-
563
- # Spacing
564
- for name, s in normalized.spacing.items():
565
- result["spacing"][name] = {
566
- "value": s.value,
567
- "value_px": s.value_px,
568
- "frequency": s.frequency,
569
- }
570
-
571
- # Radius
572
- for name, r in normalized.radius.items():
573
- result["radius"][name] = {
574
- "value": r.value,
575
- "frequency": r.frequency,
576
- }
577
-
578
- # Shadows
579
- for name, s in normalized.shadows.items():
580
- result["shadows"][name] = {
581
- "value": s.value,
582
- "frequency": s.frequency,
583
- }
584
-
585
- return result
586
-
587
-
588
- def build_analysis_status(final_recs: dict, cost_tracking: dict, errors: list) -> str:
589
- """Build status markdown from analysis results."""
590
-
591
- lines = ["## 🧠 Multi-Agent Analysis Complete!"]
592
- lines.append("")
593
-
594
- # Cost summary
595
- if cost_tracking:
596
- total_cost = cost_tracking.get("total_cost", 0)
597
- lines.append(f"### 💰 Cost Summary")
598
- lines.append(f"**Total estimated cost:** ${total_cost:.4f}")
599
- lines.append(f"*(Free tier: $0.10/mo | Pro: $2.00/mo)*")
600
- lines.append("")
601
-
602
- # Final recommendations
603
- if final_recs and "final_recommendations" in final_recs:
604
- recs = final_recs["final_recommendations"]
605
- lines.append("### 📋 Recommendations")
606
-
607
- if recs.get("type_scale"):
608
- lines.append(f"**Type Scale:** {recs['type_scale']}")
609
- if recs.get("type_scale_rationale"):
610
- lines.append(f" *{recs['type_scale_rationale'][:100]}*")
611
-
612
- if recs.get("spacing_base"):
613
- lines.append(f"**Spacing:** {recs['spacing_base']}")
614
-
615
- lines.append("")
616
-
617
- # Summary
618
- if final_recs.get("summary"):
619
- lines.append("### 📝 Summary")
620
- lines.append(final_recs["summary"])
621
- lines.append("")
622
-
623
- # Confidence
624
- if final_recs.get("overall_confidence"):
625
- lines.append(f"**Confidence:** {final_recs['overall_confidence']}%")
626
-
627
- # Errors
628
- if errors:
629
- lines.append("")
630
- lines.append("### ⚠️ Warnings")
631
- for err in errors[:3]:
632
- lines.append(f"- {err[:100]}")
633
-
634
- return "\n".join(lines)
635
-
636
-
637
- def format_multi_agent_comparison(llm1: dict, llm2: dict, final: dict) -> str:
638
- """Format comparison from multi-agent analysis."""
639
-
640
- lines = ["### 📊 Multi-Agent Analysis Comparison"]
641
- lines.append("")
642
-
643
- # Agreements
644
- if final.get("agreements"):
645
- lines.append("#### ✅ Agreements (High Confidence)")
646
- for a in final["agreements"][:5]:
647
- topic = a.get("topic", "?")
648
- finding = a.get("finding", "?")[:80]
649
- lines.append(f"- **{topic}**: {finding}")
650
- lines.append("")
651
-
652
- # Disagreements and resolutions
653
- if final.get("disagreements"):
654
- lines.append("#### 🔄 Resolved Disagreements")
655
- for d in final["disagreements"][:3]:
656
- topic = d.get("topic", "?")
657
- resolution = d.get("resolution", "?")[:100]
658
- lines.append(f"- **{topic}**: {resolution}")
659
- lines.append("")
660
-
661
- # Score comparison
662
- lines.append("#### 📈 Score Comparison")
663
- lines.append("")
664
- lines.append("| Category | LLM 1 (Qwen) | LLM 2 (Llama) |")
665
- lines.append("|----------|--------------|---------------|")
666
-
667
- categories = ["typography", "colors", "accessibility", "spacing"]
668
- for cat in categories:
669
- llm1_score = llm1.get(cat, {}).get("score", "?") if isinstance(llm1.get(cat), dict) else "?"
670
- llm2_score = llm2.get(cat, {}).get("score", "?") if isinstance(llm2.get(cat), dict) else "?"
671
- lines.append(f"| {cat.title()} | {llm1_score}/10 | {llm2_score}/10 |")
672
-
673
- return "\n".join(lines)
674
-
675
-
676
- def format_spacing_comparison_from_rules(rule_calculations: dict) -> list:
677
- """Format spacing comparison from rule engine."""
678
- if not rule_calculations:
679
- return []
680
-
681
- spacing_options = rule_calculations.get("spacing_options", {})
682
-
683
- data = []
684
- for i in range(10):
685
- current = f"{(i+1) * 4}px" if i < 5 else f"{(i+1) * 8}px"
686
- grid_8 = spacing_options.get("8px", [])
687
- grid_4 = spacing_options.get("4px", [])
688
-
689
- val_8 = f"{grid_8[i+1]}px" if i+1 < len(grid_8) else "—"
690
- val_4 = f"{grid_4[i+1]}px" if i+1 < len(grid_4) else "—"
691
-
692
- data.append([current, val_8, val_4])
693
-
694
- return data
695
-
696
-
697
- def format_color_ramps_from_rules(rule_calculations: dict) -> str:
698
- """Format color ramps from rule engine."""
699
- if not rule_calculations:
700
- return "*No color ramps generated*"
701
-
702
- ramps = rule_calculations.get("color_ramps", {})
703
- if not ramps:
704
- return "*No color ramps generated*"
705
-
706
- lines = ["### 🌈 Generated Color Ramps"]
707
- lines.append("")
708
-
709
- for name, ramp in list(ramps.items())[:6]:
710
- lines.append(f"**{name}**")
711
- if isinstance(ramp, list) and len(ramp) >= 10:
712
- lines.append("| 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 |")
713
- lines.append("|---|---|---|---|---|---|---|---|---|---|")
714
- row = "| " + " | ".join([f"`{ramp[i]}`" for i in range(10)]) + " |"
715
- lines.append(row)
716
- lines.append("")
717
-
718
- return "\n".join(lines)
719
-
720
-
721
- def get_detected_fonts() -> dict:
722
- """Get detected font information."""
723
- if not state.desktop_normalized:
724
- return {"primary": "Unknown", "weights": []}
725
-
726
- fonts = {}
727
- weights = set()
728
-
729
- for t in state.desktop_normalized.typography.values():
730
- family = t.font_family
731
- weight = t.font_weight
732
-
733
- if family not in fonts:
734
- fonts[family] = 0
735
- fonts[family] += t.frequency
736
-
737
- if weight:
738
- try:
739
- weights.add(int(weight))
740
- except:
741
- pass
742
-
743
- primary = max(fonts.items(), key=lambda x: x[1])[0] if fonts else "Unknown"
744
-
745
- return {
746
- "primary": primary,
747
- "weights": sorted(weights) if weights else [400],
748
- "all_fonts": fonts,
749
- }
750
-
751
-
752
- def get_base_font_size() -> int:
753
- """Detect base font size from typography."""
754
- if not state.desktop_normalized:
755
- return 16
756
-
757
- # Find most common size in body range (14-18px)
758
- sizes = {}
759
- for t in state.desktop_normalized.typography.values():
760
- size_str = str(t.font_size).replace('px', '').replace('rem', '').replace('em', '')
761
- try:
762
- size = float(size_str)
763
- if 14 <= size <= 18:
764
- sizes[size] = sizes.get(size, 0) + t.frequency
765
- except:
766
- pass
767
-
768
- if sizes:
769
- return int(max(sizes.items(), key=lambda x: x[1])[0])
770
- return 16
771
-
772
-
773
- def format_brand_comparison(recommendations) -> str:
774
- """Format brand comparison as markdown table."""
775
- if not recommendations.brand_analysis:
776
- return "*Brand analysis not available*"
777
-
778
- lines = [
779
- "### 📊 Design System Comparison (5 Top Brands)",
780
- "",
781
- "| Brand | Type Ratio | Base Size | Spacing | Notes |",
782
- "|-------|------------|-----------|---------|-------|",
783
- ]
784
-
785
- for brand in recommendations.brand_analysis[:5]:
786
- name = brand.get("brand", "Unknown")
787
- ratio = brand.get("ratio", "?")
788
- base = brand.get("base", "?")
789
- spacing = brand.get("spacing", "?")
790
- notes = brand.get("notes", "")[:50] + ("..." if len(brand.get("notes", "")) > 50 else "")
791
- lines.append(f"| {name} | {ratio} | {base}px | {spacing} | {notes} |")
792
-
793
- return "\n".join(lines)
794
-
795
-
796
- def format_font_families_display(fonts: dict) -> str:
797
- """Format detected font families for display."""
798
- lines = []
799
-
800
- primary = fonts.get("primary", "Unknown")
801
- weights = fonts.get("weights", [400])
802
- all_fonts = fonts.get("all_fonts", {})
803
-
804
- lines.append(f"### Primary Font: **{primary}**")
805
- lines.append("")
806
- lines.append(f"**Weights detected:** {', '.join(map(str, weights))}")
807
- lines.append("")
808
-
809
- if all_fonts and len(all_fonts) > 1:
810
- lines.append("### All Fonts Detected")
811
- lines.append("")
812
- lines.append("| Font Family | Usage Count |")
813
- lines.append("|-------------|-------------|")
814
-
815
- sorted_fonts = sorted(all_fonts.items(), key=lambda x: -x[1])
816
- for font, count in sorted_fonts[:5]:
817
- lines.append(f"| {font} | {count:,} |")
818
-
819
- lines.append("")
820
- lines.append("*Note: This analysis focuses on English typography only.*")
821
-
822
- return "\n".join(lines)
823
-
824
-
825
- def format_typography_comparison_viewport(normalized_tokens, base_size: int, viewport: str) -> list:
826
- """Format typography comparison for a specific viewport."""
827
- if not normalized_tokens:
828
- return []
829
-
830
- # Get current typography sorted by size
831
- current_typo = list(normalized_tokens.typography.values())
832
-
833
- # Parse and sort sizes
834
- def parse_size(t):
835
- size_str = str(t.font_size).replace('px', '').replace('rem', '').replace('em', '')
836
- try:
837
- return float(size_str)
838
- except:
839
- return 16
840
-
841
- current_typo.sort(key=lambda t: -parse_size(t))
842
- sizes = [parse_size(t) for t in current_typo]
843
-
844
- # Use detected base or default
845
- base = base_size if base_size else 16
846
-
847
- # Scale factors for mobile (typically 0.85-0.9 of desktop)
848
- mobile_factor = 0.875 if viewport == "mobile" else 1.0
849
-
850
- # Token names (13 levels)
851
- token_names = [
852
- "display.2xl", "display.xl", "display.lg", "display.md",
853
- "heading.xl", "heading.lg", "heading.md", "heading.sm",
854
- "body.lg", "body.md", "body.sm",
855
- "caption", "overline"
856
- ]
857
-
858
- # Generate scales - use base size and round to sensible values
859
- def round_to_even(val):
860
- """Round to even numbers for cleaner type scales."""
861
- return int(round(val / 2) * 2)
862
-
863
- scales = {
864
- "1.2": [round_to_even(base * mobile_factor * (1.2 ** (8-i))) for i in range(13)],
865
- "1.25": [round_to_even(base * mobile_factor * (1.25 ** (8-i))) for i in range(13)],
866
- "1.333": [round_to_even(base * mobile_factor * (1.333 ** (8-i))) for i in range(13)],
867
- }
868
-
869
- # Build comparison table
870
- data = []
871
- for i, name in enumerate(token_names):
872
- current = f"{int(sizes[i])}px" if i < len(sizes) else "—"
873
- s12 = f"{scales['1.2'][i]}px"
874
- s125 = f"{scales['1.25'][i]}px"
875
- s133 = f"{scales['1.333'][i]}px"
876
- keep = current
877
- data.append([name, current, s12, s125, s133, keep])
878
-
879
- return data
880
-
881
-
882
- def format_base_colors() -> str:
883
- """Format base colors (detected) separately from ramps."""
884
- if not state.desktop_normalized:
885
- return "*No colors detected*"
886
-
887
- colors = list(state.desktop_normalized.colors.values())
888
- colors.sort(key=lambda c: -c.frequency)
889
-
890
- lines = [
891
- "### 🎨 Base Colors (Detected)",
892
- "",
893
- "These are the primary colors extracted from your website:",
894
- "",
895
- "| Color | Hex | Role | Frequency | Contrast |",
896
- "|-------|-----|------|-----------|----------|",
897
- ]
898
-
899
- for color in colors[:10]:
900
- hex_val = color.value
901
- role = "Primary" if color.suggested_name and "primary" in color.suggested_name.lower() else \
902
- "Text" if color.suggested_name and "text" in color.suggested_name.lower() else \
903
- "Background" if color.suggested_name and "background" in color.suggested_name.lower() else \
904
- "Border" if color.suggested_name and "border" in color.suggested_name.lower() else \
905
- "Accent"
906
- freq = f"{color.frequency:,}"
907
- contrast = f"{color.contrast_white:.1f}:1" if color.contrast_white else "—"
908
-
909
- # Create a simple color indicator
910
- lines.append(f"| 🟦 | `{hex_val}` | {role} | {freq} | {contrast} |")
911
-
912
- return "\n".join(lines)
913
-
914
-
915
- def format_color_ramps_visual(recommendations) -> str:
916
- """Format color ramps with visual display showing all shades."""
917
- if not state.desktop_normalized:
918
- return "*No colors to display*"
919
-
920
- colors = list(state.desktop_normalized.colors.values())
921
- colors.sort(key=lambda c: -c.frequency)
922
-
923
- lines = [
924
- "### 🌈 Generated Color Ramps",
925
- "",
926
- "Full ramp (50-950) generated for each base color:",
927
- "",
928
- ]
929
-
930
- from core.color_utils import generate_color_ramp
931
-
932
- for color in colors[:6]: # Top 6 colors
933
- hex_val = color.value
934
- role = color.suggested_name.split('.')[1] if color.suggested_name and '.' in color.suggested_name else "color"
935
-
936
- # Generate ramp
937
- try:
938
- ramp = generate_color_ramp(hex_val)
939
-
940
- lines.append(f"**{role.upper()}** (base: `{hex_val}`)")
941
- lines.append("")
942
- lines.append("| 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 |")
943
- lines.append("|---|---|---|---|---|---|---|---|---|---|")
944
-
945
- # Create row with hex values
946
- row = "|"
947
- for i in range(10):
948
- if i < len(ramp):
949
- row += f" `{ramp[i]}` |"
950
- else:
951
- row += " — |"
952
- lines.append(row)
953
- lines.append("")
954
-
955
- except Exception as e:
956
- lines.append(f"**{role}** (`{hex_val}`) — Could not generate ramp: {str(e)}")
957
- lines.append("")
958
-
959
- return "\n".join(lines)
960
-
961
-
962
- def format_radius_with_tokens() -> str:
963
- """Format radius with token name suggestions."""
964
- if not state.desktop_normalized or not state.desktop_normalized.radius:
965
- return "*No border radius values detected.*"
966
-
967
- radii = list(state.desktop_normalized.radius.values())
968
-
969
- lines = [
970
- "### 🔘 Border Radius Tokens",
971
- "",
972
- "| Detected | Suggested Token | Usage |",
973
- "|----------|-----------------|-------|",
974
- ]
975
-
976
- # Sort by pixel value
977
- def parse_radius(r):
978
- val = str(r.value).replace('px', '').replace('%', '')
979
- try:
980
- return float(val)
981
- except:
982
- return 999
983
-
984
- radii.sort(key=lambda r: parse_radius(r))
985
-
986
- token_map = {
987
- (0, 2): ("radius.none", "Sharp corners"),
988
- (2, 4): ("radius.xs", "Subtle rounding"),
989
- (4, 6): ("radius.sm", "Small elements"),
990
- (6, 10): ("radius.md", "Buttons, cards"),
991
- (10, 16): ("radius.lg", "Modals, panels"),
992
- (16, 32): ("radius.xl", "Large containers"),
993
- (32, 100): ("radius.2xl", "Pill shapes"),
994
- }
995
-
996
- for r in radii[:8]:
997
- val = str(r.value)
998
- px = parse_radius(r)
999
-
1000
- if "%" in str(r.value) or px >= 50:
1001
- token = "radius.full"
1002
- usage = "Circles, avatars"
1003
- else:
1004
- token = "radius.md"
1005
- usage = "General use"
1006
- for (low, high), (t, u) in token_map.items():
1007
- if low <= px < high:
1008
- token = t
1009
- usage = u
1010
- break
1011
-
1012
- lines.append(f"| {val} | `{token}` | {usage} |")
1013
-
1014
- return "\n".join(lines)
1015
-
1016
-
1017
- def format_shadows_with_tokens() -> str:
1018
- """Format shadows with token name suggestions."""
1019
- if not state.desktop_normalized or not state.desktop_normalized.shadows:
1020
- return "*No shadow values detected.*"
1021
-
1022
- shadows = list(state.desktop_normalized.shadows.values())
1023
-
1024
- lines = [
1025
- "### 🌫️ Shadow Tokens",
1026
- "",
1027
- "| Detected Value | Suggested Token | Use Case |",
1028
- "|----------------|-----------------|----------|",
1029
- ]
1030
-
1031
- shadow_sizes = ["shadow.xs", "shadow.sm", "shadow.md", "shadow.lg", "shadow.xl", "shadow.2xl"]
1032
-
1033
- for i, s in enumerate(shadows[:6]):
1034
- val = str(s.value)[:40] + ("..." if len(str(s.value)) > 40 else "")
1035
- token = shadow_sizes[i] if i < len(shadow_sizes) else f"shadow.custom-{i}"
1036
-
1037
- # Guess use case based on index
1038
- use_cases = ["Subtle elevation", "Cards, dropdowns", "Modals, dialogs", "Popovers", "Floating elements", "Dramatic effect"]
1039
- use = use_cases[i] if i < len(use_cases) else "Custom"
1040
-
1041
- lines.append(f"| `{val}` | `{token}` | {use} |")
1042
-
1043
- return "\n".join(lines)
1044
-
1045
-
1046
- def format_spacing_comparison(recommendations) -> list:
1047
- """Format spacing comparison table."""
1048
- if not state.desktop_normalized:
1049
- return []
1050
-
1051
- # Get current spacing
1052
- current_spacing = list(state.desktop_normalized.spacing.values())
1053
- current_spacing.sort(key=lambda s: s.value_px)
1054
-
1055
- data = []
1056
- for s in current_spacing[:10]:
1057
- current = f"{s.value_px}px"
1058
- grid_8 = f"{snap_to_grid(s.value_px, 8)}px"
1059
- grid_4 = f"{snap_to_grid(s.value_px, 4)}px"
1060
-
1061
- # Mark if value fits
1062
- if s.value_px == snap_to_grid(s.value_px, 8):
1063
- grid_8 += " ✓"
1064
- if s.value_px == snap_to_grid(s.value_px, 4):
1065
- grid_4 += " ✓"
1066
-
1067
- data.append([current, grid_8, grid_4])
1068
-
1069
- return data
1070
-
1071
-
1072
- def snap_to_grid(value: float, base: int) -> int:
1073
- """Snap value to grid."""
1074
- return round(value / base) * base
1075
-
1076
-
1077
- def apply_selected_upgrades(type_choice: str, spacing_choice: str, apply_ramps: bool):
1078
- """Apply selected upgrade options."""
1079
- if not state.upgrade_recommendations:
1080
- return "❌ Run analysis first", ""
1081
-
1082
- state.log("✨ Applying selected upgrades...")
1083
-
1084
- # Store selections
1085
- state.selected_upgrades = {
1086
- "type_scale": type_choice,
1087
- "spacing": spacing_choice,
1088
- "color_ramps": apply_ramps,
1089
- }
1090
-
1091
- state.log(f" Type Scale: {type_choice}")
1092
- state.log(f" Spacing: {spacing_choice}")
1093
- state.log(f" Color Ramps: {'Yes' if apply_ramps else 'No'}")
1094
-
1095
- state.log("✅ Upgrades applied! Proceed to Stage 3 for export.")
1096
-
1097
- return "✅ Upgrades applied! Proceed to Stage 3 to export.", state.get_logs()
1098
-
1099
-
1100
- def export_stage1_json():
1101
- """Export Stage 1 tokens (as-is extraction) to JSON."""
1102
- if not state.desktop_normalized:
1103
- return json.dumps({"error": "No tokens extracted. Please run extraction first."}, indent=2)
1104
-
1105
- result = {
1106
- "metadata": {
1107
- "source_url": state.base_url,
1108
- "extracted_at": datetime.now().isoformat(),
1109
- "version": "v1-stage1-as-is",
1110
- "stage": "extraction",
1111
- "description": "Raw extracted tokens before upgrades",
1112
- },
1113
- "fonts": {}, # Detected font families
1114
- "colors": {}, # Viewport-agnostic
1115
- "typography": {
1116
- "desktop": {},
1117
- "mobile": {},
1118
- },
1119
- "spacing": {
1120
- "desktop": {},
1121
- "mobile": {},
1122
- },
1123
- "radius": {}, # Viewport-agnostic
1124
- "shadows": {}, # Viewport-agnostic
1125
- }
1126
-
1127
- # Font families detected
1128
- fonts_info = get_detected_fonts()
1129
- result["fonts"] = {
1130
- "primary": fonts_info.get("primary", "Unknown"),
1131
- "weights": fonts_info.get("weights", [400]),
1132
- "all_fonts": fonts_info.get("all_fonts", {}),
1133
- }
1134
-
1135
- # Colors (no viewport prefix - same across devices)
1136
- if state.desktop_normalized and state.desktop_normalized.colors:
1137
- for name, c in state.desktop_normalized.colors.items():
1138
- key = c.suggested_name or c.value
1139
- result["colors"][key] = {
1140
- "value": c.value,
1141
- "frequency": c.frequency,
1142
- "confidence": c.confidence.value if c.confidence else "medium",
1143
- "contrast_white": round(c.contrast_white, 2) if c.contrast_white else None,
1144
- "contrast_black": round(c.contrast_black, 2) if c.contrast_black else None,
1145
- "contexts": c.contexts[:3] if c.contexts else [],
1146
- }
1147
-
1148
- # Typography Desktop
1149
- if state.desktop_normalized and state.desktop_normalized.typography:
1150
- for name, t in state.desktop_normalized.typography.items():
1151
- key = t.suggested_name or f"{t.font_family}-{t.font_size}"
1152
- result["typography"]["desktop"][key] = {
1153
- "font_family": t.font_family,
1154
- "font_size": t.font_size,
1155
- "font_weight": t.font_weight,
1156
- "line_height": t.line_height,
1157
- "frequency": t.frequency,
1158
- }
1159
-
1160
- # Typography Mobile
1161
- if state.mobile_normalized and state.mobile_normalized.typography:
1162
- for name, t in state.mobile_normalized.typography.items():
1163
- key = t.suggested_name or f"{t.font_family}-{t.font_size}"
1164
- result["typography"]["mobile"][key] = {
1165
- "font_family": t.font_family,
1166
- "font_size": t.font_size,
1167
- "font_weight": t.font_weight,
1168
- "line_height": t.line_height,
1169
- "frequency": t.frequency,
1170
- }
1171
-
1172
- # Spacing Desktop
1173
- if state.desktop_normalized and state.desktop_normalized.spacing:
1174
- for name, s in state.desktop_normalized.spacing.items():
1175
- key = s.suggested_name or s.value
1176
- result["spacing"]["desktop"][key] = {
1177
- "value": s.value,
1178
- "value_px": s.value_px,
1179
- "fits_base_8": s.fits_base_8,
1180
- "frequency": s.frequency,
1181
- }
1182
-
1183
- # Spacing Mobile
1184
- if state.mobile_normalized and state.mobile_normalized.spacing:
1185
- for name, s in state.mobile_normalized.spacing.items():
1186
- key = s.suggested_name or s.value
1187
- result["spacing"]["mobile"][key] = {
1188
- "value": s.value,
1189
- "value_px": s.value_px,
1190
- "fits_base_8": s.fits_base_8,
1191
- "frequency": s.frequency,
1192
- }
1193
-
1194
- # Radius (no viewport prefix)
1195
- if state.desktop_normalized and state.desktop_normalized.radius:
1196
- for name, r in state.desktop_normalized.radius.items():
1197
- result["radius"][name] = {
1198
- "value": r.value,
1199
- "frequency": r.frequency,
1200
- }
1201
-
1202
- # Shadows (no viewport prefix)
1203
- if state.desktop_normalized and state.desktop_normalized.shadows:
1204
- for name, s in state.desktop_normalized.shadows.items():
1205
- result["shadows"][name] = {
1206
- "value": s.value,
1207
- "frequency": s.frequency,
1208
- }
1209
-
1210
- return json.dumps(result, indent=2, default=str)
1211
-
1212
-
1213
- def export_tokens_json():
1214
- """Export final tokens with selected upgrades applied."""
1215
- if not state.desktop_normalized:
1216
- return json.dumps({"error": "No tokens extracted. Please run extraction first."}, indent=2)
1217
-
1218
- # Get selected upgrades
1219
- upgrades = getattr(state, 'selected_upgrades', {})
1220
- type_scale_choice = upgrades.get('type_scale', 'Keep Current')
1221
- spacing_choice = upgrades.get('spacing', 'Keep Current')
1222
- apply_ramps = upgrades.get('color_ramps', True)
1223
-
1224
- # Determine ratio from choice
1225
- ratio = None
1226
- if "1.2" in type_scale_choice:
1227
- ratio = 1.2
1228
- elif "1.25" in type_scale_choice:
1229
- ratio = 1.25
1230
- elif "1.333" in type_scale_choice:
1231
- ratio = 1.333
1232
-
1233
- # Determine spacing base
1234
- spacing_base = None
1235
- if "8px" in spacing_choice:
1236
- spacing_base = 8
1237
- elif "4px" in spacing_choice:
1238
- spacing_base = 4
1239
-
1240
- result = {
1241
- "metadata": {
1242
- "source_url": state.base_url,
1243
- "extracted_at": datetime.now().isoformat(),
1244
- "version": "v2-upgraded",
1245
- "stage": "final",
1246
- "upgrades_applied": {
1247
- "type_scale": type_scale_choice,
1248
- "spacing": spacing_choice,
1249
- "color_ramps": apply_ramps,
1250
- },
1251
- },
1252
- "fonts": {},
1253
- "colors": {},
1254
- "typography": {
1255
- "desktop": {},
1256
- "mobile": {},
1257
- },
1258
- "spacing": {
1259
- "desktop": {},
1260
- "mobile": {},
1261
- },
1262
- "radius": {},
1263
- "shadows": {},
1264
- }
1265
-
1266
- # Font families
1267
- fonts_info = get_detected_fonts()
1268
- result["fonts"] = {
1269
- "primary": fonts_info.get("primary", "Unknown"),
1270
- "weights": fonts_info.get("weights", [400]),
1271
- }
1272
-
1273
- # Colors with optional ramps
1274
- if state.desktop_normalized and state.desktop_normalized.colors:
1275
- from core.color_utils import generate_color_ramp
1276
-
1277
- for name, c in state.desktop_normalized.colors.items():
1278
- base_key = c.suggested_name or c.value
1279
-
1280
- if apply_ramps:
1281
- # Generate full ramp
1282
- try:
1283
- ramp = generate_color_ramp(c.value)
1284
- shades = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"]
1285
- for i, shade in enumerate(shades):
1286
- if i < len(ramp):
1287
- result["colors"][f"{base_key}.{shade}"] = {
1288
- "value": ramp[i],
1289
- "source": "upgraded" if shade != "500" else "detected",
1290
- }
1291
- except:
1292
- result["colors"][base_key] = {"value": c.value, "source": "detected"}
1293
- else:
1294
- result["colors"][base_key] = {"value": c.value, "source": "detected"}
1295
-
1296
- # Typography with optional type scale
1297
- base_size = get_base_font_size()
1298
- token_names = [
1299
- "display.2xl", "display.xl", "display.lg", "display.md",
1300
- "heading.xl", "heading.lg", "heading.md", "heading.sm",
1301
- "body.lg", "body.md", "body.sm", "caption", "overline"
1302
- ]
1303
-
1304
- # Desktop typography
1305
- if state.desktop_normalized and state.desktop_normalized.typography:
1306
- if ratio:
1307
- # Apply type scale
1308
- scales = [int(round(base_size * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
1309
- for i, token_name in enumerate(token_names):
1310
- result["typography"]["desktop"][token_name] = {
1311
- "font_family": fonts_info.get("primary", "sans-serif"),
1312
- "font_size": f"{scales[i]}px",
1313
- "source": "upgraded",
1314
- }
1315
- else:
1316
- # Keep original
1317
- for name, t in state.desktop_normalized.typography.items():
1318
- key = t.suggested_name or f"{t.font_family}-{t.font_size}"
1319
- result["typography"]["desktop"][key] = {
1320
- "font_family": t.font_family,
1321
- "font_size": t.font_size,
1322
- "font_weight": t.font_weight,
1323
- "line_height": t.line_height,
1324
- "source": "detected",
1325
- }
1326
-
1327
- # Mobile typography
1328
- if state.mobile_normalized and state.mobile_normalized.typography:
1329
- if ratio:
1330
- # Apply type scale with mobile factor
1331
- mobile_factor = 0.875
1332
- scales = [int(round(base_size * mobile_factor * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
1333
- for i, token_name in enumerate(token_names):
1334
- result["typography"]["mobile"][token_name] = {
1335
- "font_family": fonts_info.get("primary", "sans-serif"),
1336
- "font_size": f"{scales[i]}px",
1337
- "source": "upgraded",
1338
- }
1339
- else:
1340
- for name, t in state.mobile_normalized.typography.items():
1341
- key = t.suggested_name or f"{t.font_family}-{t.font_size}"
1342
- result["typography"]["mobile"][key] = {
1343
- "font_family": t.font_family,
1344
- "font_size": t.font_size,
1345
- "font_weight": t.font_weight,
1346
- "line_height": t.line_height,
1347
- "source": "detected",
1348
- }
1349
-
1350
- # Spacing with optional grid alignment
1351
- spacing_tokens = ["space.1", "space.2", "space.3", "space.4", "space.5",
1352
- "space.6", "space.8", "space.10", "space.12", "space.16"]
1353
-
1354
- if state.desktop_normalized and state.desktop_normalized.spacing:
1355
- if spacing_base:
1356
- # Generate grid-aligned spacing
1357
- for i, token_name in enumerate(spacing_tokens):
1358
- value = spacing_base * (i + 1)
1359
- result["spacing"]["desktop"][token_name] = {
1360
- "value": f"{value}px",
1361
- "value_px": value,
1362
- "source": "upgraded",
1363
- }
1364
- else:
1365
- for name, s in state.desktop_normalized.spacing.items():
1366
- key = s.suggested_name or s.value
1367
- result["spacing"]["desktop"][key] = {
1368
- "value": s.value,
1369
- "value_px": s.value_px,
1370
- "source": "detected",
1371
- }
1372
-
1373
- if state.mobile_normalized and state.mobile_normalized.spacing:
1374
- if spacing_base:
1375
- for i, token_name in enumerate(spacing_tokens):
1376
- value = spacing_base * (i + 1)
1377
- result["spacing"]["mobile"][token_name] = {
1378
- "value": f"{value}px",
1379
- "value_px": value,
1380
- "source": "upgraded",
1381
- }
1382
- else:
1383
- for name, s in state.mobile_normalized.spacing.items():
1384
- key = s.suggested_name or s.value
1385
- result["spacing"]["mobile"][key] = {
1386
- "value": s.value,
1387
- "value_px": s.value_px,
1388
- "source": "detected",
1389
- }
1390
-
1391
- # Radius
1392
- if state.desktop_normalized and state.desktop_normalized.radius:
1393
- for name, r in state.desktop_normalized.radius.items():
1394
- result["radius"][name] = {
1395
- "value": r.value,
1396
- "source": "detected",
1397
- }
1398
-
1399
- # Shadows
1400
- if state.desktop_normalized and state.desktop_normalized.shadows:
1401
- for name, s in state.desktop_normalized.shadows.items():
1402
- result["shadows"][name] = {
1403
- "value": s.value,
1404
- "source": "detected",
1405
- }
1406
-
1407
- return json.dumps(result, indent=2, default=str)
1408
-
1409
-
1410
- # =============================================================================
1411
- # UI BUILDING
1412
- # =============================================================================
1413
-
1414
- def create_ui():
1415
- """Create the Gradio interface."""
1416
-
1417
- with gr.Blocks(
1418
- title="Design System Extractor v2",
1419
- theme=gr.themes.Soft(),
1420
- css="""
1421
- .color-swatch { display: inline-block; width: 24px; height: 24px; border-radius: 4px; margin-right: 8px; vertical-align: middle; }
1422
- """
1423
- ) as app:
1424
-
1425
- gr.Markdown("""
1426
- # 🎨 Design System Extractor v2
1427
-
1428
- **Reverse-engineer design systems from live websites.**
1429
-
1430
- A semi-automated, human-in-the-loop system that extracts, normalizes, and upgrades design tokens.
1431
-
1432
- ---
1433
- """)
1434
-
1435
- # =================================================================
1436
- # CONFIGURATION
1437
- # =================================================================
1438
-
1439
- with gr.Accordion("⚙️ Configuration", open=not bool(HF_TOKEN_FROM_ENV)):
1440
- gr.Markdown("**HuggingFace Token** — Required for Stage 2 (AI upgrades)")
1441
- with gr.Row():
1442
- hf_token_input = gr.Textbox(
1443
- label="HF Token", placeholder="hf_xxxx", type="password",
1444
- scale=4, value=HF_TOKEN_FROM_ENV,
1445
- )
1446
- save_token_btn = gr.Button("💾 Save", scale=1)
1447
- token_status = gr.Markdown("✅ Token loaded" if HF_TOKEN_FROM_ENV else "⏳ Enter token")
1448
-
1449
- def save_token(token):
1450
- if token and len(token) > 10:
1451
- os.environ["HF_TOKEN"] = token.strip()
1452
- return "✅ Token saved!"
1453
- return "❌ Invalid token"
1454
-
1455
- save_token_btn.click(save_token, [hf_token_input], [token_status])
1456
-
1457
- # =================================================================
1458
- # URL INPUT & PAGE DISCOVERY
1459
- # =================================================================
1460
-
1461
- with gr.Accordion("🔍 Step 1: Discover Pages", open=True):
1462
- gr.Markdown("Enter your website URL to discover pages for extraction.")
1463
-
1464
- with gr.Row():
1465
- url_input = gr.Textbox(label="Website URL", placeholder="https://example.com", scale=4)
1466
- discover_btn = gr.Button("🔍 Discover Pages", variant="primary", scale=1)
1467
-
1468
- discover_status = gr.Markdown("")
1469
-
1470
- with gr.Row():
1471
- log_output = gr.Textbox(label="📋 Log", lines=8, interactive=False)
1472
-
1473
- pages_table = gr.Dataframe(
1474
- headers=["Select", "URL", "Title", "Type", "Status"],
1475
- datatype=["bool", "str", "str", "str", "str"],
1476
- label="Discovered Pages",
1477
- interactive=True,
1478
- visible=False,
1479
- )
1480
-
1481
- extract_btn = gr.Button("🚀 Extract Tokens (Desktop + Mobile)", variant="primary", visible=False)
1482
-
1483
- # =================================================================
1484
- # STAGE 1: EXTRACTION REVIEW
1485
- # =================================================================
1486
-
1487
- with gr.Accordion("📊 Stage 1: Review Extracted Tokens", open=False) as stage1_accordion:
1488
-
1489
- extraction_status = gr.Markdown("")
1490
-
1491
- gr.Markdown("""
1492
- **Review the extracted tokens.** Toggle between Desktop and Mobile viewports.
1493
- Accept or reject tokens, then proceed to Stage 2 for AI-powered upgrades.
1494
- """)
1495
-
1496
- viewport_toggle = gr.Radio(
1497
- choices=["Desktop (1440px)", "Mobile (375px)"],
1498
- value="Desktop (1440px)",
1499
- label="Viewport",
1500
- )
1501
-
1502
- with gr.Tabs():
1503
- with gr.Tab("🎨 Colors"):
1504
- colors_table = gr.Dataframe(
1505
- headers=["Accept", "Color", "Suggested Name", "Frequency", "Confidence", "Contrast", "AA", "Context"],
1506
- datatype=["bool", "str", "str", "number", "str", "str", "str", "str"],
1507
- label="Colors",
1508
- interactive=True,
1509
- )
1510
-
1511
- with gr.Tab("📝 Typography"):
1512
- typography_table = gr.Dataframe(
1513
- headers=["Accept", "Font", "Size", "Weight", "Line Height", "Suggested Name", "Frequency", "Confidence"],
1514
- datatype=["bool", "str", "str", "str", "str", "str", "number", "str"],
1515
- label="Typography",
1516
- interactive=True,
1517
- )
1518
-
1519
- with gr.Tab("📏 Spacing"):
1520
- spacing_table = gr.Dataframe(
1521
- headers=["Accept", "Value", "Pixels", "Suggested Name", "Frequency", "Base 8", "Confidence"],
1522
- datatype=["bool", "str", "str", "str", "number", "str", "str"],
1523
- label="Spacing",
1524
- interactive=True,
1525
- )
1526
-
1527
- with gr.Tab("🔘 Radius"):
1528
- radius_table = gr.Dataframe(
1529
- headers=["Accept", "Value", "Frequency", "Context"],
1530
- datatype=["bool", "str", "number", "str"],
1531
- label="Border Radius",
1532
- interactive=True,
1533
- )
1534
-
1535
- # =============================================================
1536
- # VISUAL PREVIEWS (Stage 1)
1537
- # =============================================================
1538
- gr.Markdown("---")
1539
- gr.Markdown("## 👁️ Visual Previews")
1540
-
1541
- with gr.Tabs():
1542
- with gr.Tab("🔤 Typography Preview"):
1543
- gr.Markdown("*See how your typography looks with the detected font*")
1544
- stage1_typography_preview = gr.HTML(
1545
- value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Typography preview will appear after extraction...</div>",
1546
- label="Typography Preview"
1547
- )
1548
-
1549
- with gr.Tab("🎨 Color Ramps Preview"):
1550
- gr.Markdown("*Base colors with generated shades (50-950) + AA compliance*")
1551
- stage1_color_ramps_preview = gr.HTML(
1552
- value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Color ramps preview will appear after extraction...</div>",
1553
- label="Color Ramps Preview"
1554
- )
1555
-
1556
- with gr.Row():
1557
- proceed_stage2_btn = gr.Button("➡️ Proceed to Stage 2: AI Upgrades", variant="primary")
1558
- download_stage1_btn = gr.Button("📥 Download Stage 1 JSON", variant="secondary")
1559
-
1560
- # =================================================================
1561
- # STAGE 2: AI UPGRADES
1562
- # =================================================================
1563
-
1564
- with gr.Accordion("🧠 Stage 2: AI-Powered Upgrades", open=False) as stage2_accordion:
1565
-
1566
- stage2_status = gr.Markdown("Click 'Analyze' to start AI-powered design system analysis.")
1567
-
1568
- # =============================================================
1569
- # LLM CONFIGURATION & COMPETITORS
1570
- # =============================================================
1571
- with gr.Accordion("⚙️ Analysis Configuration", open=False):
1572
- gr.Markdown("""
1573
- ### 🤖 LLM Models Used
1574
-
1575
- | Role | Model | Expertise |
1576
- |------|-------|-----------|
1577
- | **Typography Analyst** | meta-llama/Llama-3.1-70B | Type scale patterns, readability |
1578
- | **Color Analyst** | meta-llama/Llama-3.1-70B | Color theory, accessibility |
1579
- | **Spacing Analyst** | Rule-based | Grid alignment, consistency |
1580
-
1581
- *Analysis compares your design against industry leaders.*
1582
- """)
1583
-
1584
- gr.Markdown("### 🎯 Competitor Design Systems")
1585
- gr.Markdown("Enter design systems to compare against (comma-separated):")
1586
- competitors_input = gr.Textbox(
1587
- value="Material Design 3, Apple HIG, Shopify Polaris, IBM Carbon, Atlassian",
1588
- label="Competitors",
1589
- placeholder="Material Design 3, Apple HIG, Shopify Polaris...",
1590
- )
1591
- gr.Markdown("*Suggestions: Ant Design, Chakra UI, Tailwind, Bootstrap, Salesforce Lightning*")
1592
-
1593
- analyze_btn = gr.Button("🤖 Analyze Design System", variant="primary", size="lg")
1594
-
1595
- with gr.Accordion("📋 AI Analysis Log", open=True):
1596
- stage2_log = gr.Textbox(label="Log", lines=18, interactive=False)
1597
-
1598
- # =============================================================
1599
- # BRAND COMPARISON (LLM Research)
1600
- # =============================================================
1601
- gr.Markdown("---")
1602
- brand_comparison = gr.Markdown("*Brand comparison will appear after analysis*")
1603
-
1604
- # =============================================================
1605
- # FONT FAMILIES DETECTED
1606
- # =============================================================
1607
- gr.Markdown("---")
1608
- gr.Markdown("## 🔤 Font Families Detected")
1609
- font_families_display = gr.Markdown("*Font information will appear after analysis*")
1610
-
1611
- # =============================================================
1612
- # TYPOGRAPHY SECTION - Desktop & Mobile
1613
- # =============================================================
1614
- gr.Markdown("---")
1615
- gr.Markdown("## 📐 Typography")
1616
-
1617
- # Visual Preview
1618
- with gr.Accordion("👁️ Typography Visual Preview", open=True):
1619
- stage2_typography_preview = gr.HTML(
1620
- value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Typography preview will appear after analysis...</div>",
1621
- label="Typography Preview"
1622
- )
1623
-
1624
- with gr.Row():
1625
- with gr.Column(scale=2):
1626
- gr.Markdown("### 🖥️ Desktop (1440px)")
1627
- typography_desktop = gr.Dataframe(
1628
- headers=["Token", "Current", "Scale 1.2", "Scale 1.25 ⭐", "Scale 1.333", "Keep"],
1629
- datatype=["str", "str", "str", "str", "str", "str"],
1630
- label="Desktop Typography",
1631
- interactive=False,
1632
- )
1633
-
1634
- with gr.Column(scale=2):
1635
- gr.Markdown("### 📱 Mobile (375px)")
1636
- typography_mobile = gr.Dataframe(
1637
- headers=["Token", "Current", "Scale 1.2", "Scale 1.25 ⭐", "Scale 1.333", "Keep"],
1638
- datatype=["str", "str", "str", "str", "str", "str"],
1639
- label="Mobile Typography",
1640
- interactive=False,
1641
- )
1642
-
1643
- with gr.Row():
1644
- with gr.Column():
1645
- gr.Markdown("### Select Type Scale Option")
1646
- type_scale_radio = gr.Radio(
1647
- choices=["Keep Current", "Scale 1.2 (Minor Third)", "Scale 1.25 (Major Third) ⭐", "Scale 1.333 (Perfect Fourth)"],
1648
- value="Scale 1.25 (Major Third) ⭐",
1649
- label="Type Scale",
1650
- interactive=True,
1651
- )
1652
- gr.Markdown("*Font family will be preserved. Sizes rounded to even numbers.*")
1653
-
1654
- # =============================================================
1655
- # COLORS SECTION - Base Colors + Ramps
1656
- # =============================================================
1657
- gr.Markdown("---")
1658
- gr.Markdown("## 🎨 Colors")
1659
-
1660
- # Visual Preview
1661
- with gr.Accordion("👁️ Color Ramps Visual Preview", open=True):
1662
- stage2_color_ramps_preview = gr.HTML(
1663
- value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Color ramps preview will appear after analysis...</div>",
1664
- label="Color Ramps Preview"
1665
- )
1666
-
1667
- base_colors_display = gr.Markdown("*Base colors will appear after analysis*")
1668
-
1669
- gr.Markdown("---")
1670
-
1671
- color_ramps_display = gr.Markdown("*Color ramps will appear after analysis*")
1672
-
1673
- color_ramps_checkbox = gr.Checkbox(
1674
- label="✓ Generate color ramps (keeps base colors, adds 50-950 shades)",
1675
- value=True,
1676
- )
1677
-
1678
- # =============================================================
1679
- # SPACING SECTION
1680
- # =============================================================
1681
- gr.Markdown("---")
1682
- gr.Markdown("## 📏 Spacing (Rule-Based)")
1683
-
1684
- with gr.Row():
1685
- with gr.Column(scale=2):
1686
- spacing_comparison = gr.Dataframe(
1687
- headers=["Current", "8px Grid", "4px Grid"],
1688
- datatype=["str", "str", "str"],
1689
- label="Spacing Comparison",
1690
- interactive=False,
1691
- )
1692
-
1693
- with gr.Column(scale=1):
1694
- spacing_radio = gr.Radio(
1695
- choices=["Keep Current", "8px Base Grid ⭐", "4px Base Grid"],
1696
- value="8px Base Grid ⭐",
1697
- label="Spacing System",
1698
- interactive=True,
1699
- )
1700
-
1701
- # =============================================================
1702
- # RADIUS SECTION
1703
- # =============================================================
1704
- gr.Markdown("---")
1705
- gr.Markdown("## 🔘 Border Radius (Rule-Based)")
1706
-
1707
- radius_display = gr.Markdown("*Radius tokens will appear after analysis*")
1708
-
1709
- # =============================================================
1710
- # SHADOWS SECTION
1711
- # =============================================================
1712
- gr.Markdown("---")
1713
- gr.Markdown("## 🌫️ Shadows (Rule-Based)")
1714
-
1715
- shadows_display = gr.Markdown("*Shadow tokens will appear after analysis*")
1716
-
1717
- # =============================================================
1718
- # APPLY SECTION
1719
- # =============================================================
1720
- gr.Markdown("---")
1721
-
1722
- with gr.Row():
1723
- apply_upgrades_btn = gr.Button("✨ Apply Selected Upgrades", variant="primary", scale=2)
1724
- reset_btn = gr.Button("↩️ Reset to Original", variant="secondary", scale=1)
1725
-
1726
- apply_status = gr.Markdown("")
1727
-
1728
- # =================================================================
1729
- # STAGE 3: EXPORT
1730
- # =================================================================
1731
-
1732
- with gr.Accordion("📦 Stage 3: Export", open=False):
1733
- gr.Markdown("""
1734
- Export your design tokens to JSON (compatible with Figma Tokens Studio).
1735
-
1736
- - **Stage 1 JSON**: Raw extracted tokens (as-is)
1737
- - **Final JSON**: Upgraded tokens with selected improvements
1738
- """)
1739
-
1740
- with gr.Row():
1741
- export_stage1_btn = gr.Button("📥 Export Stage 1 (As-Is)", variant="secondary")
1742
- export_final_btn = gr.Button("📥 Export Final (Upgraded)", variant="primary")
1743
-
1744
- export_output = gr.Code(label="Tokens JSON", language="json", lines=25)
1745
-
1746
- export_stage1_btn.click(export_stage1_json, outputs=[export_output])
1747
- export_final_btn.click(export_tokens_json, outputs=[export_output])
1748
-
1749
- # =================================================================
1750
- # EVENT HANDLERS
1751
- # =================================================================
1752
-
1753
- # Store data for viewport toggle
1754
- desktop_data = gr.State({})
1755
- mobile_data = gr.State({})
1756
-
1757
- # Discover pages
1758
- discover_btn.click(
1759
- fn=discover_pages,
1760
- inputs=[url_input],
1761
- outputs=[discover_status, log_output, pages_table],
1762
- ).then(
1763
- fn=lambda: (gr.update(visible=True), gr.update(visible=True)),
1764
- outputs=[pages_table, extract_btn],
1765
- )
1766
-
1767
- # Extract tokens
1768
- extract_btn.click(
1769
- fn=extract_tokens,
1770
- inputs=[pages_table],
1771
- outputs=[extraction_status, log_output, desktop_data, mobile_data,
1772
- stage1_typography_preview, stage1_color_ramps_preview],
1773
- ).then(
1774
- fn=lambda d: (d.get("colors", []), d.get("typography", []), d.get("spacing", [])),
1775
- inputs=[desktop_data],
1776
- outputs=[colors_table, typography_table, spacing_table],
1777
- ).then(
1778
- fn=lambda: gr.update(open=True),
1779
- outputs=[stage1_accordion],
1780
- )
1781
-
1782
- # Viewport toggle
1783
- viewport_toggle.change(
1784
- fn=switch_viewport,
1785
- inputs=[viewport_toggle],
1786
- outputs=[colors_table, typography_table, spacing_table],
1787
- )
1788
-
1789
- # Stage 2: Analyze
1790
- analyze_btn.click(
1791
- fn=run_stage2_analysis,
1792
- inputs=[competitors_input],
1793
- outputs=[stage2_status, stage2_log, brand_comparison, font_families_display,
1794
- typography_desktop, typography_mobile, spacing_comparison,
1795
- base_colors_display, color_ramps_display, radius_display, shadows_display,
1796
- stage2_typography_preview, stage2_color_ramps_preview],
1797
- )
1798
-
1799
- # Stage 2: Apply upgrades
1800
- apply_upgrades_btn.click(
1801
- fn=apply_selected_upgrades,
1802
- inputs=[type_scale_radio, spacing_radio, color_ramps_checkbox],
1803
- outputs=[apply_status, stage2_log],
1804
- )
1805
-
1806
- # Stage 1: Download JSON
1807
- download_stage1_btn.click(
1808
- fn=export_stage1_json,
1809
- outputs=[export_output],
1810
- )
1811
-
1812
- # Proceed to Stage 2 button
1813
- proceed_stage2_btn.click(
1814
- fn=lambda: gr.update(open=True),
1815
- outputs=[stage2_accordion],
1816
- )
1817
-
1818
- # =================================================================
1819
- # FOOTER
1820
- # =================================================================
1821
-
1822
- gr.Markdown("""
1823
- ---
1824
- **Design System Extractor v2** | Built with Playwright + Gradio + LangGraph + HuggingFace
1825
-
1826
- *A semi-automated co-pilot for design system recovery and modernization.*
1827
- """)
1828
-
1829
- return app
1830
-
1831
-
1832
- # =============================================================================
1833
- # MAIN
1834
- # =============================================================================
1835
-
1836
- if __name__ == "__main__":
1837
- app = create_ui()
1838
- app.launch(server_name="0.0.0.0", server_port=7860)