""" Preview Generator for Typography and Color Previews Generates HTML previews for: 1. Typography - Actual font rendering with detected styles 2. Colors AS-IS - Simple swatches showing extracted colors (Stage 1) 3. Color Ramps - 11 shades (50-950) with AA compliance (Stage 2) 4. Spacing AS-IS - Visual spacing blocks 5. Radius AS-IS - Rounded corner examples 6. Shadows AS-IS - Shadow examples """ from typing import Optional import colorsys import re # ============================================================================= # STAGE 1: AS-IS PREVIEWS (No enhancements, just raw extracted values) # ============================================================================= def generate_colors_asis_preview_html( color_tokens: dict, background: str = "#FAFAFA", max_colors: int = 50 ) -> str: """ Generate HTML preview for AS-IS colors (Stage 1). Shows simple color swatches without generated ramps. Sorted by frequency (most used first). Args: color_tokens: Dict of colors {name: {value: "#hex", ...}} background: Background color max_colors: Maximum colors to display (default 50) Returns: HTML string for Gradio HTML component """ # Sort by frequency (highest first) sorted_tokens = [] for name, token in color_tokens.items(): if isinstance(token, dict): freq = token.get("frequency", 0) else: freq = 0 sorted_tokens.append((name, token, freq)) sorted_tokens.sort(key=lambda x: -x[2]) # Descending by frequency rows_html = "" for name, token, freq in sorted_tokens[:max_colors]: # Get hex value if isinstance(token, dict): hex_val = token.get("value", "#888888") frequency = token.get("frequency", 0) contexts = token.get("contexts", []) contrast_white = token.get("contrast_white", 0) contrast_black = token.get("contrast_black", 0) else: hex_val = str(token) frequency = 0 contexts = [] contrast_white = 0 contrast_black = 0 # Clean up hex if not hex_val.startswith("#"): hex_val = f"#{hex_val}" # Determine text color based on background luminance # Use contrast ratios to pick best text color text_color = "#1a1a1a" if contrast_white and contrast_white < 4.5 else "#ffffff" if not contrast_white: # Fallback: calculate from hex try: r = int(hex_val[1:3], 16) g = int(hex_val[3:5], 16) b = int(hex_val[5:7], 16) luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 text_color = "#1a1a1a" if luminance > 0.5 else "#ffffff" except: text_color = "#1a1a1a" # Clean name display_name = name.replace("_", " ").replace("-", " ").replace(".", " ").title() if len(display_name) > 25: display_name = display_name[:22] + "..." # AA compliance check aa_status = "✓ AA" if contrast_white and contrast_white >= 4.5 else "✗ AA" if contrast_white else "" aa_class = "aa-pass" if contrast_white and contrast_white >= 4.5 else "aa-fail" # Context badges (limit to 3) context_html = "" for ctx in contexts[:3]: ctx_display = ctx[:12] + "..." if len(ctx) > 12 else ctx context_html += f'{ctx_display}' rows_html += f'''
{hex_val}
{display_name}
Used {frequency}x {aa_status}
{context_html}
''' # Show count info total_colors = len(color_tokens) showing = min(max_colors, total_colors) count_info = f"Showing {showing} of {total_colors} colors (sorted by frequency)" html = f'''
{count_info}
{rows_html}
''' return html def generate_spacing_asis_preview_html( spacing_tokens: dict, background: str = "#FAFAFA" ) -> str: """ Generate HTML preview for AS-IS spacing (Stage 1). Shows visual blocks representing each spacing value. """ rows_html = "" # Sort by pixel value sorted_tokens = [] for name, token in spacing_tokens.items(): if isinstance(token, dict): value_px = token.get("value_px", 0) value = token.get("value", "0px") else: value = str(token) value_px = float(re.sub(r'[^0-9.]', '', value) or 0) sorted_tokens.append((name, token, value_px, value)) sorted_tokens.sort(key=lambda x: x[2]) for name, token, value_px, value in sorted_tokens[:15]: # Cap visual width at 200px visual_width = min(value_px, 200) rows_html += f'''
{value}
''' html = f'''
{rows_html}
''' return html def generate_radius_asis_preview_html( radius_tokens: dict, background: str = "#FAFAFA" ) -> str: """ Generate HTML preview for AS-IS border radius (Stage 1). Shows boxes with each radius value applied. """ rows_html = "" for name, token in list(radius_tokens.items())[:12]: if isinstance(token, dict): value = token.get("value", "0px") else: value = str(token) rows_html += f'''
{value}
''' html = f'''
{rows_html}
''' return html def generate_shadows_asis_preview_html( shadow_tokens: dict, background: str = "#FAFAFA" ) -> str: """ Generate HTML preview for AS-IS shadows (Stage 1). Shows cards with each shadow value applied. """ rows_html = "" for name, token in list(shadow_tokens.items())[:8]: if isinstance(token, dict): value = token.get("value", "none") else: value = str(token) # Clean name for display display_name = name.replace("_", " ").replace("-", " ").title() if len(display_name) > 15: display_name = display_name[:12] + "..." rows_html += f'''
{display_name}
{value[:40]}...
''' html = f'''
{rows_html}
''' return html # ============================================================================= # STAGE 2: TYPOGRAPHY PREVIEW (with rendered font) # ============================================================================= def generate_typography_preview_html( typography_tokens: dict, font_family: str = "Open Sans", background: str = "#FAFAFA", sample_text: str = "The quick brown fox jumps over the lazy dog" ) -> str: """ Generate HTML preview for typography tokens. Args: typography_tokens: Dict of typography styles {name: {font_size, font_weight, line_height, letter_spacing}} font_family: Primary font family detected background: Background color (neutral) sample_text: Text to render for preview Returns: HTML string for Gradio HTML component """ # Sort tokens by font size (largest first) sorted_tokens = [] for name, token in typography_tokens.items(): size_str = str(token.get("font_size", "16px")) size_num = float(re.sub(r'[^0-9.]', '', size_str) or 16) sorted_tokens.append((name, token, size_num)) sorted_tokens.sort(key=lambda x: -x[2]) # Descending by size # Generate rows rows_html = "" for name, token, size_num in sorted_tokens[:15]: # Limit to 15 styles font_size = token.get("font_size", "16px") font_weight = token.get("font_weight", "400") line_height = token.get("line_height", "1.5") letter_spacing = token.get("letter_spacing", "0") # Convert weight names to numbers weight_map = { "thin": 100, "extralight": 200, "light": 300, "regular": 400, "medium": 500, "semibold": 600, "bold": 700, "extrabold": 800, "black": 900 } if isinstance(font_weight, str) and font_weight.lower() in weight_map: font_weight = weight_map[font_weight.lower()] # Weight label weight_labels = { 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium", 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black" } weight_label = weight_labels.get(int(font_weight) if str(font_weight).isdigit() else 400, "Regular") # Clean up name for display display_name = name.replace("_", " ").replace("-", " ").title() if len(display_name) > 15: display_name = display_name[:15] + "..." # Truncate sample text for large sizes display_text = sample_text if size_num > 48: display_text = sample_text[:30] + "..." elif size_num > 32: display_text = sample_text[:40] + "..." rows_html += f'''
{display_name}
{font_family} {weight_label} {int(size_num)} Sentence {letter_spacing}
{display_text}
''' html = f'''
{rows_html}
Scale Category Typeface Weight Size Case Letter Spacing
''' return html # ============================================================================= # COLOR RAMP PREVIEW # ============================================================================= def hex_to_rgb(hex_color: str) -> tuple: """Convert hex color to RGB tuple.""" hex_color = hex_color.lstrip('#') if len(hex_color) == 3: hex_color = ''.join([c*2 for c in hex_color]) return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) def rgb_to_hex(rgb: tuple) -> str: """Convert RGB tuple to hex string.""" return '#{:02x}{:02x}{:02x}'.format(int(rgb[0]), int(rgb[1]), int(rgb[2])) def get_luminance(rgb: tuple) -> float: """Calculate relative luminance for contrast ratio.""" def adjust(c): c = c / 255 return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 r, g, b = rgb return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b) def get_contrast_ratio(color1: tuple, color2: tuple) -> float: """Calculate contrast ratio between two colors.""" l1 = get_luminance(color1) l2 = get_luminance(color2) lighter = max(l1, l2) darker = min(l1, l2) return (lighter + 0.05) / (darker + 0.05) def generate_color_ramp(base_hex: str) -> list[dict]: """ Generate 11 shades (50-950) from a base color. Uses OKLCH-like approach for perceptually uniform steps. """ try: rgb = hex_to_rgb(base_hex) except: return [] # Convert to HLS for easier manipulation r, g, b = [x / 255 for x in rgb] h, l, s = colorsys.rgb_to_hls(r, g, b) # Define lightness levels for each shade # 50 = very light (0.95), 500 = base, 950 = very dark (0.05) shade_lightness = { 50: 0.95, 100: 0.90, 200: 0.80, 300: 0.70, 400: 0.60, 500: l, # Keep original lightness for 500 600: 0.45, 700: 0.35, 800: 0.25, 900: 0.15, 950: 0.08, } # Adjust saturation for light/dark shades ramp = [] for shade, target_l in shade_lightness.items(): # Reduce saturation for very light colors if target_l > 0.8: adjusted_s = s * 0.6 elif target_l < 0.2: adjusted_s = s * 0.8 else: adjusted_s = s # Generate new RGB new_r, new_g, new_b = colorsys.hls_to_rgb(h, target_l, adjusted_s) new_rgb = (int(new_r * 255), int(new_g * 255), int(new_b * 255)) new_hex = rgb_to_hex(new_rgb) # Check AA compliance white = (255, 255, 255) black = (0, 0, 0) contrast_white = get_contrast_ratio(new_rgb, white) contrast_black = get_contrast_ratio(new_rgb, black) # AA requires 4.5:1 for normal text aa_on_white = contrast_white >= 4.5 aa_on_black = contrast_black >= 4.5 ramp.append({ "shade": shade, "hex": new_hex, "rgb": new_rgb, "contrast_white": round(contrast_white, 2), "contrast_black": round(contrast_black, 2), "aa_on_white": aa_on_white, "aa_on_black": aa_on_black, }) return ramp def generate_color_ramps_preview_html( color_tokens: dict, background: str = "#FAFAFA", max_colors: int = 20 ) -> str: """ Generate HTML preview for color ramps. Sorts colors by frequency and filters out near-white/near-black to prioritize showing actual brand colors. Args: color_tokens: Dict of colors {name: {value: "#hex", ...}} background: Background color max_colors: Maximum colors to show ramps for Returns: HTML string for Gradio HTML component """ def get_color_priority(name, token): """Calculate priority score for a color (higher = more important).""" if isinstance(token, dict): hex_val = token.get("value", "#888888") frequency = token.get("frequency", 0) else: hex_val = str(token) frequency = 0 # Clean hex if not hex_val.startswith("#"): hex_val = f"#{hex_val}" # Calculate luminance try: r = int(hex_val[1:3], 16) g = int(hex_val[3:5], 16) b = int(hex_val[5:7], 16) luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 # Calculate saturation (simplified) max_c = max(r, g, b) min_c = min(r, g, b) saturation = (max_c - min_c) / 255 if max_c > 0 else 0 except: luminance = 0.5 saturation = 0 # Priority scoring: # - Penalize near-white (luminance > 0.9) # - Penalize near-black (luminance < 0.1) # - Penalize low saturation (grays) # - Reward high frequency # - Reward colors with "primary", "brand", "accent" in name score = frequency * 10 # Base score from frequency # Penalize extremes if luminance > 0.9: score -= 500 # Near white if luminance < 0.1: score -= 300 # Near black # Reward saturated colors (actual brand colors) score += saturation * 200 # Reward named brand colors name_lower = name.lower() if any(kw in name_lower for kw in ['primary', 'brand', 'accent', 'cyan', 'blue', 'green', 'red', 'orange', 'purple']): score += 100 # Penalize "background", "border", "text" colors if any(kw in name_lower for kw in ['background', 'border', 'neutral', 'gray', 'grey']): score -= 50 return score # Sort colors by priority sorted_colors = [] for name, token in color_tokens.items(): priority = get_color_priority(name, token) sorted_colors.append((name, token, priority)) sorted_colors.sort(key=lambda x: -x[2]) # Descending by priority rows_html = "" shown_count = 0 for name, token, priority in sorted_colors: if shown_count >= max_colors: break # Get hex value if isinstance(token, dict): hex_val = token.get("value", "#888888") else: hex_val = str(token) # Clean up hex if not hex_val.startswith("#"): hex_val = f"#{hex_val}" # Skip invalid hex if len(hex_val) < 7: continue # Generate ramp ramp = generate_color_ramp(hex_val) if not ramp: continue # Clean name display_name = name.replace("_", " ").replace("-", " ").replace(".", " ").title() if len(display_name) > 18: display_name = display_name[:15] + "..." # Generate shade cells shades_html = "" for shade_info in ramp: shade = shade_info["shade"] hex_color = shade_info["hex"] aa_white = shade_info["aa_on_white"] aa_black = shade_info["aa_on_black"] # Determine text color for label text_color = "#000" if shade < 500 else "#FFF" # AA indicator if aa_white or aa_black: aa_indicator = "✓" aa_class = "aa-pass" else: aa_indicator = "" aa_class = "" shades_html += f'''
{shade} {aa_indicator}
''' rows_html += f'''
{display_name}
{hex_val}
{shades_html}
''' shown_count += 1 # Count info total_colors = len(color_tokens) count_info = f"Showing {shown_count} of {total_colors} colors (sorted by brand priority)" html = f'''
{count_info}
50 100 200 300 400 500 600 700 800 900 950
{rows_html}
''' return html # ============================================================================= # SEMANTIC COLOR RAMPS WITH LLM RECOMMENDATIONS (Stage 2) # ============================================================================= def generate_semantic_color_ramps_html( semantic_analysis: dict, color_tokens: dict, llm_recommendations: dict = None, background: str = "#F5F5F5" ) -> str: """ Generate HTML preview for colors organized by semantic role with LLM recommendations. Args: semantic_analysis: Output from SemanticColorAnalyzer color_tokens: Dict of all color tokens llm_recommendations: LLM suggestions for color improvements background: Background color Returns: HTML string for Gradio HTML component """ def generate_single_ramp(hex_val: str) -> str: """Generate a single color ramp HTML.""" ramp = generate_color_ramp(hex_val) if not ramp: return "" shades_html = "" for shade_info in ramp: shade = shade_info["shade"] hex_color = shade_info["hex"] aa_white = shade_info["aa_on_white"] aa_black = shade_info["aa_on_black"] text_color = "#000" if shade < 500 else "#FFF" aa_indicator = "✓" if aa_white or aa_black else "" shades_html += f'''
{shade} {aa_indicator}
''' return shades_html def color_row_with_recommendation(hex_val: str, role: str, role_display: str, recommendation: dict = None) -> str: """Generate a color row with optional LLM recommendation.""" ramp_html = generate_single_ramp(hex_val) # Calculate contrast try: from core.color_utils import get_contrast_with_white contrast = get_contrast_with_white(hex_val) aa_status = "✓ AA" if contrast >= 4.5 else f"⚠️ {contrast:.1f}:1" aa_class = "aa-ok" if contrast >= 4.5 else "aa-warn" except: aa_status = "" aa_class = "" # LLM recommendation display rec_html = "" if recommendation: suggested = recommendation.get("suggested", "") issue = recommendation.get("issue", "") if suggested and suggested != hex_val: rec_html = f'''
💡 LLM: {issue} {suggested}
''' return f'''
{role_display}
{hex_val} {aa_status}
{ramp_html}
{rec_html}
''' def category_section(title: str, icon: str, colors: dict, category_key: str) -> str: """Generate a category section with color rows.""" if not colors: return "" rows_html = "" for role, data in colors.items(): if data and isinstance(data, dict) and "hex" in data: # Get LLM recommendation for this role rec = None if llm_recommendations: color_recs = llm_recommendations.get("color_recommendations", {}) rec = color_recs.get(f"{category_key}.{role}", {}) role_display = role.replace("_", " ").title() rows_html += color_row_with_recommendation( data["hex"], f"{category_key}.{role}", role_display, rec ) if not rows_html: return "" return f'''

{icon} {title}

{rows_html}
''' # Handle empty analysis if not semantic_analysis: return '''

⚠️ No semantic analysis available.

''' # Build sections sections_html = "" sections_html += category_section("Brand Colors", "🎨", semantic_analysis.get("brand", {}), "brand") sections_html += category_section("Text Colors", "📝", semantic_analysis.get("text", {}), "text") sections_html += category_section("Background Colors", "🖼️", semantic_analysis.get("background", {}), "background") sections_html += category_section("Border Colors", "📏", semantic_analysis.get("border", {}), "border") sections_html += category_section("Feedback Colors", "🚨", semantic_analysis.get("feedback", {}), "feedback") # LLM Impact Summary llm_summary = "" if llm_recommendations: changes = llm_recommendations.get("changes_made", []) if changes: changes_html = "".join([f"
  • {c}
  • " for c in changes[:5]]) llm_summary = f'''

    🤖 LLM Recommendations Applied:

    ''' html = f'''
    {sections_html} {llm_summary}
    ''' return html # ============================================================================= # COMBINED PREVIEW # ============================================================================= def generate_design_system_preview_html( typography_tokens: dict, color_tokens: dict, font_family: str = "Open Sans", sample_text: str = "The quick brown fox jumps over the lazy dog" ) -> tuple[str, str]: """ Generate both typography and color ramp previews. Returns: Tuple of (typography_html, color_ramps_html) """ typography_html = generate_typography_preview_html( typography_tokens=typography_tokens, font_family=font_family, sample_text=sample_text, ) color_ramps_html = generate_color_ramps_preview_html( color_tokens=color_tokens, ) return typography_html, color_ramps_html