riazmo commited on
Commit
ee7b64f
Β·
verified Β·
1 Parent(s): d29821d

Upload app.py

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