diff --git "a/app.py" "b/app.py" deleted file mode 100644--- "a/app.py" +++ /dev/null @@ -1,3626 +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() - - -# ============================================================================= -# 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 "❌ Please enter a valid URL", "", 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 = f"βœ… Found {len(pages)} pages. Review and click 'Extract Tokens' to continue." - - return status, state.get_logs(), pages_data - - except Exception as e: - import traceback - state.log(f"❌ Error: {str(e)}") - return f"❌ Error: {str(e)}", 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 "❌ Please 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 "❌ No pages selected. Please select pages or rediscover.", 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()) - return f"❌ Error: {str(e)}", 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("❌ Please complete Stage 1 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(f"❌ Rule Engine failed: {str(e)}") - - 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, - ) - except Exception as e: - state.log(f" ⚠️ Brand Identifier failed: {str(e)[:50]}") - 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, - ) - except Exception as e: - state.log(f" ⚠️ Benchmark Advisor failed: {str(e)[:50]}") - 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, - ) - except Exception as e: - state.log(f" ⚠️ Best Practices Validator failed: {str(e)[:50]}") - 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, - ) - except Exception as e: - state.log(f" ⚠️ HEAD Synthesizer failed: {str(e)[:50]}") - 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 - - 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 visual previews - typography_preview_html = "" - try: - from core.preview_generator import generate_typography_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) - except Exception as preview_err: - state.log(f" ⚠️ Preview generation failed: {str(preview_err)[:50]}") - typography_preview_html = "
Preview unavailable
" - - except Exception as format_err: - state.log(f" ⚠️ Formatting failed: {str(format_err)[:100]}") - # Return minimal results - return ( - f"⚠️ Analysis completed with formatting errors: {str(format_err)[:50]}", - state.get_logs(), - "*Benchmark comparison unavailable*", - "
Scores unavailable
", - "
Actions unavailable
", - [], - None, - None, - "", - ) - - 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, - ) - - except Exception as e: - import traceback - state.log(f"❌ Critical Error: {str(e)}") - state.log(traceback.format_exc()) - return create_stage2_error_response(f"❌ Analysis failed: {str(e)}") - - -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.""" - return ( - error_msg, - state.get_logs(), - "", # benchmark_md - "", # scores_html - "", # actions_html - [], # color_recs_table - None, # typography_desktop - None, # typography_mobile - "", # typography_preview - ) - - -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)") - - 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""" -
-
-
{overall}
-
OVERALL
-
-
-
{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''' -
-
-
-
- Before - {current} -
- β†’ -
-
- 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", "" - - state.log("✨ Applying selected upgrades...") - - # 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 = [] - 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: - state.log(f" β”œβ”€ ❌ REJECTED: {role} (keeping {current})") - - # Store accepted changes - state.selected_upgrades["color_changes"] = accepted_color_changes - - if accepted_color_changes: - state.log("") - state.log(f" πŸ“Š {len(accepted_color_changes)} color change(s) will be applied to export") - - state.log("") - state.log("βœ… Upgrades applied! Proceed to Stage 3 for export.") - - return "βœ… Upgrades applied! Proceed to Stage 3 to export.", 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. Please run extraction first."}, 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. Please run extraction first."}, indent=2) - - # Get selected upgrades - upgrades = getattr(state, 'selected_upgrades', {}) - 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; - padding: 12px; - text-align: left; - font-weight: 600; - border-bottom: 2px solid #e2e8f0; - } - td { - padding: 12px; - border-bottom: 1px solid #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 .benchmark-card { - background: #1e293b; - border-color: #334155; - } - .dark .action-item { - background: #1e293b; - border-color: #334155; - } - """ - - with gr.Blocks( - title="Design System Extractor v2", - theme=corporate_theme, - css=custom_css - ) as app: - - # Header with branding - gr.HTML(""" -
-

🎨 Design System Extractor v2

-

Reverse-engineer design systems from live websites β€’ AI-powered analysis β€’ Figma-ready export

-
- """) - - # ================================================================= - # CONFIGURATION - # ================================================================= - - with gr.Accordion("βš™οΈ Configuration", open=not bool(HF_TOKEN_FROM_ENV)): - gr.Markdown("**HuggingFace Token** β€” Required for Stage 2 (AI upgrades)") - 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!" - return "❌ Invalid token" - - 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 your website URL to discover pages for extraction.") - - 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) - - 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, - ) - - 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 extracted tokens.** Toggle between Desktop and Mobile viewports. - Accept or reject tokens, then proceed to Stage 2 for AI-powered upgrades. - """) - - viewport_toggle = gr.Radio( - choices=["Desktop (1440px)", "Mobile (375px)"], - value="Desktop (1440px)", - label="Viewport", - ) - - with gr.Tabs(): - with gr.Tab("🎨 Colors"): - 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.Tab("πŸ“ Typography"): - 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.Tab("πŸ“ Spacing"): - 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.Tab("πŸ”˜ Radius"): - radius_table = gr.Dataframe( - headers=["Accept", "Value", "Frequency", "Context"], - datatype=["bool", "str", "number", "str"], - label="Border Radius", - interactive=True, - ) - - # ============================================================= - # VISUAL PREVIEWS (Stage 1) - AS-IS only, no enhancements - # ============================================================= - gr.Markdown("---") - gr.Markdown("## πŸ‘οΈ Visual Previews (AS-IS)") - gr.Markdown("*Raw extracted values from the website β€” no enhancements applied*") - - with gr.Tabs(): - with gr.Tab("πŸ”€ Typography"): - gr.Markdown("*Actual typography rendered with the detected font*") - stage1_typography_preview = gr.HTML( - value="
Typography preview will appear after extraction...
", - label="Typography Preview" - ) - - with gr.Tab("🎨 Colors"): - gr.Markdown("*All detected colors (AS-IS β€” no generated ramps)*") - stage1_colors_preview = gr.HTML( - value="
Colors preview will appear after extraction...
", - label="Colors Preview" - ) - - with gr.Tab("🧠 Semantic Colors"): - gr.Markdown("*Colors categorized by usage: Brand, Text, Background, Border, Feedback*") - stage1_semantic_preview = gr.HTML( - value="
Semantic color analysis will appear after extraction...
", - label="Semantic Colors Preview" - ) - - with gr.Tab("πŸ“ Spacing"): - gr.Markdown("*All detected spacing values*") - stage1_spacing_preview = gr.HTML( - value="
Spacing preview will appear after extraction...
", - label="Spacing Preview" - ) - - with gr.Tab("πŸ”˜ Radius"): - gr.Markdown("*All detected border radius values*") - stage1_radius_preview = gr.HTML( - value="
Radius preview will appear after extraction...
", - label="Radius Preview" - ) - - with gr.Tab("πŸŒ‘ Shadows"): - gr.Markdown("*All detected box shadow values*") - stage1_shadows_preview = gr.HTML( - value="
Shadows preview will appear after extraction...
", - label="Shadows Preview" - ) - - 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(""" -
-

🧠 Stage 2: Multi-Agent Analysis

-

Rule Engine + Benchmark Research + LLM Agents

-
- """) - - stage2_status = gr.Markdown("Click 'Analyze' to start AI-powered design system analysis.") - - # ============================================================= - # 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. -
- """) - - # 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): - stage2_log = gr.Textbox( - label="Log", - lines=20, - interactive=False, - elem_classes=["log-container"] - ) - - # ============================================================= - # SCORES DASHBOARD - # ============================================================= - gr.Markdown("---") - gr.Markdown("## πŸ“Š Analysis Results") - - 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("---") - benchmark_comparison_md = gr.Markdown("*Benchmark comparison will appear after analysis*") - - # ============================================================= - # COLOR RECOMMENDATIONS - # ============================================================= - gr.Markdown("---") - gr.Markdown("## 🎨 Color Recommendations") - gr.Markdown("*Accept or reject AI-suggested color changes:*") - - color_recommendations_table = gr.Dataframe( - headers=["Accept", "Role", "Current", "Issue", "Suggested", "New Contrast"], - datatype=["bool", "str", "str", "str", "str", "str"], - label="Color Recommendations", - interactive=True, - row_count=(0, "dynamic"), - ) - - # ============================================================= - # TYPOGRAPHY SECTION - # ============================================================= - gr.Markdown("---") - gr.Markdown("## πŸ“ Typography") - - 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") - - # LLM Recommendations Section (NEW) - with gr.Accordion("πŸ€– LLM Color Recommendations", open=True): - gr.Markdown(""" - *The LLMs analyzed your colors and made these suggestions. Accept or reject each one.* - """) - - 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): - stage2_color_ramps_preview = gr.HTML( - value="
Color ramps preview will appear after analysis...
", - label="Color Ramps Preview" - ) - - base_colors_display = gr.Markdown("*Base colors will appear after analysis*") - - gr.Markdown("---") - - 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)") - - 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)") - - radius_display = gr.Markdown("*Radius tokens will appear after analysis*") - - # ============================================================= - # SHADOWS SECTION - # ============================================================= - gr.Markdown("---") - gr.Markdown("## 🌫️ Shadows (Rule-Based)") - - shadows_display = gr.Markdown("*Shadow tokens will appear after analysis*") - - # ============================================================= - # APPLY SECTION - # ============================================================= - gr.Markdown("---") - - 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("") - - # ================================================================= - # STAGE 3: EXPORT - # ================================================================= - - with gr.Accordion("πŸ“¦ Stage 3: Export", open=False): - gr.Markdown(""" - Export your design tokens to JSON (compatible with Figma Tokens Studio). - - - **Stage 1 JSON**: Raw extracted tokens (as-is) - - **Final JSON**: Upgraded tokens with selected improvements - """) - - 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") - - 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, - ], - ) - - # 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)