Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |
| # ============================================================================= | |
| 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, | |
| } | |
| 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, | |
| } | |
| 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, | |
| } | |
| 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, | |
| } | |
| 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, | |
| ) |