riazmo commited on
Commit
7829696
Β·
verified Β·
1 Parent(s): 026ed8f

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +982 -0
app.py ADDED
@@ -0,0 +1,982 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)