riazmo commited on
Commit
77580de
·
verified ·
1 Parent(s): eee5a89

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -982
app.py DELETED
@@ -1,982 +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, None
357
-
358
- state.log("")
359
- state.log("=" * 50)
360
- state.log("🧠 STAGE 2: AI-POWERED ANALYSIS")
361
- state.log("=" * 50)
362
-
363
- progress(0.1, desc="🤖 Starting AI analysis...")
364
-
365
- try:
366
- advisor_mod = get_advisor()
367
-
368
- # Run analysis with logging
369
- state.log("🔍 Analyzing design patterns...")
370
- progress(0.3, desc="🔍 Analyzing patterns...")
371
-
372
- recommendations = await advisor_mod.analyze_design_system(
373
- desktop_tokens=state.desktop_normalized,
374
- mobile_tokens=state.mobile_normalized,
375
- log_callback=state.log,
376
- )
377
-
378
- state.upgrade_recommendations = recommendations
379
-
380
- progress(0.9, desc="📊 Preparing recommendations...")
381
-
382
- # Format for display
383
- type_scale_options = format_type_scale_options(recommendations.typography_scales)
384
- spacing_options = format_spacing_options(recommendations.spacing_systems)
385
- color_ramp_preview = format_color_ramps(recommendations.color_ramps)
386
-
387
- state.log("✅ Analysis complete!")
388
- progress(1.0, desc="✅ Complete!")
389
-
390
- status = f"""## 🧠 AI Analysis Complete!
391
-
392
- ### Detected Patterns
393
- {chr(10).join(['• ' + p for p in recommendations.detected_patterns]) if recommendations.detected_patterns else '• No specific patterns detected'}
394
-
395
- ### LLM Recommendation
396
- {recommendations.llm_rationale}
397
-
398
- ### Options Ready
399
- - **{len(recommendations.typography_scales)}** type scale options
400
- - **{len(recommendations.spacing_systems)}** spacing system options
401
- - **{len(recommendations.color_ramps)}** color ramp options
402
-
403
- **Select your preferred options below, then apply upgrades.**
404
- """
405
-
406
- return status, state.get_logs(), type_scale_options, spacing_options, color_ramp_preview, format_as_is_vs_to_be()
407
-
408
- except Exception as e:
409
- import traceback
410
- state.log(f"❌ Error: {str(e)}")
411
- state.log(traceback.format_exc())
412
- return f"❌ Analysis failed: {str(e)}", state.get_logs(), None, None, None, None
413
-
414
-
415
- def format_type_scale_options(options) -> list:
416
- """Format type scale options for radio buttons."""
417
- if not options:
418
- return []
419
-
420
- formatted = []
421
- for opt in options:
422
- label = f"{opt.name}"
423
- if opt.recommended:
424
- label += " ⭐"
425
- label += f" — {opt.description}"
426
- formatted.append(label)
427
-
428
- return formatted
429
-
430
-
431
- def format_spacing_options(options) -> list:
432
- """Format spacing options for radio buttons."""
433
- if not options:
434
- return []
435
-
436
- formatted = []
437
- for opt in options:
438
- label = f"{opt.name}"
439
- if opt.recommended:
440
- label += " ⭐"
441
- label += f" — {opt.description}"
442
- formatted.append(label)
443
-
444
- return formatted
445
-
446
-
447
- def format_color_ramps(options) -> str:
448
- """Format color ramp options for display."""
449
- if not options:
450
- return "No color ramps generated"
451
-
452
- lines = []
453
- for opt in options:
454
- ramp = opt.values.get("ramp", {})
455
- base = opt.values.get("base_color", "")
456
- role = opt.values.get("role", "unknown")
457
-
458
- lines.append(f"**{role.title()} Ramp** (from {base})")
459
- shades = " → ".join([f"`{ramp.get(f'{role}.{s}', '?')}`" for s in ["50", "200", "500", "700", "900"]])
460
- lines.append(shades)
461
- lines.append("")
462
-
463
- return "\n".join(lines)
464
-
465
-
466
- def format_as_is_vs_to_be() -> str:
467
- """Format As-Is vs To-Be comparison."""
468
- if not state.upgrade_recommendations:
469
- return ""
470
-
471
- rec = state.upgrade_recommendations
472
-
473
- # Get current values
474
- desktop_typo = list(state.desktop_normalized.typography.values()) if state.desktop_normalized else []
475
- desktop_spacing = list(state.desktop_normalized.spacing.values()) if state.desktop_normalized else []
476
- desktop_colors = list(state.desktop_normalized.colors.values()) if state.desktop_normalized else []
477
-
478
- # Format current typography
479
- current_sizes = sorted(set([t.font_size for t in desktop_typo[:10]]))
480
-
481
- # Get recommended type scale
482
- rec_scale = None
483
- for opt in rec.typography_scales:
484
- if opt.recommended:
485
- rec_scale = opt.values.get("scale", {})
486
- break
487
-
488
- # Format comparison
489
- lines = [
490
- "## As-Is vs To-Be Comparison",
491
- "",
492
- "### Typography",
493
- "| As-Is (Current) | To-Be (Upgraded) |",
494
- "|-----------------|------------------|",
495
- ]
496
-
497
- if rec_scale:
498
- for name, size in list(rec_scale.items())[:6]:
499
- lines.append(f"| {current_sizes[0] if current_sizes else '?'}px | {size}px ({name}) |")
500
- if current_sizes:
501
- current_sizes.pop(0)
502
-
503
- lines.extend([
504
- "",
505
- "### Spacing",
506
- "| As-Is | To-Be |",
507
- "|-------|-------|",
508
- ])
509
-
510
- current_spacing = sorted([s.value_px for s in desktop_spacing[:8]])
511
-
512
- # Get recommended spacing
513
- rec_spacing = None
514
- for opt in rec.spacing_systems:
515
- if opt.recommended:
516
- rec_spacing = opt.values.get("scale", {})
517
- break
518
-
519
- if rec_spacing:
520
- to_be_spacing = list(rec_spacing.values())[:8]
521
- for i, cs in enumerate(current_spacing[:6]):
522
- tb = to_be_spacing[i] if i < len(to_be_spacing) else "—"
523
- lines.append(f"| {cs}px | {tb} |")
524
-
525
- lines.extend([
526
- "",
527
- "### Colors",
528
- f"**{len(desktop_colors)} colors detected** → Full ramps will be generated",
529
- ])
530
-
531
- return "\n".join(lines)
532
-
533
-
534
- def apply_selected_upgrades(type_choice: str, spacing_choice: str, apply_ramps: bool):
535
- """Apply selected upgrade options."""
536
- if not state.upgrade_recommendations:
537
- return "❌ Run analysis first", ""
538
-
539
- state.log("✨ Applying selected upgrades...")
540
-
541
- # Store selections
542
- state.selected_upgrades = {
543
- "type_scale": type_choice,
544
- "spacing": spacing_choice,
545
- "color_ramps": apply_ramps,
546
- }
547
-
548
- state.log(f" Type Scale: {type_choice}")
549
- state.log(f" Spacing: {spacing_choice}")
550
- state.log(f" Color Ramps: {'Yes' if apply_ramps else 'No'}")
551
-
552
- state.log("✅ Upgrades applied! Proceed to Stage 3 for export.")
553
-
554
- return "✅ Upgrades applied! Proceed to Stage 3 to export.", state.get_logs()
555
-
556
-
557
- def export_stage1_json():
558
- """Export Stage 1 tokens (as-is extraction) to JSON."""
559
- result = {
560
- "metadata": {
561
- "source_url": state.base_url,
562
- "extracted_at": datetime.now().isoformat(),
563
- "version": "v1-stage1-extracted",
564
- "stage": "extraction",
565
- },
566
- "colors": {}, # Viewport-agnostic
567
- "typography": {
568
- "desktop": {},
569
- "mobile": {},
570
- },
571
- "spacing": {
572
- "desktop": {},
573
- "mobile": {},
574
- },
575
- "radius": {}, # Viewport-agnostic
576
- }
577
-
578
- # Colors (no viewport prefix - same across devices)
579
- if state.desktop_normalized:
580
- for name, c in state.desktop_normalized.colors.items():
581
- result["colors"][c.suggested_name or c.value] = {
582
- "value": c.value,
583
- "frequency": c.frequency,
584
- "confidence": c.confidence.value if c.confidence else "medium",
585
- "contexts": c.contexts[:3],
586
- }
587
-
588
- # Typography (viewport-specific)
589
- if state.desktop_normalized:
590
- for name, t in state.desktop_normalized.typography.items():
591
- key = t.suggested_name or f"{t.font_family}-{t.font_size}"
592
- result["typography"]["desktop"][key] = {
593
- "font_family": t.font_family,
594
- "font_size": t.font_size,
595
- "font_weight": t.font_weight,
596
- "line_height": t.line_height,
597
- "frequency": t.frequency,
598
- }
599
-
600
- if state.mobile_normalized:
601
- for name, t in state.mobile_normalized.typography.items():
602
- key = t.suggested_name or f"{t.font_family}-{t.font_size}"
603
- result["typography"]["mobile"][key] = {
604
- "font_family": t.font_family,
605
- "font_size": t.font_size,
606
- "font_weight": t.font_weight,
607
- "line_height": t.line_height,
608
- "frequency": t.frequency,
609
- }
610
-
611
- # Spacing (viewport-specific if different)
612
- if state.desktop_normalized:
613
- for name, s in state.desktop_normalized.spacing.items():
614
- key = s.suggested_name or s.value
615
- result["spacing"]["desktop"][key] = {
616
- "value": s.value,
617
- "value_px": s.value_px,
618
- "fits_base_8": s.fits_base_8,
619
- "frequency": s.frequency,
620
- }
621
-
622
- if state.mobile_normalized:
623
- for name, s in state.mobile_normalized.spacing.items():
624
- key = s.suggested_name or s.value
625
- result["spacing"]["mobile"][key] = {
626
- "value": s.value,
627
- "value_px": s.value_px,
628
- "fits_base_8": s.fits_base_8,
629
- "frequency": s.frequency,
630
- }
631
-
632
- # Radius (no viewport prefix)
633
- if state.desktop_normalized:
634
- for name, r in state.desktop_normalized.radius.items():
635
- result["radius"][name] = {
636
- "value": r.value,
637
- "frequency": r.frequency,
638
- }
639
-
640
- return json.dumps(result, indent=2, default=str)
641
-
642
-
643
- def export_tokens_json():
644
- """Export tokens to JSON."""
645
- result = {
646
- "metadata": {
647
- "source_url": state.base_url,
648
- "extracted_at": datetime.now().isoformat(),
649
- "version": "v1-extracted",
650
- },
651
- "desktop": None,
652
- "mobile": None,
653
- }
654
-
655
- if state.desktop_normalized:
656
- result["desktop"] = {
657
- "colors": [
658
- {"value": c.value, "name": c.suggested_name, "frequency": c.frequency,
659
- "confidence": c.confidence.value if c.confidence else "medium"}
660
- for c in state.desktop_normalized.colors
661
- ],
662
- "typography": [
663
- {"font_family": t.font_family, "font_size": t.font_size,
664
- "font_weight": t.font_weight, "line_height": t.line_height,
665
- "name": t.suggested_name, "frequency": t.frequency}
666
- for t in state.desktop_normalized.typography
667
- ],
668
- "spacing": [
669
- {"value": s.value, "value_px": s.value_px, "name": s.suggested_name,
670
- "frequency": s.frequency, "fits_base_8": s.fits_base_8}
671
- for s in state.desktop_normalized.spacing
672
- ],
673
- }
674
-
675
- if state.mobile_normalized:
676
- result["mobile"] = {
677
- "colors": [
678
- {"value": c.value, "name": c.suggested_name, "frequency": c.frequency,
679
- "confidence": c.confidence.value if c.confidence else "medium"}
680
- for c in state.mobile_normalized.colors
681
- ],
682
- "typography": [
683
- {"font_family": t.font_family, "font_size": t.font_size,
684
- "font_weight": t.font_weight, "line_height": t.line_height,
685
- "name": t.suggested_name, "frequency": t.frequency}
686
- for t in state.mobile_normalized.typography
687
- ],
688
- "spacing": [
689
- {"value": s.value, "value_px": s.value_px, "name": s.suggested_name,
690
- "frequency": s.frequency, "fits_base_8": s.fits_base_8}
691
- for s in state.mobile_normalized.spacing
692
- ],
693
- }
694
-
695
- return json.dumps(result, indent=2, default=str)
696
-
697
-
698
- # =============================================================================
699
- # UI BUILDING
700
- # =============================================================================
701
-
702
- def create_ui():
703
- """Create the Gradio interface."""
704
-
705
- with gr.Blocks(
706
- title="Design System Extractor v2",
707
- theme=gr.themes.Soft(),
708
- css="""
709
- .color-swatch { display: inline-block; width: 24px; height: 24px; border-radius: 4px; margin-right: 8px; vertical-align: middle; }
710
- """
711
- ) as app:
712
-
713
- gr.Markdown("""
714
- # 🎨 Design System Extractor v2
715
-
716
- **Reverse-engineer design systems from live websites.**
717
-
718
- A semi-automated, human-in-the-loop system that extracts, normalizes, and upgrades design tokens.
719
-
720
- ---
721
- """)
722
-
723
- # =================================================================
724
- # CONFIGURATION
725
- # =================================================================
726
-
727
- with gr.Accordion("⚙️ Configuration", open=not bool(HF_TOKEN_FROM_ENV)):
728
- gr.Markdown("**HuggingFace Token** — Required for Stage 2 (AI upgrades)")
729
- with gr.Row():
730
- hf_token_input = gr.Textbox(
731
- label="HF Token", placeholder="hf_xxxx", type="password",
732
- scale=4, value=HF_TOKEN_FROM_ENV,
733
- )
734
- save_token_btn = gr.Button("💾 Save", scale=1)
735
- token_status = gr.Markdown("✅ Token loaded" if HF_TOKEN_FROM_ENV else "⏳ Enter token")
736
-
737
- def save_token(token):
738
- if token and len(token) > 10:
739
- os.environ["HF_TOKEN"] = token.strip()
740
- return "✅ Token saved!"
741
- return "❌ Invalid token"
742
-
743
- save_token_btn.click(save_token, [hf_token_input], [token_status])
744
-
745
- # =================================================================
746
- # URL INPUT & PAGE DISCOVERY
747
- # =================================================================
748
-
749
- with gr.Accordion("🔍 Step 1: Discover Pages", open=True):
750
- gr.Markdown("Enter your website URL to discover pages for extraction.")
751
-
752
- with gr.Row():
753
- url_input = gr.Textbox(label="Website URL", placeholder="https://example.com", scale=4)
754
- discover_btn = gr.Button("🔍 Discover Pages", variant="primary", scale=1)
755
-
756
- discover_status = gr.Markdown("")
757
-
758
- with gr.Row():
759
- log_output = gr.Textbox(label="📋 Log", lines=8, interactive=False)
760
-
761
- pages_table = gr.Dataframe(
762
- headers=["Select", "URL", "Title", "Type", "Status"],
763
- datatype=["bool", "str", "str", "str", "str"],
764
- label="Discovered Pages",
765
- interactive=True,
766
- visible=False,
767
- )
768
-
769
- extract_btn = gr.Button("🚀 Extract Tokens (Desktop + Mobile)", variant="primary", visible=False)
770
-
771
- # =================================================================
772
- # STAGE 1: EXTRACTION REVIEW
773
- # =================================================================
774
-
775
- with gr.Accordion("📊 Stage 1: Review Extracted Tokens", open=False) as stage1_accordion:
776
-
777
- extraction_status = gr.Markdown("")
778
-
779
- gr.Markdown("""
780
- **Review the extracted tokens.** Toggle between Desktop and Mobile viewports.
781
- Accept or reject tokens, then proceed to Stage 2 for AI-powered upgrades.
782
- """)
783
-
784
- viewport_toggle = gr.Radio(
785
- choices=["Desktop (1440px)", "Mobile (375px)"],
786
- value="Desktop (1440px)",
787
- label="Viewport",
788
- )
789
-
790
- with gr.Tabs():
791
- with gr.Tab("🎨 Colors"):
792
- colors_table = gr.Dataframe(
793
- headers=["Accept", "Color", "Suggested Name", "Frequency", "Confidence", "Contrast", "AA", "Context"],
794
- datatype=["bool", "str", "str", "number", "str", "str", "str", "str"],
795
- label="Colors",
796
- interactive=True,
797
- )
798
-
799
- with gr.Tab("📝 Typography"):
800
- typography_table = gr.Dataframe(
801
- headers=["Accept", "Font", "Size", "Weight", "Line Height", "Suggested Name", "Frequency", "Confidence"],
802
- datatype=["bool", "str", "str", "str", "str", "str", "number", "str"],
803
- label="Typography",
804
- interactive=True,
805
- )
806
-
807
- with gr.Tab("📏 Spacing"):
808
- spacing_table = gr.Dataframe(
809
- headers=["Accept", "Value", "Pixels", "Suggested Name", "Frequency", "Base 8", "Confidence"],
810
- datatype=["bool", "str", "str", "str", "number", "str", "str"],
811
- label="Spacing",
812
- interactive=True,
813
- )
814
-
815
- with gr.Tab("🔘 Radius"):
816
- radius_table = gr.Dataframe(
817
- headers=["Accept", "Value", "Frequency", "Context"],
818
- datatype=["bool", "str", "number", "str"],
819
- label="Border Radius",
820
- interactive=True,
821
- )
822
-
823
- with gr.Row():
824
- proceed_stage2_btn = gr.Button("➡️ Proceed to Stage 2: AI Upgrades", variant="primary")
825
- download_stage1_btn = gr.Button("📥 Download Stage 1 JSON", variant="secondary")
826
-
827
- # =================================================================
828
- # STAGE 2: AI UPGRADES
829
- # =================================================================
830
-
831
- with gr.Accordion("🧠 Stage 2: AI-Powered Upgrades", open=False) as stage2_accordion:
832
-
833
- stage2_status = gr.Markdown("Click 'Analyze' to start AI-powered design system analysis.")
834
-
835
- analyze_btn = gr.Button("🤖 Analyze Design System", variant="primary")
836
-
837
- with gr.Row():
838
- stage2_log = gr.Textbox(label="📋 AI Analysis Log", lines=10, interactive=False)
839
-
840
- gr.Markdown("---")
841
-
842
- with gr.Row():
843
- # Left column: Options
844
- with gr.Column(scale=1):
845
- gr.Markdown("### 📐 Type Scale")
846
- type_scale_radio = gr.Radio(
847
- choices=[],
848
- label="Select type scale ratio",
849
- interactive=True,
850
- )
851
-
852
- gr.Markdown("### 📏 Spacing System")
853
- spacing_radio = gr.Radio(
854
- choices=[],
855
- label="Select spacing base",
856
- interactive=True,
857
- )
858
-
859
- gr.Markdown("### 🎨 Color Ramps")
860
- color_ramps_checkbox = gr.Checkbox(
861
- label="Generate color ramps (50-900 shades)",
862
- value=True,
863
- )
864
- color_ramps_preview = gr.Markdown("")
865
-
866
- # Right column: As-Is vs To-Be
867
- with gr.Column(scale=1):
868
- gr.Markdown("### 🔄 As-Is vs To-Be Preview")
869
- comparison_display = gr.Markdown("")
870
-
871
- gr.Markdown("---")
872
-
873
- with gr.Row():
874
- apply_upgrades_btn = gr.Button("✨ Apply Selected Upgrades", variant="primary")
875
- reset_btn = gr.Button("↩️ Reset to Original", variant="secondary")
876
-
877
- apply_status = gr.Markdown("")
878
-
879
- # =================================================================
880
- # STAGE 3: EXPORT
881
- # =================================================================
882
-
883
- with gr.Accordion("📦 Stage 3: Export", open=False):
884
- gr.Markdown("""
885
- Export your design tokens to JSON (compatible with Figma Tokens Studio).
886
-
887
- - **Stage 1 JSON**: Raw extracted tokens (as-is)
888
- - **Final JSON**: Upgraded tokens with selected improvements
889
- """)
890
-
891
- with gr.Row():
892
- export_stage1_btn = gr.Button("📥 Export Stage 1 (As-Is)", variant="secondary")
893
- export_final_btn = gr.Button("📥 Export Final (Upgraded)", variant="primary")
894
-
895
- export_output = gr.Code(label="Tokens JSON", language="json", lines=25)
896
-
897
- export_stage1_btn.click(export_stage1_json, outputs=[export_output])
898
- export_final_btn.click(export_tokens_json, outputs=[export_output])
899
-
900
- # =================================================================
901
- # EVENT HANDLERS
902
- # =================================================================
903
-
904
- # Store data for viewport toggle
905
- desktop_data = gr.State({})
906
- mobile_data = gr.State({})
907
-
908
- # Discover pages
909
- discover_btn.click(
910
- fn=discover_pages,
911
- inputs=[url_input],
912
- outputs=[discover_status, log_output, pages_table],
913
- ).then(
914
- fn=lambda: (gr.update(visible=True), gr.update(visible=True)),
915
- outputs=[pages_table, extract_btn],
916
- )
917
-
918
- # Extract tokens
919
- extract_btn.click(
920
- fn=extract_tokens,
921
- inputs=[pages_table],
922
- outputs=[extraction_status, log_output, desktop_data, mobile_data],
923
- ).then(
924
- fn=lambda d: (d.get("colors", []), d.get("typography", []), d.get("spacing", [])),
925
- inputs=[desktop_data],
926
- outputs=[colors_table, typography_table, spacing_table],
927
- ).then(
928
- fn=lambda: gr.update(open=True),
929
- outputs=[stage1_accordion],
930
- )
931
-
932
- # Viewport toggle
933
- viewport_toggle.change(
934
- fn=switch_viewport,
935
- inputs=[viewport_toggle],
936
- outputs=[colors_table, typography_table, spacing_table],
937
- )
938
-
939
- # Stage 2: Analyze
940
- analyze_btn.click(
941
- fn=run_stage2_analysis,
942
- outputs=[stage2_status, stage2_log, type_scale_radio, spacing_radio, color_ramps_preview, comparison_display],
943
- ).then(
944
- fn=lambda opts: gr.update(choices=opts) if opts else gr.update(),
945
- inputs=[type_scale_radio],
946
- outputs=[type_scale_radio],
947
- )
948
-
949
- # Stage 2: Apply upgrades
950
- apply_upgrades_btn.click(
951
- fn=apply_selected_upgrades,
952
- inputs=[type_scale_radio, spacing_radio, color_ramps_checkbox],
953
- outputs=[apply_status, stage2_log],
954
- )
955
-
956
- # Proceed to Stage 2 button
957
- proceed_stage2_btn.click(
958
- fn=lambda: gr.update(open=True),
959
- outputs=[stage2_accordion],
960
- )
961
-
962
- # =================================================================
963
- # FOOTER
964
- # =================================================================
965
-
966
- gr.Markdown("""
967
- ---
968
- **Design System Extractor v2** | Built with Playwright + Gradio + LangGraph + HuggingFace
969
-
970
- *A semi-automated co-pilot for design system recovery and modernization.*
971
- """)
972
-
973
- return app
974
-
975
-
976
- # =============================================================================
977
- # MAIN
978
- # =============================================================================
979
-
980
- if __name__ == "__main__":
981
- app = create_ui()
982
- app.launch(server_name="0.0.0.0", server_port=7860)