| | """ |
| | Preview Generator for Typography and Color Ramps |
| | |
| | Generates HTML previews for: |
| | 1. Typography - Actual font rendering with detected styles |
| | 2. Color Ramps - 11 shades (50-950) with AA compliance indicators |
| | """ |
| |
|
| | from typing import Optional |
| | import colorsys |
| | import re |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | 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 |
| | """ |
| | |
| | |
| | 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]) |
| | |
| | |
| | rows_html = "" |
| | for name, token, size_num in sorted_tokens[:15]: |
| | 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") |
| | |
| | |
| | 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_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") |
| | |
| | |
| | display_name = name.replace("_", " ").replace("-", " ").title() |
| | if len(display_name) > 15: |
| | display_name = display_name[:15] + "..." |
| | |
| | |
| | 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''' |
| | <tr> |
| | <td class="scale-name"> |
| | <div class="scale-label">{display_name}</div> |
| | </td> |
| | <td class="meta">{font_family}</td> |
| | <td class="meta">{weight_label}</td> |
| | <td class="meta">{int(size_num)}</td> |
| | <td class="meta">Sentence</td> |
| | <td class="meta">{letter_spacing}</td> |
| | </tr> |
| | <tr> |
| | <td colspan="6" class="preview-cell"> |
| | <div class="preview-text" style=" |
| | font-family: '{font_family}', sans-serif; |
| | font-size: {font_size}; |
| | font-weight: {font_weight}; |
| | line-height: {line_height}; |
| | letter-spacing: {letter_spacing}px; |
| | ">{display_text}</div> |
| | </td> |
| | </tr> |
| | ''' |
| | |
| | html = f''' |
| | <style> |
| | @import url('https://fonts.googleapis.com/css2?family={font_family.replace(" ", "+")}:wght@100;200;300;400;500;600;700;800;900&display=swap'); |
| | |
| | .typography-preview {{ |
| | font-family: system-ui, -apple-system, sans-serif; |
| | background: {background}; |
| | border-radius: 12px; |
| | padding: 20px; |
| | overflow-x: auto; |
| | }} |
| | |
| | .typography-preview table {{ |
| | width: 100%; |
| | border-collapse: collapse; |
| | }} |
| | |
| | .typography-preview th {{ |
| | text-align: left; |
| | padding: 12px 16px; |
| | font-size: 12px; |
| | font-weight: 600; |
| | color: #666; |
| | text-transform: uppercase; |
| | letter-spacing: 0.5px; |
| | border-bottom: 2px solid #E0E0E0; |
| | }} |
| | |
| | .typography-preview td {{ |
| | padding: 8px 16px; |
| | vertical-align: middle; |
| | }} |
| | |
| | .typography-preview .scale-name {{ |
| | font-weight: 600; |
| | color: #333; |
| | min-width: 100px; |
| | }} |
| | |
| | .typography-preview .scale-label {{ |
| | font-size: 14px; |
| | }} |
| | |
| | .typography-preview .meta {{ |
| | font-size: 13px; |
| | color: #666; |
| | white-space: nowrap; |
| | }} |
| | |
| | .typography-preview .preview-cell {{ |
| | padding: 16px; |
| | background: #FFFFFF; |
| | border-bottom: 1px solid #E8E8E8; |
| | }} |
| | |
| | .typography-preview .preview-text {{ |
| | color: #1A1A1A; |
| | margin: 0; |
| | word-break: break-word; |
| | }} |
| | |
| | .typography-preview tr:hover .preview-cell {{ |
| | background: #F5F5F5; |
| | }} |
| | </style> |
| | |
| | <div class="typography-preview"> |
| | <table> |
| | <thead> |
| | <tr> |
| | <th>Scale Category</th> |
| | <th>Typeface</th> |
| | <th>Weight</th> |
| | <th>Size</th> |
| | <th>Case</th> |
| | <th>Letter Spacing</th> |
| | </tr> |
| | </thead> |
| | <tbody> |
| | {rows_html} |
| | </tbody> |
| | </table> |
| | </div> |
| | ''' |
| | |
| | return html |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | 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 [] |
| | |
| | |
| | r, g, b = [x / 255 for x in rgb] |
| | h, l, s = colorsys.rgb_to_hls(r, g, b) |
| | |
| | |
| | |
| | shade_lightness = { |
| | 50: 0.95, |
| | 100: 0.90, |
| | 200: 0.80, |
| | 300: 0.70, |
| | 400: 0.60, |
| | 500: l, |
| | 600: 0.45, |
| | 700: 0.35, |
| | 800: 0.25, |
| | 900: 0.15, |
| | 950: 0.08, |
| | } |
| | |
| | |
| | ramp = [] |
| | for shade, target_l in shade_lightness.items(): |
| | |
| | if target_l > 0.8: |
| | adjusted_s = s * 0.6 |
| | elif target_l < 0.2: |
| | adjusted_s = s * 0.8 |
| | else: |
| | adjusted_s = s |
| | |
| | |
| | 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) |
| | |
| | |
| | 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_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" |
| | ) -> str: |
| | """ |
| | Generate HTML preview for color ramps. |
| | |
| | Args: |
| | color_tokens: Dict of colors {name: {value: "#hex", ...}} |
| | background: Background color |
| | |
| | Returns: |
| | HTML string for Gradio HTML component |
| | """ |
| | |
| | rows_html = "" |
| | |
| | for name, token in list(color_tokens.items())[:10]: |
| | |
| | if isinstance(token, dict): |
| | hex_val = token.get("value", "#888888") |
| | else: |
| | hex_val = str(token) |
| | |
| | |
| | if not hex_val.startswith("#"): |
| | hex_val = f"#{hex_val}" |
| | |
| | |
| | ramp = generate_color_ramp(hex_val) |
| | if not ramp: |
| | continue |
| | |
| | |
| | display_name = name.replace("_", " ").replace("-", " ").title() |
| | if len(display_name) > 15: |
| | display_name = display_name[:12] + "..." |
| | |
| | |
| | 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" |
| | |
| | |
| | if aa_white or aa_black: |
| | aa_indicator = "✓" |
| | aa_class = "aa-pass" |
| | else: |
| | aa_indicator = "✗" |
| | aa_class = "aa-fail" |
| | |
| | shades_html += f''' |
| | <div class="shade-cell" style="background-color: {hex_color};" title="{hex_color} | AA: {'Pass' if aa_white or aa_black else 'Fail'}"> |
| | <span class="shade-label" style="color: {text_color};">{shade}</span> |
| | <span class="aa-badge {aa_class}">{aa_indicator}</span> |
| | </div> |
| | ''' |
| | |
| | rows_html += f''' |
| | <div class="color-row"> |
| | <div class="color-info"> |
| | <div class="color-swatch" style="background-color: {hex_val};"></div> |
| | <div class="color-meta"> |
| | <div class="color-name">{display_name}</div> |
| | <div class="color-hex">{hex_val}</div> |
| | </div> |
| | </div> |
| | <div class="color-ramp"> |
| | {shades_html} |
| | </div> |
| | </div> |
| | ''' |
| | |
| | html = f''' |
| | <style> |
| | .color-ramps-preview {{ |
| | font-family: system-ui, -apple-system, sans-serif; |
| | background: {background}; |
| | border-radius: 12px; |
| | padding: 20px; |
| | overflow-x: auto; |
| | }} |
| | |
| | .color-row {{ |
| | display: flex; |
| | align-items: center; |
| | margin-bottom: 16px; |
| | padding-bottom: 16px; |
| | border-bottom: 1px solid #E8E8E8; |
| | }} |
| | |
| | .color-row:last-child {{ |
| | border-bottom: none; |
| | margin-bottom: 0; |
| | padding-bottom: 0; |
| | }} |
| | |
| | .color-info {{ |
| | display: flex; |
| | align-items: center; |
| | min-width: 140px; |
| | margin-right: 20px; |
| | }} |
| | |
| | .color-swatch {{ |
| | width: 40px; |
| | height: 40px; |
| | border-radius: 50%; |
| | border: 2px solid rgba(0,0,0,0.1); |
| | margin-right: 12px; |
| | flex-shrink: 0; |
| | }} |
| | |
| | .color-meta {{ |
| | flex: 1; |
| | }} |
| | |
| | .color-name {{ |
| | font-weight: 600; |
| | font-size: 14px; |
| | color: #333; |
| | margin-bottom: 2px; |
| | }} |
| | |
| | .color-hex {{ |
| | font-size: 12px; |
| | color: #666; |
| | font-family: monospace; |
| | }} |
| | |
| | .color-ramp {{ |
| | display: flex; |
| | gap: 4px; |
| | flex: 1; |
| | }} |
| | |
| | .shade-cell {{ |
| | width: 48px; |
| | height: 48px; |
| | border-radius: 6px; |
| | display: flex; |
| | flex-direction: column; |
| | align-items: center; |
| | justify-content: center; |
| | position: relative; |
| | cursor: pointer; |
| | transition: transform 0.15s; |
| | }} |
| | |
| | .shade-cell:hover {{ |
| | transform: scale(1.1); |
| | z-index: 10; |
| | box-shadow: 0 4px 12px rgba(0,0,0,0.2); |
| | }} |
| | |
| | .shade-label {{ |
| | font-size: 10px; |
| | font-weight: 600; |
| | opacity: 0.9; |
| | }} |
| | |
| | .aa-badge {{ |
| | font-size: 9px; |
| | margin-top: 2px; |
| | }} |
| | |
| | .aa-pass {{ |
| | color: #22C55E; |
| | }} |
| | |
| | .aa-fail {{ |
| | color: #EF4444; |
| | }} |
| | |
| | .shade-cell:hover .shade-label, |
| | .shade-cell:hover .aa-badge {{ |
| | opacity: 1; |
| | }} |
| | |
| | /* Header row */ |
| | .ramp-header {{ |
| | display: flex; |
| | margin-bottom: 12px; |
| | padding-left: 160px; |
| | }} |
| | |
| | .ramp-header-label {{ |
| | width: 48px; |
| | text-align: center; |
| | font-size: 11px; |
| | font-weight: 600; |
| | color: #666; |
| | margin-right: 4px; |
| | }} |
| | </style> |
| | |
| | <div class="color-ramps-preview"> |
| | <div class="ramp-header"> |
| | <span class="ramp-header-label">50</span> |
| | <span class="ramp-header-label">100</span> |
| | <span class="ramp-header-label">200</span> |
| | <span class="ramp-header-label">300</span> |
| | <span class="ramp-header-label">400</span> |
| | <span class="ramp-header-label">500</span> |
| | <span class="ramp-header-label">600</span> |
| | <span class="ramp-header-label">700</span> |
| | <span class="ramp-header-label">800</span> |
| | <span class="ramp-header-label">900</span> |
| | <span class="ramp-header-label">950</span> |
| | </div> |
| | {rows_html} |
| | </div> |
| | ''' |
| | |
| | return html |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | 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 |
| |
|