riazmo commited on
Commit
dcc5281
Β·
verified Β·
1 Parent(s): 23546ce

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1198 -0
app.py ADDED
@@ -0,0 +1,1198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, "", "", "")
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("πŸ” Researching top design systems...")
370
+ progress(0.2, desc="πŸ” Researching brands...")
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
+ # Log brand analysis
381
+ state.log("")
382
+ state.log("πŸ“Š BRAND COMPARISON RESULTS:")
383
+ for brand in recommendations.brand_analysis:
384
+ state.log(f" β€’ {brand.get('brand', 'Unknown')}: ratio={brand.get('ratio', '?')}, spacing={brand.get('spacing', '?')}")
385
+ if brand.get('notes'):
386
+ state.log(f" {brand.get('notes', '')[:80]}")
387
+
388
+ state.log("")
389
+ state.log("πŸ’‘ LLM RECOMMENDATION:")
390
+ if recommendations.llm_rationale:
391
+ for line in recommendations.llm_rationale.split('. ')[:3]:
392
+ state.log(f" {line.strip()}.")
393
+
394
+ if recommendations.color_observations:
395
+ state.log("")
396
+ state.log("🎨 COLOR ANALYSIS:")
397
+ state.log(f" {recommendations.color_observations[:150]}")
398
+
399
+ progress(0.9, desc="πŸ“Š Preparing recommendations...")
400
+
401
+ # Format brand comparison markdown
402
+ brand_md = format_brand_comparison(recommendations)
403
+
404
+ # Format typography comparison table (extended)
405
+ typography_data = format_typography_comparison_extended(recommendations)
406
+
407
+ # Format spacing comparison table
408
+ spacing_data = format_spacing_comparison(recommendations)
409
+
410
+ # Format color ramps display (with visual swatches)
411
+ color_ramps_md = format_color_ramps_visual(recommendations)
412
+
413
+ # Format radius display (with token suggestions)
414
+ radius_md = format_radius_with_tokens()
415
+
416
+ # Format shadows display (with token suggestions)
417
+ shadows_md = format_shadows_with_tokens()
418
+
419
+ state.log("")
420
+ state.log("βœ… Analysis complete!")
421
+ progress(1.0, desc="βœ… Complete!")
422
+
423
+ status = f"""## 🧠 AI Analysis Complete!
424
+
425
+ ### Recommendation Summary
426
+ {recommendations.llm_rationale if recommendations.llm_rationale else "Analysis based on rule-based comparison with industry design systems."}
427
+
428
+ {f"### Color Observations{chr(10)}{recommendations.color_observations}" if recommendations.color_observations else ""}
429
+
430
+ {f"### Accessibility Notes{chr(10)}" + chr(10).join(['β€’ ' + a for a in recommendations.accessibility_issues]) if recommendations.accessibility_issues else ""}
431
+ """
432
+
433
+ return (status, state.get_logs(), brand_md, typography_data, spacing_data,
434
+ color_ramps_md, radius_md, shadows_md)
435
+
436
+ except Exception as e:
437
+ import traceback
438
+ state.log(f"❌ Error: {str(e)}")
439
+ state.log(traceback.format_exc())
440
+ return (f"❌ Analysis failed: {str(e)}", state.get_logs(), "", None, None, "", "", "")
441
+
442
+
443
+ def format_brand_comparison(recommendations) -> str:
444
+ """Format brand comparison as markdown table."""
445
+ if not recommendations.brand_analysis:
446
+ return "*Brand analysis not available*"
447
+
448
+ lines = [
449
+ "### πŸ“Š Design System Comparison (5 Top Brands)",
450
+ "",
451
+ "| Brand | Type Ratio | Base Size | Spacing | Notes |",
452
+ "|-------|------------|-----------|---------|-------|",
453
+ ]
454
+
455
+ for brand in recommendations.brand_analysis[:5]:
456
+ name = brand.get("brand", "Unknown")
457
+ ratio = brand.get("ratio", "?")
458
+ base = brand.get("base", "?")
459
+ spacing = brand.get("spacing", "?")
460
+ notes = brand.get("notes", "")[:50] + ("..." if len(brand.get("notes", "")) > 50 else "")
461
+ lines.append(f"| {name} | {ratio} | {base}px | {spacing} | {notes} |")
462
+
463
+ return "\n".join(lines)
464
+
465
+
466
+ def format_typography_comparison_extended(recommendations) -> list:
467
+ """Format extended typography comparison table with 12 token levels."""
468
+ if not state.desktop_normalized:
469
+ return []
470
+
471
+ # Get current typography sorted by size
472
+ current_typo = list(state.desktop_normalized.typography.values())
473
+ current_typo.sort(key=lambda t: -float(str(t.font_size).replace('px', '').replace('rem', '').replace('em', '') or 16))
474
+
475
+ # Get base font size
476
+ sizes = [float(str(t.font_size).replace('px', '').replace('rem', '').replace('em', '') or 16) for t in current_typo]
477
+ base = 16
478
+ for s in sizes:
479
+ if 14 <= s <= 18:
480
+ base = s
481
+ break
482
+
483
+ # Token names (12 levels)
484
+ token_names = [
485
+ "display.2xl", "display.xl", "display.lg", "display.md",
486
+ "heading.xl", "heading.lg", "heading.md", "heading.sm",
487
+ "body.lg", "body.md", "body.sm",
488
+ "caption", "overline"
489
+ ]
490
+
491
+ # Generate scales for each ratio
492
+ scales = {
493
+ "1.2": generate_full_type_scale(base, 1.2, 13),
494
+ "1.25": generate_full_type_scale(base, 1.25, 13),
495
+ "1.333": generate_full_type_scale(base, 1.333, 13),
496
+ }
497
+
498
+ # Build comparison table
499
+ data = []
500
+ for i, name in enumerate(token_names):
501
+ current = f"{int(sizes[i])}px" if i < len(sizes) else "β€”"
502
+ s12 = f"{scales['1.2'][i]}px"
503
+ s125 = f"{scales['1.25'][i]}px"
504
+ s133 = f"{scales['1.333'][i]}px"
505
+ keep = current
506
+ data.append([name, current, s12, s125, s133, keep])
507
+
508
+ return data
509
+
510
+
511
+ def generate_full_type_scale(base: float, ratio: float, count: int) -> list:
512
+ """Generate full type scale with specified count."""
513
+ # Calculate how many steps up and down from base
514
+ # Assume body.md is at base, so we need steps up for display/heading and down for caption
515
+ steps_up = 8 # display.2xl to heading.sm
516
+ steps_down = 4 # body.sm, caption, overline
517
+
518
+ scales = []
519
+
520
+ # Steps up from base (display sizes)
521
+ for i in range(steps_up, 0, -1):
522
+ scales.append(int(base * (ratio ** i)))
523
+
524
+ # Base
525
+ scales.append(int(base))
526
+
527
+ # Steps down from base
528
+ for i in range(1, steps_down + 1):
529
+ scales.append(int(base / (ratio ** i)))
530
+
531
+ return scales[:count]
532
+
533
+
534
+ def format_color_ramps_visual(recommendations) -> str:
535
+ """Format color ramps with visual display showing all shades."""
536
+ if not state.desktop_normalized:
537
+ return "*No colors to display*"
538
+
539
+ colors = list(state.desktop_normalized.colors.values())
540
+ colors.sort(key=lambda c: -c.frequency)
541
+
542
+ lines = [
543
+ "### 🎨 Color Ramps (11 shades each)",
544
+ "",
545
+ "Each detected color generates a full ramp from light to dark.",
546
+ "",
547
+ ]
548
+
549
+ from core.color_utils import generate_color_ramp
550
+
551
+ for color in colors[:8]: # Top 8 colors
552
+ hex_val = color.value
553
+ role = color.suggested_name.split('.')[1] if color.suggested_name and '.' in color.suggested_name else "color"
554
+ freq = color.frequency
555
+
556
+ # Generate ramp
557
+ try:
558
+ ramp = generate_color_ramp(hex_val)
559
+ shades = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"]
560
+
561
+ lines.append(f"**{role.title()}** `{hex_val}` β€” {freq:,} occurrences")
562
+
563
+ # Create shade display
564
+ shade_line = " | ".join([f"{s}: `{ramp[i] if i < len(ramp) else '?'}`" for i, s in enumerate(shades[:6])])
565
+ lines.append(f"Light: {shade_line}")
566
+ shade_line = " | ".join([f"{s}: `{ramp[i] if i < len(ramp) else '?'}`" for i, s in enumerate(shades[5:], 5)])
567
+ lines.append(f"Dark: {shade_line}")
568
+ lines.append("")
569
+ except:
570
+ lines.append(f"**{role}** `{hex_val}` β€” Could not generate ramp")
571
+ lines.append("")
572
+
573
+ return "\n".join(lines)
574
+
575
+
576
+ def format_radius_with_tokens() -> str:
577
+ """Format radius with token name suggestions."""
578
+ if not state.desktop_normalized or not state.desktop_normalized.radius:
579
+ return "*No border radius values detected.*"
580
+
581
+ radii = list(state.desktop_normalized.radius.values())
582
+
583
+ lines = [
584
+ "### πŸ”˜ Border Radius Tokens",
585
+ "",
586
+ "| Detected | Suggested Token | Usage |",
587
+ "|----------|-----------------|-------|",
588
+ ]
589
+
590
+ # Sort by pixel value
591
+ def parse_radius(r):
592
+ val = str(r.value).replace('px', '').replace('%', '')
593
+ try:
594
+ return float(val)
595
+ except:
596
+ return 999
597
+
598
+ radii.sort(key=lambda r: parse_radius(r))
599
+
600
+ token_map = {
601
+ (0, 2): ("radius.none", "Sharp corners"),
602
+ (2, 4): ("radius.xs", "Subtle rounding"),
603
+ (4, 6): ("radius.sm", "Small elements"),
604
+ (6, 10): ("radius.md", "Buttons, cards"),
605
+ (10, 16): ("radius.lg", "Modals, panels"),
606
+ (16, 32): ("radius.xl", "Large containers"),
607
+ (32, 100): ("radius.2xl", "Pill shapes"),
608
+ }
609
+
610
+ for r in radii[:8]:
611
+ val = str(r.value)
612
+ px = parse_radius(r)
613
+
614
+ if "%" in str(r.value) or px >= 50:
615
+ token = "radius.full"
616
+ usage = "Circles, avatars"
617
+ else:
618
+ token = "radius.md"
619
+ usage = "General use"
620
+ for (low, high), (t, u) in token_map.items():
621
+ if low <= px < high:
622
+ token = t
623
+ usage = u
624
+ break
625
+
626
+ lines.append(f"| {val} | `{token}` | {usage} |")
627
+
628
+ return "\n".join(lines)
629
+
630
+
631
+ def format_shadows_with_tokens() -> str:
632
+ """Format shadows with token name suggestions."""
633
+ if not state.desktop_normalized or not state.desktop_normalized.shadows:
634
+ return "*No shadow values detected.*"
635
+
636
+ shadows = list(state.desktop_normalized.shadows.values())
637
+
638
+ lines = [
639
+ "### 🌫️ Shadow Tokens",
640
+ "",
641
+ "| Detected Value | Suggested Token | Use Case |",
642
+ "|----------------|-----------------|----------|",
643
+ ]
644
+
645
+ shadow_sizes = ["shadow.xs", "shadow.sm", "shadow.md", "shadow.lg", "shadow.xl", "shadow.2xl"]
646
+
647
+ for i, s in enumerate(shadows[:6]):
648
+ val = str(s.value)[:40] + ("..." if len(str(s.value)) > 40 else "")
649
+ token = shadow_sizes[i] if i < len(shadow_sizes) else f"shadow.custom-{i}"
650
+
651
+ # Guess use case based on index
652
+ use_cases = ["Subtle elevation", "Cards, dropdowns", "Modals, dialogs", "Popovers", "Floating elements", "Dramatic effect"]
653
+ use = use_cases[i] if i < len(use_cases) else "Custom"
654
+
655
+ lines.append(f"| `{val}` | `{token}` | {use} |")
656
+
657
+ return "\n".join(lines)
658
+
659
+
660
+ def format_spacing_comparison(recommendations) -> list:
661
+ """Format spacing comparison table."""
662
+ if not state.desktop_normalized:
663
+ return []
664
+
665
+ # Get current spacing
666
+ current_spacing = list(state.desktop_normalized.spacing.values())
667
+ current_spacing.sort(key=lambda s: s.value_px)
668
+
669
+ data = []
670
+ for s in current_spacing[:10]:
671
+ current = f"{s.value_px}px"
672
+ grid_8 = f"{snap_to_grid(s.value_px, 8)}px"
673
+ grid_4 = f"{snap_to_grid(s.value_px, 4)}px"
674
+
675
+ # Mark if value fits
676
+ if s.value_px == snap_to_grid(s.value_px, 8):
677
+ grid_8 += " βœ“"
678
+ if s.value_px == snap_to_grid(s.value_px, 4):
679
+ grid_4 += " βœ“"
680
+
681
+ data.append([current, grid_8, grid_4])
682
+
683
+ return data
684
+
685
+
686
+ def snap_to_grid(value: float, base: int) -> int:
687
+ """Snap value to grid."""
688
+ return round(value / base) * base
689
+
690
+
691
+ def apply_selected_upgrades(type_choice: str, spacing_choice: str, apply_ramps: bool):
692
+ """Apply selected upgrade options."""
693
+ if not state.upgrade_recommendations:
694
+ return "❌ Run analysis first", ""
695
+
696
+ state.log("✨ Applying selected upgrades...")
697
+
698
+ # Store selections
699
+ state.selected_upgrades = {
700
+ "type_scale": type_choice,
701
+ "spacing": spacing_choice,
702
+ "color_ramps": apply_ramps,
703
+ }
704
+
705
+ state.log(f" Type Scale: {type_choice}")
706
+ state.log(f" Spacing: {spacing_choice}")
707
+ state.log(f" Color Ramps: {'Yes' if apply_ramps else 'No'}")
708
+
709
+ state.log("βœ… Upgrades applied! Proceed to Stage 3 for export.")
710
+
711
+ return "βœ… Upgrades applied! Proceed to Stage 3 to export.", state.get_logs()
712
+
713
+
714
+ def export_stage1_json():
715
+ """Export Stage 1 tokens (as-is extraction) to JSON."""
716
+ result = {
717
+ "metadata": {
718
+ "source_url": state.base_url,
719
+ "extracted_at": datetime.now().isoformat(),
720
+ "version": "v1-stage1-extracted",
721
+ "stage": "extraction",
722
+ },
723
+ "colors": {}, # Viewport-agnostic
724
+ "typography": {
725
+ "desktop": {},
726
+ "mobile": {},
727
+ },
728
+ "spacing": {
729
+ "desktop": {},
730
+ "mobile": {},
731
+ },
732
+ "radius": {}, # Viewport-agnostic
733
+ }
734
+
735
+ # Colors (no viewport prefix - same across devices)
736
+ if state.desktop_normalized:
737
+ for name, c in state.desktop_normalized.colors.items():
738
+ result["colors"][c.suggested_name or c.value] = {
739
+ "value": c.value,
740
+ "frequency": c.frequency,
741
+ "confidence": c.confidence.value if c.confidence else "medium",
742
+ "contexts": c.contexts[:3],
743
+ }
744
+
745
+ # Typography (viewport-specific)
746
+ if state.desktop_normalized:
747
+ for name, t in state.desktop_normalized.typography.items():
748
+ key = t.suggested_name or f"{t.font_family}-{t.font_size}"
749
+ result["typography"]["desktop"][key] = {
750
+ "font_family": t.font_family,
751
+ "font_size": t.font_size,
752
+ "font_weight": t.font_weight,
753
+ "line_height": t.line_height,
754
+ "frequency": t.frequency,
755
+ }
756
+
757
+ if state.mobile_normalized:
758
+ for name, t in state.mobile_normalized.typography.items():
759
+ key = t.suggested_name or f"{t.font_family}-{t.font_size}"
760
+ result["typography"]["mobile"][key] = {
761
+ "font_family": t.font_family,
762
+ "font_size": t.font_size,
763
+ "font_weight": t.font_weight,
764
+ "line_height": t.line_height,
765
+ "frequency": t.frequency,
766
+ }
767
+
768
+ # Spacing (viewport-specific if different)
769
+ if state.desktop_normalized:
770
+ for name, s in state.desktop_normalized.spacing.items():
771
+ key = s.suggested_name or s.value
772
+ result["spacing"]["desktop"][key] = {
773
+ "value": s.value,
774
+ "value_px": s.value_px,
775
+ "fits_base_8": s.fits_base_8,
776
+ "frequency": s.frequency,
777
+ }
778
+
779
+ if state.mobile_normalized:
780
+ for name, s in state.mobile_normalized.spacing.items():
781
+ key = s.suggested_name or s.value
782
+ result["spacing"]["mobile"][key] = {
783
+ "value": s.value,
784
+ "value_px": s.value_px,
785
+ "fits_base_8": s.fits_base_8,
786
+ "frequency": s.frequency,
787
+ }
788
+
789
+ # Radius (no viewport prefix)
790
+ if state.desktop_normalized:
791
+ for name, r in state.desktop_normalized.radius.items():
792
+ result["radius"][name] = {
793
+ "value": r.value,
794
+ "frequency": r.frequency,
795
+ }
796
+
797
+ return json.dumps(result, indent=2, default=str)
798
+
799
+
800
+ def export_tokens_json():
801
+ """Export tokens to JSON."""
802
+ result = {
803
+ "metadata": {
804
+ "source_url": state.base_url,
805
+ "extracted_at": datetime.now().isoformat(),
806
+ "version": "v1-extracted",
807
+ },
808
+ "desktop": None,
809
+ "mobile": None,
810
+ }
811
+
812
+ if state.desktop_normalized:
813
+ result["desktop"] = {
814
+ "colors": [
815
+ {"value": c.value, "name": c.suggested_name, "frequency": c.frequency,
816
+ "confidence": c.confidence.value if c.confidence else "medium"}
817
+ for c in state.desktop_normalized.colors
818
+ ],
819
+ "typography": [
820
+ {"font_family": t.font_family, "font_size": t.font_size,
821
+ "font_weight": t.font_weight, "line_height": t.line_height,
822
+ "name": t.suggested_name, "frequency": t.frequency}
823
+ for t in state.desktop_normalized.typography
824
+ ],
825
+ "spacing": [
826
+ {"value": s.value, "value_px": s.value_px, "name": s.suggested_name,
827
+ "frequency": s.frequency, "fits_base_8": s.fits_base_8}
828
+ for s in state.desktop_normalized.spacing
829
+ ],
830
+ }
831
+
832
+ if state.mobile_normalized:
833
+ result["mobile"] = {
834
+ "colors": [
835
+ {"value": c.value, "name": c.suggested_name, "frequency": c.frequency,
836
+ "confidence": c.confidence.value if c.confidence else "medium"}
837
+ for c in state.mobile_normalized.colors
838
+ ],
839
+ "typography": [
840
+ {"font_family": t.font_family, "font_size": t.font_size,
841
+ "font_weight": t.font_weight, "line_height": t.line_height,
842
+ "name": t.suggested_name, "frequency": t.frequency}
843
+ for t in state.mobile_normalized.typography
844
+ ],
845
+ "spacing": [
846
+ {"value": s.value, "value_px": s.value_px, "name": s.suggested_name,
847
+ "frequency": s.frequency, "fits_base_8": s.fits_base_8}
848
+ for s in state.mobile_normalized.spacing
849
+ ],
850
+ }
851
+
852
+ return json.dumps(result, indent=2, default=str)
853
+
854
+
855
+ # =============================================================================
856
+ # UI BUILDING
857
+ # =============================================================================
858
+
859
+ def create_ui():
860
+ """Create the Gradio interface."""
861
+
862
+ with gr.Blocks(
863
+ title="Design System Extractor v2",
864
+ theme=gr.themes.Soft(),
865
+ css="""
866
+ .color-swatch { display: inline-block; width: 24px; height: 24px; border-radius: 4px; margin-right: 8px; vertical-align: middle; }
867
+ """
868
+ ) as app:
869
+
870
+ gr.Markdown("""
871
+ # 🎨 Design System Extractor v2
872
+
873
+ **Reverse-engineer design systems from live websites.**
874
+
875
+ A semi-automated, human-in-the-loop system that extracts, normalizes, and upgrades design tokens.
876
+
877
+ ---
878
+ """)
879
+
880
+ # =================================================================
881
+ # CONFIGURATION
882
+ # =================================================================
883
+
884
+ with gr.Accordion("βš™οΈ Configuration", open=not bool(HF_TOKEN_FROM_ENV)):
885
+ gr.Markdown("**HuggingFace Token** β€” Required for Stage 2 (AI upgrades)")
886
+ with gr.Row():
887
+ hf_token_input = gr.Textbox(
888
+ label="HF Token", placeholder="hf_xxxx", type="password",
889
+ scale=4, value=HF_TOKEN_FROM_ENV,
890
+ )
891
+ save_token_btn = gr.Button("πŸ’Ύ Save", scale=1)
892
+ token_status = gr.Markdown("βœ… Token loaded" if HF_TOKEN_FROM_ENV else "⏳ Enter token")
893
+
894
+ def save_token(token):
895
+ if token and len(token) > 10:
896
+ os.environ["HF_TOKEN"] = token.strip()
897
+ return "βœ… Token saved!"
898
+ return "❌ Invalid token"
899
+
900
+ save_token_btn.click(save_token, [hf_token_input], [token_status])
901
+
902
+ # =================================================================
903
+ # URL INPUT & PAGE DISCOVERY
904
+ # =================================================================
905
+
906
+ with gr.Accordion("πŸ” Step 1: Discover Pages", open=True):
907
+ gr.Markdown("Enter your website URL to discover pages for extraction.")
908
+
909
+ with gr.Row():
910
+ url_input = gr.Textbox(label="Website URL", placeholder="https://example.com", scale=4)
911
+ discover_btn = gr.Button("πŸ” Discover Pages", variant="primary", scale=1)
912
+
913
+ discover_status = gr.Markdown("")
914
+
915
+ with gr.Row():
916
+ log_output = gr.Textbox(label="πŸ“‹ Log", lines=8, interactive=False)
917
+
918
+ pages_table = gr.Dataframe(
919
+ headers=["Select", "URL", "Title", "Type", "Status"],
920
+ datatype=["bool", "str", "str", "str", "str"],
921
+ label="Discovered Pages",
922
+ interactive=True,
923
+ visible=False,
924
+ )
925
+
926
+ extract_btn = gr.Button("πŸš€ Extract Tokens (Desktop + Mobile)", variant="primary", visible=False)
927
+
928
+ # =================================================================
929
+ # STAGE 1: EXTRACTION REVIEW
930
+ # =================================================================
931
+
932
+ with gr.Accordion("πŸ“Š Stage 1: Review Extracted Tokens", open=False) as stage1_accordion:
933
+
934
+ extraction_status = gr.Markdown("")
935
+
936
+ gr.Markdown("""
937
+ **Review the extracted tokens.** Toggle between Desktop and Mobile viewports.
938
+ Accept or reject tokens, then proceed to Stage 2 for AI-powered upgrades.
939
+ """)
940
+
941
+ viewport_toggle = gr.Radio(
942
+ choices=["Desktop (1440px)", "Mobile (375px)"],
943
+ value="Desktop (1440px)",
944
+ label="Viewport",
945
+ )
946
+
947
+ with gr.Tabs():
948
+ with gr.Tab("🎨 Colors"):
949
+ colors_table = gr.Dataframe(
950
+ headers=["Accept", "Color", "Suggested Name", "Frequency", "Confidence", "Contrast", "AA", "Context"],
951
+ datatype=["bool", "str", "str", "number", "str", "str", "str", "str"],
952
+ label="Colors",
953
+ interactive=True,
954
+ )
955
+
956
+ with gr.Tab("πŸ“ Typography"):
957
+ typography_table = gr.Dataframe(
958
+ headers=["Accept", "Font", "Size", "Weight", "Line Height", "Suggested Name", "Frequency", "Confidence"],
959
+ datatype=["bool", "str", "str", "str", "str", "str", "number", "str"],
960
+ label="Typography",
961
+ interactive=True,
962
+ )
963
+
964
+ with gr.Tab("πŸ“ Spacing"):
965
+ spacing_table = gr.Dataframe(
966
+ headers=["Accept", "Value", "Pixels", "Suggested Name", "Frequency", "Base 8", "Confidence"],
967
+ datatype=["bool", "str", "str", "str", "number", "str", "str"],
968
+ label="Spacing",
969
+ interactive=True,
970
+ )
971
+
972
+ with gr.Tab("πŸ”˜ Radius"):
973
+ radius_table = gr.Dataframe(
974
+ headers=["Accept", "Value", "Frequency", "Context"],
975
+ datatype=["bool", "str", "number", "str"],
976
+ label="Border Radius",
977
+ interactive=True,
978
+ )
979
+
980
+ with gr.Row():
981
+ proceed_stage2_btn = gr.Button("➑️ Proceed to Stage 2: AI Upgrades", variant="primary")
982
+ download_stage1_btn = gr.Button("πŸ“₯ Download Stage 1 JSON", variant="secondary")
983
+
984
+ # =================================================================
985
+ # STAGE 2: AI UPGRADES
986
+ # =================================================================
987
+
988
+ with gr.Accordion("🧠 Stage 2: AI-Powered Upgrades", open=False) as stage2_accordion:
989
+
990
+ stage2_status = gr.Markdown("Click 'Analyze' to start AI-powered design system analysis.")
991
+
992
+ analyze_btn = gr.Button("πŸ€– Analyze Design System", variant="primary")
993
+
994
+ with gr.Accordion("πŸ“‹ AI Analysis Log (Click to expand)", open=True):
995
+ stage2_log = gr.Textbox(label="Log", lines=12, interactive=False)
996
+
997
+ # =============================================================
998
+ # BRAND COMPARISON (LLM Research)
999
+ # =============================================================
1000
+ gr.Markdown("---")
1001
+ brand_comparison = gr.Markdown("*Brand comparison will appear after analysis*")
1002
+
1003
+ # =============================================================
1004
+ # TYPOGRAPHY SECTION
1005
+ # =============================================================
1006
+ gr.Markdown("---")
1007
+ gr.Markdown("## πŸ“ Typography")
1008
+
1009
+ with gr.Row():
1010
+ with gr.Column(scale=3):
1011
+ gr.Markdown("### Type Scale Comparison (13 Tokens)")
1012
+ typography_comparison = gr.Dataframe(
1013
+ headers=["Token", "Current", "Scale 1.2", "Scale 1.25 ⭐", "Scale 1.333", "Keep"],
1014
+ datatype=["str", "str", "str", "str", "str", "str"],
1015
+ label="Typography Comparison",
1016
+ interactive=False,
1017
+ )
1018
+
1019
+ with gr.Column(scale=1):
1020
+ gr.Markdown("### Select Option")
1021
+ type_scale_radio = gr.Radio(
1022
+ choices=["Keep Current", "Scale 1.2 (Minor Third)", "Scale 1.25 (Major Third) ⭐", "Scale 1.333 (Perfect Fourth)"],
1023
+ value="Scale 1.25 (Major Third) ⭐",
1024
+ label="Type Scale",
1025
+ interactive=True,
1026
+ )
1027
+ gr.Markdown("*Font family preserved*")
1028
+
1029
+ # =============================================================
1030
+ # COLORS SECTION
1031
+ # =============================================================
1032
+ gr.Markdown("---")
1033
+ gr.Markdown("## 🎨 Colors (with generated ramps)")
1034
+
1035
+ color_ramps_display = gr.Markdown("*Color ramps will appear after analysis*")
1036
+
1037
+ color_ramps_checkbox = gr.Checkbox(
1038
+ label="βœ“ Generate color ramps (keeps base colors, adds 50-950 shades)",
1039
+ value=True,
1040
+ )
1041
+
1042
+ # =============================================================
1043
+ # SPACING SECTION
1044
+ # =============================================================
1045
+ gr.Markdown("---")
1046
+ gr.Markdown("## πŸ“ Spacing (Rule-Based)")
1047
+
1048
+ with gr.Row():
1049
+ with gr.Column(scale=2):
1050
+ spacing_comparison = gr.Dataframe(
1051
+ headers=["Current", "8px Grid", "4px Grid"],
1052
+ datatype=["str", "str", "str"],
1053
+ label="Spacing Comparison",
1054
+ interactive=False,
1055
+ )
1056
+
1057
+ with gr.Column(scale=1):
1058
+ spacing_radio = gr.Radio(
1059
+ choices=["Keep Current", "8px Base Grid ⭐", "4px Base Grid"],
1060
+ value="8px Base Grid ⭐",
1061
+ label="Spacing System",
1062
+ interactive=True,
1063
+ )
1064
+
1065
+ # =============================================================
1066
+ # RADIUS SECTION
1067
+ # =============================================================
1068
+ gr.Markdown("---")
1069
+ gr.Markdown("## πŸ”˜ Border Radius (Rule-Based)")
1070
+
1071
+ radius_display = gr.Markdown("*Radius tokens will appear after analysis*")
1072
+
1073
+ # =============================================================
1074
+ # SHADOWS SECTION
1075
+ # =============================================================
1076
+ gr.Markdown("---")
1077
+ gr.Markdown("## 🌫️ Shadows (Rule-Based)")
1078
+
1079
+ shadows_display = gr.Markdown("*Shadow tokens will appear after analysis*")
1080
+
1081
+ # =============================================================
1082
+ # APPLY SECTION
1083
+ # =============================================================
1084
+ gr.Markdown("---")
1085
+
1086
+ with gr.Row():
1087
+ apply_upgrades_btn = gr.Button("✨ Apply Selected Upgrades", variant="primary", scale=2)
1088
+ reset_btn = gr.Button("↩️ Reset to Original", variant="secondary", scale=1)
1089
+
1090
+ apply_status = gr.Markdown("")
1091
+
1092
+ # =================================================================
1093
+ # STAGE 3: EXPORT
1094
+ # =================================================================
1095
+
1096
+ with gr.Accordion("πŸ“¦ Stage 3: Export", open=False):
1097
+ gr.Markdown("""
1098
+ Export your design tokens to JSON (compatible with Figma Tokens Studio).
1099
+
1100
+ - **Stage 1 JSON**: Raw extracted tokens (as-is)
1101
+ - **Final JSON**: Upgraded tokens with selected improvements
1102
+ """)
1103
+
1104
+ with gr.Row():
1105
+ export_stage1_btn = gr.Button("πŸ“₯ Export Stage 1 (As-Is)", variant="secondary")
1106
+ export_final_btn = gr.Button("πŸ“₯ Export Final (Upgraded)", variant="primary")
1107
+
1108
+ export_output = gr.Code(label="Tokens JSON", language="json", lines=25)
1109
+
1110
+ export_stage1_btn.click(export_stage1_json, outputs=[export_output])
1111
+ export_final_btn.click(export_tokens_json, outputs=[export_output])
1112
+
1113
+ # =================================================================
1114
+ # EVENT HANDLERS
1115
+ # =================================================================
1116
+
1117
+ # Store data for viewport toggle
1118
+ desktop_data = gr.State({})
1119
+ mobile_data = gr.State({})
1120
+
1121
+ # Discover pages
1122
+ discover_btn.click(
1123
+ fn=discover_pages,
1124
+ inputs=[url_input],
1125
+ outputs=[discover_status, log_output, pages_table],
1126
+ ).then(
1127
+ fn=lambda: (gr.update(visible=True), gr.update(visible=True)),
1128
+ outputs=[pages_table, extract_btn],
1129
+ )
1130
+
1131
+ # Extract tokens
1132
+ extract_btn.click(
1133
+ fn=extract_tokens,
1134
+ inputs=[pages_table],
1135
+ outputs=[extraction_status, log_output, desktop_data, mobile_data],
1136
+ ).then(
1137
+ fn=lambda d: (d.get("colors", []), d.get("typography", []), d.get("spacing", [])),
1138
+ inputs=[desktop_data],
1139
+ outputs=[colors_table, typography_table, spacing_table],
1140
+ ).then(
1141
+ fn=lambda: gr.update(open=True),
1142
+ outputs=[stage1_accordion],
1143
+ )
1144
+
1145
+ # Viewport toggle
1146
+ viewport_toggle.change(
1147
+ fn=switch_viewport,
1148
+ inputs=[viewport_toggle],
1149
+ outputs=[colors_table, typography_table, spacing_table],
1150
+ )
1151
+
1152
+ # Stage 2: Analyze
1153
+ analyze_btn.click(
1154
+ fn=run_stage2_analysis,
1155
+ outputs=[stage2_status, stage2_log, brand_comparison, typography_comparison, spacing_comparison,
1156
+ color_ramps_display, radius_display, shadows_display],
1157
+ )
1158
+
1159
+ # Stage 2: Apply upgrades
1160
+ apply_upgrades_btn.click(
1161
+ fn=apply_selected_upgrades,
1162
+ inputs=[type_scale_radio, spacing_radio, color_ramps_checkbox],
1163
+ outputs=[apply_status, stage2_log],
1164
+ )
1165
+
1166
+ # Stage 1: Download JSON
1167
+ download_stage1_btn.click(
1168
+ fn=export_stage1_json,
1169
+ outputs=[export_output],
1170
+ )
1171
+
1172
+ # Proceed to Stage 2 button
1173
+ proceed_stage2_btn.click(
1174
+ fn=lambda: gr.update(open=True),
1175
+ outputs=[stage2_accordion],
1176
+ )
1177
+
1178
+ # =================================================================
1179
+ # FOOTER
1180
+ # =================================================================
1181
+
1182
+ gr.Markdown("""
1183
+ ---
1184
+ **Design System Extractor v2** | Built with Playwright + Gradio + LangGraph + HuggingFace
1185
+
1186
+ *A semi-automated co-pilot for design system recovery and modernization.*
1187
+ """)
1188
+
1189
+ return app
1190
+
1191
+
1192
+ # =============================================================================
1193
+ # MAIN
1194
+ # =============================================================================
1195
+
1196
+ if __name__ == "__main__":
1197
+ app = create_ui()
1198
+ app.launch(server_name="0.0.0.0", server_port=7860)