riazmo commited on
Commit
e060090
Β·
verified Β·
1 Parent(s): 0c0c17c

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1838 -0
app.py ADDED
@@ -0,0 +1,1838 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Generate visual previews
257
+ state.log("")
258
+ state.log("🎨 Generating visual previews...")
259
+
260
+ from core.preview_generator import generate_typography_preview_html, generate_color_ramps_preview_html
261
+
262
+ # Get detected font
263
+ fonts = get_detected_fonts()
264
+ primary_font = fonts.get("primary", "Open Sans")
265
+
266
+ # Convert typography tokens to dict format for preview
267
+ typo_dict = {}
268
+ for name, t in state.desktop_normalized.typography.items():
269
+ typo_dict[name] = {
270
+ "font_size": t.font_size,
271
+ "font_weight": t.font_weight,
272
+ "line_height": t.line_height or "1.5",
273
+ "letter_spacing": "0",
274
+ }
275
+
276
+ # Convert color tokens to dict format for preview
277
+ color_dict = {}
278
+ for name, c in state.desktop_normalized.colors.items():
279
+ color_dict[name] = {"value": c.value}
280
+
281
+ typography_preview_html = generate_typography_preview_html(
282
+ typography_tokens=typo_dict,
283
+ font_family=primary_font,
284
+ sample_text="The quick brown fox jumps over the lazy dog",
285
+ )
286
+
287
+ color_ramps_preview_html = generate_color_ramps_preview_html(
288
+ color_tokens=color_dict,
289
+ )
290
+
291
+ state.log(" βœ… Typography preview generated")
292
+ state.log(" βœ… Color ramps preview generated")
293
+
294
+ state.log("")
295
+ state.log("=" * 50)
296
+ state.log("βœ… EXTRACTION COMPLETE!")
297
+ state.log("=" * 50)
298
+
299
+ progress(1.0, desc="βœ… Complete!")
300
+
301
+ status = f"""## βœ… Extraction Complete!
302
+
303
+ | Viewport | Colors | Typography | Spacing |
304
+ |----------|--------|------------|---------|
305
+ | Desktop | {len(state.desktop_normalized.colors)} | {len(state.desktop_normalized.typography)} | {len(state.desktop_normalized.spacing)} |
306
+ | Mobile | {len(state.mobile_normalized.colors)} | {len(state.mobile_normalized.typography)} | {len(state.mobile_normalized.spacing)} |
307
+
308
+ **Primary Font:** {primary_font}
309
+
310
+ **Next:** Review the tokens below. Accept or reject, then proceed to Stage 2.
311
+ """
312
+
313
+ return status, state.get_logs(), desktop_data, mobile_data, typography_preview_html, color_ramps_preview_html
314
+
315
+ except Exception as e:
316
+ import traceback
317
+ state.log(f"❌ Error: {str(e)}")
318
+ state.log(traceback.format_exc())
319
+ return f"❌ Error: {str(e)}", state.get_logs(), None, None, "", ""
320
+
321
+
322
+ def format_tokens_for_display(normalized) -> dict:
323
+ """Format normalized tokens for Gradio display."""
324
+ if normalized is None:
325
+ return {"colors": [], "typography": [], "spacing": []}
326
+
327
+ # Colors are now a dict
328
+ colors = []
329
+ color_items = list(normalized.colors.values()) if isinstance(normalized.colors, dict) else normalized.colors
330
+ for c in sorted(color_items, key=lambda x: -x.frequency)[:50]:
331
+ colors.append([
332
+ True, # Accept checkbox
333
+ c.value,
334
+ c.suggested_name or "",
335
+ c.frequency,
336
+ c.confidence.value if c.confidence else "medium",
337
+ f"{c.contrast_white:.1f}:1" if c.contrast_white else "N/A",
338
+ "βœ“" if c.wcag_aa_small_text else "βœ—",
339
+ ", ".join(c.contexts[:2]) if c.contexts else "",
340
+ ])
341
+
342
+ # Typography
343
+ typography = []
344
+ typo_items = list(normalized.typography.values()) if isinstance(normalized.typography, dict) else normalized.typography
345
+ for t in sorted(typo_items, key=lambda x: -x.frequency)[:30]:
346
+ typography.append([
347
+ True, # Accept checkbox
348
+ t.font_family,
349
+ t.font_size,
350
+ str(t.font_weight),
351
+ t.line_height or "",
352
+ t.suggested_name or "",
353
+ t.frequency,
354
+ t.confidence.value if t.confidence else "medium",
355
+ ])
356
+
357
+ # Spacing
358
+ spacing = []
359
+ spacing_items = list(normalized.spacing.values()) if isinstance(normalized.spacing, dict) else normalized.spacing
360
+ for s in sorted(spacing_items, key=lambda x: x.value_px)[:20]:
361
+ spacing.append([
362
+ True, # Accept checkbox
363
+ s.value,
364
+ f"{s.value_px}px",
365
+ s.suggested_name or "",
366
+ s.frequency,
367
+ "βœ“" if s.fits_base_8 else "",
368
+ s.confidence.value if s.confidence else "medium",
369
+ ])
370
+
371
+ return {
372
+ "colors": colors,
373
+ "typography": typography,
374
+ "spacing": spacing,
375
+ }
376
+
377
+
378
+ def switch_viewport(viewport: str):
379
+ """Switch between desktop and mobile view."""
380
+ if viewport == "Desktop (1440px)":
381
+ data = format_tokens_for_display(state.desktop_normalized)
382
+ else:
383
+ data = format_tokens_for_display(state.mobile_normalized)
384
+
385
+ return data["colors"], data["typography"], data["spacing"]
386
+
387
+
388
+ # =============================================================================
389
+ # STAGE 2: AI ANALYSIS (Multi-Agent)
390
+ # =============================================================================
391
+
392
+ async def run_stage2_analysis(competitors_str: str = "", progress=gr.Progress()):
393
+ """Run multi-agent analysis on extracted tokens."""
394
+
395
+ if not state.desktop_normalized or not state.mobile_normalized:
396
+ return ("❌ Please complete Stage 1 first", "", "", "", None, None, None, "", "", "", "")
397
+
398
+ # Parse competitors from input
399
+ default_competitors = [
400
+ "Material Design 3",
401
+ "Apple Human Interface Guidelines",
402
+ "Shopify Polaris",
403
+ "IBM Carbon",
404
+ "Atlassian Design System"
405
+ ]
406
+
407
+ if competitors_str and competitors_str.strip():
408
+ competitors = [c.strip() for c in competitors_str.split(",") if c.strip()]
409
+ else:
410
+ competitors = default_competitors
411
+
412
+ progress(0.05, desc="πŸ€– Initializing multi-agent analysis...")
413
+
414
+ try:
415
+ # Import the multi-agent workflow
416
+ from agents.stage2_graph import run_stage2_multi_agent
417
+
418
+ # Convert normalized tokens to dict for the workflow
419
+ desktop_dict = normalized_to_dict(state.desktop_normalized)
420
+ mobile_dict = normalized_to_dict(state.mobile_normalized)
421
+
422
+ # Run multi-agent analysis
423
+ progress(0.1, desc="πŸš€ Running parallel LLM analysis...")
424
+
425
+ result = await run_stage2_multi_agent(
426
+ desktop_tokens=desktop_dict,
427
+ mobile_tokens=mobile_dict,
428
+ competitors=competitors,
429
+ log_callback=state.log,
430
+ )
431
+
432
+ progress(0.8, desc="πŸ“Š Processing results...")
433
+
434
+ # Extract results
435
+ final_recs = result.get("final_recommendations", {})
436
+ llm1_analysis = result.get("llm1_analysis", {})
437
+ llm2_analysis = result.get("llm2_analysis", {})
438
+ rule_calculations = result.get("rule_calculations", {})
439
+ cost_tracking = result.get("cost_tracking", {})
440
+
441
+ # Store for later use
442
+ state.upgrade_recommendations = final_recs
443
+ state.multi_agent_result = result
444
+
445
+ # Get font info
446
+ fonts = get_detected_fonts()
447
+ base_size = get_base_font_size()
448
+
449
+ progress(0.9, desc="πŸ“Š Formatting results...")
450
+
451
+ # Build status markdown
452
+ status = build_analysis_status(final_recs, cost_tracking, result.get("errors", []))
453
+
454
+ # Format brand/competitor comparison from LLM analyses
455
+ brand_md = format_multi_agent_comparison(llm1_analysis, llm2_analysis, final_recs)
456
+
457
+ # Format font families display
458
+ font_families_md = format_font_families_display(fonts)
459
+
460
+ # Format typography with BOTH desktop and mobile
461
+ typography_desktop_data = format_typography_comparison_viewport(
462
+ state.desktop_normalized, base_size, "desktop"
463
+ )
464
+ typography_mobile_data = format_typography_comparison_viewport(
465
+ state.mobile_normalized, base_size, "mobile"
466
+ )
467
+
468
+ # Format spacing comparison table
469
+ spacing_data = format_spacing_comparison_from_rules(rule_calculations)
470
+
471
+ # Format color display: BASE colors + ramps separately
472
+ base_colors_md = format_base_colors()
473
+ color_ramps_md = format_color_ramps_from_rules(rule_calculations)
474
+
475
+ # Format radius display (with token suggestions)
476
+ radius_md = format_radius_with_tokens()
477
+
478
+ # Format shadows display (with token suggestions)
479
+ shadows_md = format_shadows_with_tokens()
480
+
481
+ # Generate visual previews for Stage 2
482
+ state.log("")
483
+ state.log("🎨 Generating visual previews...")
484
+
485
+ from core.preview_generator import generate_typography_preview_html, generate_color_ramps_preview_html
486
+
487
+ primary_font = fonts.get("primary", "Open Sans")
488
+
489
+ # Convert typography tokens to dict format for preview
490
+ typo_dict = {}
491
+ for name, t in state.desktop_normalized.typography.items():
492
+ typo_dict[name] = {
493
+ "font_size": t.font_size,
494
+ "font_weight": t.font_weight,
495
+ "line_height": t.line_height or "1.5",
496
+ "letter_spacing": "0",
497
+ }
498
+
499
+ # Convert color tokens to dict format for preview
500
+ color_dict = {}
501
+ for name, c in state.desktop_normalized.colors.items():
502
+ color_dict[name] = {"value": c.value}
503
+
504
+ typography_preview_html = generate_typography_preview_html(
505
+ typography_tokens=typo_dict,
506
+ font_family=primary_font,
507
+ sample_text="The quick brown fox jumps over the lazy dog",
508
+ )
509
+
510
+ color_ramps_preview_html = generate_color_ramps_preview_html(
511
+ color_tokens=color_dict,
512
+ )
513
+
514
+ state.log(" βœ… Visual previews generated")
515
+
516
+ progress(1.0, desc="βœ… Analysis complete!")
517
+
518
+ return (status, state.get_logs(), brand_md, font_families_md,
519
+ typography_desktop_data, typography_mobile_data, spacing_data,
520
+ base_colors_md, color_ramps_md, radius_md, shadows_md,
521
+ typography_preview_html, color_ramps_preview_html)
522
+
523
+ except Exception as e:
524
+ import traceback
525
+ state.log(f"❌ Error: {str(e)}")
526
+ state.log(traceback.format_exc())
527
+ return (f"❌ Analysis failed: {str(e)}", state.get_logs(), "", "", None, None, None, "", "", "", "", "", "")
528
+
529
+
530
+ def normalized_to_dict(normalized) -> dict:
531
+ """Convert NormalizedTokens to dict for workflow."""
532
+ if not normalized:
533
+ return {}
534
+
535
+ result = {
536
+ "colors": {},
537
+ "typography": {},
538
+ "spacing": {},
539
+ "radius": {},
540
+ "shadows": {},
541
+ }
542
+
543
+ # Colors
544
+ for name, c in normalized.colors.items():
545
+ result["colors"][name] = {
546
+ "value": c.value,
547
+ "frequency": c.frequency,
548
+ "suggested_name": c.suggested_name,
549
+ "contrast_white": c.contrast_white,
550
+ "contrast_black": c.contrast_black,
551
+ }
552
+
553
+ # Typography
554
+ for name, t in normalized.typography.items():
555
+ result["typography"][name] = {
556
+ "font_family": t.font_family,
557
+ "font_size": t.font_size,
558
+ "font_weight": t.font_weight,
559
+ "line_height": t.line_height,
560
+ "frequency": t.frequency,
561
+ }
562
+
563
+ # Spacing
564
+ for name, s in normalized.spacing.items():
565
+ result["spacing"][name] = {
566
+ "value": s.value,
567
+ "value_px": s.value_px,
568
+ "frequency": s.frequency,
569
+ }
570
+
571
+ # Radius
572
+ for name, r in normalized.radius.items():
573
+ result["radius"][name] = {
574
+ "value": r.value,
575
+ "frequency": r.frequency,
576
+ }
577
+
578
+ # Shadows
579
+ for name, s in normalized.shadows.items():
580
+ result["shadows"][name] = {
581
+ "value": s.value,
582
+ "frequency": s.frequency,
583
+ }
584
+
585
+ return result
586
+
587
+
588
+ def build_analysis_status(final_recs: dict, cost_tracking: dict, errors: list) -> str:
589
+ """Build status markdown from analysis results."""
590
+
591
+ lines = ["## 🧠 Multi-Agent Analysis Complete!"]
592
+ lines.append("")
593
+
594
+ # Cost summary
595
+ if cost_tracking:
596
+ total_cost = cost_tracking.get("total_cost", 0)
597
+ lines.append(f"### πŸ’° Cost Summary")
598
+ lines.append(f"**Total estimated cost:** ${total_cost:.4f}")
599
+ lines.append(f"*(Free tier: $0.10/mo | Pro: $2.00/mo)*")
600
+ lines.append("")
601
+
602
+ # Final recommendations
603
+ if final_recs and "final_recommendations" in final_recs:
604
+ recs = final_recs["final_recommendations"]
605
+ lines.append("### πŸ“‹ Recommendations")
606
+
607
+ if recs.get("type_scale"):
608
+ lines.append(f"**Type Scale:** {recs['type_scale']}")
609
+ if recs.get("type_scale_rationale"):
610
+ lines.append(f" *{recs['type_scale_rationale'][:100]}*")
611
+
612
+ if recs.get("spacing_base"):
613
+ lines.append(f"**Spacing:** {recs['spacing_base']}")
614
+
615
+ lines.append("")
616
+
617
+ # Summary
618
+ if final_recs.get("summary"):
619
+ lines.append("### πŸ“ Summary")
620
+ lines.append(final_recs["summary"])
621
+ lines.append("")
622
+
623
+ # Confidence
624
+ if final_recs.get("overall_confidence"):
625
+ lines.append(f"**Confidence:** {final_recs['overall_confidence']}%")
626
+
627
+ # Errors
628
+ if errors:
629
+ lines.append("")
630
+ lines.append("### ⚠️ Warnings")
631
+ for err in errors[:3]:
632
+ lines.append(f"- {err[:100]}")
633
+
634
+ return "\n".join(lines)
635
+
636
+
637
+ def format_multi_agent_comparison(llm1: dict, llm2: dict, final: dict) -> str:
638
+ """Format comparison from multi-agent analysis."""
639
+
640
+ lines = ["### πŸ“Š Multi-Agent Analysis Comparison"]
641
+ lines.append("")
642
+
643
+ # Agreements
644
+ if final.get("agreements"):
645
+ lines.append("#### βœ… Agreements (High Confidence)")
646
+ for a in final["agreements"][:5]:
647
+ topic = a.get("topic", "?")
648
+ finding = a.get("finding", "?")[:80]
649
+ lines.append(f"- **{topic}**: {finding}")
650
+ lines.append("")
651
+
652
+ # Disagreements and resolutions
653
+ if final.get("disagreements"):
654
+ lines.append("#### πŸ”„ Resolved Disagreements")
655
+ for d in final["disagreements"][:3]:
656
+ topic = d.get("topic", "?")
657
+ resolution = d.get("resolution", "?")[:100]
658
+ lines.append(f"- **{topic}**: {resolution}")
659
+ lines.append("")
660
+
661
+ # Score comparison
662
+ lines.append("#### πŸ“ˆ Score Comparison")
663
+ lines.append("")
664
+ lines.append("| Category | LLM 1 (Qwen) | LLM 2 (Llama) |")
665
+ lines.append("|----------|--------------|---------------|")
666
+
667
+ categories = ["typography", "colors", "accessibility", "spacing"]
668
+ for cat in categories:
669
+ llm1_score = llm1.get(cat, {}).get("score", "?") if isinstance(llm1.get(cat), dict) else "?"
670
+ llm2_score = llm2.get(cat, {}).get("score", "?") if isinstance(llm2.get(cat), dict) else "?"
671
+ lines.append(f"| {cat.title()} | {llm1_score}/10 | {llm2_score}/10 |")
672
+
673
+ return "\n".join(lines)
674
+
675
+
676
+ def format_spacing_comparison_from_rules(rule_calculations: dict) -> list:
677
+ """Format spacing comparison from rule engine."""
678
+ if not rule_calculations:
679
+ return []
680
+
681
+ spacing_options = rule_calculations.get("spacing_options", {})
682
+
683
+ data = []
684
+ for i in range(10):
685
+ current = f"{(i+1) * 4}px" if i < 5 else f"{(i+1) * 8}px"
686
+ grid_8 = spacing_options.get("8px", [])
687
+ grid_4 = spacing_options.get("4px", [])
688
+
689
+ val_8 = f"{grid_8[i+1]}px" if i+1 < len(grid_8) else "β€”"
690
+ val_4 = f"{grid_4[i+1]}px" if i+1 < len(grid_4) else "β€”"
691
+
692
+ data.append([current, val_8, val_4])
693
+
694
+ return data
695
+
696
+
697
+ def format_color_ramps_from_rules(rule_calculations: dict) -> str:
698
+ """Format color ramps from rule engine."""
699
+ if not rule_calculations:
700
+ return "*No color ramps generated*"
701
+
702
+ ramps = rule_calculations.get("color_ramps", {})
703
+ if not ramps:
704
+ return "*No color ramps generated*"
705
+
706
+ lines = ["### 🌈 Generated Color Ramps"]
707
+ lines.append("")
708
+
709
+ for name, ramp in list(ramps.items())[:6]:
710
+ lines.append(f"**{name}**")
711
+ if isinstance(ramp, list) and len(ramp) >= 10:
712
+ lines.append("| 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 |")
713
+ lines.append("|---|---|---|---|---|---|---|---|---|---|")
714
+ row = "| " + " | ".join([f"`{ramp[i]}`" for i in range(10)]) + " |"
715
+ lines.append(row)
716
+ lines.append("")
717
+
718
+ return "\n".join(lines)
719
+
720
+
721
+ def get_detected_fonts() -> dict:
722
+ """Get detected font information."""
723
+ if not state.desktop_normalized:
724
+ return {"primary": "Unknown", "weights": []}
725
+
726
+ fonts = {}
727
+ weights = set()
728
+
729
+ for t in state.desktop_normalized.typography.values():
730
+ family = t.font_family
731
+ weight = t.font_weight
732
+
733
+ if family not in fonts:
734
+ fonts[family] = 0
735
+ fonts[family] += t.frequency
736
+
737
+ if weight:
738
+ try:
739
+ weights.add(int(weight))
740
+ except:
741
+ pass
742
+
743
+ primary = max(fonts.items(), key=lambda x: x[1])[0] if fonts else "Unknown"
744
+
745
+ return {
746
+ "primary": primary,
747
+ "weights": sorted(weights) if weights else [400],
748
+ "all_fonts": fonts,
749
+ }
750
+
751
+
752
+ def get_base_font_size() -> int:
753
+ """Detect base font size from typography."""
754
+ if not state.desktop_normalized:
755
+ return 16
756
+
757
+ # Find most common size in body range (14-18px)
758
+ sizes = {}
759
+ for t in state.desktop_normalized.typography.values():
760
+ size_str = str(t.font_size).replace('px', '').replace('rem', '').replace('em', '')
761
+ try:
762
+ size = float(size_str)
763
+ if 14 <= size <= 18:
764
+ sizes[size] = sizes.get(size, 0) + t.frequency
765
+ except:
766
+ pass
767
+
768
+ if sizes:
769
+ return int(max(sizes.items(), key=lambda x: x[1])[0])
770
+ return 16
771
+
772
+
773
+ def format_brand_comparison(recommendations) -> str:
774
+ """Format brand comparison as markdown table."""
775
+ if not recommendations.brand_analysis:
776
+ return "*Brand analysis not available*"
777
+
778
+ lines = [
779
+ "### πŸ“Š Design System Comparison (5 Top Brands)",
780
+ "",
781
+ "| Brand | Type Ratio | Base Size | Spacing | Notes |",
782
+ "|-------|------------|-----------|---------|-------|",
783
+ ]
784
+
785
+ for brand in recommendations.brand_analysis[:5]:
786
+ name = brand.get("brand", "Unknown")
787
+ ratio = brand.get("ratio", "?")
788
+ base = brand.get("base", "?")
789
+ spacing = brand.get("spacing", "?")
790
+ notes = brand.get("notes", "")[:50] + ("..." if len(brand.get("notes", "")) > 50 else "")
791
+ lines.append(f"| {name} | {ratio} | {base}px | {spacing} | {notes} |")
792
+
793
+ return "\n".join(lines)
794
+
795
+
796
+ def format_font_families_display(fonts: dict) -> str:
797
+ """Format detected font families for display."""
798
+ lines = []
799
+
800
+ primary = fonts.get("primary", "Unknown")
801
+ weights = fonts.get("weights", [400])
802
+ all_fonts = fonts.get("all_fonts", {})
803
+
804
+ lines.append(f"### Primary Font: **{primary}**")
805
+ lines.append("")
806
+ lines.append(f"**Weights detected:** {', '.join(map(str, weights))}")
807
+ lines.append("")
808
+
809
+ if all_fonts and len(all_fonts) > 1:
810
+ lines.append("### All Fonts Detected")
811
+ lines.append("")
812
+ lines.append("| Font Family | Usage Count |")
813
+ lines.append("|-------------|-------------|")
814
+
815
+ sorted_fonts = sorted(all_fonts.items(), key=lambda x: -x[1])
816
+ for font, count in sorted_fonts[:5]:
817
+ lines.append(f"| {font} | {count:,} |")
818
+
819
+ lines.append("")
820
+ lines.append("*Note: This analysis focuses on English typography only.*")
821
+
822
+ return "\n".join(lines)
823
+
824
+
825
+ def format_typography_comparison_viewport(normalized_tokens, base_size: int, viewport: str) -> list:
826
+ """Format typography comparison for a specific viewport."""
827
+ if not normalized_tokens:
828
+ return []
829
+
830
+ # Get current typography sorted by size
831
+ current_typo = list(normalized_tokens.typography.values())
832
+
833
+ # Parse and sort sizes
834
+ def parse_size(t):
835
+ size_str = str(t.font_size).replace('px', '').replace('rem', '').replace('em', '')
836
+ try:
837
+ return float(size_str)
838
+ except:
839
+ return 16
840
+
841
+ current_typo.sort(key=lambda t: -parse_size(t))
842
+ sizes = [parse_size(t) for t in current_typo]
843
+
844
+ # Use detected base or default
845
+ base = base_size if base_size else 16
846
+
847
+ # Scale factors for mobile (typically 0.85-0.9 of desktop)
848
+ mobile_factor = 0.875 if viewport == "mobile" else 1.0
849
+
850
+ # Token names (13 levels)
851
+ token_names = [
852
+ "display.2xl", "display.xl", "display.lg", "display.md",
853
+ "heading.xl", "heading.lg", "heading.md", "heading.sm",
854
+ "body.lg", "body.md", "body.sm",
855
+ "caption", "overline"
856
+ ]
857
+
858
+ # Generate scales - use base size and round to sensible values
859
+ def round_to_even(val):
860
+ """Round to even numbers for cleaner type scales."""
861
+ return int(round(val / 2) * 2)
862
+
863
+ scales = {
864
+ "1.2": [round_to_even(base * mobile_factor * (1.2 ** (8-i))) for i in range(13)],
865
+ "1.25": [round_to_even(base * mobile_factor * (1.25 ** (8-i))) for i in range(13)],
866
+ "1.333": [round_to_even(base * mobile_factor * (1.333 ** (8-i))) for i in range(13)],
867
+ }
868
+
869
+ # Build comparison table
870
+ data = []
871
+ for i, name in enumerate(token_names):
872
+ current = f"{int(sizes[i])}px" if i < len(sizes) else "β€”"
873
+ s12 = f"{scales['1.2'][i]}px"
874
+ s125 = f"{scales['1.25'][i]}px"
875
+ s133 = f"{scales['1.333'][i]}px"
876
+ keep = current
877
+ data.append([name, current, s12, s125, s133, keep])
878
+
879
+ return data
880
+
881
+
882
+ def format_base_colors() -> str:
883
+ """Format base colors (detected) separately from ramps."""
884
+ if not state.desktop_normalized:
885
+ return "*No colors detected*"
886
+
887
+ colors = list(state.desktop_normalized.colors.values())
888
+ colors.sort(key=lambda c: -c.frequency)
889
+
890
+ lines = [
891
+ "### 🎨 Base Colors (Detected)",
892
+ "",
893
+ "These are the primary colors extracted from your website:",
894
+ "",
895
+ "| Color | Hex | Role | Frequency | Contrast |",
896
+ "|-------|-----|------|-----------|----------|",
897
+ ]
898
+
899
+ for color in colors[:10]:
900
+ hex_val = color.value
901
+ role = "Primary" if color.suggested_name and "primary" in color.suggested_name.lower() else \
902
+ "Text" if color.suggested_name and "text" in color.suggested_name.lower() else \
903
+ "Background" if color.suggested_name and "background" in color.suggested_name.lower() else \
904
+ "Border" if color.suggested_name and "border" in color.suggested_name.lower() else \
905
+ "Accent"
906
+ freq = f"{color.frequency:,}"
907
+ contrast = f"{color.contrast_white:.1f}:1" if color.contrast_white else "β€”"
908
+
909
+ # Create a simple color indicator
910
+ lines.append(f"| 🟦 | `{hex_val}` | {role} | {freq} | {contrast} |")
911
+
912
+ return "\n".join(lines)
913
+
914
+
915
+ def format_color_ramps_visual(recommendations) -> str:
916
+ """Format color ramps with visual display showing all shades."""
917
+ if not state.desktop_normalized:
918
+ return "*No colors to display*"
919
+
920
+ colors = list(state.desktop_normalized.colors.values())
921
+ colors.sort(key=lambda c: -c.frequency)
922
+
923
+ lines = [
924
+ "### 🌈 Generated Color Ramps",
925
+ "",
926
+ "Full ramp (50-950) generated for each base color:",
927
+ "",
928
+ ]
929
+
930
+ from core.color_utils import generate_color_ramp
931
+
932
+ for color in colors[:6]: # Top 6 colors
933
+ hex_val = color.value
934
+ role = color.suggested_name.split('.')[1] if color.suggested_name and '.' in color.suggested_name else "color"
935
+
936
+ # Generate ramp
937
+ try:
938
+ ramp = generate_color_ramp(hex_val)
939
+
940
+ lines.append(f"**{role.upper()}** (base: `{hex_val}`)")
941
+ lines.append("")
942
+ lines.append("| 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 |")
943
+ lines.append("|---|---|---|---|---|---|---|---|---|---|")
944
+
945
+ # Create row with hex values
946
+ row = "|"
947
+ for i in range(10):
948
+ if i < len(ramp):
949
+ row += f" `{ramp[i]}` |"
950
+ else:
951
+ row += " β€” |"
952
+ lines.append(row)
953
+ lines.append("")
954
+
955
+ except Exception as e:
956
+ lines.append(f"**{role}** (`{hex_val}`) β€” Could not generate ramp: {str(e)}")
957
+ lines.append("")
958
+
959
+ return "\n".join(lines)
960
+
961
+
962
+ def format_radius_with_tokens() -> str:
963
+ """Format radius with token name suggestions."""
964
+ if not state.desktop_normalized or not state.desktop_normalized.radius:
965
+ return "*No border radius values detected.*"
966
+
967
+ radii = list(state.desktop_normalized.radius.values())
968
+
969
+ lines = [
970
+ "### πŸ”˜ Border Radius Tokens",
971
+ "",
972
+ "| Detected | Suggested Token | Usage |",
973
+ "|----------|-----------------|-------|",
974
+ ]
975
+
976
+ # Sort by pixel value
977
+ def parse_radius(r):
978
+ val = str(r.value).replace('px', '').replace('%', '')
979
+ try:
980
+ return float(val)
981
+ except:
982
+ return 999
983
+
984
+ radii.sort(key=lambda r: parse_radius(r))
985
+
986
+ token_map = {
987
+ (0, 2): ("radius.none", "Sharp corners"),
988
+ (2, 4): ("radius.xs", "Subtle rounding"),
989
+ (4, 6): ("radius.sm", "Small elements"),
990
+ (6, 10): ("radius.md", "Buttons, cards"),
991
+ (10, 16): ("radius.lg", "Modals, panels"),
992
+ (16, 32): ("radius.xl", "Large containers"),
993
+ (32, 100): ("radius.2xl", "Pill shapes"),
994
+ }
995
+
996
+ for r in radii[:8]:
997
+ val = str(r.value)
998
+ px = parse_radius(r)
999
+
1000
+ if "%" in str(r.value) or px >= 50:
1001
+ token = "radius.full"
1002
+ usage = "Circles, avatars"
1003
+ else:
1004
+ token = "radius.md"
1005
+ usage = "General use"
1006
+ for (low, high), (t, u) in token_map.items():
1007
+ if low <= px < high:
1008
+ token = t
1009
+ usage = u
1010
+ break
1011
+
1012
+ lines.append(f"| {val} | `{token}` | {usage} |")
1013
+
1014
+ return "\n".join(lines)
1015
+
1016
+
1017
+ def format_shadows_with_tokens() -> str:
1018
+ """Format shadows with token name suggestions."""
1019
+ if not state.desktop_normalized or not state.desktop_normalized.shadows:
1020
+ return "*No shadow values detected.*"
1021
+
1022
+ shadows = list(state.desktop_normalized.shadows.values())
1023
+
1024
+ lines = [
1025
+ "### 🌫️ Shadow Tokens",
1026
+ "",
1027
+ "| Detected Value | Suggested Token | Use Case |",
1028
+ "|----------------|-----------------|----------|",
1029
+ ]
1030
+
1031
+ shadow_sizes = ["shadow.xs", "shadow.sm", "shadow.md", "shadow.lg", "shadow.xl", "shadow.2xl"]
1032
+
1033
+ for i, s in enumerate(shadows[:6]):
1034
+ val = str(s.value)[:40] + ("..." if len(str(s.value)) > 40 else "")
1035
+ token = shadow_sizes[i] if i < len(shadow_sizes) else f"shadow.custom-{i}"
1036
+
1037
+ # Guess use case based on index
1038
+ use_cases = ["Subtle elevation", "Cards, dropdowns", "Modals, dialogs", "Popovers", "Floating elements", "Dramatic effect"]
1039
+ use = use_cases[i] if i < len(use_cases) else "Custom"
1040
+
1041
+ lines.append(f"| `{val}` | `{token}` | {use} |")
1042
+
1043
+ return "\n".join(lines)
1044
+
1045
+
1046
+ def format_spacing_comparison(recommendations) -> list:
1047
+ """Format spacing comparison table."""
1048
+ if not state.desktop_normalized:
1049
+ return []
1050
+
1051
+ # Get current spacing
1052
+ current_spacing = list(state.desktop_normalized.spacing.values())
1053
+ current_spacing.sort(key=lambda s: s.value_px)
1054
+
1055
+ data = []
1056
+ for s in current_spacing[:10]:
1057
+ current = f"{s.value_px}px"
1058
+ grid_8 = f"{snap_to_grid(s.value_px, 8)}px"
1059
+ grid_4 = f"{snap_to_grid(s.value_px, 4)}px"
1060
+
1061
+ # Mark if value fits
1062
+ if s.value_px == snap_to_grid(s.value_px, 8):
1063
+ grid_8 += " βœ“"
1064
+ if s.value_px == snap_to_grid(s.value_px, 4):
1065
+ grid_4 += " βœ“"
1066
+
1067
+ data.append([current, grid_8, grid_4])
1068
+
1069
+ return data
1070
+
1071
+
1072
+ def snap_to_grid(value: float, base: int) -> int:
1073
+ """Snap value to grid."""
1074
+ return round(value / base) * base
1075
+
1076
+
1077
+ def apply_selected_upgrades(type_choice: str, spacing_choice: str, apply_ramps: bool):
1078
+ """Apply selected upgrade options."""
1079
+ if not state.upgrade_recommendations:
1080
+ return "❌ Run analysis first", ""
1081
+
1082
+ state.log("✨ Applying selected upgrades...")
1083
+
1084
+ # Store selections
1085
+ state.selected_upgrades = {
1086
+ "type_scale": type_choice,
1087
+ "spacing": spacing_choice,
1088
+ "color_ramps": apply_ramps,
1089
+ }
1090
+
1091
+ state.log(f" Type Scale: {type_choice}")
1092
+ state.log(f" Spacing: {spacing_choice}")
1093
+ state.log(f" Color Ramps: {'Yes' if apply_ramps else 'No'}")
1094
+
1095
+ state.log("βœ… Upgrades applied! Proceed to Stage 3 for export.")
1096
+
1097
+ return "βœ… Upgrades applied! Proceed to Stage 3 to export.", state.get_logs()
1098
+
1099
+
1100
+ def export_stage1_json():
1101
+ """Export Stage 1 tokens (as-is extraction) to JSON."""
1102
+ if not state.desktop_normalized:
1103
+ return json.dumps({"error": "No tokens extracted. Please run extraction first."}, indent=2)
1104
+
1105
+ result = {
1106
+ "metadata": {
1107
+ "source_url": state.base_url,
1108
+ "extracted_at": datetime.now().isoformat(),
1109
+ "version": "v1-stage1-as-is",
1110
+ "stage": "extraction",
1111
+ "description": "Raw extracted tokens before upgrades",
1112
+ },
1113
+ "fonts": {}, # Detected font families
1114
+ "colors": {}, # Viewport-agnostic
1115
+ "typography": {
1116
+ "desktop": {},
1117
+ "mobile": {},
1118
+ },
1119
+ "spacing": {
1120
+ "desktop": {},
1121
+ "mobile": {},
1122
+ },
1123
+ "radius": {}, # Viewport-agnostic
1124
+ "shadows": {}, # Viewport-agnostic
1125
+ }
1126
+
1127
+ # Font families detected
1128
+ fonts_info = get_detected_fonts()
1129
+ result["fonts"] = {
1130
+ "primary": fonts_info.get("primary", "Unknown"),
1131
+ "weights": fonts_info.get("weights", [400]),
1132
+ "all_fonts": fonts_info.get("all_fonts", {}),
1133
+ }
1134
+
1135
+ # Colors (no viewport prefix - same across devices)
1136
+ if state.desktop_normalized and state.desktop_normalized.colors:
1137
+ for name, c in state.desktop_normalized.colors.items():
1138
+ key = c.suggested_name or c.value
1139
+ result["colors"][key] = {
1140
+ "value": c.value,
1141
+ "frequency": c.frequency,
1142
+ "confidence": c.confidence.value if c.confidence else "medium",
1143
+ "contrast_white": round(c.contrast_white, 2) if c.contrast_white else None,
1144
+ "contrast_black": round(c.contrast_black, 2) if c.contrast_black else None,
1145
+ "contexts": c.contexts[:3] if c.contexts else [],
1146
+ }
1147
+
1148
+ # Typography Desktop
1149
+ if state.desktop_normalized and state.desktop_normalized.typography:
1150
+ for name, t in state.desktop_normalized.typography.items():
1151
+ key = t.suggested_name or f"{t.font_family}-{t.font_size}"
1152
+ result["typography"]["desktop"][key] = {
1153
+ "font_family": t.font_family,
1154
+ "font_size": t.font_size,
1155
+ "font_weight": t.font_weight,
1156
+ "line_height": t.line_height,
1157
+ "frequency": t.frequency,
1158
+ }
1159
+
1160
+ # Typography Mobile
1161
+ if state.mobile_normalized and state.mobile_normalized.typography:
1162
+ for name, t in state.mobile_normalized.typography.items():
1163
+ key = t.suggested_name or f"{t.font_family}-{t.font_size}"
1164
+ result["typography"]["mobile"][key] = {
1165
+ "font_family": t.font_family,
1166
+ "font_size": t.font_size,
1167
+ "font_weight": t.font_weight,
1168
+ "line_height": t.line_height,
1169
+ "frequency": t.frequency,
1170
+ }
1171
+
1172
+ # Spacing Desktop
1173
+ if state.desktop_normalized and state.desktop_normalized.spacing:
1174
+ for name, s in state.desktop_normalized.spacing.items():
1175
+ key = s.suggested_name or s.value
1176
+ result["spacing"]["desktop"][key] = {
1177
+ "value": s.value,
1178
+ "value_px": s.value_px,
1179
+ "fits_base_8": s.fits_base_8,
1180
+ "frequency": s.frequency,
1181
+ }
1182
+
1183
+ # Spacing Mobile
1184
+ if state.mobile_normalized and state.mobile_normalized.spacing:
1185
+ for name, s in state.mobile_normalized.spacing.items():
1186
+ key = s.suggested_name or s.value
1187
+ result["spacing"]["mobile"][key] = {
1188
+ "value": s.value,
1189
+ "value_px": s.value_px,
1190
+ "fits_base_8": s.fits_base_8,
1191
+ "frequency": s.frequency,
1192
+ }
1193
+
1194
+ # Radius (no viewport prefix)
1195
+ if state.desktop_normalized and state.desktop_normalized.radius:
1196
+ for name, r in state.desktop_normalized.radius.items():
1197
+ result["radius"][name] = {
1198
+ "value": r.value,
1199
+ "frequency": r.frequency,
1200
+ }
1201
+
1202
+ # Shadows (no viewport prefix)
1203
+ if state.desktop_normalized and state.desktop_normalized.shadows:
1204
+ for name, s in state.desktop_normalized.shadows.items():
1205
+ result["shadows"][name] = {
1206
+ "value": s.value,
1207
+ "frequency": s.frequency,
1208
+ }
1209
+
1210
+ return json.dumps(result, indent=2, default=str)
1211
+
1212
+
1213
+ def export_tokens_json():
1214
+ """Export final tokens with selected upgrades applied."""
1215
+ if not state.desktop_normalized:
1216
+ return json.dumps({"error": "No tokens extracted. Please run extraction first."}, indent=2)
1217
+
1218
+ # Get selected upgrades
1219
+ upgrades = getattr(state, 'selected_upgrades', {})
1220
+ type_scale_choice = upgrades.get('type_scale', 'Keep Current')
1221
+ spacing_choice = upgrades.get('spacing', 'Keep Current')
1222
+ apply_ramps = upgrades.get('color_ramps', True)
1223
+
1224
+ # Determine ratio from choice
1225
+ ratio = None
1226
+ if "1.2" in type_scale_choice:
1227
+ ratio = 1.2
1228
+ elif "1.25" in type_scale_choice:
1229
+ ratio = 1.25
1230
+ elif "1.333" in type_scale_choice:
1231
+ ratio = 1.333
1232
+
1233
+ # Determine spacing base
1234
+ spacing_base = None
1235
+ if "8px" in spacing_choice:
1236
+ spacing_base = 8
1237
+ elif "4px" in spacing_choice:
1238
+ spacing_base = 4
1239
+
1240
+ result = {
1241
+ "metadata": {
1242
+ "source_url": state.base_url,
1243
+ "extracted_at": datetime.now().isoformat(),
1244
+ "version": "v2-upgraded",
1245
+ "stage": "final",
1246
+ "upgrades_applied": {
1247
+ "type_scale": type_scale_choice,
1248
+ "spacing": spacing_choice,
1249
+ "color_ramps": apply_ramps,
1250
+ },
1251
+ },
1252
+ "fonts": {},
1253
+ "colors": {},
1254
+ "typography": {
1255
+ "desktop": {},
1256
+ "mobile": {},
1257
+ },
1258
+ "spacing": {
1259
+ "desktop": {},
1260
+ "mobile": {},
1261
+ },
1262
+ "radius": {},
1263
+ "shadows": {},
1264
+ }
1265
+
1266
+ # Font families
1267
+ fonts_info = get_detected_fonts()
1268
+ result["fonts"] = {
1269
+ "primary": fonts_info.get("primary", "Unknown"),
1270
+ "weights": fonts_info.get("weights", [400]),
1271
+ }
1272
+
1273
+ # Colors with optional ramps
1274
+ if state.desktop_normalized and state.desktop_normalized.colors:
1275
+ from core.color_utils import generate_color_ramp
1276
+
1277
+ for name, c in state.desktop_normalized.colors.items():
1278
+ base_key = c.suggested_name or c.value
1279
+
1280
+ if apply_ramps:
1281
+ # Generate full ramp
1282
+ try:
1283
+ ramp = generate_color_ramp(c.value)
1284
+ shades = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"]
1285
+ for i, shade in enumerate(shades):
1286
+ if i < len(ramp):
1287
+ result["colors"][f"{base_key}.{shade}"] = {
1288
+ "value": ramp[i],
1289
+ "source": "upgraded" if shade != "500" else "detected",
1290
+ }
1291
+ except:
1292
+ result["colors"][base_key] = {"value": c.value, "source": "detected"}
1293
+ else:
1294
+ result["colors"][base_key] = {"value": c.value, "source": "detected"}
1295
+
1296
+ # Typography with optional type scale
1297
+ base_size = get_base_font_size()
1298
+ token_names = [
1299
+ "display.2xl", "display.xl", "display.lg", "display.md",
1300
+ "heading.xl", "heading.lg", "heading.md", "heading.sm",
1301
+ "body.lg", "body.md", "body.sm", "caption", "overline"
1302
+ ]
1303
+
1304
+ # Desktop typography
1305
+ if state.desktop_normalized and state.desktop_normalized.typography:
1306
+ if ratio:
1307
+ # Apply type scale
1308
+ scales = [int(round(base_size * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
1309
+ for i, token_name in enumerate(token_names):
1310
+ result["typography"]["desktop"][token_name] = {
1311
+ "font_family": fonts_info.get("primary", "sans-serif"),
1312
+ "font_size": f"{scales[i]}px",
1313
+ "source": "upgraded",
1314
+ }
1315
+ else:
1316
+ # Keep original
1317
+ for name, t in state.desktop_normalized.typography.items():
1318
+ key = t.suggested_name or f"{t.font_family}-{t.font_size}"
1319
+ result["typography"]["desktop"][key] = {
1320
+ "font_family": t.font_family,
1321
+ "font_size": t.font_size,
1322
+ "font_weight": t.font_weight,
1323
+ "line_height": t.line_height,
1324
+ "source": "detected",
1325
+ }
1326
+
1327
+ # Mobile typography
1328
+ if state.mobile_normalized and state.mobile_normalized.typography:
1329
+ if ratio:
1330
+ # Apply type scale with mobile factor
1331
+ mobile_factor = 0.875
1332
+ scales = [int(round(base_size * mobile_factor * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
1333
+ for i, token_name in enumerate(token_names):
1334
+ result["typography"]["mobile"][token_name] = {
1335
+ "font_family": fonts_info.get("primary", "sans-serif"),
1336
+ "font_size": f"{scales[i]}px",
1337
+ "source": "upgraded",
1338
+ }
1339
+ else:
1340
+ for name, t in state.mobile_normalized.typography.items():
1341
+ key = t.suggested_name or f"{t.font_family}-{t.font_size}"
1342
+ result["typography"]["mobile"][key] = {
1343
+ "font_family": t.font_family,
1344
+ "font_size": t.font_size,
1345
+ "font_weight": t.font_weight,
1346
+ "line_height": t.line_height,
1347
+ "source": "detected",
1348
+ }
1349
+
1350
+ # Spacing with optional grid alignment
1351
+ spacing_tokens = ["space.1", "space.2", "space.3", "space.4", "space.5",
1352
+ "space.6", "space.8", "space.10", "space.12", "space.16"]
1353
+
1354
+ if state.desktop_normalized and state.desktop_normalized.spacing:
1355
+ if spacing_base:
1356
+ # Generate grid-aligned spacing
1357
+ for i, token_name in enumerate(spacing_tokens):
1358
+ value = spacing_base * (i + 1)
1359
+ result["spacing"]["desktop"][token_name] = {
1360
+ "value": f"{value}px",
1361
+ "value_px": value,
1362
+ "source": "upgraded",
1363
+ }
1364
+ else:
1365
+ for name, s in state.desktop_normalized.spacing.items():
1366
+ key = s.suggested_name or s.value
1367
+ result["spacing"]["desktop"][key] = {
1368
+ "value": s.value,
1369
+ "value_px": s.value_px,
1370
+ "source": "detected",
1371
+ }
1372
+
1373
+ if state.mobile_normalized and state.mobile_normalized.spacing:
1374
+ if spacing_base:
1375
+ for i, token_name in enumerate(spacing_tokens):
1376
+ value = spacing_base * (i + 1)
1377
+ result["spacing"]["mobile"][token_name] = {
1378
+ "value": f"{value}px",
1379
+ "value_px": value,
1380
+ "source": "upgraded",
1381
+ }
1382
+ else:
1383
+ for name, s in state.mobile_normalized.spacing.items():
1384
+ key = s.suggested_name or s.value
1385
+ result["spacing"]["mobile"][key] = {
1386
+ "value": s.value,
1387
+ "value_px": s.value_px,
1388
+ "source": "detected",
1389
+ }
1390
+
1391
+ # Radius
1392
+ if state.desktop_normalized and state.desktop_normalized.radius:
1393
+ for name, r in state.desktop_normalized.radius.items():
1394
+ result["radius"][name] = {
1395
+ "value": r.value,
1396
+ "source": "detected",
1397
+ }
1398
+
1399
+ # Shadows
1400
+ if state.desktop_normalized and state.desktop_normalized.shadows:
1401
+ for name, s in state.desktop_normalized.shadows.items():
1402
+ result["shadows"][name] = {
1403
+ "value": s.value,
1404
+ "source": "detected",
1405
+ }
1406
+
1407
+ return json.dumps(result, indent=2, default=str)
1408
+
1409
+
1410
+ # =============================================================================
1411
+ # UI BUILDING
1412
+ # =============================================================================
1413
+
1414
+ def create_ui():
1415
+ """Create the Gradio interface."""
1416
+
1417
+ with gr.Blocks(
1418
+ title="Design System Extractor v2",
1419
+ theme=gr.themes.Soft(),
1420
+ css="""
1421
+ .color-swatch { display: inline-block; width: 24px; height: 24px; border-radius: 4px; margin-right: 8px; vertical-align: middle; }
1422
+ """
1423
+ ) as app:
1424
+
1425
+ gr.Markdown("""
1426
+ # 🎨 Design System Extractor v2
1427
+
1428
+ **Reverse-engineer design systems from live websites.**
1429
+
1430
+ A semi-automated, human-in-the-loop system that extracts, normalizes, and upgrades design tokens.
1431
+
1432
+ ---
1433
+ """)
1434
+
1435
+ # =================================================================
1436
+ # CONFIGURATION
1437
+ # =================================================================
1438
+
1439
+ with gr.Accordion("βš™οΈ Configuration", open=not bool(HF_TOKEN_FROM_ENV)):
1440
+ gr.Markdown("**HuggingFace Token** β€” Required for Stage 2 (AI upgrades)")
1441
+ with gr.Row():
1442
+ hf_token_input = gr.Textbox(
1443
+ label="HF Token", placeholder="hf_xxxx", type="password",
1444
+ scale=4, value=HF_TOKEN_FROM_ENV,
1445
+ )
1446
+ save_token_btn = gr.Button("πŸ’Ύ Save", scale=1)
1447
+ token_status = gr.Markdown("βœ… Token loaded" if HF_TOKEN_FROM_ENV else "⏳ Enter token")
1448
+
1449
+ def save_token(token):
1450
+ if token and len(token) > 10:
1451
+ os.environ["HF_TOKEN"] = token.strip()
1452
+ return "βœ… Token saved!"
1453
+ return "❌ Invalid token"
1454
+
1455
+ save_token_btn.click(save_token, [hf_token_input], [token_status])
1456
+
1457
+ # =================================================================
1458
+ # URL INPUT & PAGE DISCOVERY
1459
+ # =================================================================
1460
+
1461
+ with gr.Accordion("πŸ” Step 1: Discover Pages", open=True):
1462
+ gr.Markdown("Enter your website URL to discover pages for extraction.")
1463
+
1464
+ with gr.Row():
1465
+ url_input = gr.Textbox(label="Website URL", placeholder="https://example.com", scale=4)
1466
+ discover_btn = gr.Button("πŸ” Discover Pages", variant="primary", scale=1)
1467
+
1468
+ discover_status = gr.Markdown("")
1469
+
1470
+ with gr.Row():
1471
+ log_output = gr.Textbox(label="πŸ“‹ Log", lines=8, interactive=False)
1472
+
1473
+ pages_table = gr.Dataframe(
1474
+ headers=["Select", "URL", "Title", "Type", "Status"],
1475
+ datatype=["bool", "str", "str", "str", "str"],
1476
+ label="Discovered Pages",
1477
+ interactive=True,
1478
+ visible=False,
1479
+ )
1480
+
1481
+ extract_btn = gr.Button("πŸš€ Extract Tokens (Desktop + Mobile)", variant="primary", visible=False)
1482
+
1483
+ # =================================================================
1484
+ # STAGE 1: EXTRACTION REVIEW
1485
+ # =================================================================
1486
+
1487
+ with gr.Accordion("πŸ“Š Stage 1: Review Extracted Tokens", open=False) as stage1_accordion:
1488
+
1489
+ extraction_status = gr.Markdown("")
1490
+
1491
+ gr.Markdown("""
1492
+ **Review the extracted tokens.** Toggle between Desktop and Mobile viewports.
1493
+ Accept or reject tokens, then proceed to Stage 2 for AI-powered upgrades.
1494
+ """)
1495
+
1496
+ viewport_toggle = gr.Radio(
1497
+ choices=["Desktop (1440px)", "Mobile (375px)"],
1498
+ value="Desktop (1440px)",
1499
+ label="Viewport",
1500
+ )
1501
+
1502
+ with gr.Tabs():
1503
+ with gr.Tab("🎨 Colors"):
1504
+ colors_table = gr.Dataframe(
1505
+ headers=["Accept", "Color", "Suggested Name", "Frequency", "Confidence", "Contrast", "AA", "Context"],
1506
+ datatype=["bool", "str", "str", "number", "str", "str", "str", "str"],
1507
+ label="Colors",
1508
+ interactive=True,
1509
+ )
1510
+
1511
+ with gr.Tab("πŸ“ Typography"):
1512
+ typography_table = gr.Dataframe(
1513
+ headers=["Accept", "Font", "Size", "Weight", "Line Height", "Suggested Name", "Frequency", "Confidence"],
1514
+ datatype=["bool", "str", "str", "str", "str", "str", "number", "str"],
1515
+ label="Typography",
1516
+ interactive=True,
1517
+ )
1518
+
1519
+ with gr.Tab("πŸ“ Spacing"):
1520
+ spacing_table = gr.Dataframe(
1521
+ headers=["Accept", "Value", "Pixels", "Suggested Name", "Frequency", "Base 8", "Confidence"],
1522
+ datatype=["bool", "str", "str", "str", "number", "str", "str"],
1523
+ label="Spacing",
1524
+ interactive=True,
1525
+ )
1526
+
1527
+ with gr.Tab("πŸ”˜ Radius"):
1528
+ radius_table = gr.Dataframe(
1529
+ headers=["Accept", "Value", "Frequency", "Context"],
1530
+ datatype=["bool", "str", "number", "str"],
1531
+ label="Border Radius",
1532
+ interactive=True,
1533
+ )
1534
+
1535
+ # =============================================================
1536
+ # VISUAL PREVIEWS (Stage 1)
1537
+ # =============================================================
1538
+ gr.Markdown("---")
1539
+ gr.Markdown("## πŸ‘οΈ Visual Previews")
1540
+
1541
+ with gr.Tabs():
1542
+ with gr.Tab("πŸ”€ Typography Preview"):
1543
+ gr.Markdown("*See how your typography looks with the detected font*")
1544
+ stage1_typography_preview = gr.HTML(
1545
+ value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Typography preview will appear after extraction...</div>",
1546
+ label="Typography Preview"
1547
+ )
1548
+
1549
+ with gr.Tab("🎨 Color Ramps Preview"):
1550
+ gr.Markdown("*Base colors with generated shades (50-950) + AA compliance*")
1551
+ stage1_color_ramps_preview = gr.HTML(
1552
+ value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Color ramps preview will appear after extraction...</div>",
1553
+ label="Color Ramps Preview"
1554
+ )
1555
+
1556
+ with gr.Row():
1557
+ proceed_stage2_btn = gr.Button("➑️ Proceed to Stage 2: AI Upgrades", variant="primary")
1558
+ download_stage1_btn = gr.Button("πŸ“₯ Download Stage 1 JSON", variant="secondary")
1559
+
1560
+ # =================================================================
1561
+ # STAGE 2: AI UPGRADES
1562
+ # =================================================================
1563
+
1564
+ with gr.Accordion("🧠 Stage 2: AI-Powered Upgrades", open=False) as stage2_accordion:
1565
+
1566
+ stage2_status = gr.Markdown("Click 'Analyze' to start AI-powered design system analysis.")
1567
+
1568
+ # =============================================================
1569
+ # LLM CONFIGURATION & COMPETITORS
1570
+ # =============================================================
1571
+ with gr.Accordion("βš™οΈ Analysis Configuration", open=False):
1572
+ gr.Markdown("""
1573
+ ### πŸ€– LLM Models Used
1574
+
1575
+ | Role | Model | Expertise |
1576
+ |------|-------|-----------|
1577
+ | **Typography Analyst** | meta-llama/Llama-3.1-70B | Type scale patterns, readability |
1578
+ | **Color Analyst** | meta-llama/Llama-3.1-70B | Color theory, accessibility |
1579
+ | **Spacing Analyst** | Rule-based | Grid alignment, consistency |
1580
+
1581
+ *Analysis compares your design against industry leaders.*
1582
+ """)
1583
+
1584
+ gr.Markdown("### 🎯 Competitor Design Systems")
1585
+ gr.Markdown("Enter design systems to compare against (comma-separated):")
1586
+ competitors_input = gr.Textbox(
1587
+ value="Material Design 3, Apple HIG, Shopify Polaris, IBM Carbon, Atlassian",
1588
+ label="Competitors",
1589
+ placeholder="Material Design 3, Apple HIG, Shopify Polaris...",
1590
+ )
1591
+ gr.Markdown("*Suggestions: Ant Design, Chakra UI, Tailwind, Bootstrap, Salesforce Lightning*")
1592
+
1593
+ analyze_btn = gr.Button("πŸ€– Analyze Design System", variant="primary", size="lg")
1594
+
1595
+ with gr.Accordion("πŸ“‹ AI Analysis Log", open=True):
1596
+ stage2_log = gr.Textbox(label="Log", lines=18, interactive=False)
1597
+
1598
+ # =============================================================
1599
+ # BRAND COMPARISON (LLM Research)
1600
+ # =============================================================
1601
+ gr.Markdown("---")
1602
+ brand_comparison = gr.Markdown("*Brand comparison will appear after analysis*")
1603
+
1604
+ # =============================================================
1605
+ # FONT FAMILIES DETECTED
1606
+ # =============================================================
1607
+ gr.Markdown("---")
1608
+ gr.Markdown("## πŸ”€ Font Families Detected")
1609
+ font_families_display = gr.Markdown("*Font information will appear after analysis*")
1610
+
1611
+ # =============================================================
1612
+ # TYPOGRAPHY SECTION - Desktop & Mobile
1613
+ # =============================================================
1614
+ gr.Markdown("---")
1615
+ gr.Markdown("## πŸ“ Typography")
1616
+
1617
+ # Visual Preview
1618
+ with gr.Accordion("πŸ‘οΈ Typography Visual Preview", open=True):
1619
+ stage2_typography_preview = gr.HTML(
1620
+ value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Typography preview will appear after analysis...</div>",
1621
+ label="Typography Preview"
1622
+ )
1623
+
1624
+ with gr.Row():
1625
+ with gr.Column(scale=2):
1626
+ gr.Markdown("### πŸ–₯️ Desktop (1440px)")
1627
+ typography_desktop = gr.Dataframe(
1628
+ headers=["Token", "Current", "Scale 1.2", "Scale 1.25 ⭐", "Scale 1.333", "Keep"],
1629
+ datatype=["str", "str", "str", "str", "str", "str"],
1630
+ label="Desktop Typography",
1631
+ interactive=False,
1632
+ )
1633
+
1634
+ with gr.Column(scale=2):
1635
+ gr.Markdown("### πŸ“± Mobile (375px)")
1636
+ typography_mobile = gr.Dataframe(
1637
+ headers=["Token", "Current", "Scale 1.2", "Scale 1.25 ⭐", "Scale 1.333", "Keep"],
1638
+ datatype=["str", "str", "str", "str", "str", "str"],
1639
+ label="Mobile Typography",
1640
+ interactive=False,
1641
+ )
1642
+
1643
+ with gr.Row():
1644
+ with gr.Column():
1645
+ gr.Markdown("### Select Type Scale Option")
1646
+ type_scale_radio = gr.Radio(
1647
+ choices=["Keep Current", "Scale 1.2 (Minor Third)", "Scale 1.25 (Major Third) ⭐", "Scale 1.333 (Perfect Fourth)"],
1648
+ value="Scale 1.25 (Major Third) ⭐",
1649
+ label="Type Scale",
1650
+ interactive=True,
1651
+ )
1652
+ gr.Markdown("*Font family will be preserved. Sizes rounded to even numbers.*")
1653
+
1654
+ # =============================================================
1655
+ # COLORS SECTION - Base Colors + Ramps
1656
+ # =============================================================
1657
+ gr.Markdown("---")
1658
+ gr.Markdown("## 🎨 Colors")
1659
+
1660
+ # Visual Preview
1661
+ with gr.Accordion("πŸ‘οΈ Color Ramps Visual Preview", open=True):
1662
+ stage2_color_ramps_preview = gr.HTML(
1663
+ value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Color ramps preview will appear after analysis...</div>",
1664
+ label="Color Ramps Preview"
1665
+ )
1666
+
1667
+ base_colors_display = gr.Markdown("*Base colors will appear after analysis*")
1668
+
1669
+ gr.Markdown("---")
1670
+
1671
+ color_ramps_display = gr.Markdown("*Color ramps will appear after analysis*")
1672
+
1673
+ color_ramps_checkbox = gr.Checkbox(
1674
+ label="βœ“ Generate color ramps (keeps base colors, adds 50-950 shades)",
1675
+ value=True,
1676
+ )
1677
+
1678
+ # =============================================================
1679
+ # SPACING SECTION
1680
+ # =============================================================
1681
+ gr.Markdown("---")
1682
+ gr.Markdown("## πŸ“ Spacing (Rule-Based)")
1683
+
1684
+ with gr.Row():
1685
+ with gr.Column(scale=2):
1686
+ spacing_comparison = gr.Dataframe(
1687
+ headers=["Current", "8px Grid", "4px Grid"],
1688
+ datatype=["str", "str", "str"],
1689
+ label="Spacing Comparison",
1690
+ interactive=False,
1691
+ )
1692
+
1693
+ with gr.Column(scale=1):
1694
+ spacing_radio = gr.Radio(
1695
+ choices=["Keep Current", "8px Base Grid ⭐", "4px Base Grid"],
1696
+ value="8px Base Grid ⭐",
1697
+ label="Spacing System",
1698
+ interactive=True,
1699
+ )
1700
+
1701
+ # =============================================================
1702
+ # RADIUS SECTION
1703
+ # =============================================================
1704
+ gr.Markdown("---")
1705
+ gr.Markdown("## πŸ”˜ Border Radius (Rule-Based)")
1706
+
1707
+ radius_display = gr.Markdown("*Radius tokens will appear after analysis*")
1708
+
1709
+ # =============================================================
1710
+ # SHADOWS SECTION
1711
+ # =============================================================
1712
+ gr.Markdown("---")
1713
+ gr.Markdown("## 🌫️ Shadows (Rule-Based)")
1714
+
1715
+ shadows_display = gr.Markdown("*Shadow tokens will appear after analysis*")
1716
+
1717
+ # =============================================================
1718
+ # APPLY SECTION
1719
+ # =============================================================
1720
+ gr.Markdown("---")
1721
+
1722
+ with gr.Row():
1723
+ apply_upgrades_btn = gr.Button("✨ Apply Selected Upgrades", variant="primary", scale=2)
1724
+ reset_btn = gr.Button("↩️ Reset to Original", variant="secondary", scale=1)
1725
+
1726
+ apply_status = gr.Markdown("")
1727
+
1728
+ # =================================================================
1729
+ # STAGE 3: EXPORT
1730
+ # =================================================================
1731
+
1732
+ with gr.Accordion("πŸ“¦ Stage 3: Export", open=False):
1733
+ gr.Markdown("""
1734
+ Export your design tokens to JSON (compatible with Figma Tokens Studio).
1735
+
1736
+ - **Stage 1 JSON**: Raw extracted tokens (as-is)
1737
+ - **Final JSON**: Upgraded tokens with selected improvements
1738
+ """)
1739
+
1740
+ with gr.Row():
1741
+ export_stage1_btn = gr.Button("πŸ“₯ Export Stage 1 (As-Is)", variant="secondary")
1742
+ export_final_btn = gr.Button("πŸ“₯ Export Final (Upgraded)", variant="primary")
1743
+
1744
+ export_output = gr.Code(label="Tokens JSON", language="json", lines=25)
1745
+
1746
+ export_stage1_btn.click(export_stage1_json, outputs=[export_output])
1747
+ export_final_btn.click(export_tokens_json, outputs=[export_output])
1748
+
1749
+ # =================================================================
1750
+ # EVENT HANDLERS
1751
+ # =================================================================
1752
+
1753
+ # Store data for viewport toggle
1754
+ desktop_data = gr.State({})
1755
+ mobile_data = gr.State({})
1756
+
1757
+ # Discover pages
1758
+ discover_btn.click(
1759
+ fn=discover_pages,
1760
+ inputs=[url_input],
1761
+ outputs=[discover_status, log_output, pages_table],
1762
+ ).then(
1763
+ fn=lambda: (gr.update(visible=True), gr.update(visible=True)),
1764
+ outputs=[pages_table, extract_btn],
1765
+ )
1766
+
1767
+ # Extract tokens
1768
+ extract_btn.click(
1769
+ fn=extract_tokens,
1770
+ inputs=[pages_table],
1771
+ outputs=[extraction_status, log_output, desktop_data, mobile_data,
1772
+ stage1_typography_preview, stage1_color_ramps_preview],
1773
+ ).then(
1774
+ fn=lambda d: (d.get("colors", []), d.get("typography", []), d.get("spacing", [])),
1775
+ inputs=[desktop_data],
1776
+ outputs=[colors_table, typography_table, spacing_table],
1777
+ ).then(
1778
+ fn=lambda: gr.update(open=True),
1779
+ outputs=[stage1_accordion],
1780
+ )
1781
+
1782
+ # Viewport toggle
1783
+ viewport_toggle.change(
1784
+ fn=switch_viewport,
1785
+ inputs=[viewport_toggle],
1786
+ outputs=[colors_table, typography_table, spacing_table],
1787
+ )
1788
+
1789
+ # Stage 2: Analyze
1790
+ analyze_btn.click(
1791
+ fn=run_stage2_analysis,
1792
+ inputs=[competitors_input],
1793
+ outputs=[stage2_status, stage2_log, brand_comparison, font_families_display,
1794
+ typography_desktop, typography_mobile, spacing_comparison,
1795
+ base_colors_display, color_ramps_display, radius_display, shadows_display,
1796
+ stage2_typography_preview, stage2_color_ramps_preview],
1797
+ )
1798
+
1799
+ # Stage 2: Apply upgrades
1800
+ apply_upgrades_btn.click(
1801
+ fn=apply_selected_upgrades,
1802
+ inputs=[type_scale_radio, spacing_radio, color_ramps_checkbox],
1803
+ outputs=[apply_status, stage2_log],
1804
+ )
1805
+
1806
+ # Stage 1: Download JSON
1807
+ download_stage1_btn.click(
1808
+ fn=export_stage1_json,
1809
+ outputs=[export_output],
1810
+ )
1811
+
1812
+ # Proceed to Stage 2 button
1813
+ proceed_stage2_btn.click(
1814
+ fn=lambda: gr.update(open=True),
1815
+ outputs=[stage2_accordion],
1816
+ )
1817
+
1818
+ # =================================================================
1819
+ # FOOTER
1820
+ # =================================================================
1821
+
1822
+ gr.Markdown("""
1823
+ ---
1824
+ **Design System Extractor v2** | Built with Playwright + Gradio + LangGraph + HuggingFace
1825
+
1826
+ *A semi-automated co-pilot for design system recovery and modernization.*
1827
+ """)
1828
+
1829
+ return app
1830
+
1831
+
1832
+ # =============================================================================
1833
+ # MAIN
1834
+ # =============================================================================
1835
+
1836
+ if __name__ == "__main__":
1837
+ app = create_ui()
1838
+ app.launch(server_name="0.0.0.0", server_port=7860)