diff --git "a/app.py" "b/app.py"
new file mode 100644--- /dev/null
+++ "b/app.py"
@@ -0,0 +1,4455 @@
+"""
+Design System Extractor v2 — Main Application
+==============================================
+
+Flow:
+1. User enters URL
+2. Agent 1 discovers pages → User confirms
+3. Agent 1 extracts tokens (Desktop + Mobile)
+4. Agent 2 normalizes tokens
+5. Stage 1 UI: User reviews tokens (accept/reject, Desktop↔Mobile toggle)
+6. Agent 3 proposes upgrades
+7. Stage 2 UI: User selects options with live preview
+8. Agent 4 generates JSON
+9. Stage 3 UI: User exports
+"""
+
+import os
+import asyncio
+import json
+import gradio as gr
+from datetime import datetime
+from typing import Optional
+
+# Get HF token from environment
+HF_TOKEN_FROM_ENV = os.getenv("HF_TOKEN", "")
+
+# =============================================================================
+# GLOBAL STATE
+# =============================================================================
+
+class AppState:
+ """Global application state."""
+ def __init__(self):
+ self.reset()
+
+ def reset(self):
+ self.discovered_pages = []
+ self.base_url = ""
+ self.desktop_raw = None # ExtractedTokens
+ self.mobile_raw = None # ExtractedTokens
+ self.desktop_normalized = None # NormalizedTokens
+ self.mobile_normalized = None # NormalizedTokens
+ self.upgrade_recommendations = None # UpgradeRecommendations
+ self.selected_upgrades = {} # User selections
+ self.logs = []
+
+ def log(self, message: str):
+ timestamp = datetime.now().strftime("%H:%M:%S")
+ self.logs.append(f"[{timestamp}] {message}")
+ if len(self.logs) > 100:
+ self.logs.pop(0)
+
+ def get_logs(self) -> str:
+ return "\n".join(self.logs)
+
+state = AppState()
+
+
+# =============================================================================
+# MESSAGE HELPERS
+# =============================================================================
+
+def success_message(title: str, details: str, next_step: str) -> str:
+ """Generate a formatted success message with next-step guidance."""
+ return f"## ✅ {title}\n\n{details}\n\n**Next step:** {next_step}"
+
+
+def error_message(title: str, details: str, how_to_fix: str) -> str:
+ """Generate a formatted error message with fix guidance."""
+ return f"## ❌ {title}\n\n{details}\n\n**How to fix:** {how_to_fix}"
+
+
+# =============================================================================
+# LAZY IMPORTS
+# =============================================================================
+
+def get_crawler():
+ import agents.crawler
+ return agents.crawler
+
+def get_extractor():
+ import agents.extractor
+ return agents.extractor
+
+def get_normalizer():
+ import agents.normalizer
+ return agents.normalizer
+
+def get_advisor():
+ import agents.advisor
+ return agents.advisor
+
+def get_schema():
+ import core.token_schema
+ return core.token_schema
+
+
+# =============================================================================
+# PHASE 1: DISCOVER PAGES
+# =============================================================================
+
+async def discover_pages(url: str, progress=gr.Progress()):
+ """Discover pages from URL."""
+ state.reset()
+
+ if not url or not url.startswith(("http://", "https://")):
+ return error_message("Invalid URL",
+ "The URL must start with `https://` or `http://`.",
+ "Enter a full URL like `https://example.com` and try again."), "", None
+
+ state.log(f"🚀 Starting discovery for: {url}")
+ progress(0.1, desc="🔍 Discovering pages...")
+
+ try:
+ crawler = get_crawler()
+ discoverer = crawler.PageDiscoverer()
+
+ pages = await discoverer.discover(url)
+
+ state.discovered_pages = pages
+ state.base_url = url
+
+ state.log(f"✅ Found {len(pages)} pages")
+
+ # Format for display
+ pages_data = []
+ for page in pages:
+ pages_data.append([
+ True, # Selected by default
+ page.url,
+ page.title if page.title else "(No title)",
+ page.page_type.value,
+ "✓" if not page.error else f"⚠ {page.error}"
+ ])
+
+ progress(1.0, desc="✅ Discovery complete!")
+
+ status = success_message(
+ f"Found {len(pages)} Pages",
+ f"The crawler discovered **{len(pages)} pages** on `{url}`. Review the table below — "
+ "use the **Select** checkboxes to choose which pages to scan for design tokens.",
+ "Click **'Extract Tokens (Desktop + Mobile)'** to begin extraction."
+ )
+
+ return status, state.get_logs(), pages_data
+
+ except Exception as e:
+ import traceback
+ state.log(f"❌ Error: {str(e)}")
+ error_detail = str(e).lower()
+ if "timeout" in error_detail:
+ hint = "The website took too long to respond. Try again, or check if the site is accessible in your browser."
+ elif "dns" in error_detail or "name resolution" in error_detail:
+ hint = "Could not find this website. Please check the URL for typos."
+ elif "ssl" in error_detail or "certificate" in error_detail:
+ hint = "SSL/certificate error. Try using `http://` instead of `https://`, or check if the site has a valid certificate."
+ else:
+ hint = "Check that the URL is correct and the site is publicly accessible. Review the log above for details."
+ return error_message("Discovery Failed", str(e)[:200], hint), state.get_logs(), None
+
+
+# =============================================================================
+# PHASE 2: EXTRACT TOKENS
+# =============================================================================
+
+async def extract_tokens(pages_data, progress=gr.Progress()):
+ """Extract tokens from selected pages (both viewports)."""
+
+ state.log(f"📥 Received pages_data type: {type(pages_data)}")
+
+ if pages_data is None:
+ return (error_message("No Pages Discovered",
+ "No pages have been discovered yet.",
+ "Go to **Step 1** above, enter a URL, and click **'Discover Pages'** first."),
+ state.get_logs(), None, None)
+
+ # Get selected URLs - handle pandas DataFrame
+ selected_urls = []
+
+ try:
+ # Check if it's a pandas DataFrame
+ if hasattr(pages_data, 'iterrows'):
+ state.log(f"📥 DataFrame with {len(pages_data)} rows, columns: {list(pages_data.columns)}")
+
+ for idx, row in pages_data.iterrows():
+ # Get values by column name or position
+ try:
+ # Try column names first
+ is_selected = row.get('Select', row.iloc[0] if len(row) > 0 else False)
+ url = row.get('URL', row.iloc[1] if len(row) > 1 else '')
+ except:
+ # Fallback to positional
+ is_selected = row.iloc[0] if len(row) > 0 else False
+ url = row.iloc[1] if len(row) > 1 else ''
+
+ if is_selected and url:
+ selected_urls.append(url)
+
+ # If it's a dict (Gradio sometimes sends this)
+ elif isinstance(pages_data, dict):
+ state.log(f"📥 Dict with keys: {list(pages_data.keys())}")
+ data = pages_data.get('data', [])
+ for row in data:
+ if isinstance(row, (list, tuple)) and len(row) >= 2 and row[0]:
+ selected_urls.append(row[1])
+
+ # If it's a list
+ elif isinstance(pages_data, (list, tuple)):
+ state.log(f"📥 List with {len(pages_data)} items")
+ for row in pages_data:
+ if isinstance(row, (list, tuple)) and len(row) >= 2 and row[0]:
+ selected_urls.append(row[1])
+
+ except Exception as e:
+ state.log(f"❌ Error parsing pages_data: {str(e)}")
+ import traceback
+ state.log(traceback.format_exc())
+
+ state.log(f"📋 Found {len(selected_urls)} selected URLs")
+
+ # If still no URLs, try using stored discovered pages
+ if not selected_urls and state.discovered_pages:
+ state.log("⚠️ No URLs from table, using all discovered pages")
+ selected_urls = [p.url for p in state.discovered_pages if not p.error][:10]
+
+ if not selected_urls:
+ return (error_message("No Pages Selected",
+ "No pages are selected for extraction.",
+ "Go back to the pages table above and check the **Select** boxes for the pages you want to extract, then click this button again."),
+ state.get_logs(), None, None)
+
+ # Limit to 10 pages for performance
+ selected_urls = selected_urls[:10]
+
+ state.log(f"📋 Extracting from {len(selected_urls)} pages:")
+ for url in selected_urls[:3]:
+ state.log(f" • {url}")
+ if len(selected_urls) > 3:
+ state.log(f" ... and {len(selected_urls) - 3} more")
+
+ progress(0.05, desc="🚀 Starting extraction...")
+
+ try:
+ schema = get_schema()
+ extractor_mod = get_extractor()
+ normalizer_mod = get_normalizer()
+
+ # === DESKTOP EXTRACTION ===
+ state.log("")
+ state.log("=" * 60)
+ state.log("🖥️ DESKTOP EXTRACTION (1440px)")
+ state.log("=" * 60)
+ state.log("")
+ state.log("📡 Enhanced extraction from 7 sources:")
+ state.log(" 1. DOM computed styles (getComputedStyle)")
+ state.log(" 2. CSS variables (:root { --color: })")
+ state.log(" 3. SVG colors (fill, stroke)")
+ state.log(" 4. Inline styles (style='color:')")
+ state.log(" 5. Stylesheet rules (CSS files)")
+ state.log(" 6. External CSS files (fetch & parse)")
+ state.log(" 7. Page content scan (brute-force)")
+ state.log("")
+
+ progress(0.1, desc="🖥️ Extracting desktop tokens...")
+
+ desktop_extractor = extractor_mod.TokenExtractor(viewport=schema.Viewport.DESKTOP)
+
+ def desktop_progress(p):
+ progress(0.1 + (p * 0.35), desc=f"🖥️ Desktop... {int(p*100)}%")
+
+ state.desktop_raw = await desktop_extractor.extract(selected_urls, progress_callback=desktop_progress)
+
+ # Log extraction details
+ state.log("📊 EXTRACTION RESULTS:")
+ state.log(f" Colors: {len(state.desktop_raw.colors)} unique")
+ state.log(f" Typography: {len(state.desktop_raw.typography)} styles")
+ state.log(f" Spacing: {len(state.desktop_raw.spacing)} values")
+ state.log(f" Radius: {len(state.desktop_raw.radius)} values")
+ state.log(f" Shadows: {len(state.desktop_raw.shadows)} values")
+
+ # Store foreground-background pairs for real AA checking in Stage 2
+ if hasattr(desktop_extractor, 'fg_bg_pairs') and desktop_extractor.fg_bg_pairs:
+ state.fg_bg_pairs = desktop_extractor.fg_bg_pairs
+ state.log(f" FG/BG Pairs: {len(state.fg_bg_pairs)} unique pairs for AA checking")
+ else:
+ state.fg_bg_pairs = []
+
+ # Log CSS variables if found
+ if hasattr(desktop_extractor, 'css_variables') and desktop_extractor.css_variables:
+ state.log("")
+ state.log(f"🎨 CSS Variables found: {len(desktop_extractor.css_variables)}")
+ for var_name, var_value in list(desktop_extractor.css_variables.items())[:5]:
+ state.log(f" {var_name}: {var_value}")
+ if len(desktop_extractor.css_variables) > 5:
+ state.log(f" ... and {len(desktop_extractor.css_variables) - 5} more")
+
+ # Log warnings if any
+ if desktop_extractor.warnings:
+ state.log("")
+ state.log("⚠️ Warnings:")
+ for w in desktop_extractor.warnings[:3]:
+ state.log(f" {w}")
+
+ # Normalize desktop
+ state.log("")
+ state.log("🔄 Normalizing (deduping, naming)...")
+ state.desktop_normalized = normalizer_mod.normalize_tokens(state.desktop_raw)
+ state.log(f" ✅ Normalized: {len(state.desktop_normalized.colors)} colors, {len(state.desktop_normalized.typography)} typography, {len(state.desktop_normalized.spacing)} spacing")
+
+ # === MOBILE EXTRACTION ===
+ state.log("")
+ state.log("=" * 60)
+ state.log("📱 MOBILE EXTRACTION (375px)")
+ state.log("=" * 60)
+ state.log("")
+
+ progress(0.5, desc="📱 Extracting mobile tokens...")
+
+ mobile_extractor = extractor_mod.TokenExtractor(viewport=schema.Viewport.MOBILE)
+
+ def mobile_progress(p):
+ progress(0.5 + (p * 0.35), desc=f"📱 Mobile... {int(p*100)}%")
+
+ state.mobile_raw = await mobile_extractor.extract(selected_urls, progress_callback=mobile_progress)
+
+ # Log extraction details
+ state.log("📊 EXTRACTION RESULTS:")
+ state.log(f" Colors: {len(state.mobile_raw.colors)} unique")
+ state.log(f" Typography: {len(state.mobile_raw.typography)} styles")
+ state.log(f" Spacing: {len(state.mobile_raw.spacing)} values")
+ state.log(f" Radius: {len(state.mobile_raw.radius)} values")
+ state.log(f" Shadows: {len(state.mobile_raw.shadows)} values")
+
+ # Normalize mobile
+ state.log("")
+ state.log("🔄 Normalizing...")
+ state.mobile_normalized = normalizer_mod.normalize_tokens(state.mobile_raw)
+ state.log(f" ✅ Normalized: {len(state.mobile_normalized.colors)} colors, {len(state.mobile_normalized.typography)} typography, {len(state.mobile_normalized.spacing)} spacing")
+
+ # === FIRECRAWL CSS EXTRACTION (Agent 1B) ===
+ progress(0.88, desc="🔥 Firecrawl CSS analysis...")
+
+ try:
+ from agents.firecrawl_extractor import extract_css_colors
+
+ # Get base URL for Firecrawl
+ base_url = selected_urls[0] if selected_urls else state.base_url
+
+ # Extract CSS colors using Firecrawl
+ firecrawl_result = await extract_css_colors(
+ url=base_url,
+ api_key=None, # Will use fallback method
+ log_callback=state.log
+ )
+
+ # Merge Firecrawl colors into desktop normalized
+ firecrawl_colors = firecrawl_result.get("colors", {})
+
+ if firecrawl_colors:
+ state.log("")
+ state.log("🔀 Merging Firecrawl colors with Playwright extraction...")
+
+ # Count new colors
+ new_colors_count = 0
+
+ for hex_val, color_data in firecrawl_colors.items():
+ # Check if this color already exists
+ existing = False
+ for name, existing_color in state.desktop_normalized.colors.items():
+ if existing_color.value.lower() == hex_val.lower():
+ existing = True
+ # Update frequency
+ existing_color.frequency += color_data.get("frequency", 1)
+ if "firecrawl" not in existing_color.contexts:
+ existing_color.contexts.append("firecrawl")
+ break
+
+ if not existing:
+ # Add new color from Firecrawl
+ from core.token_schema import ColorToken, TokenSource, Confidence
+
+ new_token = ColorToken(
+ value=hex_val,
+ frequency=color_data.get("frequency", 1),
+ contexts=["firecrawl"] + color_data.get("contexts", []),
+ elements=["css-file"],
+ css_properties=color_data.get("sources", []),
+ contrast_white=color_data.get("contrast_white", 0),
+ contrast_black=color_data.get("contrast_black", 0),
+ source=TokenSource.DETECTED,
+ confidence=Confidence.MEDIUM,
+ )
+
+ # Generate name
+ new_token.suggested_name = f"color.firecrawl.{len(state.desktop_normalized.colors)}"
+
+ state.desktop_normalized.colors[hex_val] = new_token
+ new_colors_count += 1
+
+ state.log(f" ✅ Added {new_colors_count} new colors from Firecrawl")
+ state.log(f" 📊 Total colors now: {len(state.desktop_normalized.colors)}")
+
+ except Exception as e:
+ state.log(f" ⚠️ Firecrawl extraction skipped: {str(e)}")
+
+ # === SEMANTIC COLOR ANALYSIS (Agent 1C) ===
+ progress(0.92, desc="🧠 Semantic color analysis...")
+
+ semantic_result = {}
+ semantic_preview_html = ""
+
+ try:
+ from agents.semantic_analyzer import SemanticColorAnalyzer, generate_semantic_preview_html
+
+ # Create analyzer (using rule-based for now, can add LLM later)
+ semantic_analyzer = SemanticColorAnalyzer(llm_provider=None)
+
+ # Run analysis
+ semantic_result = semantic_analyzer.analyze_sync(
+ colors=state.desktop_normalized.colors,
+ log_callback=state.log
+ )
+
+ # Store in state for Stage 2
+ state.semantic_analysis = semantic_result
+
+ # Generate preview HTML
+ semantic_preview_html = generate_semantic_preview_html(semantic_result)
+
+ except Exception as e:
+ state.log(f" ⚠️ Semantic analysis skipped: {str(e)}")
+ import traceback
+ state.log(traceback.format_exc())
+
+ progress(0.95, desc="📊 Preparing results...")
+
+ # Format results for Stage 1 UI
+ desktop_data = format_tokens_for_display(state.desktop_normalized)
+ mobile_data = format_tokens_for_display(state.mobile_normalized)
+
+ # Generate visual previews - AS-IS for Stage 1 (no ramps, no enhancements)
+ state.log("")
+ state.log("🎨 Generating AS-IS visual previews...")
+
+ from core.preview_generator import (
+ generate_typography_preview_html,
+ generate_colors_asis_preview_html,
+ generate_spacing_asis_preview_html,
+ generate_radius_asis_preview_html,
+ generate_shadows_asis_preview_html,
+ )
+
+ # Get detected font
+ fonts = get_detected_fonts()
+ primary_font = fonts.get("primary", "Open Sans")
+
+ # Convert typography tokens to dict format for preview
+ typo_dict = {}
+ for name, t in state.desktop_normalized.typography.items():
+ typo_dict[name] = {
+ "font_size": t.font_size,
+ "font_weight": t.font_weight,
+ "line_height": t.line_height or "1.5",
+ "letter_spacing": "0",
+ }
+
+ # Convert color tokens to dict format for preview (with full metadata)
+ color_dict = {}
+ for name, c in state.desktop_normalized.colors.items():
+ color_dict[name] = {
+ "value": c.value,
+ "frequency": c.frequency,
+ "contexts": c.contexts[:3] if c.contexts else [],
+ "elements": c.elements[:3] if c.elements else [],
+ "css_properties": c.css_properties[:3] if c.css_properties else [],
+ "contrast_white": c.contrast_white,
+ "contrast_black": getattr(c, 'contrast_black', 0),
+ }
+
+ # Convert spacing tokens to dict format
+ spacing_dict = {}
+ for name, s in state.desktop_normalized.spacing.items():
+ spacing_dict[name] = {
+ "value": s.value,
+ "value_px": s.value_px,
+ }
+
+ # Convert radius tokens to dict format
+ radius_dict = {}
+ for name, r in state.desktop_normalized.radius.items():
+ radius_dict[name] = {"value": r.value}
+
+ # Convert shadow tokens to dict format
+ shadow_dict = {}
+ for name, s in state.desktop_normalized.shadows.items():
+ shadow_dict[name] = {"value": s.value}
+
+ # Generate AS-IS previews (Stage 1 - raw extracted values)
+ typography_preview_html = generate_typography_preview_html(
+ typography_tokens=typo_dict,
+ font_family=primary_font,
+ sample_text="The quick brown fox jumps over the lazy dog",
+ )
+
+ # AS-IS color preview (no ramps)
+ colors_asis_preview_html = generate_colors_asis_preview_html(
+ color_tokens=color_dict,
+ )
+
+ # AS-IS spacing preview
+ spacing_asis_preview_html = generate_spacing_asis_preview_html(
+ spacing_tokens=spacing_dict,
+ )
+
+ # AS-IS radius preview
+ radius_asis_preview_html = generate_radius_asis_preview_html(
+ radius_tokens=radius_dict,
+ )
+
+ # AS-IS shadows preview
+ shadows_asis_preview_html = generate_shadows_asis_preview_html(
+ shadow_tokens=shadow_dict,
+ )
+
+ state.log(" ✅ Typography preview generated")
+ state.log(" ✅ Colors AS-IS preview generated (no ramps)")
+ state.log(" ✅ Semantic color analysis preview generated")
+ state.log(" ✅ Spacing AS-IS preview generated")
+ state.log(" ✅ Radius AS-IS preview generated")
+ state.log(" ✅ Shadows AS-IS preview generated")
+
+ # Get semantic summary for status
+ brand_count = len(semantic_result.get("brand", {}))
+ text_count = len(semantic_result.get("text", {}))
+ bg_count = len(semantic_result.get("background", {}))
+
+ state.log("")
+ state.log("=" * 50)
+ state.log("✅ EXTRACTION COMPLETE!")
+ state.log(f" Enhanced extraction captured:")
+ state.log(f" • {len(state.desktop_normalized.colors)} colors (DOM + CSS vars + SVG + inline)")
+ state.log(f" • {len(state.desktop_normalized.typography)} typography styles")
+ state.log(f" • {len(state.desktop_normalized.spacing)} spacing values")
+ state.log(f" • {len(state.desktop_normalized.radius)} radius values")
+ state.log(f" • {len(state.desktop_normalized.shadows)} shadow values")
+ state.log(f" Semantic Analysis:")
+ state.log(f" • {brand_count} brand colors identified")
+ state.log(f" • {text_count} text colors identified")
+ state.log(f" • {bg_count} background colors identified")
+ state.log("=" * 50)
+
+ progress(1.0, desc="✅ Complete!")
+
+ status = f"""## ✅ Extraction Complete!
+
+| Viewport | Colors | Typography | Spacing | Radius | Shadows |
+|----------|--------|------------|---------|--------|---------|
+| 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)} |
+| 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)} |
+
+**Primary Font:** {primary_font}
+
+**Semantic Analysis:** {brand_count} brand, {text_count} text, {bg_count} background colors
+
+**Enhanced Extraction:** DOM + CSS Variables + SVG + Inline + Stylesheets + Firecrawl
+
+**Next:** Review the tokens below. Accept or reject, then proceed to Stage 2.
+"""
+
+ # Return all AS-IS previews including semantic
+ return (
+ status,
+ state.get_logs(),
+ desktop_data,
+ mobile_data,
+ typography_preview_html,
+ colors_asis_preview_html,
+ semantic_preview_html,
+ spacing_asis_preview_html,
+ radius_asis_preview_html,
+ shadows_asis_preview_html,
+ )
+
+ except Exception as e:
+ import traceback
+ state.log(f"❌ Error: {str(e)}")
+ state.log(traceback.format_exc())
+ error_detail = str(e).lower()
+ if "timeout" in error_detail or "navigation" in error_detail:
+ hint = "The page took too long to load. Try selecting fewer pages, or check if the site requires authentication."
+ elif "no tokens" in error_detail or "empty" in error_detail:
+ hint = "No design tokens could be extracted. The site may use unusual CSS patterns. Try a different page selection."
+ else:
+ hint = "Check the log above for details. Try selecting fewer pages or a different set of pages."
+ return (error_message("Extraction Failed", str(e)[:200], hint),
+ state.get_logs(), None, None, "", "", "", "", "", "")
+
+
+def format_tokens_for_display(normalized) -> dict:
+ """Format normalized tokens for Gradio display."""
+ if normalized is None:
+ return {"colors": [], "typography": [], "spacing": []}
+
+ # Colors are now a dict
+ colors = []
+ color_items = list(normalized.colors.values()) if isinstance(normalized.colors, dict) else normalized.colors
+ for c in sorted(color_items, key=lambda x: -x.frequency)[:50]:
+ colors.append([
+ True, # Accept checkbox
+ c.value,
+ c.suggested_name or "",
+ c.frequency,
+ c.confidence.value if c.confidence else "medium",
+ f"{c.contrast_white:.1f}:1" if c.contrast_white else "N/A",
+ "✓" if c.wcag_aa_small_text else "✗",
+ ", ".join(c.contexts[:2]) if c.contexts else "",
+ ])
+
+ # Typography
+ typography = []
+ typo_items = list(normalized.typography.values()) if isinstance(normalized.typography, dict) else normalized.typography
+ for t in sorted(typo_items, key=lambda x: -x.frequency)[:30]:
+ typography.append([
+ True, # Accept checkbox
+ t.font_family,
+ t.font_size,
+ str(t.font_weight),
+ t.line_height or "",
+ t.suggested_name or "",
+ t.frequency,
+ t.confidence.value if t.confidence else "medium",
+ ])
+
+ # Spacing
+ spacing = []
+ spacing_items = list(normalized.spacing.values()) if isinstance(normalized.spacing, dict) else normalized.spacing
+ for s in sorted(spacing_items, key=lambda x: x.value_px)[:20]:
+ spacing.append([
+ True, # Accept checkbox
+ s.value,
+ f"{s.value_px}px",
+ s.suggested_name or "",
+ s.frequency,
+ "✓" if s.fits_base_8 else "",
+ s.confidence.value if s.confidence else "medium",
+ ])
+
+ # Radius
+ radius = []
+ radius_items = list(normalized.radius.values()) if isinstance(normalized.radius, dict) else normalized.radius
+ for r in sorted(radius_items, key=lambda x: -x.frequency)[:20]:
+ radius.append([
+ True, # Accept checkbox
+ r.value,
+ r.frequency,
+ ", ".join(r.elements[:3]) if r.elements else "",
+ ])
+
+ return {
+ "colors": colors,
+ "typography": typography,
+ "spacing": spacing,
+ "radius": radius,
+ }
+
+
+def switch_viewport(viewport: str):
+ """Switch between desktop and mobile view."""
+ if viewport == "Desktop (1440px)":
+ data = format_tokens_for_display(state.desktop_normalized)
+ else:
+ data = format_tokens_for_display(state.mobile_normalized)
+
+ return data["colors"], data["typography"], data["spacing"], data["radius"]
+
+
+# =============================================================================
+# STAGE 2: AI ANALYSIS (Multi-Agent)
+# =============================================================================
+
+async def run_stage2_analysis(competitors_str: str = "", progress=gr.Progress()):
+ """Run multi-agent analysis on extracted tokens."""
+
+ if not state.desktop_normalized or not state.mobile_normalized:
+ return ("❌ Please complete Stage 1 first", "", "", "", None, None, None, "", "", "", "")
+
+ # Parse competitors from input
+ default_competitors = [
+ "Material Design 3",
+ "Apple Human Interface Guidelines",
+ "Shopify Polaris",
+ "IBM Carbon",
+ "Atlassian Design System"
+ ]
+
+ if competitors_str and competitors_str.strip():
+ competitors = [c.strip() for c in competitors_str.split(",") if c.strip()]
+ else:
+ competitors = default_competitors
+
+ progress(0.05, desc="🤖 Initializing multi-agent analysis...")
+
+ try:
+ # Import the multi-agent workflow
+ from agents.stage2_graph import run_stage2_multi_agent
+
+ # Convert normalized tokens to dict for the workflow
+ desktop_dict = normalized_to_dict(state.desktop_normalized)
+ mobile_dict = normalized_to_dict(state.mobile_normalized)
+
+ # Run multi-agent analysis with semantic context
+ progress(0.1, desc="🚀 Running parallel LLM analysis...")
+
+ result = await run_stage2_multi_agent(
+ desktop_tokens=desktop_dict,
+ mobile_tokens=mobile_dict,
+ competitors=competitors,
+ log_callback=state.log,
+ semantic_analysis=getattr(state, 'semantic_analysis', None), # Pass semantic context!
+ )
+
+ progress(0.8, desc="📊 Processing results...")
+
+ # Extract results
+ final_recs = result.get("final_recommendations", {})
+ llm1_analysis = result.get("llm1_analysis", {})
+ llm2_analysis = result.get("llm2_analysis", {})
+ rule_calculations = result.get("rule_calculations", {})
+ cost_tracking = result.get("cost_tracking", {})
+
+ # Store for later use
+ state.upgrade_recommendations = final_recs
+ state.multi_agent_result = result
+
+ # Get font info
+ fonts = get_detected_fonts()
+ base_size = get_base_font_size()
+
+ progress(0.9, desc="📊 Formatting results...")
+
+ # Build status markdown
+ status = build_analysis_status(final_recs, cost_tracking, result.get("errors", []))
+
+ # Format brand/competitor comparison from LLM analyses
+ brand_md = format_multi_agent_comparison(llm1_analysis, llm2_analysis, final_recs)
+
+ # Format font families display
+ font_families_md = format_font_families_display(fonts)
+
+ # Format typography with BOTH desktop and mobile
+ typography_desktop_data = format_typography_comparison_viewport(
+ state.desktop_normalized, base_size, "desktop"
+ )
+ typography_mobile_data = format_typography_comparison_viewport(
+ state.mobile_normalized, base_size, "mobile"
+ )
+
+ # Format spacing comparison table
+ spacing_data = format_spacing_comparison_from_rules(rule_calculations)
+
+ # Format color display: BASE colors + ramps separately
+ base_colors_md = format_base_colors()
+ color_ramps_md = format_color_ramps_from_rules(rule_calculations)
+
+ # Format radius display (with token suggestions)
+ radius_md = format_radius_with_tokens()
+
+ # Format shadows display (with token suggestions)
+ shadows_md = format_shadows_with_tokens()
+
+ # Generate visual previews for Stage 2
+ state.log("")
+ state.log("🎨 Generating visual previews...")
+
+ from core.preview_generator import (
+ generate_typography_preview_html,
+ generate_color_ramps_preview_html,
+ generate_semantic_color_ramps_html
+ )
+
+ primary_font = fonts.get("primary", "Open Sans")
+
+ # Convert typography tokens to dict format for preview
+ typo_dict = {}
+ for name, t in state.desktop_normalized.typography.items():
+ typo_dict[name] = {
+ "font_size": t.font_size,
+ "font_weight": t.font_weight,
+ "line_height": t.line_height or "1.5",
+ "letter_spacing": "0",
+ }
+
+ # Convert color tokens to dict format for preview (with frequency for sorting)
+ color_dict = {}
+ for name, c in state.desktop_normalized.colors.items():
+ color_dict[name] = {
+ "value": c.value,
+ "frequency": c.frequency,
+ }
+
+ typography_preview_html = generate_typography_preview_html(
+ typography_tokens=typo_dict,
+ font_family=primary_font,
+ sample_text="The quick brown fox jumps over the lazy dog",
+ )
+
+ # Use semantic color ramps if available, otherwise fallback to regular
+ semantic_analysis = getattr(state, 'semantic_analysis', None)
+ if semantic_analysis:
+ # Extract LLM color recommendations
+ llm_color_recs = {}
+ if final_recs and isinstance(final_recs, dict):
+ llm_color_recs = final_recs.get("color_recommendations", {})
+ # Also add accessibility fixes
+ aa_fixes = final_recs.get("accessibility_fixes", [])
+ if aa_fixes:
+ llm_color_recs["changes_made"] = [
+ f"AA fix suggested for {f.get('color', '?')}"
+ for f in aa_fixes if isinstance(f, dict)
+ ][:5]
+
+ color_ramps_preview_html = generate_semantic_color_ramps_html(
+ semantic_analysis=semantic_analysis,
+ color_tokens=color_dict,
+ llm_recommendations={"color_recommendations": llm_color_recs} if llm_color_recs else None,
+ )
+ state.log(" ✅ Semantic color ramps preview generated (with LLM recommendations)")
+ else:
+ color_ramps_preview_html = generate_color_ramps_preview_html(
+ color_tokens=color_dict,
+ )
+ state.log(" ✅ Color ramps preview generated (no semantic data)")
+
+ state.log(" ✅ Typography preview generated")
+
+ # Generate LLM recommendations display
+ llm_recs_html = format_llm_color_recommendations_html(final_recs, semantic_analysis)
+ llm_recs_table = format_llm_color_recommendations_table(final_recs, semantic_analysis)
+
+ state.log(" ✅ LLM recommendations formatted")
+
+ progress(1.0, desc="✅ Analysis complete!")
+
+ return (status, state.get_logs(), brand_md, font_families_md,
+ typography_desktop_data, typography_mobile_data, spacing_data,
+ base_colors_md, color_ramps_md, radius_md, shadows_md,
+ typography_preview_html, color_ramps_preview_html,
+ llm_recs_html, llm_recs_table)
+
+ except Exception as e:
+ import traceback
+ state.log(f"❌ Error: {str(e)}")
+ state.log(traceback.format_exc())
+ return (f"❌ Analysis failed: {str(e)}", state.get_logs(), "", "", None, None, None, "", "", "", "", "", "", "", [])
+
+
+def normalized_to_dict(normalized) -> dict:
+ """Convert NormalizedTokens to dict for workflow."""
+ if not normalized:
+ return {}
+
+ result = {
+ "colors": {},
+ "typography": {},
+ "spacing": {},
+ "radius": {},
+ "shadows": {},
+ }
+
+ # Colors
+ for name, c in normalized.colors.items():
+ result["colors"][name] = {
+ "value": c.value,
+ "frequency": c.frequency,
+ "suggested_name": c.suggested_name,
+ "contrast_white": c.contrast_white,
+ "contrast_black": c.contrast_black,
+ }
+
+ # Typography
+ for name, t in normalized.typography.items():
+ result["typography"][name] = {
+ "font_family": t.font_family,
+ "font_size": t.font_size,
+ "font_weight": t.font_weight,
+ "line_height": t.line_height,
+ "frequency": t.frequency,
+ }
+
+ # Spacing
+ for name, s in normalized.spacing.items():
+ result["spacing"][name] = {
+ "value": s.value,
+ "value_px": s.value_px,
+ "frequency": s.frequency,
+ }
+
+ # Radius
+ for name, r in normalized.radius.items():
+ result["radius"][name] = {
+ "value": r.value,
+ "frequency": r.frequency,
+ }
+
+ # Shadows
+ for name, s in normalized.shadows.items():
+ result["shadows"][name] = {
+ "value": s.value,
+ "frequency": s.frequency,
+ }
+
+ return result
+
+
+# =============================================================================
+# STAGE 2: NEW ARCHITECTURE (Rule Engine + Benchmark Research + LLM Agents)
+# =============================================================================
+
+async def run_stage2_analysis_v2(
+ selected_benchmarks: list[str] = None,
+ progress=gr.Progress()
+):
+ """
+ Run Stage 2 analysis with new architecture:
+ - Layer 1: Rule Engine (FREE)
+ - Layer 2: Benchmark Research (Firecrawl + Cache)
+ - Layer 3: LLM Agents (Brand ID, Benchmark Advisor, Best Practices)
+ - Layer 4: HEAD Synthesizer
+
+ Includes comprehensive error handling for graceful degradation.
+ """
+
+ # Validate Stage 1 completion
+ if not state.desktop_normalized or not state.mobile_normalized:
+ return create_stage2_error_response(
+ error_message("Stage 1 Not Complete",
+ "No extracted tokens found. Stage 1 extraction must be completed before running analysis.",
+ "Go back to **Step 1**, enter a URL, discover pages, and extract tokens first.")
+ )
+
+ # Default benchmarks if none selected
+ if not selected_benchmarks or len(selected_benchmarks) == 0:
+ selected_benchmarks = [
+ "material_design_3",
+ "shopify_polaris",
+ "atlassian_design",
+ ]
+
+ state.log("")
+ state.log("═" * 60)
+ state.log("🚀 STAGE 2: MULTI-AGENT ANALYSIS")
+ state.log("═" * 60)
+ state.log(f" Started: {datetime.now().strftime('%H:%M:%S')}")
+ state.log(f" Benchmarks: {', '.join(selected_benchmarks)}")
+ state.log("")
+
+ # Import dataclasses early so fallbacks always work
+ # (even if the full agent import fails at runtime)
+ try:
+ from agents.llm_agents import (
+ BrandIdentification,
+ BenchmarkAdvice,
+ BestPracticesResult,
+ )
+ except ImportError:
+ # Minimal fallback dataclasses if agents module unavailable
+ from dataclasses import dataclass, field
+ @dataclass
+ class BrandIdentification:
+ brand_primary: dict = field(default_factory=dict)
+ brand_secondary: dict = field(default_factory=dict)
+ brand_accent: dict = field(default_factory=dict)
+ palette_strategy: str = ""
+ cohesion_score: int = 5
+ cohesion_notes: str = ""
+ semantic_names: dict = field(default_factory=dict)
+ self_evaluation: dict = field(default_factory=dict)
+ def to_dict(self):
+ return {k: getattr(self, k) for k in ['brand_primary', 'brand_secondary', 'brand_accent', 'palette_strategy', 'cohesion_score', 'cohesion_notes', 'semantic_names', 'self_evaluation']}
+
+ @dataclass
+ class BenchmarkAdvice:
+ recommended_benchmark: str = ""
+ recommended_benchmark_name: str = ""
+ reasoning: str = ""
+ alignment_changes: list = field(default_factory=list)
+ pros_of_alignment: list = field(default_factory=list)
+ cons_of_alignment: list = field(default_factory=list)
+ alternative_benchmarks: list = field(default_factory=list)
+ def to_dict(self):
+ return {k: getattr(self, k) for k in ['recommended_benchmark', 'recommended_benchmark_name', 'reasoning', 'alignment_changes', 'pros_of_alignment', 'cons_of_alignment', 'alternative_benchmarks']}
+
+ @dataclass
+ class BestPracticesResult:
+ overall_score: int = 50
+ checks: dict = field(default_factory=dict)
+ priority_fixes: list = field(default_factory=list)
+ passing_practices: list = field(default_factory=list)
+ failing_practices: list = field(default_factory=list)
+ def to_dict(self):
+ return {k: getattr(self, k) for k in ['overall_score', 'checks', 'priority_fixes', 'passing_practices', 'failing_practices']}
+
+ # Initialize results with defaults (for graceful degradation)
+ rule_results = None
+ benchmark_comparisons = []
+ brand_result = None
+ benchmark_advice = None
+ best_practices = None
+ final_synthesis = None
+
+ progress(0.05, desc="⚙️ Running Rule Engine...")
+
+ try:
+ # =================================================================
+ # LAYER 1: RULE ENGINE (FREE) - Critical, must succeed
+ # =================================================================
+ try:
+ from core.rule_engine import run_rule_engine
+
+ # Convert tokens to dict
+ desktop_dict = normalized_to_dict(state.desktop_normalized)
+ mobile_dict = normalized_to_dict(state.mobile_normalized)
+
+ # Validate we have data
+ if not desktop_dict.get("colors") and not desktop_dict.get("typography"):
+ raise ValueError("No tokens extracted from Stage 1")
+
+ # Run rule engine
+ rule_results = run_rule_engine(
+ typography_tokens=desktop_dict.get("typography", {}),
+ color_tokens=desktop_dict.get("colors", {}),
+ spacing_tokens=desktop_dict.get("spacing", {}),
+ radius_tokens=desktop_dict.get("radius", {}),
+ shadow_tokens=desktop_dict.get("shadows", {}),
+ log_callback=state.log,
+ fg_bg_pairs=getattr(state, 'fg_bg_pairs', None),
+ )
+
+ state.rule_engine_results = rule_results
+ state.log("")
+ state.log(" ✅ Rule Engine: SUCCESS")
+
+ except Exception as e:
+ state.log(f" ❌ Rule Engine FAILED: {str(e)[:100]}")
+ state.log(" └─ Cannot proceed without rule engine results")
+ import traceback
+ state.log(traceback.format_exc()[:500])
+ return create_stage2_error_response(
+ error_message("Rule Engine Failed",
+ f"The rule engine could not analyze your tokens: {str(e)[:150]}",
+ "This usually means the extracted tokens are incomplete. Try re-running Stage 1 extraction with different pages selected.")
+ )
+
+ progress(0.20, desc="🔬 Researching benchmarks...")
+
+ # =================================================================
+ # LAYER 2: BENCHMARK RESEARCH - Can use fallback
+ # =================================================================
+ try:
+ from agents.benchmark_researcher import BenchmarkResearcher, FALLBACK_BENCHMARKS, BenchmarkData
+
+ # Try to get Firecrawl client (optional)
+ firecrawl_client = None
+ try:
+ from agents.firecrawl_extractor import get_firecrawl_client
+ firecrawl_client = get_firecrawl_client()
+ state.log(" ├─ Firecrawl client: Available")
+ except Exception as fc_err:
+ state.log(f" ├─ Firecrawl client: Not available ({str(fc_err)[:30]})")
+ state.log(" │ └─ Will use cached/fallback data")
+
+ # Get HF client for LLM extraction (optional)
+ hf_client = None
+ try:
+ from core.hf_inference import get_inference_client
+ hf_client = get_inference_client()
+ state.log(" ├─ HF client: Available")
+ except Exception as hf_err:
+ state.log(f" ├─ HF client: Not available ({str(hf_err)[:30]})")
+
+ researcher = BenchmarkResearcher(
+ firecrawl_client=firecrawl_client,
+ hf_client=hf_client,
+ )
+
+ # Research selected benchmarks (with fallback)
+ try:
+ benchmarks = await researcher.research_selected_benchmarks(
+ selected_keys=selected_benchmarks,
+ log_callback=state.log,
+ )
+ except Exception as research_err:
+ state.log(f" ⚠️ Research failed, using fallback: {str(research_err)[:50]}")
+ # Use fallback data
+ benchmarks = []
+ for key in selected_benchmarks:
+ if key in FALLBACK_BENCHMARKS:
+ data = FALLBACK_BENCHMARKS[key]
+ benchmarks.append(BenchmarkData(
+ key=key,
+ name=key.replace("_", " ").title(),
+ short_name=key.split("_")[0].title(),
+ vendor="",
+ icon="📦",
+ typography=data.get("typography", {}),
+ spacing=data.get("spacing", {}),
+ colors=data.get("colors", {}),
+ fetched_at=datetime.now().isoformat(),
+ confidence="fallback",
+ best_for=[],
+ ))
+
+ # Compare to benchmarks
+ if benchmarks and rule_results:
+ benchmark_comparisons = researcher.compare_to_benchmarks(
+ your_ratio=rule_results.typography.detected_ratio,
+ your_base_size=int(rule_results.typography.base_size) if rule_results.typography.sizes_px else 16,
+ your_spacing_grid=rule_results.spacing.detected_base,
+ benchmarks=benchmarks,
+ log_callback=state.log,
+ )
+ state.benchmark_comparisons = benchmark_comparisons
+ state.log("")
+ state.log(f" ✅ Benchmark Research: SUCCESS ({len(benchmarks)} systems)")
+ else:
+ state.log(" ⚠️ No benchmarks available for comparison")
+
+ except Exception as e:
+ state.log(f" ⚠️ Benchmark Research FAILED: {str(e)[:100]}")
+ state.log(" └─ Continuing without benchmark comparison...")
+ benchmark_comparisons = []
+
+ progress(0.40, desc="🤖 Running LLM Agents...")
+
+ # =================================================================
+ # LAYER 3: LLM AGENTS - Can fail gracefully
+ # =================================================================
+ try:
+ from agents.llm_agents import (
+ BrandIdentifierAgent,
+ BenchmarkAdvisorAgent,
+ BestPracticesValidatorAgent,
+ BrandIdentification,
+ BenchmarkAdvice,
+ BestPracticesResult,
+ )
+
+ state.log("")
+ state.log("═" * 60)
+ state.log("🤖 LAYER 3: LLM ANALYSIS")
+ state.log("═" * 60)
+
+ # Check if HF client is available
+ if not hf_client:
+ try:
+ from core.hf_inference import get_inference_client
+ hf_client = get_inference_client()
+ except Exception:
+ state.log(" ⚠️ HF client not available - skipping LLM agents")
+ hf_client = None
+
+ if hf_client:
+ # Initialize agents
+ brand_agent = BrandIdentifierAgent(hf_client)
+ benchmark_agent = BenchmarkAdvisorAgent(hf_client)
+ best_practices_agent = BestPracticesValidatorAgent(hf_client)
+
+ # Get semantic analysis from Stage 1
+ semantic_analysis = getattr(state, 'semantic_analysis', {})
+ desktop_dict = normalized_to_dict(state.desktop_normalized)
+
+ # Run agents (with individual error handling)
+ # Brand Identifier
+ try:
+ brand_result = await brand_agent.analyze(
+ color_tokens=desktop_dict.get("colors", {}),
+ semantic_analysis=semantic_analysis,
+ log_callback=state.log,
+ )
+ # Log what the LLM contributed
+ if brand_result:
+ bp = brand_result.brand_primary or {}
+ bs = brand_result.brand_secondary or {}
+ state.log(f" ├─ Brand Primary: {bp.get('color', 'N/A')} ({bp.get('confidence', 'N/A')} confidence)")
+ state.log(f" │ └��� Reasoning: {bp.get('reasoning', 'N/A')[:80]}")
+ state.log(f" ├─ Brand Secondary: {bs.get('color', 'N/A')}")
+ state.log(f" ├─ Palette Strategy: {brand_result.palette_strategy or 'N/A'}")
+ state.log(f" └─ Cohesion Score: {brand_result.cohesion_score}/10 — {brand_result.cohesion_notes[:60] if brand_result.cohesion_notes else 'N/A'}")
+ except Exception as e:
+ state.log(f" ⚠️ Brand Identifier failed: {str(e)[:120]}")
+ brand_result = BrandIdentification()
+
+ # Benchmark Advisor
+ if benchmark_comparisons:
+ try:
+ benchmark_advice = await benchmark_agent.analyze(
+ user_ratio=rule_results.typography.detected_ratio,
+ user_base=int(rule_results.typography.base_size) if rule_results.typography.sizes_px else 16,
+ user_spacing=rule_results.spacing.detected_base,
+ benchmark_comparisons=benchmark_comparisons,
+ log_callback=state.log,
+ )
+ # Log what the LLM contributed
+ if benchmark_advice:
+ state.log(f" ├─ Recommended: {benchmark_advice.recommended_benchmark_name or benchmark_advice.recommended_benchmark or 'N/A'}")
+ state.log(f" │ └─ Reasoning: {benchmark_advice.reasoning[:80] if benchmark_advice.reasoning else 'N/A'}")
+ changes = benchmark_advice.alignment_changes or []
+ state.log(f" ├─ Changes Needed: {len(changes)}")
+ for i, ch in enumerate(changes[:3]):
+ if isinstance(ch, dict):
+ state.log(f" │ {i+1}. {ch.get('change', ch.get('what', str(ch)[:60]))}")
+ else:
+ state.log(f" │ {i+1}. {str(ch)[:60]}")
+ if benchmark_advice.pros_of_alignment:
+ state.log(f" └─ Pros: {', '.join(str(p)[:30] for p in benchmark_advice.pros_of_alignment[:2])}")
+ except Exception as e:
+ state.log(f" ⚠️ Benchmark Advisor failed: {str(e)[:120]}")
+ benchmark_advice = BenchmarkAdvice()
+ else:
+ benchmark_advice = BenchmarkAdvice()
+
+ # Best Practices Validator
+ try:
+ best_practices = await best_practices_agent.analyze(
+ rule_engine_results=rule_results,
+ log_callback=state.log,
+ )
+ # Log what the LLM contributed
+ if best_practices:
+ state.log(f" ├─ Overall Score: {best_practices.overall_score}/100")
+ passing_count = len(best_practices.passing_practices) if best_practices.passing_practices else 0
+ failing_count = len(best_practices.failing_practices) if best_practices.failing_practices else 0
+ state.log(f" ├─ Passing: {passing_count} | Failing: {failing_count}")
+ # Show checks dict
+ if best_practices.checks:
+ for check_name, check_data in list(best_practices.checks.items())[:3]:
+ if isinstance(check_data, dict):
+ status = check_data.get('status', '?')
+ note = check_data.get('note', '')[:50]
+ icon = "✅" if status == "pass" else "⚠️" if status == "warn" else "❌"
+ state.log(f" │ {icon} {check_name}: {note}")
+ else:
+ state.log(f" │ • {check_name}: {check_data}")
+ # Show priority fixes
+ if best_practices.priority_fixes:
+ top_fix = best_practices.priority_fixes[0]
+ if isinstance(top_fix, dict):
+ state.log(f" └─ Top Fix: {top_fix.get('issue', top_fix.get('action', str(top_fix)[:60]))}")
+ else:
+ state.log(f" └─ Top Fix: {str(top_fix)[:60]}")
+ except Exception as e:
+ state.log(f" ⚠️ Best Practices Validator failed: {str(e)[:120]}")
+ best_practices = BestPracticesResult(overall_score=rule_results.consistency_score)
+ else:
+ # No HF client - use defaults
+ state.log(" └─ Using default values (no LLM)")
+ brand_result = BrandIdentification()
+ benchmark_advice = BenchmarkAdvice()
+ best_practices = BestPracticesResult(overall_score=rule_results.consistency_score)
+
+ except Exception as e:
+ state.log(f" ⚠️ LLM Agents FAILED: {str(e)[:100]}")
+ brand_result = BrandIdentification() if not brand_result else brand_result
+ benchmark_advice = BenchmarkAdvice() if not benchmark_advice else benchmark_advice
+ best_practices = BestPracticesResult(overall_score=rule_results.consistency_score if rule_results else 50)
+
+ progress(0.70, desc="🧠 Synthesizing results...")
+
+ # =================================================================
+ # LAYER 4: HEAD SYNTHESIZER - Can use fallback
+ # =================================================================
+ try:
+ from agents.llm_agents import HeadSynthesizerAgent, HeadSynthesis
+
+ if hf_client and brand_result and benchmark_advice and best_practices:
+ head_agent = HeadSynthesizerAgent(hf_client)
+
+ try:
+ final_synthesis = await head_agent.synthesize(
+ rule_engine_results=rule_results,
+ benchmark_comparisons=benchmark_comparisons,
+ brand_identification=brand_result,
+ benchmark_advice=benchmark_advice,
+ best_practices=best_practices,
+ log_callback=state.log,
+ )
+ if final_synthesis:
+ # Detailed logging is handled by NEXUS agent persona itself
+ if final_synthesis.executive_summary:
+ state.log(f" 📝 Summary: {final_synthesis.executive_summary[:120]}...")
+ except Exception as e:
+ state.log(f" ⚠️ HEAD Synthesizer failed: {str(e)[:120]}")
+ import traceback
+ state.log(f" └─ {traceback.format_exc()[:200]}")
+ final_synthesis = None
+
+ # Create fallback synthesis if needed
+ if not final_synthesis:
+ state.log(" └─ Creating fallback synthesis...")
+ final_synthesis = create_fallback_synthesis(
+ rule_results, benchmark_comparisons, brand_result, best_practices
+ )
+
+ state.final_synthesis = final_synthesis
+
+ # ═══════════════════════════════════════
+ # AGENT EVALUATION SUMMARY
+ # ═══════════════════════════════════════
+ state.log("")
+ state.log("═" * 50)
+ state.log("🔍 AGENT EVALUATION SUMMARY")
+ state.log("═" * 50)
+
+ def _eval_line(name, emoji, result_obj):
+ se = getattr(result_obj, 'self_evaluation', None) or {}
+ if isinstance(se, dict) and se:
+ conf = se.get('confidence', '?')
+ dq = se.get('data_quality', '?')
+ flags = se.get('flags', [])
+ flag_str = f", flags={flags}" if flags else ""
+ return f" {emoji} {name}: confidence={conf}/10, data={dq}{flag_str}"
+ return f" {emoji} {name}: no self-evaluation returned"
+
+ if brand_result:
+ state.log(_eval_line("AURORA (Brand ID)", "🎨", brand_result))
+ if benchmark_advice:
+ state.log(_eval_line("ATLAS (Benchmark)", "🏢", benchmark_advice))
+ if best_practices:
+ bp_se = getattr(best_practices, 'self_evaluation', None) or {}
+ bp_score = getattr(best_practices, 'overall_score', '?')
+ state.log(_eval_line("SENTINEL (Practices)", "✅", best_practices) + f", score={bp_score}/100")
+ if final_synthesis:
+ synth_se = getattr(final_synthesis, 'self_evaluation', None) or {}
+ synth_overall = final_synthesis.scores.get('overall', '?') if final_synthesis.scores else '?'
+ state.log(_eval_line("NEXUS (Synthesis)", "🧠", final_synthesis) + f", overall={synth_overall}/100")
+
+ state.log("═" * 50)
+ state.log("")
+
+ except Exception as e:
+ state.log(f" ⚠️ Synthesis FAILED: {str(e)[:100]}")
+ final_synthesis = create_fallback_synthesis(
+ rule_results, benchmark_comparisons, brand_result, best_practices
+ )
+ state.final_synthesis = final_synthesis
+
+ progress(0.85, desc="📊 Formatting results...")
+
+ # =================================================================
+ # FORMAT OUTPUTS FOR UI
+ # =================================================================
+
+ try:
+ # Build status markdown
+ status_md = format_stage2_status_v2(
+ rule_results=rule_results,
+ final_synthesis=final_synthesis,
+ best_practices=best_practices,
+ )
+
+ # Build benchmark comparison HTML
+ benchmark_md = format_benchmark_comparison_v2(
+ benchmark_comparisons=benchmark_comparisons,
+ benchmark_advice=benchmark_advice,
+ )
+
+ # Build scores dashboard HTML
+ scores_html = format_scores_dashboard_v2(
+ rule_results=rule_results,
+ final_synthesis=final_synthesis,
+ best_practices=best_practices,
+ )
+
+ # Build priority actions HTML
+ actions_html = format_priority_actions_v2(
+ rule_results=rule_results,
+ final_synthesis=final_synthesis,
+ best_practices=best_practices,
+ )
+
+ # Build color recommendations table
+ color_recs_table = format_color_recommendations_table_v2(
+ rule_results=rule_results,
+ brand_result=brand_result,
+ final_synthesis=final_synthesis,
+ )
+
+ # Get fonts and typography data
+ fonts = get_detected_fonts()
+ base_size = get_base_font_size()
+
+ typography_desktop_data = format_typography_comparison_viewport(
+ state.desktop_normalized, base_size, "desktop"
+ )
+ typography_mobile_data = format_typography_comparison_viewport(
+ state.mobile_normalized, base_size, "mobile"
+ )
+
+ # Generate spacing comparison table from rule_results
+ spacing_data = []
+ if rule_results and rule_results.spacing:
+ sp = rule_results.spacing
+ current_vals = sp.current_values or []
+ suggested_8 = [i * 8 for i in range(1, 11)]
+ suggested_4 = [i * 4 for i in range(1, 11)]
+ for i in range(min(10, max(len(current_vals), 10))):
+ cur = f"{current_vals[i]}px" if i < len(current_vals) else "—"
+ g8 = f"{suggested_8[i]}px" if i < len(suggested_8) else "—"
+ g4 = f"{suggested_4[i]}px" if i < len(suggested_4) else "—"
+ spacing_data.append([cur, g8, g4])
+
+ # Generate base colors, color ramps, radius, shadows markdown
+ base_colors_md = format_base_colors()
+ color_ramps_md = "" # Visual ramps are in color_ramps_preview_html
+ try:
+ from core.preview_generator import generate_color_ramp
+ colors = list(state.desktop_normalized.colors.values())
+ colors.sort(key=lambda c: -c.frequency)
+ ramp_lines = ["### 🌈 Color Ramps (Top Colors)", ""]
+ for c in colors[:6]:
+ ramp = generate_color_ramp(c.value)
+ if ramp:
+ shades_str = " → ".join(f"`{s['hex']}`" for s in ramp[::2]) # every other shade
+ ramp_lines.append(f"**{c.value}** ({c.frequency}x): {shades_str}")
+ ramp_lines.append("")
+ color_ramps_md = "\n".join(ramp_lines)
+ except Exception:
+ color_ramps_md = "*Color ramps shown in visual preview above*"
+
+ radius_md = format_radius_with_tokens()
+ shadows_md = format_shadows_with_tokens()
+
+ # Generate visual previews
+ typography_preview_html = ""
+ color_ramps_preview_html = ""
+ llm_recs_html = ""
+
+ try:
+ from core.preview_generator import (
+ generate_typography_preview_html,
+ generate_semantic_color_ramps_html,
+ generate_color_ramps_preview_html,
+ )
+
+ primary_font = fonts.get("primary", "Open Sans")
+ desktop_typo_dict = {
+ name: {
+ "font_size": t.font_size,
+ "font_weight": t.font_weight,
+ "line_height": t.line_height,
+ }
+ for name, t in state.desktop_normalized.typography.items()
+ }
+ typography_preview_html = generate_typography_preview_html(desktop_typo_dict, primary_font)
+
+ # Generate color ramps preview (semantic groups)
+ semantic_analysis = getattr(state, 'semantic_analysis', {})
+ desktop_dict_for_colors = normalized_to_dict(state.desktop_normalized)
+
+ if semantic_analysis:
+ color_ramps_preview_html = generate_semantic_color_ramps_html(
+ semantic_analysis=semantic_analysis,
+ color_tokens=desktop_dict_for_colors.get("colors", {}),
+ )
+ else:
+ color_ramps_preview_html = generate_color_ramps_preview_html(
+ color_tokens=desktop_dict_for_colors.get("colors", {}),
+ )
+
+ state.log(" ✅ Color ramps preview generated")
+
+ except Exception as preview_err:
+ state.log(f" ⚠️ Preview generation failed: {str(preview_err)[:80]}")
+ typography_preview_html = typography_preview_html or "
Preview unavailable
"
+ color_ramps_preview_html = "Color ramps preview unavailable
"
+
+ # Generate LLM recommendations HTML
+ try:
+ # Build recs dict in the format expected by the HTML formatter
+ synth_recs = {}
+ if final_synthesis:
+ # Convert list of color recs to dict keyed by role
+ # HeadSynthesis uses: {role, current, suggested, reason, accept}
+ # Formatter expects: {current, suggested, action, rationale}
+ color_recs_dict = {}
+ for rec in (final_synthesis.color_recommendations or []):
+ if isinstance(rec, dict) and rec.get("role"):
+ current_val = rec.get("current", "?")
+ suggested_val = rec.get("suggested", current_val)
+ accept = rec.get("accept", True)
+ reason = rec.get("reason", "")
+ # Determine action: if suggested differs from current, it's a change
+ if suggested_val and suggested_val != current_val and not accept:
+ action = "change"
+ elif suggested_val and suggested_val != current_val:
+ action = "change"
+ else:
+ action = "keep"
+ color_recs_dict[rec["role"]] = {
+ "current": current_val,
+ "suggested": suggested_val,
+ "action": action,
+ "rationale": reason,
+ }
+ synth_recs["color_recommendations"] = color_recs_dict
+
+ # Add AA fixes from rule engine
+ # Formatter expects: {color, role, issue, fix, current_contrast, fixed_contrast}
+ aa_fixes = []
+ if rule_results and rule_results.accessibility:
+ for a in rule_results.accessibility:
+ if not a.passes_aa_normal:
+ best_contrast = a.contrast_on_white if a.best_text_color == "#FFFFFF" else a.contrast_on_black
+ aa_fixes.append({
+ "color": a.hex_color,
+ "role": a.name or "unknown",
+ "issue": f"Fails AA normal ({best_contrast:.1f}:1 < 4.5:1)",
+ "fix": a.suggested_fix or a.hex_color,
+ "current_contrast": f"{best_contrast:.1f}",
+ "fixed_contrast": f"{a.suggested_fix_contrast:.1f}" if a.suggested_fix_contrast else "—",
+ })
+ synth_recs["accessibility_fixes"] = aa_fixes
+
+ llm_recs_html = format_llm_color_recommendations_html(
+ final_recs=synth_recs,
+ semantic_analysis=getattr(state, 'semantic_analysis', {}),
+ )
+ except Exception as recs_err:
+ state.log(f" ⚠️ LLM recs HTML failed: {str(recs_err)[:120]}")
+ import traceback
+ state.log(f" └─ {traceback.format_exc()[:200]}")
+ llm_recs_html = "LLM recommendations unavailable
"
+
+ # Store upgrade_recommendations for Apply Upgrades button
+ aa_failures_list = []
+ if rule_results and rule_results.accessibility:
+ aa_failures_list = [
+ a.to_dict() for a in rule_results.accessibility
+ if not a.passes_aa_normal
+ ]
+ state.upgrade_recommendations = {
+ "color_recommendations": (final_synthesis.color_recommendations if final_synthesis else []),
+ "accessibility_fixes": aa_failures_list,
+ "scores": (final_synthesis.scores if final_synthesis else {}),
+ "top_3_actions": (final_synthesis.top_3_actions if final_synthesis else []),
+ }
+
+ except Exception as format_err:
+ state.log(f" ⚠️ Formatting failed: {str(format_err)[:100]}")
+ import traceback
+ state.log(traceback.format_exc()[:500])
+ # Return minimal results (must match 16 outputs)
+ return (
+ f"⚠️ Analysis completed with formatting errors: {str(format_err)[:50]}",
+ state.get_logs(),
+ "*Benchmark comparison unavailable*",
+ "Scores unavailable
",
+ "Actions unavailable
",
+ [],
+ None,
+ None,
+ "Typography preview unavailable
",
+ "Color ramps preview unavailable
",
+ "LLM recommendations unavailable
",
+ [], # spacing_data
+ "*Formatting error - base colors unavailable*", # base_colors_md
+ "*Formatting error - color ramps unavailable*", # color_ramps_md
+ "*Formatting error - radius tokens unavailable*", # radius_md
+ "*Formatting error - shadow tokens unavailable*", # shadows_md
+ )
+
+ progress(0.95, desc="✅ Complete!")
+
+ # Final log summary
+ state.log("")
+ state.log("═" * 60)
+ state.log("📊 FINAL RESULTS")
+ state.log("═" * 60)
+ state.log("")
+ overall_score = final_synthesis.scores.get('overall', rule_results.consistency_score) if final_synthesis else rule_results.consistency_score
+ state.log(f" 🎯 OVERALL SCORE: {overall_score}/100")
+ if final_synthesis and final_synthesis.scores:
+ state.log(f" ├─ Accessibility: {final_synthesis.scores.get('accessibility', '?')}/100")
+ state.log(f" ├─ Consistency: {final_synthesis.scores.get('consistency', '?')}/100")
+ state.log(f" └─ Organization: {final_synthesis.scores.get('organization', '?')}/100")
+ state.log("")
+ if benchmark_comparisons:
+ state.log(f" 🏆 Closest Benchmark: {benchmark_comparisons[0].benchmark.name if benchmark_comparisons else 'N/A'}")
+ state.log("")
+ state.log(" 🎯 TOP 3 ACTIONS:")
+ if final_synthesis and final_synthesis.top_3_actions:
+ for i, action in enumerate(final_synthesis.top_3_actions[:3]):
+ impact = action.get('impact', 'medium')
+ icon = "🔴" if impact == "high" else "🟡" if impact == "medium" else "🟢"
+ state.log(f" │ {i+1}. {icon} {action.get('action', 'N/A')}")
+ else:
+ state.log(f" │ 1. 🔴 Fix {rule_results.aa_failures} AA compliance failures")
+ state.log("")
+ state.log("═" * 60)
+ state.log(f" 💰 TOTAL COST: ~$0.003")
+ state.log(f" ⏱️ COMPLETED: {datetime.now().strftime('%H:%M:%S')}")
+ state.log("═" * 60)
+
+ return (
+ status_md,
+ state.get_logs(),
+ benchmark_md,
+ scores_html,
+ actions_html,
+ color_recs_table,
+ typography_desktop_data,
+ typography_mobile_data,
+ typography_preview_html,
+ color_ramps_preview_html,
+ llm_recs_html,
+ spacing_data,
+ base_colors_md,
+ color_ramps_md,
+ radius_md,
+ shadows_md,
+ )
+
+ except Exception as e:
+ import traceback
+ state.log(f"❌ Critical Error: {str(e)}")
+ state.log(traceback.format_exc())
+ error_detail = str(e).lower()
+ if "token" in error_detail or "auth" in error_detail or "401" in error_detail:
+ hint = "Your HuggingFace token may be invalid or expired. Go to **Configuration** above and re-enter your token."
+ elif "rate" in error_detail or "429" in error_detail:
+ hint = "Rate limit reached. Wait a few minutes and try again."
+ else:
+ hint = "Check the analysis log above for details. Try running the analysis again, or try Legacy Analysis as a fallback."
+ return create_stage2_error_response(
+ error_message("Analysis Failed", str(e)[:200], hint)
+ )
+
+
+def create_fallback_synthesis(rule_results, benchmark_comparisons, brand_result, best_practices):
+ """Create a fallback synthesis when LLM synthesis fails."""
+ try:
+ from agents.llm_agents import HeadSynthesis
+ except ImportError:
+ from dataclasses import dataclass, field
+ @dataclass
+ class HeadSynthesis:
+ executive_summary: str = ""
+ scores: dict = field(default_factory=dict)
+ benchmark_fit: dict = field(default_factory=dict)
+ brand_analysis: dict = field(default_factory=dict)
+ top_3_actions: list = field(default_factory=list)
+ color_recommendations: list = field(default_factory=list)
+ type_scale_recommendation: dict = field(default_factory=dict)
+ spacing_recommendation: dict = field(default_factory=dict)
+ self_evaluation: dict = field(default_factory=dict)
+ def to_dict(self):
+ return {k: getattr(self, k) for k in ['executive_summary', 'scores', 'benchmark_fit', 'brand_analysis', 'top_3_actions', 'color_recommendations', 'type_scale_recommendation', 'spacing_recommendation', 'self_evaluation']}
+
+ # Calculate scores from rule engine
+ overall = rule_results.consistency_score if rule_results else 50
+ accessibility = max(0, 100 - (rule_results.aa_failures * 10)) if rule_results else 50
+
+ # Build actions from rule engine
+ actions = []
+ if rule_results and rule_results.aa_failures > 0:
+ actions.append({
+ "action": f"Fix {rule_results.aa_failures} colors failing AA compliance",
+ "impact": "high",
+ "effort": "30 min",
+ })
+ if rule_results and not rule_results.typography.is_consistent:
+ actions.append({
+ "action": f"Align type scale to {rule_results.typography.recommendation} ({rule_results.typography.recommendation_name})",
+ "impact": "medium",
+ "effort": "1 hour",
+ })
+ if rule_results and rule_results.color_stats.unique_count > 30:
+ actions.append({
+ "action": f"Consolidate {rule_results.color_stats.unique_count} colors to ~15 semantic colors",
+ "impact": "medium",
+ "effort": "2 hours",
+ })
+
+ return HeadSynthesis(
+ executive_summary=f"Your design system scores {overall}/100. Analysis completed with fallback synthesis.",
+ scores={
+ "overall": overall,
+ "accessibility": accessibility,
+ "consistency": overall,
+ "organization": 50,
+ },
+ benchmark_fit={
+ "closest": benchmark_comparisons[0].benchmark.name if benchmark_comparisons else "Unknown",
+ "similarity": f"{benchmark_comparisons[0].overall_match_pct:.0f}%" if benchmark_comparisons else "N/A",
+ },
+ brand_analysis={
+ "primary": brand_result.brand_primary.get("color", "Unknown") if brand_result else "Unknown",
+ "cohesion": brand_result.cohesion_score if brand_result else 5,
+ },
+ top_3_actions=actions[:3],
+ color_recommendations=[],
+ type_scale_recommendation={
+ "current_ratio": rule_results.typography.detected_ratio if rule_results else 1.0,
+ "recommended_ratio": rule_results.typography.recommendation if rule_results else 1.25,
+ },
+ spacing_recommendation={
+ "current": f"{rule_results.spacing.detected_base}px" if rule_results else "Unknown",
+ "recommended": f"{rule_results.spacing.recommendation}px" if rule_results else "8px",
+ },
+ )
+
+
+def create_stage2_error_response(error_msg: str):
+ """Create error response tuple for Stage 2 (must match 16 outputs)."""
+ return (
+ error_msg,
+ state.get_logs(),
+ "", # benchmark_md
+ f"{error_msg}
", # scores_html
+ "", # actions_html
+ [], # color_recs_table
+ None, # typography_desktop
+ None, # typography_mobile
+ "", # typography_preview
+ "", # color_ramps_preview
+ "", # llm_recs_html
+ [], # spacing_data
+ "*Run analysis to see base colors*", # base_colors_md
+ "*Run analysis to see color ramps*", # color_ramps_md
+ "*Run analysis to see radius tokens*", # radius_md
+ "*Run analysis to see shadow tokens*", # shadows_md
+ )
+
+
+def format_stage2_status_v2(rule_results, final_synthesis, best_practices) -> str:
+ """Format Stage 2 status with new architecture results."""
+
+ lines = []
+ lines.append("## ✅ Analysis Complete!")
+ lines.append("")
+
+ # Overall Score
+ overall = final_synthesis.scores.get('overall', rule_results.consistency_score)
+ lines.append(f"### 🎯 Overall Score: {overall}/100")
+ lines.append("")
+
+ # Executive Summary
+ if final_synthesis.executive_summary:
+ lines.append(f"*{final_synthesis.executive_summary}*")
+ lines.append("")
+
+ # Quick Stats
+ lines.append("### 📊 Quick Stats")
+ lines.append(f"- **AA Failures:** {rule_results.aa_failures}")
+ lines.append(f"- **Type Scale:** {rule_results.typography.detected_ratio:.3f} ({rule_results.typography.scale_name})")
+ lines.append(f"- **Spacing Grid:** {rule_results.spacing.detected_base}px ({rule_results.spacing.alignment_percentage:.0f}% aligned)")
+ lines.append(f"- **Unique Colors:** {rule_results.color_stats.unique_count}")
+ lines.append("")
+
+ # Cost
+ lines.append("### 💰 Cost")
+ lines.append("**Total:** ~$0.003 (Rule Engine: $0 + LLM: ~$0.003)")
+ lines.append("")
+
+ # Next step guidance
+ lines.append("---")
+ lines.append("**Next:** Review the analysis results below. Accept or reject color recommendations, "
+ "choose your type scale and spacing grid, then click **'Apply Selected Upgrades'** at the bottom.")
+
+ return "\n".join(lines)
+
+
+def format_benchmark_comparison_v2(benchmark_comparisons, benchmark_advice) -> str:
+ """Format benchmark comparison results."""
+
+ if not benchmark_comparisons:
+ return "*No benchmark comparison available*"
+
+ lines = []
+ lines.append("## 📊 Benchmark Comparison")
+ lines.append("")
+
+ # Recommended benchmark
+ if benchmark_advice and benchmark_advice.recommended_benchmark_name:
+ lines.append(f"### 🏆 Recommended: {benchmark_advice.recommended_benchmark_name}")
+ if benchmark_advice.reasoning:
+ lines.append(f"*{benchmark_advice.reasoning[:200]}*")
+ lines.append("")
+
+ # Comparison table
+ lines.append("### 📈 Similarity Ranking")
+ lines.append("")
+ lines.append("| Rank | Design System | Match | Type Ratio | Base | Grid |")
+ lines.append("|------|---------------|-------|------------|------|------|")
+
+ medals = ["🥇", "🥈", "🥉"]
+ for i, c in enumerate(benchmark_comparisons[:5]):
+ medal = medals[i] if i < 3 else str(i+1)
+ b = c.benchmark
+ lines.append(
+ f"| {medal} | {b.icon} {b.short_name} | {c.overall_match_pct:.0f}% | "
+ f"{b.typography.get('scale_ratio', '?')} | {b.typography.get('base_size', '?')}px | "
+ f"{b.spacing.get('base', '?')}px |"
+ )
+
+ lines.append("")
+
+ # Alignment changes needed
+ if benchmark_advice and benchmark_advice.alignment_changes:
+ lines.append("### 🔧 Changes to Align")
+ for change in benchmark_advice.alignment_changes[:3]:
+ lines.append(f"- **{change.get('change', '?')}**: {change.get('from', '?')} → {change.get('to', '?')} (effort: {change.get('effort', '?')})")
+
+ return "\n".join(lines)
+
+
+def format_scores_dashboard_v2(rule_results, final_synthesis, best_practices) -> str:
+ """Format scores dashboard HTML."""
+
+ overall = final_synthesis.scores.get('overall', rule_results.consistency_score)
+ accessibility = final_synthesis.scores.get('accessibility', 100 - (rule_results.aa_failures * 5))
+ consistency = final_synthesis.scores.get('consistency', rule_results.consistency_score)
+ organization = final_synthesis.scores.get('organization', 50)
+
+ def score_color(score):
+ if score >= 80:
+ return "#10b981" # Green
+ elif score >= 60:
+ return "#f59e0b" # Yellow
+ else:
+ return "#ef4444" # Red
+
+ html = f"""
+
+
+
+
+
{accessibility}
+
Accessibility
+
+
+
{consistency}
+
Consistency
+
+
+
{organization}
+
Organization
+
+
+ """
+
+ return html
+
+
+def format_priority_actions_v2(rule_results, final_synthesis, best_practices) -> str:
+ """Format priority actions HTML."""
+
+ actions = final_synthesis.top_3_actions if final_synthesis.top_3_actions else []
+
+ # If no synthesis actions, build from rule engine
+ if not actions and best_practices and best_practices.priority_fixes:
+ actions = best_practices.priority_fixes
+
+ if not actions:
+ # Default actions from rule engine
+ actions = []
+ if rule_results.aa_failures > 0:
+ actions.append({
+ "action": f"Fix {rule_results.aa_failures} colors failing AA compliance",
+ "impact": "high",
+ "effort": "30 min",
+ })
+ if not rule_results.typography.is_consistent:
+ actions.append({
+ "action": f"Align type scale to {rule_results.typography.recommendation} ({rule_results.typography.recommendation_name})",
+ "impact": "medium",
+ "effort": "1 hour",
+ })
+ if rule_results.color_stats.unique_count > 30:
+ actions.append({
+ "action": f"Consolidate {rule_results.color_stats.unique_count} colors to ~15 semantic colors",
+ "impact": "medium",
+ "effort": "2 hours",
+ })
+
+ html_items = []
+ for i, action in enumerate(actions[:3]):
+ impact = action.get('impact', 'medium')
+ border_color = "#ef4444" if impact == "high" else "#f59e0b" if impact == "medium" else "#10b981"
+ impact_bg = "#fee2e2" if impact == "high" else "#fef3c7" if impact == "medium" else "#dcfce7"
+ impact_text = "#991b1b" if impact == "high" else "#92400e" if impact == "medium" else "#166534"
+ icon = "🔴" if impact == "high" else "🟡" if impact == "medium" else "🟢"
+
+ html_items.append(f"""
+
+
+
+
+ {icon} {action.get('action', 'N/A')}
+
+
+ {action.get('details', '')}
+
+
+
+
+ {impact.upper()}
+
+
+ {action.get('effort', '?')}
+
+
+
+
+ """)
+
+ return f"""
+
+
+
🎯 Priority Actions
+ {''.join(html_items)}
+
+ """
+
+
+def format_color_recommendations_table_v2(rule_results, brand_result, final_synthesis) -> list:
+ """Format color recommendations as table data."""
+
+ rows = []
+
+ # Add AA failures with fixes
+ for a in rule_results.accessibility:
+ if not a.passes_aa_normal and a.suggested_fix:
+ role = "brand.primary" if brand_result and brand_result.brand_primary.get("color") == a.hex_color else a.name
+ rows.append([
+ True, # Accept checkbox
+ role,
+ a.hex_color,
+ f"Fails AA ({a.contrast_on_white:.1f}:1)",
+ a.suggested_fix,
+ f"{a.suggested_fix_contrast:.1f}:1",
+ ])
+
+ # Add recommendations from synthesis
+ if final_synthesis and final_synthesis.color_recommendations:
+ for rec in final_synthesis.color_recommendations:
+ if rec.get("current") != rec.get("suggested"):
+ # Check if not already in rows
+ if not any(r[2] == rec.get("current") for r in rows):
+ rows.append([
+ rec.get("accept", True),
+ rec.get("role", "unknown"),
+ rec.get("current", ""),
+ rec.get("reason", ""),
+ rec.get("suggested", ""),
+ "",
+ ])
+
+ return rows
+
+
+def build_analysis_status(final_recs: dict, cost_tracking: dict, errors: list) -> str:
+ """Build status markdown from analysis results."""
+
+ lines = ["## 🧠 Multi-Agent Analysis Complete!"]
+ lines.append("")
+
+ # Cost summary
+ if cost_tracking:
+ total_cost = cost_tracking.get("total_cost", 0)
+ lines.append(f"### 💰 Cost Summary")
+ lines.append(f"**Total estimated cost:** ${total_cost:.4f}")
+ lines.append(f"*(Free tier: $0.10/mo | Pro: $2.00/mo)*")
+ lines.append("")
+
+ # Final recommendations
+ if final_recs and "final_recommendations" in final_recs:
+ recs = final_recs["final_recommendations"]
+ lines.append("### 📋 Recommendations")
+
+ if recs.get("type_scale"):
+ lines.append(f"**Type Scale:** {recs['type_scale']}")
+ if recs.get("type_scale_rationale"):
+ lines.append(f" *{recs['type_scale_rationale'][:100]}*")
+
+ if recs.get("spacing_base"):
+ lines.append(f"**Spacing:** {recs['spacing_base']}")
+
+ lines.append("")
+
+ # Summary
+ if final_recs.get("summary"):
+ lines.append("### 📝 Summary")
+ lines.append(final_recs["summary"])
+ lines.append("")
+
+ # Confidence
+ if final_recs.get("overall_confidence"):
+ lines.append(f"**Confidence:** {final_recs['overall_confidence']}%")
+
+ # Errors
+ if errors:
+ lines.append("")
+ lines.append("### ⚠️ Warnings")
+ for err in errors[:3]:
+ lines.append(f"- {err[:100]}")
+
+ return "\n".join(lines)
+
+
+def format_multi_agent_comparison(llm1: dict, llm2: dict, final: dict) -> str:
+ """Format comparison from multi-agent analysis."""
+
+ lines = ["### 📊 Multi-Agent Analysis Comparison"]
+ lines.append("")
+
+ # Agreements
+ if final.get("agreements"):
+ lines.append("#### ✅ Agreements (High Confidence)")
+ for a in final["agreements"][:5]:
+ topic = a.get("topic", "?")
+ finding = a.get("finding", "?")[:80]
+ lines.append(f"- **{topic}**: {finding}")
+ lines.append("")
+
+ # Disagreements and resolutions
+ if final.get("disagreements"):
+ lines.append("#### 🔄 Resolved Disagreements")
+ for d in final["disagreements"][:3]:
+ topic = d.get("topic", "?")
+ resolution = d.get("resolution", "?")[:100]
+ lines.append(f"- **{topic}**: {resolution}")
+ lines.append("")
+
+ # Score comparison
+ lines.append("#### 📈 Score Comparison")
+ lines.append("")
+ lines.append("| Category | LLM 1 (Qwen) | LLM 2 (Llama) |")
+ lines.append("|----------|--------------|---------------|")
+
+ categories = ["typography", "colors", "accessibility", "spacing"]
+ for cat in categories:
+ llm1_score = llm1.get(cat, {}).get("score", "?") if isinstance(llm1.get(cat), dict) else "?"
+ llm2_score = llm2.get(cat, {}).get("score", "?") if isinstance(llm2.get(cat), dict) else "?"
+ lines.append(f"| {cat.title()} | {llm1_score}/10 | {llm2_score}/10 |")
+
+ return "\n".join(lines)
+
+
+def format_spacing_comparison_from_rules(rule_calculations: dict) -> list:
+ """Format spacing comparison from rule engine."""
+ if not rule_calculations:
+ return []
+
+ spacing_options = rule_calculations.get("spacing_options", {})
+
+ data = []
+ for i in range(10):
+ current = f"{(i+1) * 4}px" if i < 5 else f"{(i+1) * 8}px"
+ grid_8 = spacing_options.get("8px", [])
+ grid_4 = spacing_options.get("4px", [])
+
+ val_8 = f"{grid_8[i+1]}px" if i+1 < len(grid_8) else "—"
+ val_4 = f"{grid_4[i+1]}px" if i+1 < len(grid_4) else "—"
+
+ data.append([current, val_8, val_4])
+
+ return data
+
+
+def format_color_ramps_from_rules(rule_calculations: dict) -> str:
+ """Format color ramps from rule engine."""
+ if not rule_calculations:
+ return "*No color ramps generated*"
+
+ ramps = rule_calculations.get("color_ramps", {})
+ if not ramps:
+ return "*No color ramps generated*"
+
+ lines = ["### 🌈 Generated Color Ramps"]
+ lines.append("")
+
+ for name, ramp in list(ramps.items())[:6]:
+ lines.append(f"**{name}**")
+ if isinstance(ramp, list) and len(ramp) >= 10:
+ lines.append("| 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 |")
+ lines.append("|---|---|---|---|---|---|---|---|---|---|")
+ row = "| " + " | ".join([f"`{ramp[i]}`" for i in range(10)]) + " |"
+ lines.append(row)
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def get_detected_fonts() -> dict:
+ """Get detected font information."""
+ if not state.desktop_normalized:
+ return {"primary": "Unknown", "weights": []}
+
+ fonts = {}
+ weights = set()
+
+ for t in state.desktop_normalized.typography.values():
+ family = t.font_family
+ weight = t.font_weight
+
+ if family not in fonts:
+ fonts[family] = 0
+ fonts[family] += t.frequency
+
+ if weight:
+ try:
+ weights.add(int(weight))
+ except:
+ pass
+
+ primary = max(fonts.items(), key=lambda x: x[1])[0] if fonts else "Unknown"
+
+ return {
+ "primary": primary,
+ "weights": sorted(weights) if weights else [400],
+ "all_fonts": fonts,
+ }
+
+
+def get_base_font_size() -> int:
+ """Detect base font size from typography."""
+ if not state.desktop_normalized:
+ return 16
+
+ # Find most common size in body range (14-18px)
+ sizes = {}
+ for t in state.desktop_normalized.typography.values():
+ size_str = str(t.font_size).replace('px', '').replace('rem', '').replace('em', '')
+ try:
+ size = float(size_str)
+ if 14 <= size <= 18:
+ sizes[size] = sizes.get(size, 0) + t.frequency
+ except:
+ pass
+
+ if sizes:
+ return int(max(sizes.items(), key=lambda x: x[1])[0])
+ return 16
+
+
+def format_brand_comparison(recommendations) -> str:
+ """Format brand comparison as markdown table."""
+ if not recommendations.brand_analysis:
+ return "*Brand analysis not available*"
+
+ lines = [
+ "### 📊 Design System Comparison (5 Top Brands)",
+ "",
+ "| Brand | Type Ratio | Base Size | Spacing | Notes |",
+ "|-------|------------|-----------|---------|-------|",
+ ]
+
+ for brand in recommendations.brand_analysis[:5]:
+ name = brand.get("brand", "Unknown")
+ ratio = brand.get("ratio", "?")
+ base = brand.get("base", "?")
+ spacing = brand.get("spacing", "?")
+ notes = brand.get("notes", "")[:50] + ("..." if len(brand.get("notes", "")) > 50 else "")
+ lines.append(f"| {name} | {ratio} | {base}px | {spacing} | {notes} |")
+
+ return "\n".join(lines)
+
+
+def format_font_families_display(fonts: dict) -> str:
+ """Format detected font families for display."""
+ lines = []
+
+ primary = fonts.get("primary", "Unknown")
+ weights = fonts.get("weights", [400])
+ all_fonts = fonts.get("all_fonts", {})
+
+ lines.append(f"### Primary Font: **{primary}**")
+ lines.append("")
+ lines.append(f"**Weights detected:** {', '.join(map(str, weights))}")
+ lines.append("")
+
+ if all_fonts and len(all_fonts) > 1:
+ lines.append("### All Fonts Detected")
+ lines.append("")
+ lines.append("| Font Family | Usage Count |")
+ lines.append("|-------------|-------------|")
+
+ sorted_fonts = sorted(all_fonts.items(), key=lambda x: -x[1])
+ for font, count in sorted_fonts[:5]:
+ lines.append(f"| {font} | {count:,} |")
+
+ lines.append("")
+ lines.append("*Note: This analysis focuses on English typography only.*")
+
+ return "\n".join(lines)
+
+
+def format_llm_color_recommendations_html(final_recs: dict, semantic_analysis: dict) -> str:
+ """Generate HTML showing LLM color recommendations with before/after comparison."""
+
+ if not final_recs:
+ return '''
+
+
No LLM recommendations available yet. Run analysis first.
+
+ '''
+
+ color_recs = final_recs.get("color_recommendations", {})
+ aa_fixes = final_recs.get("accessibility_fixes", [])
+
+ if not color_recs and not aa_fixes:
+ return '''
+
+
✅ No color changes recommended. Your colors look good!
+
+
+ '''
+
+ # Build recommendations HTML
+ recs_html = ""
+
+ # Process color recommendations
+ for role, rec in color_recs.items():
+ if not isinstance(rec, dict):
+ continue
+ if role in ["generate_ramps_for", "changes_made"]:
+ continue
+
+ current = rec.get("current", "?")
+ suggested = rec.get("suggested", current)
+ action = rec.get("action", "keep")
+ rationale = rec.get("rationale", "")
+
+ if action == "keep" or suggested == current:
+ # No change needed
+ recs_html += f'''
+
+
+
+ {role}
+ {current}
+ ✓ Keep
+
+
+ '''
+ else:
+ # Change suggested
+ recs_html += f'''
+
+
+
+
→
+
+
+
After
+
{suggested}
+
+
+
+ {role}
+ {rationale[:80]}...
+
+
+ '''
+
+ # Process accessibility fixes
+ for fix in aa_fixes:
+ if not isinstance(fix, dict):
+ continue
+
+ color = fix.get("color", "?")
+ role = fix.get("role", "unknown")
+ issue = fix.get("issue", "contrast issue")
+ fix_color = fix.get("fix", color)
+ current_contrast = fix.get("current_contrast", "?")
+ fixed_contrast = fix.get("fixed_contrast", "?")
+
+ if fix_color and fix_color != color:
+ recs_html += f'''
+
+
+
+
+
⚠️ {current_contrast}:1
+
{color}
+
+
→
+
+
+
✓ {fixed_contrast}:1
+
{fix_color}
+
+
+
+ {role}
+ 🔴 {issue}
+
+
+ '''
+
+ if not recs_html:
+ return '''
+
+
✅ No color changes recommended. Your colors look good!
+
+
+ '''
+
+ html = f'''
+
+
+
+ {recs_html}
+
+ '''
+
+ return html
+
+
+def format_llm_color_recommendations_table(final_recs: dict, semantic_analysis: dict) -> list:
+ """Generate table data for LLM color recommendations with accept/reject checkboxes."""
+
+ rows = []
+
+ if not final_recs:
+ return rows
+
+ color_recs = final_recs.get("color_recommendations", {})
+ aa_fixes = final_recs.get("accessibility_fixes", [])
+
+ # Process color recommendations
+ for role, rec in color_recs.items():
+ if not isinstance(rec, dict):
+ continue
+ if role in ["generate_ramps_for", "changes_made"]:
+ continue
+
+ current = rec.get("current", "?")
+ suggested = rec.get("suggested", current)
+ action = rec.get("action", "keep")
+ rationale = rec.get("rationale", "")[:50]
+
+ if action != "keep" and suggested != current:
+ # Calculate contrast improvement
+ try:
+ from core.color_utils import get_contrast_with_white
+ old_contrast = get_contrast_with_white(current)
+ new_contrast = get_contrast_with_white(suggested)
+ contrast_str = f"{old_contrast:.1f} → {new_contrast:.1f}"
+ except:
+ contrast_str = "?"
+
+ rows.append([
+ True, # Accept checkbox (default True)
+ role,
+ current,
+ rationale or action,
+ suggested,
+ contrast_str,
+ ])
+
+ # Process accessibility fixes
+ for fix in aa_fixes:
+ if not isinstance(fix, dict):
+ continue
+
+ color = fix.get("color", "?")
+ role = fix.get("role", "unknown")
+ issue = fix.get("issue", "contrast")[:40]
+ fix_color = fix.get("fix", color)
+ current_contrast = fix.get("current_contrast", "?")
+ fixed_contrast = fix.get("fixed_contrast", "?")
+
+ if fix_color and fix_color != color:
+ rows.append([
+ True, # Accept checkbox
+ f"{role} (AA fix)",
+ color,
+ issue,
+ fix_color,
+ f"{current_contrast}:1 → {fixed_contrast}:1",
+ ])
+
+ return rows
+
+
+def format_typography_comparison_viewport(normalized_tokens, base_size: int, viewport: str) -> list:
+ """Format typography comparison for a specific viewport."""
+ if not normalized_tokens:
+ return []
+
+ # Get current typography sorted by size
+ current_typo = list(normalized_tokens.typography.values())
+
+ # Parse and sort sizes
+ def parse_size(t):
+ size_str = str(t.font_size).replace('px', '').replace('rem', '').replace('em', '')
+ try:
+ return float(size_str)
+ except:
+ return 16
+
+ current_typo.sort(key=lambda t: -parse_size(t))
+ sizes = [parse_size(t) for t in current_typo]
+
+ # Use detected base or default
+ base = base_size if base_size else 16
+
+ # Scale factors for mobile (typically 0.85-0.9 of desktop)
+ mobile_factor = 0.875 if viewport == "mobile" else 1.0
+
+ # Token names (13 levels)
+ token_names = [
+ "display.2xl", "display.xl", "display.lg", "display.md",
+ "heading.xl", "heading.lg", "heading.md", "heading.sm",
+ "body.lg", "body.md", "body.sm",
+ "caption", "overline"
+ ]
+
+ # Generate scales - use base size and round to sensible values
+ def round_to_even(val):
+ """Round to even numbers for cleaner type scales."""
+ return int(round(val / 2) * 2)
+
+ scales = {
+ "1.2": [round_to_even(base * mobile_factor * (1.2 ** (8-i))) for i in range(13)],
+ "1.25": [round_to_even(base * mobile_factor * (1.25 ** (8-i))) for i in range(13)],
+ "1.333": [round_to_even(base * mobile_factor * (1.333 ** (8-i))) for i in range(13)],
+ }
+
+ # Build comparison table
+ data = []
+ for i, name in enumerate(token_names):
+ current = f"{int(sizes[i])}px" if i < len(sizes) else "—"
+ s12 = f"{scales['1.2'][i]}px"
+ s125 = f"{scales['1.25'][i]}px"
+ s133 = f"{scales['1.333'][i]}px"
+ keep = current
+ data.append([name, current, s12, s125, s133, keep])
+
+ return data
+
+
+def format_base_colors() -> str:
+ """Format base colors (detected) separately from ramps."""
+ if not state.desktop_normalized:
+ return "*No colors detected*"
+
+ colors = list(state.desktop_normalized.colors.values())
+ colors.sort(key=lambda c: -c.frequency)
+
+ lines = [
+ "### 🎨 Base Colors (Detected)",
+ "",
+ "These are the primary colors extracted from your website:",
+ "",
+ "| Color | Hex | Role | Frequency | Contrast |",
+ "|-------|-----|------|-----------|----------|",
+ ]
+
+ for color in colors[:10]:
+ hex_val = color.value
+ role = "Primary" if color.suggested_name and "primary" in color.suggested_name.lower() else \
+ "Text" if color.suggested_name and "text" in color.suggested_name.lower() else \
+ "Background" if color.suggested_name and "background" in color.suggested_name.lower() else \
+ "Border" if color.suggested_name and "border" in color.suggested_name.lower() else \
+ "Accent"
+ freq = f"{color.frequency:,}"
+ contrast = f"{color.contrast_white:.1f}:1" if color.contrast_white else "—"
+
+ # Create a simple color indicator
+ lines.append(f"| 🟦 | `{hex_val}` | {role} | {freq} | {contrast} |")
+
+ return "\n".join(lines)
+
+
+def format_color_ramps_visual(recommendations) -> str:
+ """Format color ramps with visual display showing all shades."""
+ if not state.desktop_normalized:
+ return "*No colors to display*"
+
+ colors = list(state.desktop_normalized.colors.values())
+ colors.sort(key=lambda c: -c.frequency)
+
+ lines = [
+ "### 🌈 Generated Color Ramps",
+ "",
+ "Full ramp (50-950) generated for each base color:",
+ "",
+ ]
+
+ from core.color_utils import generate_color_ramp
+
+ for color in colors[:6]: # Top 6 colors
+ hex_val = color.value
+ role = color.suggested_name.split('.')[1] if color.suggested_name and '.' in color.suggested_name else "color"
+
+ # Generate ramp
+ try:
+ ramp = generate_color_ramp(hex_val)
+
+ lines.append(f"**{role.upper()}** (base: `{hex_val}`)")
+ lines.append("")
+ lines.append("| 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 |")
+ lines.append("|---|---|---|---|---|---|---|---|---|---|")
+
+ # Create row with hex values
+ row = "|"
+ for i in range(10):
+ if i < len(ramp):
+ row += f" `{ramp[i]}` |"
+ else:
+ row += " — |"
+ lines.append(row)
+ lines.append("")
+
+ except Exception as e:
+ lines.append(f"**{role}** (`{hex_val}`) — Could not generate ramp: {str(e)}")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def format_radius_with_tokens() -> str:
+ """Format radius with token name suggestions."""
+ if not state.desktop_normalized or not state.desktop_normalized.radius:
+ return "*No border radius values detected.*"
+
+ radii = list(state.desktop_normalized.radius.values())
+
+ lines = [
+ "### 🔘 Border Radius Tokens",
+ "",
+ "| Detected | Suggested Token | Usage |",
+ "|----------|-----------------|-------|",
+ ]
+
+ # Sort by pixel value
+ def parse_radius(r):
+ val = str(r.value).replace('px', '').replace('%', '')
+ try:
+ return float(val)
+ except:
+ return 999
+
+ radii.sort(key=lambda r: parse_radius(r))
+
+ token_map = {
+ (0, 2): ("radius.none", "Sharp corners"),
+ (2, 4): ("radius.xs", "Subtle rounding"),
+ (4, 6): ("radius.sm", "Small elements"),
+ (6, 10): ("radius.md", "Buttons, cards"),
+ (10, 16): ("radius.lg", "Modals, panels"),
+ (16, 32): ("radius.xl", "Large containers"),
+ (32, 100): ("radius.2xl", "Pill shapes"),
+ }
+
+ for r in radii[:8]:
+ val = str(r.value)
+ px = parse_radius(r)
+
+ if "%" in str(r.value) or px >= 50:
+ token = "radius.full"
+ usage = "Circles, avatars"
+ else:
+ token = "radius.md"
+ usage = "General use"
+ for (low, high), (t, u) in token_map.items():
+ if low <= px < high:
+ token = t
+ usage = u
+ break
+
+ lines.append(f"| {val} | `{token}` | {usage} |")
+
+ return "\n".join(lines)
+
+
+def format_shadows_with_tokens() -> str:
+ """Format shadows with token name suggestions."""
+ if not state.desktop_normalized or not state.desktop_normalized.shadows:
+ return "*No shadow values detected.*"
+
+ shadows = list(state.desktop_normalized.shadows.values())
+
+ lines = [
+ "### 🌫️ Shadow Tokens",
+ "",
+ "| Detected Value | Suggested Token | Use Case |",
+ "|----------------|-----------------|----------|",
+ ]
+
+ shadow_sizes = ["shadow.xs", "shadow.sm", "shadow.md", "shadow.lg", "shadow.xl", "shadow.2xl"]
+
+ for i, s in enumerate(shadows[:6]):
+ val = str(s.value)[:40] + ("..." if len(str(s.value)) > 40 else "")
+ token = shadow_sizes[i] if i < len(shadow_sizes) else f"shadow.custom-{i}"
+
+ # Guess use case based on index
+ use_cases = ["Subtle elevation", "Cards, dropdowns", "Modals, dialogs", "Popovers", "Floating elements", "Dramatic effect"]
+ use = use_cases[i] if i < len(use_cases) else "Custom"
+
+ lines.append(f"| `{val}` | `{token}` | {use} |")
+
+ return "\n".join(lines)
+
+
+def format_spacing_comparison(recommendations) -> list:
+ """Format spacing comparison table."""
+ if not state.desktop_normalized:
+ return []
+
+ # Get current spacing
+ current_spacing = list(state.desktop_normalized.spacing.values())
+ current_spacing.sort(key=lambda s: s.value_px)
+
+ data = []
+ for s in current_spacing[:10]:
+ current = f"{s.value_px}px"
+ grid_8 = f"{snap_to_grid(s.value_px, 8)}px"
+ grid_4 = f"{snap_to_grid(s.value_px, 4)}px"
+
+ # Mark if value fits
+ if s.value_px == snap_to_grid(s.value_px, 8):
+ grid_8 += " ✓"
+ if s.value_px == snap_to_grid(s.value_px, 4):
+ grid_4 += " ✓"
+
+ data.append([current, grid_8, grid_4])
+
+ return data
+
+
+def snap_to_grid(value: float, base: int) -> int:
+ """Snap value to grid."""
+ return round(value / base) * base
+
+
+def apply_selected_upgrades(type_choice: str, spacing_choice: str, apply_ramps: bool, color_recs_table: list = None):
+ """Apply selected upgrade options including LLM color recommendations."""
+ if not state.upgrade_recommendations:
+ return "## ❌ Run Analysis First\n\nPlease run the **v2 Analysis** before applying upgrades.", state.get_logs()
+
+ state.log("")
+ state.log("═" * 50)
+ state.log("✨ APPLYING SELECTED UPGRADES")
+ state.log("═" * 50)
+
+ # Store selections
+ state.selected_upgrades = {
+ "type_scale": type_choice,
+ "spacing": spacing_choice,
+ "color_ramps": apply_ramps,
+ }
+
+ state.log(f" 📐 Type Scale: {type_choice}")
+ state.log(f" 📏 Spacing: {spacing_choice}")
+ state.log(f" 🌈 Color Ramps: {'Yes' if apply_ramps else 'No'}")
+
+ # Process accepted color recommendations
+ accepted_color_changes = []
+ rejected_count = 0
+ if color_recs_table:
+ state.log("")
+ state.log(" 🎨 LLM Color Recommendations:")
+ for row in color_recs_table:
+ if len(row) >= 5:
+ accept = row[0] # Boolean checkbox
+ role = row[1] # Role name
+ current = row[2] # Current color
+ issue = row[3] # Issue description
+ suggested = row[4] # Suggested color
+
+ if accept and suggested and current != suggested:
+ accepted_color_changes.append({
+ "role": role,
+ "from": current,
+ "to": suggested,
+ "reason": issue,
+ })
+ state.log(f" ├─ ✅ ACCEPTED: {role}")
+ state.log(f" │ └─ {current} → {suggested}")
+ elif not accept:
+ rejected_count += 1
+ state.log(f" ├─ ❌ REJECTED: {role} (keeping {current})")
+
+ # Store accepted changes
+ state.selected_upgrades["color_changes"] = accepted_color_changes
+
+ state.log("")
+ if accepted_color_changes:
+ state.log(f" 📊 {len(accepted_color_changes)} color change(s) will be applied to export")
+ if rejected_count:
+ state.log(f" 📊 {rejected_count} color change(s) rejected (keeping original)")
+
+ state.log("")
+ state.log("✅ Upgrades applied! Proceed to Stage 3 for export.")
+ state.log("═" * 50)
+
+ # Build visible feedback summary
+ summary_parts = []
+ summary_parts.append(f"**Type Scale:** {type_choice}")
+ summary_parts.append(f"**Spacing:** {spacing_choice}")
+ summary_parts.append(f"**Color Ramps:** {'✅ Enabled' if apply_ramps else '❌ Disabled'}")
+ if accepted_color_changes:
+ summary_parts.append(f"**Color Changes:** {len(accepted_color_changes)} accepted")
+ if rejected_count:
+ summary_parts.append(f"**Rejected:** {rejected_count} kept as-is")
+
+ status_md = f"""## ✅ Upgrades Applied Successfully!
+
+{chr(10).join('- ' + p for p in summary_parts)}
+
+👉 **Proceed to Stage 3** to export your upgraded tokens.
+"""
+ return status_md, state.get_logs()
+
+
+def export_stage1_json():
+ """Export Stage 1 tokens (as-is extraction) to JSON - FLAT structure for Figma Tokens Studio."""
+ if not state.desktop_normalized:
+ return json.dumps({
+ "error": "No tokens extracted yet.",
+ "how_to_fix": "Go to Step 1, enter a URL, discover pages, and extract tokens first.",
+ "stage": "Stage 1 required"
+ }, indent=2)
+
+ # FLAT structure for Figma Tokens Studio compatibility
+ result = {
+ "metadata": {
+ "source_url": state.base_url,
+ "extracted_at": datetime.now().isoformat(),
+ "version": "v1-stage1-as-is",
+ "stage": "extraction",
+ "description": "Raw extracted tokens before upgrades - Figma Tokens Studio compatible",
+ },
+ "fonts": {},
+ "colors": {},
+ "typography": {}, # FLAT: font.display.xl.desktop, font.display.xl.mobile
+ "spacing": {}, # FLAT: space.1.desktop, space.1.mobile
+ "radius": {},
+ "shadows": {},
+ }
+
+ # =========================================================================
+ # FONTS
+ # =========================================================================
+ fonts_info = get_detected_fonts()
+ result["fonts"] = {
+ "primary": fonts_info.get("primary", "Unknown"),
+ "weights": fonts_info.get("weights", [400]),
+ }
+
+ # =========================================================================
+ # COLORS (viewport-agnostic - same across devices)
+ # =========================================================================
+ if state.desktop_normalized and state.desktop_normalized.colors:
+ for name, c in state.desktop_normalized.colors.items():
+ # Use semantic name or create one from value
+ base_name = c.suggested_name or name
+ # Clean up the name for Figma compatibility
+ clean_name = base_name.replace(" ", ".").replace("_", ".").lower()
+ if not clean_name.startswith("color."):
+ clean_name = f"color.{clean_name}"
+
+ result["colors"][clean_name] = {
+ "value": c.value,
+ "type": "color",
+ "source": "detected",
+ }
+
+ # =========================================================================
+ # TYPOGRAPHY - FLAT structure with viewport suffix
+ # =========================================================================
+ # Desktop typography
+ if state.desktop_normalized and state.desktop_normalized.typography:
+ for name, t in state.desktop_normalized.typography.items():
+ base_name = t.suggested_name or name
+ clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("font."):
+ clean_name = f"font.{clean_name}"
+
+ # Add .desktop suffix
+ token_key = f"{clean_name}.desktop"
+
+ result["typography"][token_key] = {
+ "value": t.font_size,
+ "type": "dimension",
+ "fontFamily": t.font_family,
+ "fontWeight": str(t.font_weight),
+ "lineHeight": t.line_height or "1.5",
+ "source": "detected",
+ }
+
+ # Mobile typography
+ if state.mobile_normalized and state.mobile_normalized.typography:
+ for name, t in state.mobile_normalized.typography.items():
+ base_name = t.suggested_name or name
+ clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("font."):
+ clean_name = f"font.{clean_name}"
+
+ # Add .mobile suffix
+ token_key = f"{clean_name}.mobile"
+
+ result["typography"][token_key] = {
+ "value": t.font_size,
+ "type": "dimension",
+ "fontFamily": t.font_family,
+ "fontWeight": str(t.font_weight),
+ "lineHeight": t.line_height or "1.5",
+ "source": "detected",
+ }
+
+ # =========================================================================
+ # SPACING - FLAT structure with viewport suffix
+ # =========================================================================
+ # Desktop spacing
+ if state.desktop_normalized and state.desktop_normalized.spacing:
+ for name, s in state.desktop_normalized.spacing.items():
+ base_name = s.suggested_name or name
+ clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("space."):
+ clean_name = f"space.{clean_name}"
+
+ # Add .desktop suffix
+ token_key = f"{clean_name}.desktop"
+
+ result["spacing"][token_key] = {
+ "value": s.value,
+ "type": "dimension",
+ "source": "detected",
+ }
+
+ # Mobile spacing
+ if state.mobile_normalized and state.mobile_normalized.spacing:
+ for name, s in state.mobile_normalized.spacing.items():
+ base_name = s.suggested_name or name
+ clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("space."):
+ clean_name = f"space.{clean_name}"
+
+ # Add .mobile suffix
+ token_key = f"{clean_name}.mobile"
+
+ result["spacing"][token_key] = {
+ "value": s.value,
+ "type": "dimension",
+ "source": "detected",
+ }
+
+ # =========================================================================
+ # RADIUS (viewport-agnostic)
+ # =========================================================================
+ if state.desktop_normalized and state.desktop_normalized.radius:
+ for name, r in state.desktop_normalized.radius.items():
+ clean_name = name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("radius."):
+ clean_name = f"radius.{clean_name}"
+
+ result["radius"][clean_name] = {
+ "value": r.value,
+ "type": "dimension",
+ "source": "detected",
+ }
+
+ # =========================================================================
+ # SHADOWS (viewport-agnostic)
+ # =========================================================================
+ if state.desktop_normalized and state.desktop_normalized.shadows:
+ for name, s in state.desktop_normalized.shadows.items():
+ clean_name = name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("shadow."):
+ clean_name = f"shadow.{clean_name}"
+
+ result["shadows"][clean_name] = {
+ "value": s.value,
+ "type": "boxShadow",
+ "source": "detected",
+ }
+
+ return json.dumps(result, indent=2, default=str)
+
+
+def export_tokens_json():
+ """Export final tokens with selected upgrades applied - FLAT structure for Figma Tokens Studio."""
+ if not state.desktop_normalized:
+ return json.dumps({
+ "error": "No tokens extracted yet.",
+ "how_to_fix": "Complete Stage 1 extraction first, then optionally run Stage 2 analysis before exporting.",
+ "stage": "Stage 1 required"
+ }, indent=2)
+
+ # Get selected upgrades
+ upgrades = getattr(state, 'selected_upgrades', {})
+ if not upgrades:
+ state.log("⚠️ Exporting final JSON without Stage 2 upgrades applied. Consider running Stage 2 analysis first.")
+ type_scale_choice = upgrades.get('type_scale', 'Keep Current')
+ spacing_choice = upgrades.get('spacing', 'Keep Current')
+ apply_ramps = upgrades.get('color_ramps', True)
+
+ # Determine ratio from choice
+ ratio = None
+ if "1.2" in type_scale_choice:
+ ratio = 1.2
+ elif "1.25" in type_scale_choice:
+ ratio = 1.25
+ elif "1.333" in type_scale_choice:
+ ratio = 1.333
+
+ # Determine spacing base
+ spacing_base = None
+ if "8px" in spacing_choice:
+ spacing_base = 8
+ elif "4px" in spacing_choice:
+ spacing_base = 4
+
+ # FLAT structure for Figma Tokens Studio compatibility
+ result = {
+ "metadata": {
+ "source_url": state.base_url,
+ "extracted_at": datetime.now().isoformat(),
+ "version": "v2-upgraded",
+ "stage": "final",
+ "description": "Upgraded tokens - Figma Tokens Studio compatible",
+ "upgrades_applied": {
+ "type_scale": type_scale_choice,
+ "spacing": spacing_choice,
+ "color_ramps": apply_ramps,
+ },
+ },
+ "fonts": {},
+ "colors": {},
+ "typography": {}, # FLAT: font.display.xl.desktop, font.display.xl.mobile
+ "spacing": {}, # FLAT: space.1.desktop, space.1.mobile
+ "radius": {},
+ "shadows": {},
+ }
+
+ # =========================================================================
+ # FONTS
+ # =========================================================================
+ fonts_info = get_detected_fonts()
+ result["fonts"] = {
+ "primary": fonts_info.get("primary", "Unknown"),
+ "weights": fonts_info.get("weights", [400]),
+ }
+ primary_font = fonts_info.get("primary", "sans-serif")
+
+ # =========================================================================
+ # COLORS with optional ramps
+ # =========================================================================
+ if state.desktop_normalized and state.desktop_normalized.colors:
+ from core.color_utils import generate_color_ramp
+
+ for name, c in state.desktop_normalized.colors.items():
+ base_name = c.suggested_name or name
+ clean_name = base_name.replace(" ", ".").replace("_", ".").lower()
+ if not clean_name.startswith("color."):
+ clean_name = f"color.{clean_name}"
+
+ if apply_ramps:
+ # Generate full ramp (50-950)
+ try:
+ ramp = generate_color_ramp(c.value)
+ shades = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"]
+ for i, shade in enumerate(shades):
+ if i < len(ramp):
+ shade_key = f"{clean_name}.{shade}"
+ result["colors"][shade_key] = {
+ "value": ramp[i] if isinstance(ramp[i], str) else ramp[i].get("hex", c.value),
+ "type": "color",
+ "source": "upgraded" if shade != "500" else "detected",
+ }
+ except:
+ result["colors"][clean_name] = {
+ "value": c.value,
+ "type": "color",
+ "source": "detected",
+ }
+ else:
+ result["colors"][clean_name] = {
+ "value": c.value,
+ "type": "color",
+ "source": "detected",
+ }
+
+ # =========================================================================
+ # TYPOGRAPHY - FLAT structure with viewport suffix
+ # =========================================================================
+ base_size = get_base_font_size()
+ token_names = [
+ "font.display.2xl", "font.display.xl", "font.display.lg", "font.display.md",
+ "font.heading.xl", "font.heading.lg", "font.heading.md", "font.heading.sm",
+ "font.body.lg", "font.body.md", "font.body.sm", "font.caption", "font.overline"
+ ]
+
+ # Desktop typography
+ if ratio:
+ # Apply type scale
+ scales = [int(round(base_size * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
+ for i, token_name in enumerate(token_names):
+ desktop_key = f"{token_name}.desktop"
+ result["typography"][desktop_key] = {
+ "value": f"{scales[i]}px",
+ "type": "dimension",
+ "fontFamily": primary_font,
+ "source": "upgraded",
+ }
+ elif state.desktop_normalized and state.desktop_normalized.typography:
+ # Keep original with flat structure
+ for name, t in state.desktop_normalized.typography.items():
+ base_name = t.suggested_name or name
+ clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("font."):
+ clean_name = f"font.{clean_name}"
+
+ desktop_key = f"{clean_name}.desktop"
+ result["typography"][desktop_key] = {
+ "value": t.font_size,
+ "type": "dimension",
+ "fontFamily": t.font_family,
+ "fontWeight": str(t.font_weight),
+ "lineHeight": t.line_height or "1.5",
+ "source": "detected",
+ }
+
+ # Mobile typography
+ if ratio:
+ # Apply type scale with mobile factor
+ mobile_factor = 0.875
+ scales = [int(round(base_size * mobile_factor * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
+ for i, token_name in enumerate(token_names):
+ mobile_key = f"{token_name}.mobile"
+ result["typography"][mobile_key] = {
+ "value": f"{scales[i]}px",
+ "type": "dimension",
+ "fontFamily": primary_font,
+ "source": "upgraded",
+ }
+ elif state.mobile_normalized and state.mobile_normalized.typography:
+ for name, t in state.mobile_normalized.typography.items():
+ base_name = t.suggested_name or name
+ clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("font."):
+ clean_name = f"font.{clean_name}"
+
+ mobile_key = f"{clean_name}.mobile"
+ result["typography"][mobile_key] = {
+ "value": t.font_size,
+ "type": "dimension",
+ "fontFamily": t.font_family,
+ "fontWeight": str(t.font_weight),
+ "lineHeight": t.line_height or "1.5",
+ "source": "detected",
+ }
+
+ # =========================================================================
+ # SPACING - FLAT structure with viewport suffix
+ # =========================================================================
+ spacing_token_names = [
+ "space.1", "space.2", "space.3", "space.4", "space.5",
+ "space.6", "space.8", "space.10", "space.12", "space.16"
+ ]
+
+ if spacing_base:
+ # Generate grid-aligned spacing for both viewports
+ for i, token_name in enumerate(spacing_token_names):
+ value = spacing_base * (i + 1)
+
+ # Desktop
+ desktop_key = f"{token_name}.desktop"
+ result["spacing"][desktop_key] = {
+ "value": f"{value}px",
+ "type": "dimension",
+ "source": "upgraded",
+ }
+
+ # Mobile (same values)
+ mobile_key = f"{token_name}.mobile"
+ result["spacing"][mobile_key] = {
+ "value": f"{value}px",
+ "type": "dimension",
+ "source": "upgraded",
+ }
+ else:
+ # Keep original with flat structure
+ if state.desktop_normalized and state.desktop_normalized.spacing:
+ for name, s in state.desktop_normalized.spacing.items():
+ base_name = s.suggested_name or name
+ clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("space."):
+ clean_name = f"space.{clean_name}"
+
+ desktop_key = f"{clean_name}.desktop"
+ result["spacing"][desktop_key] = {
+ "value": s.value,
+ "type": "dimension",
+ "source": "detected",
+ }
+
+ if state.mobile_normalized and state.mobile_normalized.spacing:
+ for name, s in state.mobile_normalized.spacing.items():
+ base_name = s.suggested_name or name
+ clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("space."):
+ clean_name = f"space.{clean_name}"
+
+ mobile_key = f"{clean_name}.mobile"
+ result["spacing"][mobile_key] = {
+ "value": s.value,
+ "type": "dimension",
+ "source": "detected",
+ }
+
+ # =========================================================================
+ # RADIUS (viewport-agnostic)
+ # =========================================================================
+ if state.desktop_normalized and state.desktop_normalized.radius:
+ for name, r in state.desktop_normalized.radius.items():
+ clean_name = name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("radius."):
+ clean_name = f"radius.{clean_name}"
+
+ result["radius"][clean_name] = {
+ "value": r.value,
+ "type": "dimension",
+ "source": "detected",
+ }
+
+ # =========================================================================
+ # SHADOWS (viewport-agnostic)
+ # =========================================================================
+ if state.desktop_normalized and state.desktop_normalized.shadows:
+ for name, s in state.desktop_normalized.shadows.items():
+ clean_name = name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
+ if not clean_name.startswith("shadow."):
+ clean_name = f"shadow.{clean_name}"
+
+ result["shadows"][clean_name] = {
+ "value": s.value,
+ "type": "boxShadow",
+ "source": "detected",
+ }
+
+ return json.dumps(result, indent=2, default=str)
+
+
+# =============================================================================
+# UI BUILDING
+# =============================================================================
+
+def create_ui():
+ """Create the Gradio interface with corporate branding."""
+
+ # Corporate theme customization
+ corporate_theme = gr.themes.Base(
+ primary_hue=gr.themes.colors.blue,
+ secondary_hue=gr.themes.colors.slate,
+ neutral_hue=gr.themes.colors.slate,
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
+ font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
+ ).set(
+ # Colors
+ body_background_fill="#f8fafc",
+ body_background_fill_dark="#0f172a",
+ block_background_fill="white",
+ block_background_fill_dark="#1e293b",
+ block_border_color="#e2e8f0",
+ block_border_color_dark="#334155",
+ block_label_background_fill="#f1f5f9",
+ block_label_background_fill_dark="#1e293b",
+ block_title_text_color="#0f172a",
+ block_title_text_color_dark="#f1f5f9",
+
+ # Primary button
+ button_primary_background_fill="#2563eb",
+ button_primary_background_fill_hover="#1d4ed8",
+ button_primary_text_color="white",
+
+ # Secondary button
+ button_secondary_background_fill="#f1f5f9",
+ button_secondary_background_fill_hover="#e2e8f0",
+ button_secondary_text_color="#1e293b",
+
+ # Input fields
+ input_background_fill="#ffffff",
+ input_background_fill_dark="#1e293b",
+ input_border_color="#cbd5e1",
+ input_border_color_dark="#475569",
+
+ # Shadows and radius
+ block_shadow="0 1px 3px rgba(0,0,0,0.1)",
+ block_shadow_dark="0 1px 3px rgba(0,0,0,0.3)",
+ block_border_width="1px",
+ block_radius="8px",
+
+ # Text
+ body_text_color="#1e293b",
+ body_text_color_dark="#e2e8f0",
+ body_text_size="14px",
+ )
+
+ # Custom CSS for additional styling
+ custom_css = """
+ /* Global styles */
+ .gradio-container {
+ max-width: 1400px !important;
+ margin: 0 auto !important;
+ }
+
+ /* Header branding */
+ .app-header {
+ background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
+ padding: 24px 32px;
+ border-radius: 12px;
+ margin-bottom: 24px;
+ color: white;
+ }
+ .app-header h1 {
+ margin: 0 0 8px 0;
+ font-size: 28px;
+ font-weight: 700;
+ }
+ .app-header p {
+ margin: 0;
+ opacity: 0.9;
+ font-size: 14px;
+ }
+
+ /* Stage indicators */
+ .stage-header {
+ background: linear-gradient(90deg, #f1f5f9 0%, #ffffff 100%);
+ padding: 16px 20px;
+ border-radius: 8px;
+ border-left: 4px solid #2563eb;
+ margin-bottom: 16px;
+ }
+ .stage-header h2 {
+ margin: 0;
+ font-size: 18px;
+ color: #1e293b;
+ }
+
+ /* Log styling */
+ .log-container textarea {
+ font-family: 'JetBrains Mono', monospace !important;
+ font-size: 12px !important;
+ line-height: 1.6 !important;
+ background: #0f172a !important;
+ color: #e2e8f0 !important;
+ border-radius: 8px !important;
+ }
+
+ /* Color swatch */
+ .color-swatch {
+ display: inline-block;
+ width: 24px;
+ height: 24px;
+ border-radius: 4px;
+ margin-right: 8px;
+ vertical-align: middle;
+ border: 1px solid rgba(0,0,0,0.1);
+ }
+
+ /* Score badges */
+ .score-badge {
+ display: inline-block;
+ padding: 4px 12px;
+ border-radius: 20px;
+ font-weight: 600;
+ font-size: 13px;
+ }
+ .score-badge.high { background: #dcfce7; color: #166534; }
+ .score-badge.medium { background: #fef3c7; color: #92400e; }
+ .score-badge.low { background: #fee2e2; color: #991b1b; }
+
+ /* Benchmark cards */
+ .benchmark-card {
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ padding: 16px;
+ margin-bottom: 12px;
+ }
+ .benchmark-card.selected {
+ border-color: #2563eb;
+ background: #eff6ff;
+ }
+
+ /* Action items */
+ .action-item {
+ background: white;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ padding: 16px;
+ margin-bottom: 8px;
+ }
+ .action-item.high-priority {
+ border-left: 4px solid #ef4444;
+ }
+ .action-item.medium-priority {
+ border-left: 4px solid #f59e0b;
+ }
+
+ /* Progress indicator */
+ .progress-bar {
+ height: 4px;
+ background: #e2e8f0;
+ border-radius: 2px;
+ overflow: hidden;
+ }
+ .progress-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #2563eb, #3b82f6);
+ transition: width 0.3s ease;
+ }
+
+ /* Accordion styling */
+ .accordion-header {
+ font-weight: 600 !important;
+ }
+
+ /* Table styling */
+ table {
+ border-collapse: collapse;
+ width: 100%;
+ }
+ th {
+ background: #f1f5f9;
+ color: #1e293b;
+ padding: 12px;
+ text-align: left;
+ font-weight: 600;
+ border-bottom: 2px solid #e2e8f0;
+ }
+ td {
+ padding: 12px;
+ color: #1e293b;
+ border-bottom: 1px solid #e2e8f0;
+ }
+
+ /* Section descriptions */
+ .section-desc p, .section-desc {
+ font-size: 13px !important;
+ color: #64748b !important;
+ line-height: 1.5 !important;
+ margin-top: -4px !important;
+ margin-bottom: 12px !important;
+ }
+ .dark .section-desc p, .dark .section-desc {
+ color: #94a3b8 !important;
+ }
+
+ /* Success messages */
+ .success-msg { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 16px; margin: 8px 0; }
+ .success-msg h2 { color: #166534 !important; }
+ .dark .success-msg { background: #052e16 !important; border-color: #166534 !important; }
+ .dark .success-msg h2 { color: #bbf7d0 !important; }
+ .dark .success-msg p { color: #d1d5db !important; }
+
+ /* Error messages */
+ .error-msg { background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 16px; margin: 8px 0; }
+ .error-msg h2 { color: #991b1b !important; }
+ .dark .error-msg { background: #450a0a !important; border-color: #991b1b !important; }
+ .dark .error-msg h2 { color: #fecaca !important; }
+ .dark .error-msg p { color: #d1d5db !important; }
+
+ /* Placeholder messages */
+ .placeholder-msg {
+ padding: 20px;
+ background: #f5f5f5;
+ border-radius: 8px;
+ color: #666;
+ }
+ .placeholder-msg.placeholder-lg {
+ padding: 40px;
+ text-align: center;
+ }
+
+ /* Progress bar */
+ .progress-bar {
+ background: #e2e8f0;
+ }
+
+ /* Dark mode adjustments */
+ .dark .stage-header {
+ background: linear-gradient(90deg, #1e293b 0%, #0f172a 100%);
+ border-left-color: #3b82f6;
+ }
+ .dark .stage-header h2 {
+ color: #f1f5f9;
+ }
+ .dark .stage-header-subtitle,
+ .dark .tip-text {
+ color: #94a3b8 !important;
+ }
+ .dark .benchmark-card {
+ background: #1e293b;
+ border-color: #334155;
+ }
+ .dark .action-item {
+ background: #1e293b;
+ border-color: #475569;
+ color: #e2e8f0;
+ }
+ /* Dark mode: Placeholder messages */
+ .dark .placeholder-msg {
+ background: #1e293b !important;
+ color: #94a3b8 !important;
+ }
+ /* Dark mode: Progress bar */
+ .dark .progress-bar {
+ background: #334155 !important;
+ }
+ /* Dark mode: Gradio Dataframe tables */
+ .dark table th {
+ background: #1e293b !important;
+ color: #e2e8f0 !important;
+ border-bottom-color: #475569 !important;
+ }
+ .dark table td {
+ color: #e2e8f0 !important;
+ border-bottom-color: #334155 !important;
+ }
+ .dark table tr {
+ background: #0f172a !important;
+ }
+ .dark table tr:nth-child(even) {
+ background: #1e293b !important;
+ }
+ /* Dark mode: HTML preview tables (typography, benchmarks) */
+ .dark .typography-preview {
+ background: #1e293b !important;
+ }
+ .dark .typography-preview th {
+ background: #334155 !important;
+ color: #e2e8f0 !important;
+ border-bottom-color: #475569 !important;
+ }
+ .dark .typography-preview td {
+ color: #e2e8f0 !important;
+ }
+ .dark .typography-preview .meta-row {
+ background: #1e293b !important;
+ border-top-color: #334155 !important;
+ }
+ .dark .typography-preview .scale-name,
+ .dark .typography-preview .scale-label {
+ color: #f1f5f9 !important;
+ background: #475569 !important;
+ }
+ .dark .typography-preview .meta {
+ color: #cbd5e1 !important;
+ }
+ .dark .typography-preview .preview-cell {
+ background: #0f172a !important;
+ border-bottom-color: #334155 !important;
+ }
+ .dark .typography-preview .preview-text {
+ color: #f1f5f9 !important;
+ }
+ .dark .typography-preview tr:hover .preview-cell {
+ background: #1e293b !important;
+ }
+
+ /* Dark mode: Colors AS-IS preview */
+ .dark .colors-asis-header {
+ color: #e2e8f0 !important;
+ background: #1e293b !important;
+ }
+ .dark .colors-asis-preview {
+ background: #0f172a !important;
+ }
+ .dark .color-row-asis {
+ background: #1e293b !important;
+ border-color: #475569 !important;
+ }
+ .dark .color-name-asis {
+ color: #f1f5f9 !important;
+ }
+ .dark .frequency {
+ color: #cbd5e1 !important;
+ }
+ .dark .color-meta-asis .aa-pass {
+ color: #22c55e !important;
+ background: #14532d !important;
+ }
+ .dark .color-meta-asis .aa-fail {
+ color: #f87171 !important;
+ background: #450a0a !important;
+ }
+ .dark .context-badge {
+ background: #334155 !important;
+ color: #e2e8f0 !important;
+ }
+
+ /* Dark mode: Color ramps preview */
+ .dark .color-ramps-preview {
+ background: #0f172a !important;
+ }
+ .dark .ramps-header-info {
+ color: #e2e8f0 !important;
+ background: #1e293b !important;
+ }
+ .dark .ramp-header {
+ background: #1e293b !important;
+ }
+ .dark .ramp-header-label {
+ color: #cbd5e1 !important;
+ }
+ .dark .color-row {
+ background: #1e293b !important;
+ border-color: #475569 !important;
+ }
+ .dark .color-name {
+ color: #f1f5f9 !important;
+ background: #475569 !important;
+ }
+ .dark .color-hex {
+ color: #cbd5e1 !important;
+ }
+
+ /* Dark mode: Spacing preview */
+ .dark .spacing-asis-preview {
+ background: #0f172a !important;
+ }
+ .dark .spacing-row-asis {
+ background: #1e293b !important;
+ }
+ .dark .spacing-label {
+ color: #f1f5f9 !important;
+ }
+
+ /* Dark mode: Radius preview */
+ .dark .radius-asis-preview {
+ background: #0f172a !important;
+ }
+ .dark .radius-item {
+ background: #1e293b !important;
+ }
+ .dark .radius-label {
+ color: #f1f5f9 !important;
+ }
+
+ /* Dark mode: Shadows preview */
+ .dark .shadows-asis-preview {
+ background: #0f172a !important;
+ }
+ .dark .shadow-item {
+ background: #1e293b !important;
+ }
+ .dark .shadow-box {
+ background: #334155 !important;
+ }
+ .dark .shadow-label {
+ color: #f1f5f9 !important;
+ }
+ .dark .shadow-value {
+ color: #94a3b8 !important;
+ }
+
+ /* Dark mode: Semantic color ramps */
+ .dark .sem-ramps-preview {
+ background: #0f172a !important;
+ }
+ .dark .sem-category {
+ background: #1e293b !important;
+ border-color: #475569 !important;
+ }
+ .dark .sem-cat-title {
+ color: #f1f5f9 !important;
+ border-bottom-color: #475569 !important;
+ }
+ .dark .sem-color-row {
+ background: #0f172a !important;
+ border-color: #334155 !important;
+ }
+ .dark .sem-role {
+ color: #f1f5f9 !important;
+ }
+ .dark .sem-hex {
+ color: #cbd5e1 !important;
+ }
+ .dark .llm-rec {
+ background: #422006 !important;
+ border-color: #b45309 !important;
+ }
+ .dark .rec-label {
+ color: #fbbf24 !important;
+ }
+ .dark .rec-issue {
+ color: #fde68a !important;
+ }
+ .dark .rec-arrow {
+ color: #fbbf24 !important;
+ }
+ .dark .llm-summary {
+ background: #1e3a5f !important;
+ border-color: #3b82f6 !important;
+ }
+ .dark .llm-summary h4 {
+ color: #93c5fd !important;
+ }
+ .dark .llm-summary ul,
+ .dark .llm-summary li {
+ color: #bfdbfe !important;
+ }
+
+ /* Dark mode: Score badges */
+ .dark .score-badge.high { background: #14532d; color: #86efac; }
+ .dark .score-badge.medium { background: #422006; color: #fde68a; }
+ .dark .score-badge.low { background: #450a0a; color: #fca5a5; }
+
+ /* Dark mode: Benchmark & action cards */
+ .dark .benchmark-card.selected {
+ border-color: #3b82f6;
+ background: #1e3a5f;
+ }
+ .dark .action-item.high-priority {
+ border-left-color: #ef4444;
+ }
+ .dark .action-item.medium-priority {
+ border-left-color: #f59e0b;
+ }
+
+ /* Dark mode: Gradio markdown rendered tables */
+ .dark .prose table th,
+ .dark .markdown-text table th {
+ background: #1e293b !important;
+ color: #e2e8f0 !important;
+ border-color: #475569 !important;
+ }
+ .dark .prose table td,
+ .dark .markdown-text table td {
+ color: #e2e8f0 !important;
+ border-color: #334155 !important;
+ }
+ .dark .prose table tr,
+ .dark .markdown-text table tr {
+ background: #0f172a !important;
+ }
+ .dark .prose table tr:nth-child(even),
+ .dark .markdown-text table tr:nth-child(even) {
+ background: #1e293b !important;
+ }
+
+ /* Dark mode: Generic text in HTML components */
+ .dark .gradio-html p,
+ .dark .gradio-html span,
+ .dark .gradio-html div {
+ color: #e2e8f0;
+ }
+ """
+
+ with gr.Blocks(
+ title="Design System Extractor v2",
+ theme=corporate_theme,
+ css=custom_css
+ ) as app:
+
+ # Header with branding
+ gr.HTML("""
+
+ """)
+ gr.Markdown("This tool works in **3 stages**: (1) Discover & extract design tokens from a live website, "
+ "(2) Run AI-powered analysis to benchmark and improve your tokens, "
+ "(3) Export Figma-ready JSON. Start by entering a URL below.",
+ elem_classes=["section-desc"])
+
+ # =================================================================
+ # CONFIGURATION
+ # =================================================================
+
+ with gr.Accordion("⚙️ Configuration", open=not bool(HF_TOKEN_FROM_ENV)):
+ gr.Markdown("**HuggingFace Token** — Required for Stage 2 AI analysis (LLM agents). "
+ "Get a free token at [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens). "
+ "Stage 1 (extraction) works without a token. If set as an environment variable, it loads automatically.",
+ elem_classes=["section-desc"])
+ with gr.Row():
+ hf_token_input = gr.Textbox(
+ label="HF Token", placeholder="hf_xxxx", type="password",
+ scale=4, value=HF_TOKEN_FROM_ENV,
+ )
+ save_token_btn = gr.Button("💾 Save", scale=1)
+ token_status = gr.Markdown("✅ Token loaded" if HF_TOKEN_FROM_ENV else "⏳ Enter token")
+
+ def save_token(token):
+ if token and len(token) > 10:
+ os.environ["HF_TOKEN"] = token.strip()
+ return "✅ **Token saved!** You can now use Stage 2 AI analysis. Close this section and enter a URL below to begin."
+ return "❌ **Invalid token** — please enter a valid HuggingFace token (starts with `hf_`, at least 10 characters). Get one free at [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens)."
+
+ save_token_btn.click(save_token, [hf_token_input], [token_status])
+
+ # =================================================================
+ # URL INPUT & PAGE DISCOVERY
+ # =================================================================
+
+ with gr.Accordion("🔍 Step 1: Discover Pages", open=True):
+ gr.Markdown("Enter the homepage URL of any website. The crawler will find up to 20 internal pages "
+ "(homepage, about, contact, product pages, etc.). You then select which pages to scan "
+ "for design tokens (colors, typography, spacing, radius, and shadows).",
+ elem_classes=["section-desc"])
+
+ with gr.Row():
+ url_input = gr.Textbox(label="Website URL", placeholder="https://example.com", scale=4)
+ discover_btn = gr.Button("🔍 Discover Pages", variant="primary", scale=1)
+ gr.Markdown("*Enter the full URL including `https://` — the crawler will follow internal links from this page.*",
+ elem_classes=["section-desc"])
+
+ discover_status = gr.Markdown("")
+
+ with gr.Row():
+ log_output = gr.Textbox(label="📋 Log", lines=8, interactive=False)
+
+ pages_table = gr.Dataframe(
+ headers=["Select", "URL", "Title", "Type", "Status"],
+ datatype=["bool", "str", "str", "str", "str"],
+ label="Discovered Pages",
+ interactive=True,
+ visible=False,
+ )
+ gr.Markdown("*Use the **Select** checkbox to choose pages for extraction. Uncheck pages you want to skip "
+ "(login pages, error pages, etc.). **Type** shows the detected page category. Up to 10 pages will be processed.*",
+ elem_classes=["section-desc"])
+
+ gr.Markdown("*Extraction scans each selected page at two viewport sizes — Desktop (1440px) and Mobile (375px) — "
+ "pulling colors, typography, spacing, radius, and shadows from computed CSS.*",
+ elem_classes=["section-desc"])
+ extract_btn = gr.Button("🚀 Extract Tokens (Desktop + Mobile)", variant="primary", visible=False)
+
+ # =================================================================
+ # STAGE 1: EXTRACTION REVIEW
+ # =================================================================
+
+ with gr.Accordion("📊 Stage 1: Review Extracted Tokens", open=False) as stage1_accordion:
+
+ extraction_status = gr.Markdown("")
+
+ gr.Markdown("Review the design tokens extracted from your website. Use the **viewport toggle** to switch between "
+ "Desktop (1440px) and Mobile (375px) data. **Accept or reject** individual tokens using the checkboxes — "
+ "rejected tokens will be excluded from your design system export.",
+ elem_classes=["section-desc"])
+
+ viewport_toggle = gr.Radio(
+ choices=["Desktop (1440px)", "Mobile (375px)"],
+ value="Desktop (1440px)",
+ label="Viewport",
+ )
+
+ with gr.Tabs():
+ with gr.Tab("🎨 Colors"):
+ gr.Markdown("*Each row is a unique color found on the site. **Confidence** shows extraction certainty. "
+ "**AA** indicates WCAG accessibility pass/fail for normal text. **Context** shows where the color was used.*",
+ elem_classes=["section-desc"])
+ colors_table = gr.Dataframe(
+ headers=["Accept", "Color", "Suggested Name", "Frequency", "Confidence", "Contrast", "AA", "Context"],
+ datatype=["bool", "str", "str", "number", "str", "str", "str", "str"],
+ label="Colors",
+ interactive=True,
+ )
+ with gr.Accordion("👁️ Visual Preview", open=False):
+ stage1_colors_preview = gr.HTML(
+ value="Colors preview will appear after extraction...
",
+ label="Colors Preview"
+ )
+
+ with gr.Tab("📝 Typography"):
+ gr.Markdown("*Detected font styles sorted by frequency. **Size** is computed font-size, **Weight** is font-weight "
+ "(400=regular, 700=bold). **Suggested Name** is a semantic token name (e.g., heading.xl). "
+ "Uncheck rows to exclude from your design system.*",
+ elem_classes=["section-desc"])
+ typography_table = gr.Dataframe(
+ headers=["Accept", "Font", "Size", "Weight", "Line Height", "Suggested Name", "Frequency", "Confidence"],
+ datatype=["bool", "str", "str", "str", "str", "str", "number", "str"],
+ label="Typography",
+ interactive=True,
+ )
+ with gr.Accordion("👁️ Visual Preview", open=False):
+ stage1_typography_preview = gr.HTML(
+ value="Typography preview will appear after extraction...
",
+ label="Typography Preview"
+ )
+
+ with gr.Tab("📏 Spacing"):
+ gr.Markdown("*Spacing values (margins, paddings, gaps) extracted from the site. **Base 8** shows whether "
+ "the value aligns with the 8px grid standard. Values are sorted smallest to largest. "
+ "Uncheck irregular spacing values you want to exclude.*",
+ elem_classes=["section-desc"])
+ spacing_table = gr.Dataframe(
+ headers=["Accept", "Value", "Pixels", "Suggested Name", "Frequency", "Base 8", "Confidence"],
+ datatype=["bool", "str", "str", "str", "number", "str", "str"],
+ label="Spacing",
+ interactive=True,
+ )
+ with gr.Accordion("👁️ Visual Preview", open=False):
+ stage1_spacing_preview = gr.HTML(
+ value="Spacing preview will appear after extraction...
",
+ label="Spacing Preview"
+ )
+
+ with gr.Tab("🔘 Radius"):
+ gr.Markdown("*Border-radius values found across UI elements (buttons, cards, inputs). **Context** shows "
+ "which elements use each value. A consistent radius scale creates a cohesive UI.*",
+ elem_classes=["section-desc"])
+ radius_table = gr.Dataframe(
+ headers=["Accept", "Value", "Frequency", "Context"],
+ datatype=["bool", "str", "number", "str"],
+ label="Border Radius",
+ interactive=True,
+ )
+ with gr.Accordion("👁️ Visual Preview", open=False):
+ stage1_radius_preview = gr.HTML(
+ value="Radius preview will appear after extraction...
",
+ label="Radius Preview"
+ )
+
+ with gr.Tab("🌑 Shadows"):
+ gr.Markdown("*Box shadow values used for elevation and depth across the site. "
+ "Shows blur radius, spread, and color for each shadow layer.*",
+ elem_classes=["section-desc"])
+ stage1_shadows_preview = gr.HTML(
+ value="Shadows preview will appear after extraction...
",
+ label="Shadows Preview"
+ )
+
+ with gr.Tab("🧠 Semantic Colors"):
+ gr.Markdown("*Colors automatically categorized by their usage role: Brand (primary, secondary, accent), "
+ "Text (headings, body, muted), Background, Border, and Feedback (success, warning, error).*",
+ elem_classes=["section-desc"])
+ stage1_semantic_preview = gr.HTML(
+ value="Semantic color analysis will appear after extraction...
",
+ label="Semantic Colors Preview"
+ )
+
+ gr.Markdown("---")
+ gr.Markdown("When you are satisfied with the accepted tokens, **proceed to Stage 2** for AI-powered analysis "
+ "and improvement suggestions. Or **download the raw Stage 1 JSON** for immediate use in Figma Tokens Studio.",
+ elem_classes=["section-desc"])
+ with gr.Row():
+ proceed_stage2_btn = gr.Button("➡️ Proceed to Stage 2: AI Upgrades", variant="primary")
+ download_stage1_btn = gr.Button("📥 Download Stage 1 JSON", variant="secondary")
+
+ # =================================================================
+ # STAGE 2: AI UPGRADES
+ # =================================================================
+
+ with gr.Accordion("🧠 Stage 2: AI-Powered Analysis", open=False) as stage2_accordion:
+
+ # Stage header
+ gr.HTML("""
+
+ """)
+
+ stage2_status = gr.Markdown("Click **'Run Analysis'** below to start AI-powered design system analysis. "
+ "This runs a 4-layer pipeline: Rule Engine → Benchmark Research → LLM Agents → Head Synthesizer.")
+
+ # =============================================================
+ # NEW ARCHITECTURE CONFIGURATION
+ # =============================================================
+ with gr.Accordion("⚙️ Analysis Configuration", open=True):
+
+ # Architecture explanation
+ gr.Markdown("""
+ ### 🏗️ New Analysis Architecture
+
+ | Layer | Type | What It Does | Cost |
+ |-------|------|--------------|------|
+ | **Layer 1** | Rule Engine | Type scale, AA check, spacing grid, color stats | FREE |
+ | **Layer 2** | Benchmark Research | Fetch live specs via Firecrawl (24h cache) | ~$0.001 |
+ | **Layer 3** | LLM Agents | Brand ID, Benchmark Advisor, Best Practices | ~$0.002 |
+ | **Layer 4** | HEAD Synthesizer | Combine all → Final recommendations | ~$0.001 |
+
+ **Total Cost:** ~$0.003-0.004 per analysis
+ """)
+
+ gr.Markdown("---")
+
+ # Benchmark selection
+ gr.Markdown("### 📊 Select Design Systems to Compare Against")
+ gr.Markdown("*Choose which design systems to benchmark your tokens against:*")
+
+ benchmark_checkboxes = gr.CheckboxGroup(
+ choices=[
+ ("🟢 Material Design 3 (Google)", "material_design_3"),
+ ("🍎 Apple HIG", "apple_hig"),
+ ("🛒 Shopify Polaris", "shopify_polaris"),
+ ("🔵 Atlassian Design System", "atlassian_design"),
+ ("🔷 IBM Carbon", "ibm_carbon"),
+ ("🌊 Tailwind CSS", "tailwind_css"),
+ ("🐜 Ant Design", "ant_design"),
+ ("⚡ Chakra UI", "chakra_ui"),
+ ],
+ value=["material_design_3", "shopify_polaris", "atlassian_design"],
+ label="Benchmarks",
+ )
+
+ gr.Markdown("""
+
+ 💡 Tip: Select 2-4 benchmarks for best results. More benchmarks = longer analysis time.
+
+ 📦 Results are cached for 24 hours to speed up subsequent analyses.
+
+ """)
+
+ gr.Markdown("**Run Analysis** triggers the full 4-layer architecture (recommended). "
+ "**Legacy Analysis** uses the older single-agent approach. After analysis completes, "
+ "review scores, recommendations, and visual previews below, then apply your chosen upgrades.",
+ elem_classes=["section-desc"])
+
+ # Analyze button
+ with gr.Row():
+ analyze_btn_v2 = gr.Button(
+ "🚀 Run Analysis (New Architecture)",
+ variant="primary",
+ size="lg",
+ scale=2
+ )
+ analyze_btn_legacy = gr.Button(
+ "🤖 Legacy Analysis",
+ variant="secondary",
+ size="lg",
+ scale=1
+ )
+
+ # =============================================================
+ # ANALYSIS LOG
+ # =============================================================
+ with gr.Accordion("📋 Analysis Log", open=True):
+ gr.Markdown("*Real-time log of the analysis pipeline. Each layer reports its progress, results, and any errors. "
+ "Scroll through to see detailed statistics and individual agent outputs.*",
+ elem_classes=["section-desc"])
+ stage2_log = gr.Textbox(
+ label="Log",
+ lines=20,
+ interactive=False,
+ elem_classes=["log-container"]
+ )
+
+ # =============================================================
+ # SCORES DASHBOARD
+ # =============================================================
+ gr.Markdown("---")
+ gr.Markdown("## 📊 Analysis Results")
+ gr.Markdown("*Overall scores for your design system across accessibility, consistency, brand alignment, and best practices. "
+ "Each score is out of 100 — aim for 70+ in all categories. Priority actions below show the highest-impact fixes.*",
+ elem_classes=["section-desc"])
+
+ scores_dashboard = gr.HTML(
+ value="Scores will appear after analysis...
",
+ label="Scores"
+ )
+
+ # =============================================================
+ # PRIORITY ACTIONS
+ # =============================================================
+ priority_actions_html = gr.HTML(
+ value="Priority actions will appear after analysis...
",
+ label="Priority Actions"
+ )
+
+ # =============================================================
+ # BENCHMARK COMPARISON
+ # =============================================================
+ gr.Markdown("---")
+ gr.Markdown("## 📊 Benchmark Comparison")
+ gr.Markdown("*Your design tokens compared against industry-leading design systems (Material Design 3, Shopify Polaris, etc.). "
+ "Shows how closely your type scale, spacing grid, and color palette align with each benchmark. "
+ "Helps you decide which system to adopt or draw inspiration from.*",
+ elem_classes=["section-desc"])
+ benchmark_comparison_md = gr.Markdown("*Benchmark comparison will appear after analysis*")
+
+ # =============================================================
+ # COLOR RECOMMENDATIONS
+ # =============================================================
+ gr.Markdown("---")
+ gr.Markdown("## 🎨 Color Recommendations")
+ gr.Markdown("*AI-suggested color changes based on WCAG AA compliance, brand consistency, and industry best practices. "
+ "Each recommendation shows the current color, the issue found, and a suggested replacement. "
+ "Use the checkboxes to accept or reject individual changes before exporting.*",
+ elem_classes=["section-desc"])
+
+ # =============================================================
+ # TYPOGRAPHY SECTION
+ # =============================================================
+ gr.Markdown("---")
+ gr.Markdown("## 📐 Typography")
+ gr.Markdown("*Your detected type scale compared against standard ratios (Minor Third 1.2, Major Third 1.25, Perfect Fourth 1.333). "
+ "The visual preview shows how text will look at each scale. Desktop and mobile sizes are shown separately — "
+ "choose a scale below to apply to your exported tokens.*",
+ elem_classes=["section-desc"])
+
+ with gr.Accordion("👁️ Typography Visual Preview", open=True):
+ stage2_typography_preview = gr.HTML(
+ value="Typography preview will appear after analysis...
",
+ label="Typography Preview"
+ )
+
+ with gr.Row():
+ with gr.Column(scale=2):
+ gr.Markdown("### 🖥️ Desktop (1440px)")
+ typography_desktop = gr.Dataframe(
+ headers=["Token", "Current", "Scale 1.2", "Scale 1.25 ⭐", "Scale 1.333", "Keep"],
+ datatype=["str", "str", "str", "str", "str", "str"],
+ label="Desktop Typography",
+ interactive=False,
+ )
+
+ with gr.Column(scale=2):
+ gr.Markdown("### 📱 Mobile (375px)")
+ typography_mobile = gr.Dataframe(
+ headers=["Token", "Current", "Scale 1.2", "Scale 1.25 ⭐", "Scale 1.333", "Keep"],
+ datatype=["str", "str", "str", "str", "str", "str"],
+ label="Mobile Typography",
+ interactive=False,
+ )
+
+ with gr.Row():
+ with gr.Column():
+ gr.Markdown("### Select Type Scale Option")
+ type_scale_radio = gr.Radio(
+ choices=["Keep Current", "Scale 1.2 (Minor Third)", "Scale 1.25 (Major Third) ⭐", "Scale 1.333 (Perfect Fourth)"],
+ value="Scale 1.25 (Major Third) ⭐",
+ label="Type Scale",
+ interactive=True,
+ )
+ gr.Markdown("*Font family will be preserved. Sizes rounded to even numbers.*")
+
+ # =============================================================
+ # COLORS SECTION - Base Colors + Ramps + LLM Recommendations
+ # =============================================================
+ gr.Markdown("---")
+ gr.Markdown("## 🎨 Colors")
+ gr.Markdown("*Complete color analysis: base colors extracted from your site, AI-generated semantic color ramps (50–950 shades), "
+ "and LLM-powered recommendations for accessibility fixes. The visual preview groups colors by semantic role "
+ "(brand, text, background, border, feedback).*",
+ elem_classes=["section-desc"])
+
+ # LLM Recommendations Section (NEW)
+ with gr.Accordion("🤖 LLM Color Recommendations", open=True):
+ gr.Markdown("*Four AI agents analyzed your colors: **Brand Identifier** (detects primary/secondary brand colors), "
+ "**Benchmark Advisor** (compares to design system standards), **Best Practices Auditor** (WCAG, contrast, naming), "
+ "and **Head Synthesizer** (combines all findings into actionable suggestions). Use the table to accept or reject each change.*",
+ elem_classes=["section-desc"])
+
+ llm_color_recommendations = gr.HTML(
+ value="LLM recommendations will appear after analysis...
",
+ label="LLM Recommendations"
+ )
+
+ # Accept/Reject table for color recommendations
+ color_recommendations_table = gr.Dataframe(
+ headers=["Accept", "Role", "Current", "Issue", "Suggested", "Contrast"],
+ datatype=["bool", "str", "str", "str", "str", "str"],
+ label="Color Recommendations",
+ interactive=True,
+ col_count=(6, "fixed"),
+ )
+
+ # Visual Preview
+ with gr.Accordion("👁️ Color Ramps Visual Preview (Semantic Groups)", open=True):
+ gr.Markdown("*AI-generated color ramps expanding each base color into a 50–950 shade scale (similar to Tailwind CSS). "
+ "Colors are grouped by semantic role. These ramps will be included in your final export if the checkbox below is enabled.*",
+ elem_classes=["section-desc"])
+ stage2_color_ramps_preview = gr.HTML(
+ value="Color ramps preview will appear after analysis...
",
+ label="Color Ramps Preview"
+ )
+
+ gr.Markdown("**Base Colors** — Primary colors extracted from your site, organized by frequency and semantic role:",
+ elem_classes=["section-desc"])
+ base_colors_display = gr.Markdown("*Base colors will appear after analysis*")
+
+ gr.Markdown("---")
+
+ gr.Markdown("**Color Ramps** — Full shade tables (50–950) generated from each base color:",
+ elem_classes=["section-desc"])
+ color_ramps_display = gr.Markdown("*Color ramps will appear after analysis*")
+
+ color_ramps_checkbox = gr.Checkbox(
+ label="✓ Generate color ramps (keeps base colors, adds 50-950 shades)",
+ value=True,
+ )
+
+ # =============================================================
+ # SPACING SECTION
+ # =============================================================
+ gr.Markdown("---")
+ gr.Markdown("## 📏 Spacing (Rule-Based)")
+ gr.Markdown("*Your detected spacing values compared against standard 8px and 4px grid systems. "
+ "Consistent spacing creates visual rhythm and alignment. The 8px grid (8, 16, 24, 32...) is the industry standard — "
+ "select your preferred system below to normalize spacing in the export.*",
+ elem_classes=["section-desc"])
+
+ with gr.Row():
+ with gr.Column(scale=2):
+ spacing_comparison = gr.Dataframe(
+ headers=["Current", "8px Grid", "4px Grid"],
+ datatype=["str", "str", "str"],
+ label="Spacing Comparison",
+ interactive=False,
+ )
+
+ with gr.Column(scale=1):
+ spacing_radio = gr.Radio(
+ choices=["Keep Current", "8px Base Grid ⭐", "4px Base Grid"],
+ value="8px Base Grid ⭐",
+ label="Spacing System",
+ interactive=True,
+ )
+
+ # =============================================================
+ # RADIUS SECTION
+ # =============================================================
+ gr.Markdown("---")
+ gr.Markdown("## 🔘 Border Radius (Rule-Based)")
+ gr.Markdown("*Border radius values detected from your site, mapped to standard design tokens (radius.none → radius.full). "
+ "Consistent radius tokens ensure buttons, cards, and modals share a cohesive visual language. "
+ "Values are sorted from sharp corners to fully rounded.*",
+ elem_classes=["section-desc"])
+
+ radius_display = gr.Markdown("*Radius tokens will appear after analysis*")
+
+ # =============================================================
+ # SHADOWS SECTION
+ # =============================================================
+ gr.Markdown("---")
+ gr.Markdown("## 🌫️ Shadows (Rule-Based)")
+ gr.Markdown("*Box shadow values detected from your site, organized into elevation tokens (shadow.xs → shadow.2xl). "
+ "A well-defined shadow scale creates depth hierarchy — subtle shadows for cards, deeper shadows for modals and popovers. "
+ "Exported tokens are ready for Figma elevation styles.*",
+ elem_classes=["section-desc"])
+
+ shadows_display = gr.Markdown("*Shadow tokens will appear after analysis*")
+
+ # =============================================================
+ # APPLY SECTION
+ # =============================================================
+ gr.Markdown("---")
+ gr.Markdown("**Apply** saves your chosen type scale, spacing grid, color ramp, and LLM recommendation selections. "
+ "These choices will be baked into your Stage 3 export. **Reset** reverts all selections back to the original extracted values.",
+ elem_classes=["section-desc"])
+
+ with gr.Row():
+ apply_upgrades_btn = gr.Button("✨ Apply Selected Upgrades", variant="primary", scale=2)
+ reset_btn = gr.Button("↩️ Reset to Original", variant="secondary", scale=1)
+
+ apply_status = gr.Markdown("", elem_classes=["apply-status-box"])
+
+ # =================================================================
+ # STAGE 3: EXPORT
+ # =================================================================
+
+ with gr.Accordion("📦 Stage 3: Export", open=False):
+ gr.Markdown("Export your finalized design tokens as JSON, compatible with **Figma Tokens Studio**.",
+ elem_classes=["section-desc"])
+ gr.Markdown("""
+- **Stage 1 JSON (As-Is):** Raw extracted tokens with no modifications — useful for archival or baseline comparison. Includes desktop and mobile viewport variants.
+- **Final JSON (Upgraded):** Tokens with your selected improvements applied (type scale, spacing grid, color ramps, and accepted LLM recommendations). **This is the recommended export.**
+
+Copy the JSON output below or save it as a `.json` file for import into Figma.
+ """, elem_classes=["section-desc"])
+
+ with gr.Row():
+ export_stage1_btn = gr.Button("📥 Export Stage 1 (As-Is)", variant="secondary")
+ export_final_btn = gr.Button("📥 Export Final (Upgraded)", variant="primary")
+
+ gr.Markdown("*The generated JSON uses a flat token structure compatible with Figma Tokens Studio. "
+ "Copy the contents or save as a `.json` file.*",
+ elem_classes=["section-desc"])
+ export_output = gr.Code(label="Tokens JSON", language="json", lines=25)
+
+ export_stage1_btn.click(export_stage1_json, outputs=[export_output])
+ export_final_btn.click(export_tokens_json, outputs=[export_output])
+
+ # =================================================================
+ # EVENT HANDLERS
+ # =================================================================
+
+ # Store data for viewport toggle
+ desktop_data = gr.State({})
+ mobile_data = gr.State({})
+
+ # Discover pages
+ discover_btn.click(
+ fn=discover_pages,
+ inputs=[url_input],
+ outputs=[discover_status, log_output, pages_table],
+ ).then(
+ fn=lambda: (gr.update(visible=True), gr.update(visible=True)),
+ outputs=[pages_table, extract_btn],
+ )
+
+ # Extract tokens
+ extract_btn.click(
+ fn=extract_tokens,
+ inputs=[pages_table],
+ outputs=[extraction_status, log_output, desktop_data, mobile_data,
+ stage1_typography_preview, stage1_colors_preview,
+ stage1_semantic_preview,
+ stage1_spacing_preview, stage1_radius_preview, stage1_shadows_preview],
+ ).then(
+ fn=lambda d: (d.get("colors", []), d.get("typography", []), d.get("spacing", []), d.get("radius", [])),
+ inputs=[desktop_data],
+ outputs=[colors_table, typography_table, spacing_table, radius_table],
+ ).then(
+ fn=lambda: gr.update(open=True),
+ outputs=[stage1_accordion],
+ )
+
+ # Viewport toggle
+ viewport_toggle.change(
+ fn=switch_viewport,
+ inputs=[viewport_toggle],
+ outputs=[colors_table, typography_table, spacing_table, radius_table],
+ )
+
+ # Stage 2: NEW Architecture Analyze
+ analyze_btn_v2.click(
+ fn=run_stage2_analysis_v2,
+ inputs=[benchmark_checkboxes],
+ outputs=[
+ stage2_status,
+ stage2_log,
+ benchmark_comparison_md,
+ scores_dashboard,
+ priority_actions_html,
+ color_recommendations_table,
+ typography_desktop,
+ typography_mobile,
+ stage2_typography_preview,
+ stage2_color_ramps_preview,
+ llm_color_recommendations,
+ spacing_comparison,
+ base_colors_display,
+ color_ramps_display,
+ radius_display,
+ shadows_display,
+ ],
+ )
+
+ # Stage 2: Legacy Analyze (keep for backward compatibility)
+ analyze_btn_legacy.click(
+ fn=run_stage2_analysis,
+ inputs=[],
+ outputs=[stage2_status, stage2_log, benchmark_comparison_md, scores_dashboard,
+ typography_desktop, typography_mobile, spacing_comparison,
+ base_colors_display, color_ramps_display, radius_display, shadows_display,
+ stage2_typography_preview, stage2_color_ramps_preview,
+ llm_color_recommendations, color_recommendations_table],
+ )
+
+ # Stage 2: Apply upgrades
+ apply_upgrades_btn.click(
+ fn=apply_selected_upgrades,
+ inputs=[type_scale_radio, spacing_radio, color_ramps_checkbox, color_recommendations_table],
+ outputs=[apply_status, stage2_log],
+ )
+
+ # Stage 1: Download JSON
+ download_stage1_btn.click(
+ fn=export_stage1_json,
+ outputs=[export_output],
+ )
+
+ # Proceed to Stage 2 button
+ proceed_stage2_btn.click(
+ fn=lambda: gr.update(open=True),
+ outputs=[stage2_accordion],
+ )
+
+ # =================================================================
+ # FOOTER
+ # =================================================================
+
+ gr.Markdown("""
+ ---
+ **Design System Extractor v2** | Built with Playwright + Firecrawl + LangGraph + HuggingFace
+
+ *A semi-automated co-pilot for design system recovery and modernization.*
+
+ **New Architecture:** Rule Engine (FREE) + Benchmark Research (Firecrawl) + LLM Agents
+ """)
+
+ return app
+
+
+# =============================================================================
+# MAIN
+# =============================================================================
+
+if __name__ == "__main__":
+ app = create_ui()
+ app.launch(server_name="0.0.0.0", server_port=7860)