diff --git "a/app.py" "b/app.py"
deleted file mode 100644--- "a/app.py"
+++ /dev/null
@@ -1,4381 +0,0 @@
-"""
-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",
- ])
-
- return {
- "colors": colors,
- "typography": typography,
- "spacing": spacing,
- }
-
-
-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"]
-
-
-# =============================================================================
-# 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("")
-
- # 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."""
- from agents.llm_agents import HeadSynthesis
-
- # 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", [])),
- inputs=[desktop_data],
- outputs=[colors_table, typography_table, spacing_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],
- )
-
- # 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)