riazmo's picture
Upload 2 files
1eb4280 verified
raw
history blame
33.3 kB
"""
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,
)