riazmo commited on
Commit
b97a033
Β·
verified Β·
1 Parent(s): ebc119a

Upload app.py

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