riazmo commited on
Commit
a0ca55f
·
verified ·
1 Parent(s): 4a2e3ee

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1343
app.py DELETED
@@ -1,1343 +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
- state.log("")
257
- state.log("=" * 50)
258
- state.log("✅ EXTRACTION COMPLETE!")
259
- state.log("=" * 50)
260
-
261
- progress(1.0, desc="✅ Complete!")
262
-
263
- status = f"""## ✅ Extraction Complete!
264
-
265
- | Viewport | Colors | Typography | Spacing |
266
- |----------|--------|------------|---------|
267
- | Desktop | {len(state.desktop_normalized.colors)} | {len(state.desktop_normalized.typography)} | {len(state.desktop_normalized.spacing)} |
268
- | Mobile | {len(state.mobile_normalized.colors)} | {len(state.mobile_normalized.typography)} | {len(state.mobile_normalized.spacing)} |
269
-
270
- **Next:** Review the tokens below. Accept or reject, then proceed to Stage 2.
271
- """
272
-
273
- return status, state.get_logs(), desktop_data, mobile_data
274
-
275
- except Exception as e:
276
- import traceback
277
- state.log(f"❌ Error: {str(e)}")
278
- state.log(traceback.format_exc())
279
- return f"❌ Error: {str(e)}", state.get_logs(), None, None
280
-
281
-
282
- def format_tokens_for_display(normalized) -> dict:
283
- """Format normalized tokens for Gradio display."""
284
- if normalized is None:
285
- return {"colors": [], "typography": [], "spacing": []}
286
-
287
- # Colors are now a dict
288
- colors = []
289
- color_items = list(normalized.colors.values()) if isinstance(normalized.colors, dict) else normalized.colors
290
- for c in sorted(color_items, key=lambda x: -x.frequency)[:50]:
291
- colors.append([
292
- True, # Accept checkbox
293
- c.value,
294
- c.suggested_name or "",
295
- c.frequency,
296
- c.confidence.value if c.confidence else "medium",
297
- f"{c.contrast_white:.1f}:1" if c.contrast_white else "N/A",
298
- "✓" if c.wcag_aa_small_text else "✗",
299
- ", ".join(c.contexts[:2]) if c.contexts else "",
300
- ])
301
-
302
- # Typography
303
- typography = []
304
- typo_items = list(normalized.typography.values()) if isinstance(normalized.typography, dict) else normalized.typography
305
- for t in sorted(typo_items, key=lambda x: -x.frequency)[:30]:
306
- typography.append([
307
- True, # Accept checkbox
308
- t.font_family,
309
- t.font_size,
310
- str(t.font_weight),
311
- t.line_height or "",
312
- t.suggested_name or "",
313
- t.frequency,
314
- t.confidence.value if t.confidence else "medium",
315
- ])
316
-
317
- # Spacing
318
- spacing = []
319
- spacing_items = list(normalized.spacing.values()) if isinstance(normalized.spacing, dict) else normalized.spacing
320
- for s in sorted(spacing_items, key=lambda x: x.value_px)[:20]:
321
- spacing.append([
322
- True, # Accept checkbox
323
- s.value,
324
- f"{s.value_px}px",
325
- s.suggested_name or "",
326
- s.frequency,
327
- "✓" if s.fits_base_8 else "",
328
- s.confidence.value if s.confidence else "medium",
329
- ])
330
-
331
- return {
332
- "colors": colors,
333
- "typography": typography,
334
- "spacing": spacing,
335
- }
336
-
337
-
338
- def switch_viewport(viewport: str):
339
- """Switch between desktop and mobile view."""
340
- if viewport == "Desktop (1440px)":
341
- data = format_tokens_for_display(state.desktop_normalized)
342
- else:
343
- data = format_tokens_for_display(state.mobile_normalized)
344
-
345
- return data["colors"], data["typography"], data["spacing"]
346
-
347
-
348
- # =============================================================================
349
- # STAGE 2: AI ANALYSIS
350
- # =============================================================================
351
-
352
- async def run_stage2_analysis(progress=gr.Progress()):
353
- """Run Agent 3 analysis on extracted tokens."""
354
-
355
- if not state.desktop_normalized or not state.mobile_normalized:
356
- return ("❌ Please complete Stage 1 first", "", "", None, None, None, "", "", "")
357
-
358
- state.log("")
359
- state.log("=" * 60)
360
- state.log("🧠 STAGE 2: AI-POWERED ANALYSIS")
361
- state.log("=" * 60)
362
- state.log("")
363
-
364
- # Log model info
365
- model_name = os.getenv("AGENT3_MODEL", "meta-llama/Llama-3.1-70B-Instruct")
366
- state.log("📦 LLM CONFIGURATION:")
367
- state.log(f" Model: {model_name}")
368
- state.log(f" Expertise: Design system reasoning, best practices comparison")
369
- state.log(f" Task: Analyze tokens against Material, Apple, Polaris, Carbon, Atlassian")
370
- state.log("")
371
-
372
- progress(0.1, desc="🤖 Starting AI analysis...")
373
-
374
- try:
375
- advisor_mod = get_advisor()
376
-
377
- # Log what we're analyzing
378
- desktop_colors = len(state.desktop_normalized.colors)
379
- desktop_typo = len(state.desktop_normalized.typography)
380
- mobile_typo = len(state.mobile_normalized.typography)
381
-
382
- state.log("📊 INPUT DATA:")
383
- state.log(f" Colors: {desktop_colors} (viewport-agnostic)")
384
- state.log(f" Typography: {desktop_typo} desktop, {mobile_typo} mobile")
385
- state.log(f" Spacing: {len(state.desktop_normalized.spacing)} values")
386
- state.log("")
387
-
388
- # Get detected font info
389
- fonts = get_detected_fonts()
390
- base_size = get_base_font_size()
391
-
392
- state.log(f"🔤 DETECTED FONT: {fonts.get('primary', 'Unknown')}")
393
- state.log(f" Weights: {', '.join(map(str, fonts.get('weights', [])))}")
394
- state.log(f" Base size: {base_size}px")
395
- state.log("")
396
-
397
- state.log("🔍 RESEARCHING TOP DESIGN SYSTEMS...")
398
- progress(0.2, desc="🔍 Researching brands...")
399
-
400
- recommendations = await advisor_mod.analyze_design_system(
401
- desktop_tokens=state.desktop_normalized,
402
- mobile_tokens=state.mobile_normalized,
403
- log_callback=state.log,
404
- )
405
-
406
- state.upgrade_recommendations = recommendations
407
-
408
- # Log brand analysis
409
- state.log("")
410
- state.log("📊 BRAND COMPARISON RESULTS:")
411
- for brand in recommendations.brand_analysis:
412
- state.log(f" • {brand.get('brand', 'Unknown')}:")
413
- state.log(f" Ratio: {brand.get('ratio', '?')}, Base: {brand.get('base', '?')}px, Grid: {brand.get('spacing', '?')}")
414
- if brand.get('notes'):
415
- state.log(f" Notes: {brand.get('notes', '')[:100]}")
416
-
417
- state.log("")
418
- state.log("💡 LLM RECOMMENDATION:")
419
- if recommendations.llm_rationale:
420
- # Split into sentences for readability
421
- sentences = recommendations.llm_rationale.split('. ')
422
- for s in sentences[:5]:
423
- if s.strip():
424
- state.log(f" {s.strip()}.")
425
-
426
- if recommendations.color_observations:
427
- state.log("")
428
- state.log("🎨 COLOR ANALYSIS:")
429
- state.log(f" {recommendations.color_observations[:200]}")
430
-
431
- if recommendations.accessibility_issues:
432
- state.log("")
433
- state.log("⚠️ ACCESSIBILITY CONCERNS:")
434
- for issue in recommendations.accessibility_issues[:3]:
435
- state.log(f" • {issue}")
436
-
437
- progress(0.9, desc="📊 Preparing recommendations...")
438
-
439
- # Format brand comparison markdown
440
- brand_md = format_brand_comparison(recommendations)
441
-
442
- # Format typography with BOTH desktop and mobile
443
- typography_desktop_data = format_typography_comparison_viewport(
444
- state.desktop_normalized, base_size, "desktop"
445
- )
446
- typography_mobile_data = format_typography_comparison_viewport(
447
- state.mobile_normalized, base_size, "mobile"
448
- )
449
-
450
- # Format spacing comparison table
451
- spacing_data = format_spacing_comparison(recommendations)
452
-
453
- # Format color display: BASE colors + ramps separately
454
- base_colors_md = format_base_colors()
455
- color_ramps_md = format_color_ramps_visual(recommendations)
456
-
457
- # Format radius display (with token suggestions)
458
- radius_md = format_radius_with_tokens()
459
-
460
- # Format shadows display (with token suggestions)
461
- shadows_md = format_shadows_with_tokens()
462
-
463
- state.log("")
464
- state.log("=" * 60)
465
- state.log("✅ ANALYSIS COMPLETE!")
466
- state.log("=" * 60)
467
- progress(1.0, desc="✅ Complete!")
468
-
469
- # Build status with font info
470
- status = f"""## 🧠 AI Analysis Complete!
471
-
472
- ### Detected Font
473
- **{fonts.get('primary', 'Unknown')}** — Weights: {', '.join(map(str, fonts.get('weights', [])))}
474
-
475
- **Base Size:** {base_size}px (detected from body text)
476
-
477
- ### LLM Recommendation
478
- {recommendations.llm_rationale if recommendations.llm_rationale else "Analysis based on rule-based comparison with industry design systems."}
479
-
480
- {f"### Accessibility Notes{chr(10)}" + chr(10).join(['• ' + a for a in recommendations.accessibility_issues]) if recommendations.accessibility_issues else ""}
481
- """
482
-
483
- return (status, state.get_logs(), brand_md,
484
- typography_desktop_data, typography_mobile_data, spacing_data,
485
- base_colors_md, color_ramps_md, radius_md, shadows_md)
486
-
487
- except Exception as e:
488
- import traceback
489
- state.log(f"❌ Error: {str(e)}")
490
- state.log(traceback.format_exc())
491
- return (f"❌ Analysis failed: {str(e)}", state.get_logs(), "", None, None, None, "", "", "", "")
492
-
493
-
494
- def get_detected_fonts() -> dict:
495
- """Get detected font information."""
496
- if not state.desktop_normalized:
497
- return {"primary": "Unknown", "weights": []}
498
-
499
- fonts = {}
500
- weights = set()
501
-
502
- for t in state.desktop_normalized.typography.values():
503
- family = t.font_family
504
- weight = t.font_weight
505
-
506
- if family not in fonts:
507
- fonts[family] = 0
508
- fonts[family] += t.frequency
509
-
510
- if weight:
511
- try:
512
- weights.add(int(weight))
513
- except:
514
- pass
515
-
516
- primary = max(fonts.items(), key=lambda x: x[1])[0] if fonts else "Unknown"
517
-
518
- return {
519
- "primary": primary,
520
- "weights": sorted(weights) if weights else [400],
521
- "all_fonts": fonts,
522
- }
523
-
524
-
525
- def get_base_font_size() -> int:
526
- """Detect base font size from typography."""
527
- if not state.desktop_normalized:
528
- return 16
529
-
530
- # Find most common size in body range (14-18px)
531
- sizes = {}
532
- for t in state.desktop_normalized.typography.values():
533
- size_str = str(t.font_size).replace('px', '').replace('rem', '').replace('em', '')
534
- try:
535
- size = float(size_str)
536
- if 14 <= size <= 18:
537
- sizes[size] = sizes.get(size, 0) + t.frequency
538
- except:
539
- pass
540
-
541
- if sizes:
542
- return int(max(sizes.items(), key=lambda x: x[1])[0])
543
- return 16
544
-
545
-
546
- def format_brand_comparison(recommendations) -> str:
547
- """Format brand comparison as markdown table."""
548
- if not recommendations.brand_analysis:
549
- return "*Brand analysis not available*"
550
-
551
- lines = [
552
- "### 📊 Design System Comparison (5 Top Brands)",
553
- "",
554
- "| Brand | Type Ratio | Base Size | Spacing | Notes |",
555
- "|-------|------------|-----------|---------|-------|",
556
- ]
557
-
558
- for brand in recommendations.brand_analysis[:5]:
559
- name = brand.get("brand", "Unknown")
560
- ratio = brand.get("ratio", "?")
561
- base = brand.get("base", "?")
562
- spacing = brand.get("spacing", "?")
563
- notes = brand.get("notes", "")[:50] + ("..." if len(brand.get("notes", "")) > 50 else "")
564
- lines.append(f"| {name} | {ratio} | {base}px | {spacing} | {notes} |")
565
-
566
- return "\n".join(lines)
567
-
568
-
569
- def format_typography_comparison_viewport(normalized_tokens, base_size: int, viewport: str) -> list:
570
- """Format typography comparison for a specific viewport."""
571
- if not normalized_tokens:
572
- return []
573
-
574
- # Get current typography sorted by size
575
- current_typo = list(normalized_tokens.typography.values())
576
-
577
- # Parse and sort sizes
578
- def parse_size(t):
579
- size_str = str(t.font_size).replace('px', '').replace('rem', '').replace('em', '')
580
- try:
581
- return float(size_str)
582
- except:
583
- return 16
584
-
585
- current_typo.sort(key=lambda t: -parse_size(t))
586
- sizes = [parse_size(t) for t in current_typo]
587
-
588
- # Use detected base or default
589
- base = base_size if base_size else 16
590
-
591
- # Scale factors for mobile (typically 0.85-0.9 of desktop)
592
- mobile_factor = 0.875 if viewport == "mobile" else 1.0
593
-
594
- # Token names (13 levels)
595
- token_names = [
596
- "display.2xl", "display.xl", "display.lg", "display.md",
597
- "heading.xl", "heading.lg", "heading.md", "heading.sm",
598
- "body.lg", "body.md", "body.sm",
599
- "caption", "overline"
600
- ]
601
-
602
- # Generate scales - use base size and round to sensible values
603
- def round_to_even(val):
604
- """Round to even numbers for cleaner type scales."""
605
- return int(round(val / 2) * 2)
606
-
607
- scales = {
608
- "1.2": [round_to_even(base * mobile_factor * (1.2 ** (8-i))) for i in range(13)],
609
- "1.25": [round_to_even(base * mobile_factor * (1.25 ** (8-i))) for i in range(13)],
610
- "1.333": [round_to_even(base * mobile_factor * (1.333 ** (8-i))) for i in range(13)],
611
- }
612
-
613
- # Build comparison table
614
- data = []
615
- for i, name in enumerate(token_names):
616
- current = f"{int(sizes[i])}px" if i < len(sizes) else "—"
617
- s12 = f"{scales['1.2'][i]}px"
618
- s125 = f"{scales['1.25'][i]}px"
619
- s133 = f"{scales['1.333'][i]}px"
620
- keep = current
621
- data.append([name, current, s12, s125, s133, keep])
622
-
623
- return data
624
-
625
-
626
- def format_base_colors() -> str:
627
- """Format base colors (detected) separately from ramps."""
628
- if not state.desktop_normalized:
629
- return "*No colors detected*"
630
-
631
- colors = list(state.desktop_normalized.colors.values())
632
- colors.sort(key=lambda c: -c.frequency)
633
-
634
- lines = [
635
- "### 🎨 Base Colors (Detected)",
636
- "",
637
- "These are the primary colors extracted from your website:",
638
- "",
639
- "| Color | Hex | Role | Frequency | Contrast |",
640
- "|-------|-----|------|-----------|----------|",
641
- ]
642
-
643
- for color in colors[:10]:
644
- hex_val = color.value
645
- role = "Primary" if color.suggested_name and "primary" in color.suggested_name.lower() else \
646
- "Text" if color.suggested_name and "text" in color.suggested_name.lower() else \
647
- "Background" if color.suggested_name and "background" in color.suggested_name.lower() else \
648
- "Border" if color.suggested_name and "border" in color.suggested_name.lower() else \
649
- "Accent"
650
- freq = f"{color.frequency:,}"
651
- contrast = f"{color.contrast_white:.1f}:1" if color.contrast_white else "—"
652
-
653
- # Create a simple color indicator
654
- lines.append(f"| 🟦 | `{hex_val}` | {role} | {freq} | {contrast} |")
655
-
656
- return "\n".join(lines)
657
-
658
-
659
- def format_color_ramps_visual(recommendations) -> str:
660
- """Format color ramps with visual display showing all shades."""
661
- if not state.desktop_normalized:
662
- return "*No colors to display*"
663
-
664
- colors = list(state.desktop_normalized.colors.values())
665
- colors.sort(key=lambda c: -c.frequency)
666
-
667
- lines = [
668
- "### 🌈 Generated Color Ramps",
669
- "",
670
- "Full ramp (50-950) generated for each base color:",
671
- "",
672
- ]
673
-
674
- from core.color_utils import generate_color_ramp
675
-
676
- for color in colors[:6]: # Top 6 colors
677
- hex_val = color.value
678
- role = color.suggested_name.split('.')[1] if color.suggested_name and '.' in color.suggested_name else "color"
679
-
680
- # Generate ramp
681
- try:
682
- ramp = generate_color_ramp(hex_val)
683
-
684
- lines.append(f"**{role.upper()}** (base: `{hex_val}`)")
685
- lines.append("")
686
- lines.append("| 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 |")
687
- lines.append("|---|---|---|---|---|---|---|---|---|---|")
688
-
689
- # Create row with hex values
690
- row = "|"
691
- for i in range(10):
692
- if i < len(ramp):
693
- row += f" `{ramp[i]}` |"
694
- else:
695
- row += " — |"
696
- lines.append(row)
697
- lines.append("")
698
-
699
- except Exception as e:
700
- lines.append(f"**{role}** (`{hex_val}`) — Could not generate ramp: {str(e)}")
701
- lines.append("")
702
-
703
- return "\n".join(lines)
704
-
705
-
706
- def format_radius_with_tokens() -> str:
707
- """Format radius with token name suggestions."""
708
- if not state.desktop_normalized or not state.desktop_normalized.radius:
709
- return "*No border radius values detected.*"
710
-
711
- radii = list(state.desktop_normalized.radius.values())
712
-
713
- lines = [
714
- "### 🔘 Border Radius Tokens",
715
- "",
716
- "| Detected | Suggested Token | Usage |",
717
- "|----------|-----------------|-------|",
718
- ]
719
-
720
- # Sort by pixel value
721
- def parse_radius(r):
722
- val = str(r.value).replace('px', '').replace('%', '')
723
- try:
724
- return float(val)
725
- except:
726
- return 999
727
-
728
- radii.sort(key=lambda r: parse_radius(r))
729
-
730
- token_map = {
731
- (0, 2): ("radius.none", "Sharp corners"),
732
- (2, 4): ("radius.xs", "Subtle rounding"),
733
- (4, 6): ("radius.sm", "Small elements"),
734
- (6, 10): ("radius.md", "Buttons, cards"),
735
- (10, 16): ("radius.lg", "Modals, panels"),
736
- (16, 32): ("radius.xl", "Large containers"),
737
- (32, 100): ("radius.2xl", "Pill shapes"),
738
- }
739
-
740
- for r in radii[:8]:
741
- val = str(r.value)
742
- px = parse_radius(r)
743
-
744
- if "%" in str(r.value) or px >= 50:
745
- token = "radius.full"
746
- usage = "Circles, avatars"
747
- else:
748
- token = "radius.md"
749
- usage = "General use"
750
- for (low, high), (t, u) in token_map.items():
751
- if low <= px < high:
752
- token = t
753
- usage = u
754
- break
755
-
756
- lines.append(f"| {val} | `{token}` | {usage} |")
757
-
758
- return "\n".join(lines)
759
-
760
-
761
- def format_shadows_with_tokens() -> str:
762
- """Format shadows with token name suggestions."""
763
- if not state.desktop_normalized or not state.desktop_normalized.shadows:
764
- return "*No shadow values detected.*"
765
-
766
- shadows = list(state.desktop_normalized.shadows.values())
767
-
768
- lines = [
769
- "### 🌫️ Shadow Tokens",
770
- "",
771
- "| Detected Value | Suggested Token | Use Case |",
772
- "|----------------|-----------------|----------|",
773
- ]
774
-
775
- shadow_sizes = ["shadow.xs", "shadow.sm", "shadow.md", "shadow.lg", "shadow.xl", "shadow.2xl"]
776
-
777
- for i, s in enumerate(shadows[:6]):
778
- val = str(s.value)[:40] + ("..." if len(str(s.value)) > 40 else "")
779
- token = shadow_sizes[i] if i < len(shadow_sizes) else f"shadow.custom-{i}"
780
-
781
- # Guess use case based on index
782
- use_cases = ["Subtle elevation", "Cards, dropdowns", "Modals, dialogs", "Popovers", "Floating elements", "Dramatic effect"]
783
- use = use_cases[i] if i < len(use_cases) else "Custom"
784
-
785
- lines.append(f"| `{val}` | `{token}` | {use} |")
786
-
787
- return "\n".join(lines)
788
-
789
-
790
- def format_spacing_comparison(recommendations) -> list:
791
- """Format spacing comparison table."""
792
- if not state.desktop_normalized:
793
- return []
794
-
795
- # Get current spacing
796
- current_spacing = list(state.desktop_normalized.spacing.values())
797
- current_spacing.sort(key=lambda s: s.value_px)
798
-
799
- data = []
800
- for s in current_spacing[:10]:
801
- current = f"{s.value_px}px"
802
- grid_8 = f"{snap_to_grid(s.value_px, 8)}px"
803
- grid_4 = f"{snap_to_grid(s.value_px, 4)}px"
804
-
805
- # Mark if value fits
806
- if s.value_px == snap_to_grid(s.value_px, 8):
807
- grid_8 += " ✓"
808
- if s.value_px == snap_to_grid(s.value_px, 4):
809
- grid_4 += " ✓"
810
-
811
- data.append([current, grid_8, grid_4])
812
-
813
- return data
814
-
815
-
816
- def snap_to_grid(value: float, base: int) -> int:
817
- """Snap value to grid."""
818
- return round(value / base) * base
819
-
820
-
821
- def apply_selected_upgrades(type_choice: str, spacing_choice: str, apply_ramps: bool):
822
- """Apply selected upgrade options."""
823
- if not state.upgrade_recommendations:
824
- return "❌ Run analysis first", ""
825
-
826
- state.log("✨ Applying selected upgrades...")
827
-
828
- # Store selections
829
- state.selected_upgrades = {
830
- "type_scale": type_choice,
831
- "spacing": spacing_choice,
832
- "color_ramps": apply_ramps,
833
- }
834
-
835
- state.log(f" Type Scale: {type_choice}")
836
- state.log(f" Spacing: {spacing_choice}")
837
- state.log(f" Color Ramps: {'Yes' if apply_ramps else 'No'}")
838
-
839
- state.log("✅ Upgrades applied! Proceed to Stage 3 for export.")
840
-
841
- return "✅ Upgrades applied! Proceed to Stage 3 to export.", state.get_logs()
842
-
843
-
844
- def export_stage1_json():
845
- """Export Stage 1 tokens (as-is extraction) to JSON."""
846
- result = {
847
- "metadata": {
848
- "source_url": state.base_url,
849
- "extracted_at": datetime.now().isoformat(),
850
- "version": "v1-stage1-extracted",
851
- "stage": "extraction",
852
- },
853
- "colors": {}, # Viewport-agnostic
854
- "typography": {
855
- "desktop": {},
856
- "mobile": {},
857
- },
858
- "spacing": {
859
- "desktop": {},
860
- "mobile": {},
861
- },
862
- "radius": {}, # Viewport-agnostic
863
- }
864
-
865
- # Colors (no viewport prefix - same across devices)
866
- if state.desktop_normalized:
867
- for name, c in state.desktop_normalized.colors.items():
868
- result["colors"][c.suggested_name or c.value] = {
869
- "value": c.value,
870
- "frequency": c.frequency,
871
- "confidence": c.confidence.value if c.confidence else "medium",
872
- "contexts": c.contexts[:3],
873
- }
874
-
875
- # Typography (viewport-specific)
876
- if state.desktop_normalized:
877
- for name, t in state.desktop_normalized.typography.items():
878
- key = t.suggested_name or f"{t.font_family}-{t.font_size}"
879
- result["typography"]["desktop"][key] = {
880
- "font_family": t.font_family,
881
- "font_size": t.font_size,
882
- "font_weight": t.font_weight,
883
- "line_height": t.line_height,
884
- "frequency": t.frequency,
885
- }
886
-
887
- if state.mobile_normalized:
888
- for name, t in state.mobile_normalized.typography.items():
889
- key = t.suggested_name or f"{t.font_family}-{t.font_size}"
890
- result["typography"]["mobile"][key] = {
891
- "font_family": t.font_family,
892
- "font_size": t.font_size,
893
- "font_weight": t.font_weight,
894
- "line_height": t.line_height,
895
- "frequency": t.frequency,
896
- }
897
-
898
- # Spacing (viewport-specific if different)
899
- if state.desktop_normalized:
900
- for name, s in state.desktop_normalized.spacing.items():
901
- key = s.suggested_name or s.value
902
- result["spacing"]["desktop"][key] = {
903
- "value": s.value,
904
- "value_px": s.value_px,
905
- "fits_base_8": s.fits_base_8,
906
- "frequency": s.frequency,
907
- }
908
-
909
- if state.mobile_normalized:
910
- for name, s in state.mobile_normalized.spacing.items():
911
- key = s.suggested_name or s.value
912
- result["spacing"]["mobile"][key] = {
913
- "value": s.value,
914
- "value_px": s.value_px,
915
- "fits_base_8": s.fits_base_8,
916
- "frequency": s.frequency,
917
- }
918
-
919
- # Radius (no viewport prefix)
920
- if state.desktop_normalized:
921
- for name, r in state.desktop_normalized.radius.items():
922
- result["radius"][name] = {
923
- "value": r.value,
924
- "frequency": r.frequency,
925
- }
926
-
927
- return json.dumps(result, indent=2, default=str)
928
-
929
-
930
- def export_tokens_json():
931
- """Export tokens to JSON."""
932
- result = {
933
- "metadata": {
934
- "source_url": state.base_url,
935
- "extracted_at": datetime.now().isoformat(),
936
- "version": "v1-extracted",
937
- },
938
- "desktop": None,
939
- "mobile": None,
940
- }
941
-
942
- if state.desktop_normalized:
943
- result["desktop"] = {
944
- "colors": [
945
- {"value": c.value, "name": c.suggested_name, "frequency": c.frequency,
946
- "confidence": c.confidence.value if c.confidence else "medium"}
947
- for c in state.desktop_normalized.colors
948
- ],
949
- "typography": [
950
- {"font_family": t.font_family, "font_size": t.font_size,
951
- "font_weight": t.font_weight, "line_height": t.line_height,
952
- "name": t.suggested_name, "frequency": t.frequency}
953
- for t in state.desktop_normalized.typography
954
- ],
955
- "spacing": [
956
- {"value": s.value, "value_px": s.value_px, "name": s.suggested_name,
957
- "frequency": s.frequency, "fits_base_8": s.fits_base_8}
958
- for s in state.desktop_normalized.spacing
959
- ],
960
- }
961
-
962
- if state.mobile_normalized:
963
- result["mobile"] = {
964
- "colors": [
965
- {"value": c.value, "name": c.suggested_name, "frequency": c.frequency,
966
- "confidence": c.confidence.value if c.confidence else "medium"}
967
- for c in state.mobile_normalized.colors
968
- ],
969
- "typography": [
970
- {"font_family": t.font_family, "font_size": t.font_size,
971
- "font_weight": t.font_weight, "line_height": t.line_height,
972
- "name": t.suggested_name, "frequency": t.frequency}
973
- for t in state.mobile_normalized.typography
974
- ],
975
- "spacing": [
976
- {"value": s.value, "value_px": s.value_px, "name": s.suggested_name,
977
- "frequency": s.frequency, "fits_base_8": s.fits_base_8}
978
- for s in state.mobile_normalized.spacing
979
- ],
980
- }
981
-
982
- return json.dumps(result, indent=2, default=str)
983
-
984
-
985
- # =============================================================================
986
- # UI BUILDING
987
- # =============================================================================
988
-
989
- def create_ui():
990
- """Create the Gradio interface."""
991
-
992
- with gr.Blocks(
993
- title="Design System Extractor v2",
994
- theme=gr.themes.Soft(),
995
- css="""
996
- .color-swatch { display: inline-block; width: 24px; height: 24px; border-radius: 4px; margin-right: 8px; vertical-align: middle; }
997
- """
998
- ) as app:
999
-
1000
- gr.Markdown("""
1001
- # 🎨 Design System Extractor v2
1002
-
1003
- **Reverse-engineer design systems from live websites.**
1004
-
1005
- A semi-automated, human-in-the-loop system that extracts, normalizes, and upgrades design tokens.
1006
-
1007
- ---
1008
- """)
1009
-
1010
- # =================================================================
1011
- # CONFIGURATION
1012
- # =================================================================
1013
-
1014
- with gr.Accordion("⚙️ Configuration", open=not bool(HF_TOKEN_FROM_ENV)):
1015
- gr.Markdown("**HuggingFace Token** — Required for Stage 2 (AI upgrades)")
1016
- with gr.Row():
1017
- hf_token_input = gr.Textbox(
1018
- label="HF Token", placeholder="hf_xxxx", type="password",
1019
- scale=4, value=HF_TOKEN_FROM_ENV,
1020
- )
1021
- save_token_btn = gr.Button("💾 Save", scale=1)
1022
- token_status = gr.Markdown("✅ Token loaded" if HF_TOKEN_FROM_ENV else "⏳ Enter token")
1023
-
1024
- def save_token(token):
1025
- if token and len(token) > 10:
1026
- os.environ["HF_TOKEN"] = token.strip()
1027
- return "✅ Token saved!"
1028
- return "❌ Invalid token"
1029
-
1030
- save_token_btn.click(save_token, [hf_token_input], [token_status])
1031
-
1032
- # =================================================================
1033
- # URL INPUT & PAGE DISCOVERY
1034
- # =================================================================
1035
-
1036
- with gr.Accordion("🔍 Step 1: Discover Pages", open=True):
1037
- gr.Markdown("Enter your website URL to discover pages for extraction.")
1038
-
1039
- with gr.Row():
1040
- url_input = gr.Textbox(label="Website URL", placeholder="https://example.com", scale=4)
1041
- discover_btn = gr.Button("🔍 Discover Pages", variant="primary", scale=1)
1042
-
1043
- discover_status = gr.Markdown("")
1044
-
1045
- with gr.Row():
1046
- log_output = gr.Textbox(label="📋 Log", lines=8, interactive=False)
1047
-
1048
- pages_table = gr.Dataframe(
1049
- headers=["Select", "URL", "Title", "Type", "Status"],
1050
- datatype=["bool", "str", "str", "str", "str"],
1051
- label="Discovered Pages",
1052
- interactive=True,
1053
- visible=False,
1054
- )
1055
-
1056
- extract_btn = gr.Button("🚀 Extract Tokens (Desktop + Mobile)", variant="primary", visible=False)
1057
-
1058
- # =================================================================
1059
- # STAGE 1: EXTRACTION REVIEW
1060
- # =================================================================
1061
-
1062
- with gr.Accordion("📊 Stage 1: Review Extracted Tokens", open=False) as stage1_accordion:
1063
-
1064
- extraction_status = gr.Markdown("")
1065
-
1066
- gr.Markdown("""
1067
- **Review the extracted tokens.** Toggle between Desktop and Mobile viewports.
1068
- Accept or reject tokens, then proceed to Stage 2 for AI-powered upgrades.
1069
- """)
1070
-
1071
- viewport_toggle = gr.Radio(
1072
- choices=["Desktop (1440px)", "Mobile (375px)"],
1073
- value="Desktop (1440px)",
1074
- label="Viewport",
1075
- )
1076
-
1077
- with gr.Tabs():
1078
- with gr.Tab("🎨 Colors"):
1079
- colors_table = gr.Dataframe(
1080
- headers=["Accept", "Color", "Suggested Name", "Frequency", "Confidence", "Contrast", "AA", "Context"],
1081
- datatype=["bool", "str", "str", "number", "str", "str", "str", "str"],
1082
- label="Colors",
1083
- interactive=True,
1084
- )
1085
-
1086
- with gr.Tab("📝 Typography"):
1087
- typography_table = gr.Dataframe(
1088
- headers=["Accept", "Font", "Size", "Weight", "Line Height", "Suggested Name", "Frequency", "Confidence"],
1089
- datatype=["bool", "str", "str", "str", "str", "str", "number", "str"],
1090
- label="Typography",
1091
- interactive=True,
1092
- )
1093
-
1094
- with gr.Tab("📏 Spacing"):
1095
- spacing_table = gr.Dataframe(
1096
- headers=["Accept", "Value", "Pixels", "Suggested Name", "Frequency", "Base 8", "Confidence"],
1097
- datatype=["bool", "str", "str", "str", "number", "str", "str"],
1098
- label="Spacing",
1099
- interactive=True,
1100
- )
1101
-
1102
- with gr.Tab("🔘 Radius"):
1103
- radius_table = gr.Dataframe(
1104
- headers=["Accept", "Value", "Frequency", "Context"],
1105
- datatype=["bool", "str", "number", "str"],
1106
- label="Border Radius",
1107
- interactive=True,
1108
- )
1109
-
1110
- with gr.Row():
1111
- proceed_stage2_btn = gr.Button("➡️ Proceed to Stage 2: AI Upgrades", variant="primary")
1112
- download_stage1_btn = gr.Button("📥 Download Stage 1 JSON", variant="secondary")
1113
-
1114
- # =================================================================
1115
- # STAGE 2: AI UPGRADES
1116
- # =================================================================
1117
-
1118
- with gr.Accordion("🧠 Stage 2: AI-Powered Upgrades", open=False) as stage2_accordion:
1119
-
1120
- stage2_status = gr.Markdown("Click 'Analyze' to start AI-powered design system analysis.")
1121
-
1122
- analyze_btn = gr.Button("🤖 Analyze Design System", variant="primary")
1123
-
1124
- with gr.Accordion("📋 AI Analysis Log (Click to expand)", open=True):
1125
- stage2_log = gr.Textbox(label="Log", lines=15, interactive=False)
1126
-
1127
- # =============================================================
1128
- # BRAND COMPARISON (LLM Research)
1129
- # =============================================================
1130
- gr.Markdown("---")
1131
- brand_comparison = gr.Markdown("*Brand comparison will appear after analysis*")
1132
-
1133
- # =============================================================
1134
- # TYPOGRAPHY SECTION - Desktop & Mobile
1135
- # =============================================================
1136
- gr.Markdown("---")
1137
- gr.Markdown("## 📐 Typography")
1138
-
1139
- with gr.Row():
1140
- with gr.Column(scale=2):
1141
- gr.Markdown("### 🖥️ Desktop (1440px)")
1142
- typography_desktop = gr.Dataframe(
1143
- headers=["Token", "Current", "Scale 1.2", "Scale 1.25 ⭐", "Scale 1.333", "Keep"],
1144
- datatype=["str", "str", "str", "str", "str", "str"],
1145
- label="Desktop Typography",
1146
- interactive=False,
1147
- )
1148
-
1149
- with gr.Column(scale=2):
1150
- gr.Markdown("### 📱 Mobile (375px)")
1151
- typography_mobile = gr.Dataframe(
1152
- headers=["Token", "Current", "Scale 1.2", "Scale 1.25 ⭐", "Scale 1.333", "Keep"],
1153
- datatype=["str", "str", "str", "str", "str", "str"],
1154
- label="Mobile Typography",
1155
- interactive=False,
1156
- )
1157
-
1158
- with gr.Row():
1159
- with gr.Column():
1160
- gr.Markdown("### Select Type Scale Option")
1161
- type_scale_radio = gr.Radio(
1162
- choices=["Keep Current", "Scale 1.2 (Minor Third)", "Scale 1.25 (Major Third) ⭐", "Scale 1.333 (Perfect Fourth)"],
1163
- value="Scale 1.25 (Major Third) ⭐",
1164
- label="Type Scale",
1165
- interactive=True,
1166
- )
1167
- gr.Markdown("*Font family will be preserved. Sizes rounded to even numbers.*")
1168
-
1169
- # =============================================================
1170
- # COLORS SECTION - Base Colors + Ramps
1171
- # =============================================================
1172
- gr.Markdown("---")
1173
- gr.Markdown("## 🎨 Colors")
1174
-
1175
- base_colors_display = gr.Markdown("*Base colors will appear after analysis*")
1176
-
1177
- gr.Markdown("---")
1178
-
1179
- color_ramps_display = gr.Markdown("*Color ramps will appear after analysis*")
1180
-
1181
- color_ramps_checkbox = gr.Checkbox(
1182
- label="✓ Generate color ramps (keeps base colors, adds 50-950 shades)",
1183
- value=True,
1184
- )
1185
-
1186
- # =============================================================
1187
- # SPACING SECTION
1188
- # =============================================================
1189
- gr.Markdown("---")
1190
- gr.Markdown("## 📏 Spacing (Rule-Based)")
1191
-
1192
- with gr.Row():
1193
- with gr.Column(scale=2):
1194
- spacing_comparison = gr.Dataframe(
1195
- headers=["Current", "8px Grid", "4px Grid"],
1196
- datatype=["str", "str", "str"],
1197
- label="Spacing Comparison",
1198
- interactive=False,
1199
- )
1200
-
1201
- with gr.Column(scale=1):
1202
- spacing_radio = gr.Radio(
1203
- choices=["Keep Current", "8px Base Grid ⭐", "4px Base Grid"],
1204
- value="8px Base Grid ⭐",
1205
- label="Spacing System",
1206
- interactive=True,
1207
- )
1208
-
1209
- # =============================================================
1210
- # RADIUS SECTION
1211
- # =============================================================
1212
- gr.Markdown("---")
1213
- gr.Markdown("## 🔘 Border Radius (Rule-Based)")
1214
-
1215
- radius_display = gr.Markdown("*Radius tokens will appear after analysis*")
1216
-
1217
- # =============================================================
1218
- # SHADOWS SECTION
1219
- # =============================================================
1220
- gr.Markdown("---")
1221
- gr.Markdown("## 🌫️ Shadows (Rule-Based)")
1222
-
1223
- shadows_display = gr.Markdown("*Shadow tokens will appear after analysis*")
1224
-
1225
- # =============================================================
1226
- # APPLY SECTION
1227
- # =============================================================
1228
- gr.Markdown("---")
1229
-
1230
- with gr.Row():
1231
- apply_upgrades_btn = gr.Button("✨ Apply Selected Upgrades", variant="primary", scale=2)
1232
- reset_btn = gr.Button("↩️ Reset to Original", variant="secondary", scale=1)
1233
-
1234
- apply_status = gr.Markdown("")
1235
-
1236
- # =================================================================
1237
- # STAGE 3: EXPORT
1238
- # =================================================================
1239
-
1240
- with gr.Accordion("📦 Stage 3: Export", open=False):
1241
- gr.Markdown("""
1242
- Export your design tokens to JSON (compatible with Figma Tokens Studio).
1243
-
1244
- - **Stage 1 JSON**: Raw extracted tokens (as-is)
1245
- - **Final JSON**: Upgraded tokens with selected improvements
1246
- """)
1247
-
1248
- with gr.Row():
1249
- export_stage1_btn = gr.Button("📥 Export Stage 1 (As-Is)", variant="secondary")
1250
- export_final_btn = gr.Button("📥 Export Final (Upgraded)", variant="primary")
1251
-
1252
- export_output = gr.Code(label="Tokens JSON", language="json", lines=25)
1253
-
1254
- export_stage1_btn.click(export_stage1_json, outputs=[export_output])
1255
- export_final_btn.click(export_tokens_json, outputs=[export_output])
1256
-
1257
- # =================================================================
1258
- # EVENT HANDLERS
1259
- # =================================================================
1260
-
1261
- # Store data for viewport toggle
1262
- desktop_data = gr.State({})
1263
- mobile_data = gr.State({})
1264
-
1265
- # Discover pages
1266
- discover_btn.click(
1267
- fn=discover_pages,
1268
- inputs=[url_input],
1269
- outputs=[discover_status, log_output, pages_table],
1270
- ).then(
1271
- fn=lambda: (gr.update(visible=True), gr.update(visible=True)),
1272
- outputs=[pages_table, extract_btn],
1273
- )
1274
-
1275
- # Extract tokens
1276
- extract_btn.click(
1277
- fn=extract_tokens,
1278
- inputs=[pages_table],
1279
- outputs=[extraction_status, log_output, desktop_data, mobile_data],
1280
- ).then(
1281
- fn=lambda d: (d.get("colors", []), d.get("typography", []), d.get("spacing", [])),
1282
- inputs=[desktop_data],
1283
- outputs=[colors_table, typography_table, spacing_table],
1284
- ).then(
1285
- fn=lambda: gr.update(open=True),
1286
- outputs=[stage1_accordion],
1287
- )
1288
-
1289
- # Viewport toggle
1290
- viewport_toggle.change(
1291
- fn=switch_viewport,
1292
- inputs=[viewport_toggle],
1293
- outputs=[colors_table, typography_table, spacing_table],
1294
- )
1295
-
1296
- # Stage 2: Analyze
1297
- analyze_btn.click(
1298
- fn=run_stage2_analysis,
1299
- outputs=[stage2_status, stage2_log, brand_comparison,
1300
- typography_desktop, typography_mobile, spacing_comparison,
1301
- base_colors_display, color_ramps_display, radius_display, shadows_display],
1302
- )
1303
-
1304
- # Stage 2: Apply upgrades
1305
- apply_upgrades_btn.click(
1306
- fn=apply_selected_upgrades,
1307
- inputs=[type_scale_radio, spacing_radio, color_ramps_checkbox],
1308
- outputs=[apply_status, stage2_log],
1309
- )
1310
-
1311
- # Stage 1: Download JSON
1312
- download_stage1_btn.click(
1313
- fn=export_stage1_json,
1314
- outputs=[export_output],
1315
- )
1316
-
1317
- # Proceed to Stage 2 button
1318
- proceed_stage2_btn.click(
1319
- fn=lambda: gr.update(open=True),
1320
- outputs=[stage2_accordion],
1321
- )
1322
-
1323
- # =================================================================
1324
- # FOOTER
1325
- # =================================================================
1326
-
1327
- gr.Markdown("""
1328
- ---
1329
- **Design System Extractor v2** | Built with Playwright + Gradio + LangGraph + HuggingFace
1330
-
1331
- *A semi-automated co-pilot for design system recovery and modernization.*
1332
- """)
1333
-
1334
- return app
1335
-
1336
-
1337
- # =============================================================================
1338
- # MAIN
1339
- # =============================================================================
1340
-
1341
- if __name__ == "__main__":
1342
- app = create_ui()
1343
- app.launch(server_name="0.0.0.0", server_port=7860)