""" Rule Engine — Deterministic Design System Analysis =================================================== This module handles ALL calculations that don't need LLM reasoning: - Type scale detection - AA/AAA contrast checking - Algorithmic color fixes - Spacing grid detection - Color statistics and deduplication LLMs should ONLY be used for: - Brand color identification (requires context understanding) - Palette cohesion (subjective assessment) - Design maturity scoring (holistic evaluation) - Prioritized recommendations (business reasoning) """ import colorsys import re from dataclasses import dataclass, field from functools import reduce from math import gcd from typing import Optional # ============================================================================= # DATA CLASSES # ============================================================================= @dataclass class TypeScaleAnalysis: """Results of type scale analysis.""" detected_ratio: float closest_standard_ratio: float scale_name: str is_consistent: bool variance: float sizes_px: list[float] ratios_between_sizes: list[float] recommendation: float recommendation_name: str base_size: float = 16.0 # Detected base/body font size def to_dict(self) -> dict: return { "detected_ratio": round(self.detected_ratio, 3), "closest_standard_ratio": self.closest_standard_ratio, "scale_name": self.scale_name, "is_consistent": self.is_consistent, "variance": round(self.variance, 3), "sizes_px": self.sizes_px, "base_size": self.base_size, "recommendation": self.recommendation, "recommendation_name": self.recommendation_name, } @dataclass class ColorAccessibility: """Accessibility analysis for a single color.""" hex_color: str name: str contrast_on_white: float contrast_on_black: float passes_aa_normal: bool # 4.5:1 passes_aa_large: bool # 3.0:1 passes_aaa_normal: bool # 7.0:1 best_text_color: str # White or black suggested_fix: Optional[str] = None suggested_fix_contrast: Optional[float] = None def to_dict(self) -> dict: return { "color": self.hex_color, "name": self.name, "contrast_white": round(self.contrast_on_white, 2), "contrast_black": round(self.contrast_on_black, 2), "aa_normal": self.passes_aa_normal, "aa_large": self.passes_aa_large, "aaa_normal": self.passes_aaa_normal, "best_text": self.best_text_color, "suggested_fix": self.suggested_fix, "suggested_fix_contrast": round(self.suggested_fix_contrast, 2) if self.suggested_fix_contrast else None, } @dataclass class SpacingGridAnalysis: """Results of spacing grid analysis.""" detected_base: int is_aligned: bool alignment_percentage: float misaligned_values: list[int] recommendation: int recommendation_reason: str current_values: list[int] suggested_scale: list[int] def to_dict(self) -> dict: return { "detected_base": self.detected_base, "is_aligned": self.is_aligned, "alignment_percentage": round(self.alignment_percentage, 1), "misaligned_values": self.misaligned_values, "recommendation": self.recommendation, "recommendation_reason": self.recommendation_reason, "current_values": self.current_values, "suggested_scale": self.suggested_scale, } @dataclass class ColorStatistics: """Statistical analysis of color palette.""" total_count: int unique_count: int duplicate_count: int gray_count: int saturated_count: int near_duplicates: list[tuple[str, str, float]] # (color1, color2, similarity) hue_distribution: dict[str, int] # {"red": 5, "blue": 3, ...} def to_dict(self) -> dict: return { "total": self.total_count, "unique": self.unique_count, "duplicates": self.duplicate_count, "grays": self.gray_count, "saturated": self.saturated_count, "near_duplicates_count": len(self.near_duplicates), "hue_distribution": self.hue_distribution, } @dataclass class RuleEngineResults: """Complete rule engine analysis results.""" typography: TypeScaleAnalysis accessibility: list[ColorAccessibility] spacing: SpacingGridAnalysis color_stats: ColorStatistics # Summary aa_failures: int consistency_score: int # 0-100 def to_dict(self) -> dict: return { "typography": self.typography.to_dict(), "accessibility": [a.to_dict() for a in self.accessibility if not a.passes_aa_normal], "accessibility_all": [a.to_dict() for a in self.accessibility], "spacing": self.spacing.to_dict(), "color_stats": self.color_stats.to_dict(), "summary": { "aa_failures": self.aa_failures, "consistency_score": self.consistency_score, } } # ============================================================================= # COLOR UTILITIES # ============================================================================= def hex_to_rgb(hex_color: str) -> tuple[int, int, int]: """Convert hex 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(r: int, g: int, b: int) -> str: """Convert RGB to hex string.""" r = max(0, min(255, r)) g = max(0, min(255, g)) b = max(0, min(255, b)) return f"#{r:02x}{g:02x}{b:02x}" def get_relative_luminance(hex_color: str) -> float: """Calculate relative luminance per WCAG 2.1.""" r, g, b = hex_to_rgb(hex_color) def channel_luminance(c): c = c / 255 return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 return 0.2126 * channel_luminance(r) + 0.7152 * channel_luminance(g) + 0.0722 * channel_luminance(b) def get_contrast_ratio(color1: str, color2: str) -> float: """Calculate WCAG contrast ratio between two colors.""" l1 = get_relative_luminance(color1) l2 = get_relative_luminance(color2) lighter = max(l1, l2) darker = min(l1, l2) return (lighter + 0.05) / (darker + 0.05) def is_gray(hex_color: str, threshold: float = 0.1) -> bool: """Check if color is a gray (low saturation).""" r, g, b = hex_to_rgb(hex_color) h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255) return s < threshold def get_saturation(hex_color: str) -> float: """Get saturation value (0-1).""" r, g, b = hex_to_rgb(hex_color) h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255) return s def get_hue_name(hex_color: str) -> str: """Get human-readable hue name.""" r, g, b = hex_to_rgb(hex_color) h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255) if s < 0.1: return "gray" hue_deg = h * 360 if hue_deg < 15 or hue_deg >= 345: return "red" elif hue_deg < 45: return "orange" elif hue_deg < 75: return "yellow" elif hue_deg < 150: return "green" elif hue_deg < 210: return "cyan" elif hue_deg < 270: return "blue" elif hue_deg < 315: return "purple" else: return "pink" def color_distance(hex1: str, hex2: str) -> float: """Calculate perceptual color distance (0-1, lower = more similar).""" r1, g1, b1 = hex_to_rgb(hex1) r2, g2, b2 = hex_to_rgb(hex2) # Simple Euclidean distance in RGB space (normalized) dr = (r1 - r2) / 255 dg = (g1 - g2) / 255 db = (b1 - b2) / 255 return (dr**2 + dg**2 + db**2) ** 0.5 / (3 ** 0.5) def darken_color(hex_color: str, factor: float) -> str: """Darken a color by a factor (0-1).""" r, g, b = hex_to_rgb(hex_color) r = int(r * (1 - factor)) g = int(g * (1 - factor)) b = int(b * (1 - factor)) return rgb_to_hex(r, g, b) def lighten_color(hex_color: str, factor: float) -> str: """Lighten a color by a factor (0-1).""" r, g, b = hex_to_rgb(hex_color) r = int(r + (255 - r) * factor) g = int(g + (255 - g) * factor) b = int(b + (255 - b) * factor) return rgb_to_hex(r, g, b) def find_aa_compliant_color(hex_color: str, background: str = "#ffffff", target_contrast: float = 4.5) -> str: """ Algorithmically adjust a color until it meets AA contrast requirements. Returns the original color if it already passes, otherwise returns a darkened/lightened version that passes. """ current_contrast = get_contrast_ratio(hex_color, background) if current_contrast >= target_contrast: return hex_color # Determine direction: move fg *away* from bg in luminance. # If fg is lighter than bg → darken fg to increase gap. # If fg is darker than bg → lighten fg to increase gap. bg_luminance = get_relative_luminance(background) color_luminance = get_relative_luminance(hex_color) should_darken = color_luminance >= bg_luminance best_color = hex_color best_contrast = current_contrast for i in range(1, 101): factor = i / 100 if should_darken: new_color = darken_color(hex_color, factor) else: new_color = lighten_color(hex_color, factor) new_contrast = get_contrast_ratio(new_color, background) if new_contrast >= target_contrast: return new_color if new_contrast > best_contrast: best_contrast = new_contrast best_color = new_color # If first direction didn't reach target, try the opposite direction # (e.g., very similar luminances where either direction could work) should_darken = not should_darken for i in range(1, 101): factor = i / 100 if should_darken: new_color = darken_color(hex_color, factor) else: new_color = lighten_color(hex_color, factor) new_contrast = get_contrast_ratio(new_color, background) if new_contrast >= target_contrast: return new_color if new_contrast > best_contrast: best_contrast = new_contrast best_color = new_color return best_color # ============================================================================= # TYPE SCALE ANALYSIS # ============================================================================= # Standard type scale ratios STANDARD_SCALES = { 1.067: "Minor Second", 1.125: "Major Second", 1.200: "Minor Third", 1.250: "Major Third", # ⭐ Recommended 1.333: "Perfect Fourth", 1.414: "Augmented Fourth", 1.500: "Perfect Fifth", 1.618: "Golden Ratio", 2.000: "Octave", } def parse_size_to_px(size: str) -> Optional[float]: """Convert any size string to pixels.""" if isinstance(size, (int, float)): return float(size) size = str(size).strip().lower() # Extract number match = re.search(r'([\d.]+)', size) if not match: return None value = float(match.group(1)) if 'rem' in size: return value * 16 # Assume 16px base elif 'em' in size: return value * 16 # Approximate elif 'px' in size or size.replace('.', '').isdigit(): return value return value def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis: """ Analyze typography tokens to detect type scale ratio. Args: typography_tokens: Dict of typography tokens with font_size Returns: TypeScaleAnalysis with detected ratio and recommendations """ # Extract and parse sizes sizes = [] for name, token in typography_tokens.items(): if isinstance(token, dict): size = token.get("font_size") or token.get("fontSize") or token.get("size") else: size = getattr(token, "font_size", None) if size: px = parse_size_to_px(size) if px and px > 0: sizes.append(px) # Sort and dedupe sizes_px = sorted(set(sizes)) if len(sizes_px) < 2: base_size = sizes_px[0] if sizes_px else 16.0 return TypeScaleAnalysis( detected_ratio=1.0, closest_standard_ratio=1.25, scale_name="Unknown", is_consistent=False, variance=0, sizes_px=sizes_px, ratios_between_sizes=[], recommendation=1.25, recommendation_name="Major Third", base_size=base_size, ) # Calculate ratios between consecutive sizes ratios = [] for i in range(len(sizes_px) - 1): if sizes_px[i] > 0: ratio = sizes_px[i + 1] / sizes_px[i] if 1.0 < ratio < 3.0: # Reasonable range ratios.append(ratio) if not ratios: # Detect base size even if no valid ratios base_candidates = [s for s in sizes_px if 14 <= s <= 18] base_size = min(base_candidates, key=lambda x: abs(x - 16)) if base_candidates else (min(sizes_px, key=lambda x: abs(x - 16)) if sizes_px else 16.0) return TypeScaleAnalysis( detected_ratio=1.0, closest_standard_ratio=1.25, scale_name="Unknown", is_consistent=False, variance=0, sizes_px=sizes_px, ratios_between_sizes=[], recommendation=1.25, recommendation_name="Major Third", base_size=base_size, ) # Average ratio avg_ratio = sum(ratios) / len(ratios) # Variance (consistency check) variance = max(ratios) - min(ratios) if ratios else 0 is_consistent = variance < 0.15 # Within 15% variance is "consistent" # Find closest standard scale closest_scale = min(STANDARD_SCALES.keys(), key=lambda x: abs(x - avg_ratio)) scale_name = STANDARD_SCALES[closest_scale] # Detect base size (closest to 16px, or 14-18px range typical for body) # The base size is typically the most common body text size base_candidates = [s for s in sizes_px if 14 <= s <= 18] if base_candidates: # Prefer 16px if present, otherwise closest to 16 if 16 in base_candidates: base_size = 16.0 else: base_size = min(base_candidates, key=lambda x: abs(x - 16)) elif sizes_px: # Fallback: find size closest to 16px base_size = min(sizes_px, key=lambda x: abs(x - 16)) else: base_size = 16.0 # Recommendation if is_consistent and abs(avg_ratio - closest_scale) < 0.05: # Already using a standard scale recommendation = closest_scale recommendation_name = scale_name else: # Recommend Major Third (1.25) as default recommendation = 1.25 recommendation_name = "Major Third" return TypeScaleAnalysis( detected_ratio=avg_ratio, closest_standard_ratio=closest_scale, scale_name=scale_name, is_consistent=is_consistent, variance=variance, sizes_px=sizes_px, ratios_between_sizes=ratios, recommendation=recommendation, recommendation_name=recommendation_name, base_size=base_size, ) # ============================================================================= # ACCESSIBILITY ANALYSIS # ============================================================================= def analyze_accessibility(color_tokens: dict, fg_bg_pairs: list[dict] = None) -> list[ColorAccessibility]: """ Analyze all colors for WCAG accessibility compliance. Args: color_tokens: Dict of color tokens with value/hex fg_bg_pairs: Optional list of actual foreground/background pairs extracted from the DOM (each dict has 'foreground', 'background', 'element' keys). Returns: List of ColorAccessibility results """ results = [] for name, token in color_tokens.items(): if isinstance(token, dict): hex_color = token.get("value") or token.get("hex") or token.get("color") else: hex_color = getattr(token, "value", None) if not hex_color or not hex_color.startswith("#"): continue try: contrast_white = get_contrast_ratio(hex_color, "#ffffff") contrast_black = get_contrast_ratio(hex_color, "#000000") passes_aa_normal = contrast_white >= 4.5 or contrast_black >= 4.5 passes_aa_large = contrast_white >= 3.0 or contrast_black >= 3.0 passes_aaa_normal = contrast_white >= 7.0 or contrast_black >= 7.0 best_text = "#ffffff" if contrast_white > contrast_black else "#000000" # Generate fix suggestion if needed suggested_fix = None suggested_fix_contrast = None if not passes_aa_normal: suggested_fix = find_aa_compliant_color(hex_color, "#ffffff", 4.5) suggested_fix_contrast = get_contrast_ratio(suggested_fix, "#ffffff") results.append(ColorAccessibility( hex_color=hex_color, name=name, contrast_on_white=contrast_white, contrast_on_black=contrast_black, passes_aa_normal=passes_aa_normal, passes_aa_large=passes_aa_large, passes_aaa_normal=passes_aaa_normal, best_text_color=best_text, suggested_fix=suggested_fix, suggested_fix_contrast=suggested_fix_contrast, )) except Exception: continue # --- Real foreground-background pair checks --- if fg_bg_pairs: for pair in fg_bg_pairs: fg = pair.get("foreground", "").lower() bg = pair.get("background", "").lower() element = pair.get("element", "") if not (fg.startswith("#") and bg.startswith("#")): continue # Skip same-color pairs (invisible/placeholder text — not real failures) if fg == bg: continue try: ratio = get_contrast_ratio(fg, bg) # Skip near-identical pairs (ratio < 1.1) — likely decorative/hidden if ratio < 1.1: continue if ratio < 4.5: # This pair fails AA — record it fix = find_aa_compliant_color(fg, bg, 4.5) fix_contrast = get_contrast_ratio(fix, bg) results.append(ColorAccessibility( hex_color=fg, name=f"fg:{fg} on bg:{bg} ({element}) [{ratio:.1f}:1]", contrast_on_white=get_contrast_ratio(fg, "#ffffff"), contrast_on_black=get_contrast_ratio(fg, "#000000"), passes_aa_normal=False, passes_aa_large=ratio >= 3.0, passes_aaa_normal=False, best_text_color="#ffffff" if get_contrast_ratio(fg, "#ffffff") > get_contrast_ratio(fg, "#000000") else "#000000", suggested_fix=fix, suggested_fix_contrast=fix_contrast, )) except Exception: continue return results # ============================================================================= # SPACING GRID ANALYSIS # ============================================================================= def analyze_spacing_grid(spacing_tokens: dict) -> SpacingGridAnalysis: """ Analyze spacing tokens to detect grid alignment. Args: spacing_tokens: Dict of spacing tokens with value_px or value Returns: SpacingGridAnalysis with detected grid and recommendations """ values = [] for name, token in spacing_tokens.items(): if isinstance(token, dict): px = token.get("value_px") or token.get("value") else: px = getattr(token, "value_px", None) or getattr(token, "value", None) if px: try: px_val = int(float(str(px).replace('px', ''))) if px_val > 0: values.append(px_val) except: continue if not values: return SpacingGridAnalysis( detected_base=8, is_aligned=False, alignment_percentage=0, misaligned_values=[], recommendation=8, recommendation_reason="No spacing values detected, defaulting to 8px grid", current_values=[], suggested_scale=[0, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64], ) values = sorted(set(values)) # Find GCD (greatest common divisor) of all values detected_base = reduce(gcd, values) # Check alignment to common grids (4px, 8px) aligned_to_4 = all(v % 4 == 0 for v in values) aligned_to_8 = all(v % 8 == 0 for v in values) # Find misaligned values (not divisible by detected base) misaligned = [v for v in values if v % detected_base != 0] if detected_base > 1 else values alignment_percentage = (len(values) - len(misaligned)) / len(values) * 100 if values else 0 # Determine recommendation if aligned_to_8: recommendation = 8 recommendation_reason = "All values already align to 8px grid" is_aligned = True elif aligned_to_4: recommendation = 4 recommendation_reason = "Values align to 4px grid (consider 8px for simpler system)" is_aligned = True elif detected_base in [4, 8]: recommendation = detected_base recommendation_reason = f"Detected {detected_base}px base with {alignment_percentage:.0f}% alignment" is_aligned = alignment_percentage >= 80 else: recommendation = 8 recommendation_reason = f"Inconsistent spacing detected (GCD={detected_base}), recommend 8px grid" is_aligned = False # Generate suggested scale base = recommendation suggested_scale = [0] + [base * i for i in [0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10, 12, 16] if base * i == int(base * i)] suggested_scale = sorted(set([int(v) for v in suggested_scale])) return SpacingGridAnalysis( detected_base=detected_base, is_aligned=is_aligned, alignment_percentage=alignment_percentage, misaligned_values=misaligned, recommendation=recommendation, recommendation_reason=recommendation_reason, current_values=values, suggested_scale=suggested_scale, ) # ============================================================================= # COLOR STATISTICS # ============================================================================= def analyze_color_statistics(color_tokens: dict, similarity_threshold: float = 0.05) -> ColorStatistics: """ Analyze color palette statistics. Args: color_tokens: Dict of color tokens similarity_threshold: Distance threshold for "near duplicate" (0-1) Returns: ColorStatistics with palette analysis """ colors = [] for name, token in color_tokens.items(): if isinstance(token, dict): hex_color = token.get("value") or token.get("hex") else: hex_color = getattr(token, "value", None) if hex_color and hex_color.startswith("#"): colors.append(hex_color.lower()) unique_colors = list(set(colors)) # Count grays and saturated grays = [c for c in unique_colors if is_gray(c)] saturated = [c for c in unique_colors if get_saturation(c) > 0.3] # Find near duplicates near_duplicates = [] for i, c1 in enumerate(unique_colors): for c2 in unique_colors[i+1:]: dist = color_distance(c1, c2) if dist < similarity_threshold and dist > 0: near_duplicates.append((c1, c2, round(dist, 4))) # Hue distribution hue_dist = {} for c in unique_colors: hue = get_hue_name(c) hue_dist[hue] = hue_dist.get(hue, 0) + 1 return ColorStatistics( total_count=len(colors), unique_count=len(unique_colors), duplicate_count=len(colors) - len(unique_colors), gray_count=len(grays), saturated_count=len(saturated), near_duplicates=near_duplicates, hue_distribution=hue_dist, ) # ============================================================================= # MAIN ANALYSIS FUNCTION # ============================================================================= def run_rule_engine( typography_tokens: dict, color_tokens: dict, spacing_tokens: dict, radius_tokens: dict = None, shadow_tokens: dict = None, log_callback: Optional[callable] = None, fg_bg_pairs: list[dict] = None, ) -> RuleEngineResults: """ Run complete rule-based analysis on design tokens. This is FREE (no LLM costs) and handles all deterministic calculations. Args: typography_tokens: Dict of typography tokens color_tokens: Dict of color tokens spacing_tokens: Dict of spacing tokens radius_tokens: Dict of border radius tokens (optional) shadow_tokens: Dict of shadow tokens (optional) log_callback: Function to log messages Returns: RuleEngineResults with all analysis data """ def log(msg: str): if log_callback: log_callback(msg) log("") log("═" * 60) log("⚙️ LAYER 1: RULE ENGINE (FREE - $0.00)") log("═" * 60) log("") # ───────────────────────────────────────────────────────────── # Typography Analysis # ───────────────────────────────────────────────────────────── log(" 📐 TYPE SCALE ANALYSIS") log(" " + "─" * 40) typography = analyze_type_scale(typography_tokens) consistency_icon = "✅" if typography.is_consistent else "⚠️" log(f" ├─ Detected Ratio: {typography.detected_ratio:.3f}") log(f" ├─ Closest Standard: {typography.scale_name} ({typography.closest_standard_ratio})") log(f" ├─ Consistent: {consistency_icon} {'Yes' if typography.is_consistent else f'No (variance: {typography.variance:.2f})'}") log(f" ├─ Sizes Found: {typography.sizes_px}") log(f" └─ 💡 Recommendation: {typography.recommendation} ({typography.recommendation_name})") log("") # ───────────────────────────────────────────────────────────── # Accessibility Analysis # ───────────────────────────────────────────────────────────── log(" ♿ ACCESSIBILITY CHECK (WCAG AA/AAA)") log(" " + "─" * 40) accessibility = analyze_accessibility(color_tokens, fg_bg_pairs=fg_bg_pairs) # Separate individual-color failures from real FG/BG pair failures pair_failures = [a for a in accessibility if not a.passes_aa_normal and a.name.startswith("fg:")] color_only_failures = [a for a in accessibility if not a.passes_aa_normal and not a.name.startswith("fg:")] failures = [a for a in accessibility if not a.passes_aa_normal] passes = len(accessibility) - len(failures) pair_count = len(fg_bg_pairs) if fg_bg_pairs else 0 log(f" ├─ Colors Analyzed: {len(accessibility)}") log(f" ├─ FG/BG Pairs Checked: {pair_count}") log(f" ├─ AA Pass: {passes} ✅") log(f" ├─ AA Fail (color vs white/black): {len(color_only_failures)} {'❌' if color_only_failures else '✅'}") log(f" ├─ AA Fail (real FG/BG pairs): {len(pair_failures)} {'❌' if pair_failures else '✅'}") if color_only_failures: log(" │") log(" │ ⚠️ FAILING COLORS (vs white/black):") for i, f in enumerate(color_only_failures[:5]): fix_info = f" → 💡 Fix: {f.suggested_fix} ({f.suggested_fix_contrast:.1f}:1)" if f.suggested_fix else "" log(f" │ ├─ {f.name}: {f.hex_color} ({f.contrast_on_white:.1f}:1 on white){fix_info}") if len(color_only_failures) > 5: log(f" │ └─ ... and {len(color_only_failures) - 5} more") if pair_failures: log(" │") log(" │ ❌ FAILING FG/BG PAIRS (actual on-page combinations):") for i, f in enumerate(pair_failures[:5]): fix_info = f" → 💡 Fix: {f.suggested_fix} ({f.suggested_fix_contrast:.1f}:1)" if f.suggested_fix else "" log(f" │ ├─ {f.name}{fix_info}") if len(pair_failures) > 5: log(f" │ └─ ... and {len(pair_failures) - 5} more") log("") # ───────────────────────────────────────────────────────────── # Spacing Grid Analysis # ───────────────────────────────────────────────────────────── log(" 📏 SPACING GRID ANALYSIS") log(" " + "─" * 40) spacing = analyze_spacing_grid(spacing_tokens) alignment_icon = "✅" if spacing.is_aligned else "⚠️" log(f" ├─ Detected Base: {spacing.detected_base}px") log(f" ├─ Grid Aligned: {alignment_icon} {spacing.alignment_percentage:.0f}%") if spacing.misaligned_values: log(f" ├─ Misaligned Values: {spacing.misaligned_values[:8]}{'...' if len(spacing.misaligned_values) > 8 else ''}") log(f" ├─ Suggested Scale: {spacing.suggested_scale[:10]}...") log(f" └─ 💡 Recommendation: {spacing.recommendation}px ({spacing.recommendation_reason})") log("") # ───────────────────────────────────────────────────────────── # Color Statistics # ───────────────────────────────────────────────────────────── log(" 🎨 COLOR PALETTE STATISTICS") log(" " + "─" * 40) color_stats = analyze_color_statistics(color_tokens) dup_icon = "⚠️" if color_stats.duplicate_count > 10 else "✅" unique_icon = "⚠️" if color_stats.unique_count > 30 else "✅" log(f" ├─ Total Colors: {color_stats.total_count}") log(f" ├─ Unique Colors: {color_stats.unique_count} {unique_icon}") log(f" ├─ Exact Duplicates: {color_stats.duplicate_count} {dup_icon}") log(f" ├─ Near-Duplicates: {len(color_stats.near_duplicates)}") log(f" ├─ Grays: {color_stats.gray_count} | Saturated: {color_stats.saturated_count}") log(f" └─ Hue Distribution: {dict(list(color_stats.hue_distribution.items())[:5])}...") log("") # ───────────────────────────────────────────────────────────── # Calculate Summary Scores # ───────────────────────────────────────────────────────────── # Consistency score (0-100) type_score = 25 if typography.is_consistent else 10 aa_score = 25 * (passes / max(len(accessibility), 1)) spacing_score = 25 * (spacing.alignment_percentage / 100) color_score = 25 * (1 - min(color_stats.duplicate_count / max(color_stats.total_count, 1), 1)) consistency_score = int(type_score + aa_score + spacing_score + color_score) log(" " + "─" * 40) log(f" 📊 RULE ENGINE SUMMARY") log(f" ├─ Consistency Score: {consistency_score}/100") log(f" ├─ AA Failures: {len(failures)}") log(f" └─ Cost: $0.00 (free)") log("") return RuleEngineResults( typography=typography, accessibility=accessibility, spacing=spacing, color_stats=color_stats, aa_failures=len(failures), consistency_score=consistency_score, )