riazmo commited on
Commit
aec429c
Β·
verified Β·
1 Parent(s): 2a8fe10

Upload rule_engine.py

Browse files
Files changed (1) hide show
  1. core/rule_engine.py +920 -0
core/rule_engine.py ADDED
@@ -0,0 +1,920 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rule Engine β€” Deterministic Design System Analysis
3
+ ===================================================
4
+
5
+ This module handles ALL calculations that don't need LLM reasoning:
6
+ - Type scale detection
7
+ - AA/AAA contrast checking
8
+ - Algorithmic color fixes
9
+ - Spacing grid detection
10
+ - Color statistics and deduplication
11
+
12
+ LLMs should ONLY be used for:
13
+ - Brand color identification (requires context understanding)
14
+ - Palette cohesion (subjective assessment)
15
+ - Design maturity scoring (holistic evaluation)
16
+ - Prioritized recommendations (business reasoning)
17
+ """
18
+
19
+ import colorsys
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from functools import reduce
23
+ from math import gcd
24
+ from typing import Optional
25
+
26
+
27
+ # =============================================================================
28
+ # DATA CLASSES
29
+ # =============================================================================
30
+
31
+ @dataclass
32
+ class TypeScaleAnalysis:
33
+ """Results of type scale analysis."""
34
+ detected_ratio: float
35
+ closest_standard_ratio: float
36
+ scale_name: str
37
+ is_consistent: bool
38
+ variance: float
39
+ sizes_px: list[float]
40
+ ratios_between_sizes: list[float]
41
+ recommendation: float
42
+ recommendation_name: str
43
+ base_size: float = 16.0 # Detected base/body font size
44
+
45
+ def to_dict(self) -> dict:
46
+ return {
47
+ "detected_ratio": round(self.detected_ratio, 3),
48
+ "closest_standard_ratio": self.closest_standard_ratio,
49
+ "scale_name": self.scale_name,
50
+ "is_consistent": self.is_consistent,
51
+ "variance": round(self.variance, 3),
52
+ "sizes_px": self.sizes_px,
53
+ "base_size": self.base_size,
54
+ "recommendation": self.recommendation,
55
+ "recommendation_name": self.recommendation_name,
56
+ }
57
+
58
+
59
+ @dataclass
60
+ class ColorAccessibility:
61
+ """Accessibility analysis for a single color."""
62
+ hex_color: str
63
+ name: str
64
+ contrast_on_white: float
65
+ contrast_on_black: float
66
+ passes_aa_normal: bool # 4.5:1
67
+ passes_aa_large: bool # 3.0:1
68
+ passes_aaa_normal: bool # 7.0:1
69
+ best_text_color: str # White or black
70
+ suggested_fix: Optional[str] = None
71
+ suggested_fix_contrast: Optional[float] = None
72
+
73
+ def to_dict(self) -> dict:
74
+ return {
75
+ "color": self.hex_color,
76
+ "name": self.name,
77
+ "contrast_white": round(self.contrast_on_white, 2),
78
+ "contrast_black": round(self.contrast_on_black, 2),
79
+ "aa_normal": self.passes_aa_normal,
80
+ "aa_large": self.passes_aa_large,
81
+ "aaa_normal": self.passes_aaa_normal,
82
+ "best_text": self.best_text_color,
83
+ "suggested_fix": self.suggested_fix,
84
+ "suggested_fix_contrast": round(self.suggested_fix_contrast, 2) if self.suggested_fix_contrast else None,
85
+ }
86
+
87
+
88
+ @dataclass
89
+ class SpacingGridAnalysis:
90
+ """Results of spacing grid analysis."""
91
+ detected_base: int
92
+ is_aligned: bool
93
+ alignment_percentage: float
94
+ misaligned_values: list[int]
95
+ recommendation: int
96
+ recommendation_reason: str
97
+ current_values: list[int]
98
+ suggested_scale: list[int]
99
+
100
+ def to_dict(self) -> dict:
101
+ return {
102
+ "detected_base": self.detected_base,
103
+ "is_aligned": self.is_aligned,
104
+ "alignment_percentage": round(self.alignment_percentage, 1),
105
+ "misaligned_values": self.misaligned_values,
106
+ "recommendation": self.recommendation,
107
+ "recommendation_reason": self.recommendation_reason,
108
+ "current_values": self.current_values,
109
+ "suggested_scale": self.suggested_scale,
110
+ }
111
+
112
+
113
+ @dataclass
114
+ class ColorStatistics:
115
+ """Statistical analysis of color palette."""
116
+ total_count: int
117
+ unique_count: int
118
+ duplicate_count: int
119
+ gray_count: int
120
+ saturated_count: int
121
+ near_duplicates: list[tuple[str, str, float]] # (color1, color2, similarity)
122
+ hue_distribution: dict[str, int] # {"red": 5, "blue": 3, ...}
123
+
124
+ def to_dict(self) -> dict:
125
+ return {
126
+ "total": self.total_count,
127
+ "unique": self.unique_count,
128
+ "duplicates": self.duplicate_count,
129
+ "grays": self.gray_count,
130
+ "saturated": self.saturated_count,
131
+ "near_duplicates_count": len(self.near_duplicates),
132
+ "hue_distribution": self.hue_distribution,
133
+ }
134
+
135
+
136
+ @dataclass
137
+ class RuleEngineResults:
138
+ """Complete rule engine analysis results."""
139
+ typography: TypeScaleAnalysis
140
+ accessibility: list[ColorAccessibility]
141
+ spacing: SpacingGridAnalysis
142
+ color_stats: ColorStatistics
143
+
144
+ # Summary
145
+ aa_failures: int
146
+ consistency_score: int # 0-100
147
+
148
+ def to_dict(self) -> dict:
149
+ return {
150
+ "typography": self.typography.to_dict(),
151
+ "accessibility": [a.to_dict() for a in self.accessibility if not a.passes_aa_normal],
152
+ "accessibility_all": [a.to_dict() for a in self.accessibility],
153
+ "spacing": self.spacing.to_dict(),
154
+ "color_stats": self.color_stats.to_dict(),
155
+ "summary": {
156
+ "aa_failures": self.aa_failures,
157
+ "consistency_score": self.consistency_score,
158
+ }
159
+ }
160
+
161
+
162
+ # =============================================================================
163
+ # COLOR UTILITIES
164
+ # =============================================================================
165
+
166
+ def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
167
+ """Convert hex to RGB tuple."""
168
+ hex_color = hex_color.lstrip('#')
169
+ if len(hex_color) == 3:
170
+ hex_color = ''.join([c*2 for c in hex_color])
171
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
172
+
173
+
174
+ def rgb_to_hex(r: int, g: int, b: int) -> str:
175
+ """Convert RGB to hex string."""
176
+ r = max(0, min(255, r))
177
+ g = max(0, min(255, g))
178
+ b = max(0, min(255, b))
179
+ return f"#{r:02x}{g:02x}{b:02x}"
180
+
181
+
182
+ def get_relative_luminance(hex_color: str) -> float:
183
+ """Calculate relative luminance per WCAG 2.1."""
184
+ r, g, b = hex_to_rgb(hex_color)
185
+
186
+ def channel_luminance(c):
187
+ c = c / 255
188
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
189
+
190
+ return 0.2126 * channel_luminance(r) + 0.7152 * channel_luminance(g) + 0.0722 * channel_luminance(b)
191
+
192
+
193
+ def get_contrast_ratio(color1: str, color2: str) -> float:
194
+ """Calculate WCAG contrast ratio between two colors."""
195
+ l1 = get_relative_luminance(color1)
196
+ l2 = get_relative_luminance(color2)
197
+ lighter = max(l1, l2)
198
+ darker = min(l1, l2)
199
+ return (lighter + 0.05) / (darker + 0.05)
200
+
201
+
202
+ def is_gray(hex_color: str, threshold: float = 0.1) -> bool:
203
+ """Check if color is a gray (low saturation)."""
204
+ r, g, b = hex_to_rgb(hex_color)
205
+ h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255)
206
+ return s < threshold
207
+
208
+
209
+ def get_saturation(hex_color: str) -> float:
210
+ """Get saturation value (0-1)."""
211
+ r, g, b = hex_to_rgb(hex_color)
212
+ h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255)
213
+ return s
214
+
215
+
216
+ def get_hue_name(hex_color: str) -> str:
217
+ """Get human-readable hue name."""
218
+ r, g, b = hex_to_rgb(hex_color)
219
+ h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255)
220
+
221
+ if s < 0.1:
222
+ return "gray"
223
+
224
+ hue_deg = h * 360
225
+
226
+ if hue_deg < 15 or hue_deg >= 345:
227
+ return "red"
228
+ elif hue_deg < 45:
229
+ return "orange"
230
+ elif hue_deg < 75:
231
+ return "yellow"
232
+ elif hue_deg < 150:
233
+ return "green"
234
+ elif hue_deg < 210:
235
+ return "cyan"
236
+ elif hue_deg < 270:
237
+ return "blue"
238
+ elif hue_deg < 315:
239
+ return "purple"
240
+ else:
241
+ return "pink"
242
+
243
+
244
+ def color_distance(hex1: str, hex2: str) -> float:
245
+ """Calculate perceptual color distance (0-1, lower = more similar)."""
246
+ r1, g1, b1 = hex_to_rgb(hex1)
247
+ r2, g2, b2 = hex_to_rgb(hex2)
248
+
249
+ # Simple Euclidean distance in RGB space (normalized)
250
+ dr = (r1 - r2) / 255
251
+ dg = (g1 - g2) / 255
252
+ db = (b1 - b2) / 255
253
+
254
+ return (dr**2 + dg**2 + db**2) ** 0.5 / (3 ** 0.5)
255
+
256
+
257
+ def darken_color(hex_color: str, factor: float) -> str:
258
+ """Darken a color by a factor (0-1)."""
259
+ r, g, b = hex_to_rgb(hex_color)
260
+ r = int(r * (1 - factor))
261
+ g = int(g * (1 - factor))
262
+ b = int(b * (1 - factor))
263
+ return rgb_to_hex(r, g, b)
264
+
265
+
266
+ def lighten_color(hex_color: str, factor: float) -> str:
267
+ """Lighten a color by a factor (0-1)."""
268
+ r, g, b = hex_to_rgb(hex_color)
269
+ r = int(r + (255 - r) * factor)
270
+ g = int(g + (255 - g) * factor)
271
+ b = int(b + (255 - b) * factor)
272
+ return rgb_to_hex(r, g, b)
273
+
274
+
275
+ def find_aa_compliant_color(hex_color: str, background: str = "#ffffff", target_contrast: float = 4.5) -> str:
276
+ """
277
+ Algorithmically adjust a color until it meets AA contrast requirements.
278
+
279
+ Returns the original color if it already passes, otherwise returns
280
+ a darkened/lightened version that passes.
281
+ """
282
+ current_contrast = get_contrast_ratio(hex_color, background)
283
+
284
+ if current_contrast >= target_contrast:
285
+ return hex_color
286
+
287
+ # Determine direction: move fg *away* from bg in luminance.
288
+ # If fg is lighter than bg β†’ darken fg to increase gap.
289
+ # If fg is darker than bg β†’ lighten fg to increase gap.
290
+ bg_luminance = get_relative_luminance(background)
291
+ color_luminance = get_relative_luminance(hex_color)
292
+
293
+ should_darken = color_luminance >= bg_luminance
294
+
295
+ best_color = hex_color
296
+ best_contrast = current_contrast
297
+
298
+ for i in range(1, 101):
299
+ factor = i / 100
300
+
301
+ if should_darken:
302
+ new_color = darken_color(hex_color, factor)
303
+ else:
304
+ new_color = lighten_color(hex_color, factor)
305
+
306
+ new_contrast = get_contrast_ratio(new_color, background)
307
+
308
+ if new_contrast >= target_contrast:
309
+ return new_color
310
+
311
+ if new_contrast > best_contrast:
312
+ best_contrast = new_contrast
313
+ best_color = new_color
314
+
315
+ # If first direction didn't reach target, try the opposite direction
316
+ # (e.g., very similar luminances where either direction could work)
317
+ should_darken = not should_darken
318
+ for i in range(1, 101):
319
+ factor = i / 100
320
+
321
+ if should_darken:
322
+ new_color = darken_color(hex_color, factor)
323
+ else:
324
+ new_color = lighten_color(hex_color, factor)
325
+
326
+ new_contrast = get_contrast_ratio(new_color, background)
327
+
328
+ if new_contrast >= target_contrast:
329
+ return new_color
330
+
331
+ if new_contrast > best_contrast:
332
+ best_contrast = new_contrast
333
+ best_color = new_color
334
+
335
+ return best_color
336
+
337
+
338
+ # =============================================================================
339
+ # TYPE SCALE ANALYSIS
340
+ # =============================================================================
341
+
342
+ # Standard type scale ratios
343
+ STANDARD_SCALES = {
344
+ 1.067: "Minor Second",
345
+ 1.125: "Major Second",
346
+ 1.200: "Minor Third",
347
+ 1.250: "Major Third", # ⭐ Recommended
348
+ 1.333: "Perfect Fourth",
349
+ 1.414: "Augmented Fourth",
350
+ 1.500: "Perfect Fifth",
351
+ 1.618: "Golden Ratio",
352
+ 2.000: "Octave",
353
+ }
354
+
355
+
356
+ def parse_size_to_px(size: str) -> Optional[float]:
357
+ """Convert any size string to pixels."""
358
+ if isinstance(size, (int, float)):
359
+ return float(size)
360
+
361
+ size = str(size).strip().lower()
362
+
363
+ # Extract number
364
+ match = re.search(r'([\d.]+)', size)
365
+ if not match:
366
+ return None
367
+
368
+ value = float(match.group(1))
369
+
370
+ if 'rem' in size:
371
+ return value * 16 # Assume 16px base
372
+ elif 'em' in size:
373
+ return value * 16 # Approximate
374
+ elif 'px' in size or size.replace('.', '').isdigit():
375
+ return value
376
+
377
+ return value
378
+
379
+
380
+ def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis:
381
+ """
382
+ Analyze typography tokens to detect type scale ratio.
383
+
384
+ Args:
385
+ typography_tokens: Dict of typography tokens with font_size
386
+
387
+ Returns:
388
+ TypeScaleAnalysis with detected ratio and recommendations
389
+ """
390
+ # Extract and parse sizes
391
+ sizes = []
392
+ for name, token in typography_tokens.items():
393
+ if isinstance(token, dict):
394
+ size = token.get("font_size") or token.get("fontSize") or token.get("size")
395
+ else:
396
+ size = getattr(token, "font_size", None)
397
+
398
+ if size:
399
+ px = parse_size_to_px(size)
400
+ if px and px > 0:
401
+ sizes.append(px)
402
+
403
+ # Sort and dedupe
404
+ sizes_px = sorted(set(sizes))
405
+
406
+ if len(sizes_px) < 2:
407
+ # Use the size if valid (>= 10px), otherwise default to 16px
408
+ if sizes_px and sizes_px[0] >= 10:
409
+ base_size = sizes_px[0]
410
+ else:
411
+ base_size = 16.0
412
+ return TypeScaleAnalysis(
413
+ detected_ratio=1.0,
414
+ closest_standard_ratio=1.25,
415
+ scale_name="Unknown",
416
+ is_consistent=False,
417
+ variance=0,
418
+ sizes_px=sizes_px,
419
+ ratios_between_sizes=[],
420
+ recommendation=1.25,
421
+ recommendation_name="Major Third",
422
+ base_size=base_size,
423
+ )
424
+
425
+ # Calculate ratios between consecutive sizes
426
+ ratios = []
427
+ for i in range(len(sizes_px) - 1):
428
+ if sizes_px[i] > 0:
429
+ ratio = sizes_px[i + 1] / sizes_px[i]
430
+ if 1.0 < ratio < 3.0: # Reasonable range
431
+ ratios.append(ratio)
432
+
433
+ if not ratios:
434
+ # Detect base size even if no valid ratios
435
+ # Filter out tiny sizes (< 10px) which are likely captions/icons
436
+ valid_body_sizes = [s for s in sizes_px if s >= 10]
437
+ base_candidates = [s for s in valid_body_sizes if 14 <= s <= 18]
438
+ if base_candidates:
439
+ base_size = min(base_candidates, key=lambda x: abs(x - 16))
440
+ elif valid_body_sizes:
441
+ base_size = min(valid_body_sizes, key=lambda x: abs(x - 16))
442
+ elif sizes_px:
443
+ base_size = max(sizes_px) # Last resort: largest of tiny sizes
444
+ else:
445
+ base_size = 16.0
446
+ return TypeScaleAnalysis(
447
+ detected_ratio=1.0,
448
+ closest_standard_ratio=1.25,
449
+ scale_name="Unknown",
450
+ is_consistent=False,
451
+ variance=0,
452
+ sizes_px=sizes_px,
453
+ ratios_between_sizes=[],
454
+ recommendation=1.25,
455
+ recommendation_name="Major Third",
456
+ base_size=base_size,
457
+ )
458
+
459
+ # Average ratio
460
+ avg_ratio = sum(ratios) / len(ratios)
461
+
462
+ # Variance (consistency check)
463
+ variance = max(ratios) - min(ratios) if ratios else 0
464
+ is_consistent = variance < 0.15 # Within 15% variance is "consistent"
465
+
466
+ # Find closest standard scale
467
+ closest_scale = min(STANDARD_SCALES.keys(), key=lambda x: abs(x - avg_ratio))
468
+ scale_name = STANDARD_SCALES[closest_scale]
469
+
470
+ # Detect base size (closest to 16px, or 14-18px range typical for body)
471
+ # The base size is typically the most common body text size
472
+ # IMPORTANT: Filter out tiny sizes (< 10px) which are likely captions/icons
473
+ valid_body_sizes = [s for s in sizes_px if s >= 10]
474
+
475
+ base_candidates = [s for s in valid_body_sizes if 14 <= s <= 18]
476
+ if base_candidates:
477
+ # Prefer 16px if present, otherwise closest to 16
478
+ if 16 in base_candidates:
479
+ base_size = 16.0
480
+ else:
481
+ base_size = min(base_candidates, key=lambda x: abs(x - 16))
482
+ elif valid_body_sizes:
483
+ # Fallback: find size closest to 16px from valid sizes (>= 10px)
484
+ # This avoids picking tiny caption/icon sizes like 7px
485
+ base_size = min(valid_body_sizes, key=lambda x: abs(x - 16))
486
+ elif sizes_px:
487
+ # Last resort: just use the largest size if all are tiny
488
+ base_size = max(sizes_px)
489
+ else:
490
+ base_size = 16.0
491
+
492
+ # Recommendation
493
+ if is_consistent and abs(avg_ratio - closest_scale) < 0.05:
494
+ # Already using a standard scale
495
+ recommendation = closest_scale
496
+ recommendation_name = scale_name
497
+ else:
498
+ # Recommend Major Third (1.25) as default
499
+ recommendation = 1.25
500
+ recommendation_name = "Major Third"
501
+
502
+ return TypeScaleAnalysis(
503
+ detected_ratio=avg_ratio,
504
+ closest_standard_ratio=closest_scale,
505
+ scale_name=scale_name,
506
+ is_consistent=is_consistent,
507
+ variance=variance,
508
+ sizes_px=sizes_px,
509
+ ratios_between_sizes=ratios,
510
+ recommendation=recommendation,
511
+ recommendation_name=recommendation_name,
512
+ base_size=base_size,
513
+ )
514
+
515
+
516
+ # =============================================================================
517
+ # ACCESSIBILITY ANALYSIS
518
+ # =============================================================================
519
+
520
+ def analyze_accessibility(color_tokens: dict, fg_bg_pairs: list[dict] = None) -> list[ColorAccessibility]:
521
+ """
522
+ Analyze all colors for WCAG accessibility compliance.
523
+
524
+ Args:
525
+ color_tokens: Dict of color tokens with value/hex
526
+ fg_bg_pairs: Optional list of actual foreground/background pairs
527
+ extracted from the DOM (each dict has 'foreground',
528
+ 'background', 'element' keys).
529
+
530
+ Returns:
531
+ List of ColorAccessibility results
532
+ """
533
+ results = []
534
+
535
+ for name, token in color_tokens.items():
536
+ if isinstance(token, dict):
537
+ hex_color = token.get("value") or token.get("hex") or token.get("color")
538
+ else:
539
+ hex_color = getattr(token, "value", None)
540
+
541
+ if not hex_color or not hex_color.startswith("#"):
542
+ continue
543
+
544
+ try:
545
+ contrast_white = get_contrast_ratio(hex_color, "#ffffff")
546
+ contrast_black = get_contrast_ratio(hex_color, "#000000")
547
+
548
+ passes_aa_normal = contrast_white >= 4.5 or contrast_black >= 4.5
549
+ passes_aa_large = contrast_white >= 3.0 or contrast_black >= 3.0
550
+ passes_aaa_normal = contrast_white >= 7.0 or contrast_black >= 7.0
551
+
552
+ best_text = "#ffffff" if contrast_white > contrast_black else "#000000"
553
+
554
+ # Generate fix suggestion if needed
555
+ suggested_fix = None
556
+ suggested_fix_contrast = None
557
+
558
+ if not passes_aa_normal:
559
+ suggested_fix = find_aa_compliant_color(hex_color, "#ffffff", 4.5)
560
+ suggested_fix_contrast = get_contrast_ratio(suggested_fix, "#ffffff")
561
+
562
+ results.append(ColorAccessibility(
563
+ hex_color=hex_color,
564
+ name=name,
565
+ contrast_on_white=contrast_white,
566
+ contrast_on_black=contrast_black,
567
+ passes_aa_normal=passes_aa_normal,
568
+ passes_aa_large=passes_aa_large,
569
+ passes_aaa_normal=passes_aaa_normal,
570
+ best_text_color=best_text,
571
+ suggested_fix=suggested_fix,
572
+ suggested_fix_contrast=suggested_fix_contrast,
573
+ ))
574
+ except Exception:
575
+ continue
576
+
577
+ # --- Real foreground-background pair checks ---
578
+ if fg_bg_pairs:
579
+ for pair in fg_bg_pairs:
580
+ fg = pair.get("foreground", "").lower()
581
+ bg = pair.get("background", "").lower()
582
+ element = pair.get("element", "")
583
+ if not (fg.startswith("#") and bg.startswith("#")):
584
+ continue
585
+ # Skip same-color pairs (invisible/placeholder text β€” not real failures)
586
+ if fg == bg:
587
+ continue
588
+ try:
589
+ ratio = get_contrast_ratio(fg, bg)
590
+ # Skip near-identical pairs (ratio < 1.1) β€” likely decorative/hidden
591
+ if ratio < 1.1:
592
+ continue
593
+ if ratio < 4.5:
594
+ # This pair fails AA β€” record it
595
+ fix = find_aa_compliant_color(fg, bg, 4.5)
596
+ fix_contrast = get_contrast_ratio(fix, bg)
597
+ results.append(ColorAccessibility(
598
+ hex_color=fg,
599
+ name=f"fg:{fg} on bg:{bg} ({element}) [{ratio:.1f}:1]",
600
+ contrast_on_white=get_contrast_ratio(fg, "#ffffff"),
601
+ contrast_on_black=get_contrast_ratio(fg, "#000000"),
602
+ passes_aa_normal=False,
603
+ passes_aa_large=ratio >= 3.0,
604
+ passes_aaa_normal=False,
605
+ best_text_color="#ffffff" if get_contrast_ratio(fg, "#ffffff") > get_contrast_ratio(fg, "#000000") else "#000000",
606
+ suggested_fix=fix,
607
+ suggested_fix_contrast=fix_contrast,
608
+ ))
609
+ except Exception:
610
+ continue
611
+
612
+ return results
613
+
614
+
615
+ # =============================================================================
616
+ # SPACING GRID ANALYSIS
617
+ # =============================================================================
618
+
619
+ def analyze_spacing_grid(spacing_tokens: dict) -> SpacingGridAnalysis:
620
+ """
621
+ Analyze spacing tokens to detect grid alignment.
622
+
623
+ Args:
624
+ spacing_tokens: Dict of spacing tokens with value_px or value
625
+
626
+ Returns:
627
+ SpacingGridAnalysis with detected grid and recommendations
628
+ """
629
+ values = []
630
+
631
+ for name, token in spacing_tokens.items():
632
+ if isinstance(token, dict):
633
+ px = token.get("value_px") or token.get("value")
634
+ else:
635
+ px = getattr(token, "value_px", None) or getattr(token, "value", None)
636
+
637
+ if px:
638
+ try:
639
+ px_val = int(float(str(px).replace('px', '')))
640
+ if px_val > 0:
641
+ values.append(px_val)
642
+ except (ValueError, TypeError):
643
+ continue
644
+
645
+ if not values:
646
+ return SpacingGridAnalysis(
647
+ detected_base=8,
648
+ is_aligned=False,
649
+ alignment_percentage=0,
650
+ misaligned_values=[],
651
+ recommendation=8,
652
+ recommendation_reason="No spacing values detected, defaulting to 8px grid",
653
+ current_values=[],
654
+ suggested_scale=[0, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64],
655
+ )
656
+
657
+ values = sorted(set(values))
658
+
659
+ # Find GCD (greatest common divisor) of all values
660
+ detected_base = reduce(gcd, values)
661
+
662
+ # Check alignment to common grids (4px, 8px)
663
+ aligned_to_4 = all(v % 4 == 0 for v in values)
664
+ aligned_to_8 = all(v % 8 == 0 for v in values)
665
+
666
+ # Find misaligned values (not divisible by detected base)
667
+ misaligned = [v for v in values if v % detected_base != 0] if detected_base > 1 else values
668
+
669
+ alignment_percentage = (len(values) - len(misaligned)) / len(values) * 100 if values else 0
670
+
671
+ # Determine recommendation
672
+ if aligned_to_8:
673
+ recommendation = 8
674
+ recommendation_reason = "All values already align to 8px grid"
675
+ is_aligned = True
676
+ elif aligned_to_4:
677
+ recommendation = 4
678
+ recommendation_reason = "Values align to 4px grid (consider 8px for simpler system)"
679
+ is_aligned = True
680
+ elif detected_base in [4, 8]:
681
+ recommendation = detected_base
682
+ recommendation_reason = f"Detected {detected_base}px base with {alignment_percentage:.0f}% alignment"
683
+ is_aligned = alignment_percentage >= 80
684
+ else:
685
+ recommendation = 8
686
+ recommendation_reason = f"Inconsistent spacing detected (GCD={detected_base}), recommend 8px grid"
687
+ is_aligned = False
688
+
689
+ # Generate suggested scale
690
+ base = recommendation
691
+ 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)]
692
+ suggested_scale = sorted(set([int(v) for v in suggested_scale]))
693
+
694
+ return SpacingGridAnalysis(
695
+ detected_base=detected_base,
696
+ is_aligned=is_aligned,
697
+ alignment_percentage=alignment_percentage,
698
+ misaligned_values=misaligned,
699
+ recommendation=recommendation,
700
+ recommendation_reason=recommendation_reason,
701
+ current_values=values,
702
+ suggested_scale=suggested_scale,
703
+ )
704
+
705
+
706
+ # =============================================================================
707
+ # COLOR STATISTICS
708
+ # =============================================================================
709
+
710
+ def analyze_color_statistics(color_tokens: dict, similarity_threshold: float = 0.05) -> ColorStatistics:
711
+ """
712
+ Analyze color palette statistics.
713
+
714
+ Args:
715
+ color_tokens: Dict of color tokens
716
+ similarity_threshold: Distance threshold for "near duplicate" (0-1)
717
+
718
+ Returns:
719
+ ColorStatistics with palette analysis
720
+ """
721
+ colors = []
722
+
723
+ for name, token in color_tokens.items():
724
+ if isinstance(token, dict):
725
+ hex_color = token.get("value") or token.get("hex")
726
+ else:
727
+ hex_color = getattr(token, "value", None)
728
+
729
+ if hex_color and hex_color.startswith("#"):
730
+ colors.append(hex_color.lower())
731
+
732
+ unique_colors = list(set(colors))
733
+
734
+ # Count grays and saturated
735
+ grays = [c for c in unique_colors if is_gray(c)]
736
+ saturated = [c for c in unique_colors if get_saturation(c) > 0.3]
737
+
738
+ # Find near duplicates
739
+ near_duplicates = []
740
+ for i, c1 in enumerate(unique_colors):
741
+ for c2 in unique_colors[i+1:]:
742
+ dist = color_distance(c1, c2)
743
+ if dist < similarity_threshold and dist > 0:
744
+ near_duplicates.append((c1, c2, round(dist, 4)))
745
+
746
+ # Hue distribution
747
+ hue_dist = {}
748
+ for c in unique_colors:
749
+ hue = get_hue_name(c)
750
+ hue_dist[hue] = hue_dist.get(hue, 0) + 1
751
+
752
+ return ColorStatistics(
753
+ total_count=len(colors),
754
+ unique_count=len(unique_colors),
755
+ duplicate_count=len(colors) - len(unique_colors),
756
+ gray_count=len(grays),
757
+ saturated_count=len(saturated),
758
+ near_duplicates=near_duplicates,
759
+ hue_distribution=hue_dist,
760
+ )
761
+
762
+
763
+ # =============================================================================
764
+ # MAIN ANALYSIS FUNCTION
765
+ # =============================================================================
766
+
767
+ def run_rule_engine(
768
+ typography_tokens: dict,
769
+ color_tokens: dict,
770
+ spacing_tokens: dict,
771
+ radius_tokens: dict = None,
772
+ shadow_tokens: dict = None,
773
+ log_callback: Optional[callable] = None,
774
+ fg_bg_pairs: list[dict] = None,
775
+ ) -> RuleEngineResults:
776
+ """
777
+ Run complete rule-based analysis on design tokens.
778
+
779
+ This is FREE (no LLM costs) and handles all deterministic calculations.
780
+
781
+ Args:
782
+ typography_tokens: Dict of typography tokens
783
+ color_tokens: Dict of color tokens
784
+ spacing_tokens: Dict of spacing tokens
785
+ radius_tokens: Dict of border radius tokens (optional)
786
+ shadow_tokens: Dict of shadow tokens (optional)
787
+ log_callback: Function to log messages
788
+
789
+ Returns:
790
+ RuleEngineResults with all analysis data
791
+ """
792
+
793
+ def log(msg: str):
794
+ if log_callback:
795
+ log_callback(msg)
796
+
797
+ log("")
798
+ log("═" * 60)
799
+ log("βš™οΈ LAYER 1: RULE ENGINE (FREE - $0.00)")
800
+ log("═" * 60)
801
+ log("")
802
+
803
+ # ─────────────────────────────────────────────────────────────
804
+ # Typography Analysis
805
+ # ─────────────────────────────────────────────────────────────
806
+ log(" πŸ“ TYPE SCALE ANALYSIS")
807
+ log(" " + "─" * 40)
808
+ typography = analyze_type_scale(typography_tokens)
809
+
810
+ consistency_icon = "βœ…" if typography.is_consistent else "⚠️"
811
+ log(f" β”œβ”€ Detected Ratio: {typography.detected_ratio:.3f}")
812
+ log(f" β”œβ”€ Closest Standard: {typography.scale_name} ({typography.closest_standard_ratio})")
813
+ log(f" β”œβ”€ Consistent: {consistency_icon} {'Yes' if typography.is_consistent else f'No (variance: {typography.variance:.2f})'}")
814
+ log(f" β”œβ”€ Sizes Found: {typography.sizes_px}")
815
+ log(f" └─ πŸ’‘ Recommendation: {typography.recommendation} ({typography.recommendation_name})")
816
+ log("")
817
+
818
+ # ─────────────────────────────────────────────────────────────
819
+ # Accessibility Analysis
820
+ # ─────────────────────────────────────────────────────────────
821
+ log(" β™Ώ ACCESSIBILITY CHECK (WCAG AA/AAA)")
822
+ log(" " + "─" * 40)
823
+ accessibility = analyze_accessibility(color_tokens, fg_bg_pairs=fg_bg_pairs)
824
+
825
+ # Separate individual-color failures from real FG/BG pair failures
826
+ pair_failures = [a for a in accessibility if not a.passes_aa_normal and a.name.startswith("fg:")]
827
+ color_only_failures = [a for a in accessibility if not a.passes_aa_normal and not a.name.startswith("fg:")]
828
+ failures = [a for a in accessibility if not a.passes_aa_normal]
829
+ passes = len(accessibility) - len(failures)
830
+
831
+ pair_count = len(fg_bg_pairs) if fg_bg_pairs else 0
832
+ log(f" β”œβ”€ Colors Analyzed: {len(accessibility)}")
833
+ log(f" β”œβ”€ FG/BG Pairs Checked: {pair_count}")
834
+ log(f" β”œβ”€ AA Pass: {passes} βœ…")
835
+ log(f" β”œβ”€ AA Fail (color vs white/black): {len(color_only_failures)} {'❌' if color_only_failures else 'βœ…'}")
836
+ log(f" β”œβ”€ AA Fail (real FG/BG pairs): {len(pair_failures)} {'❌' if pair_failures else 'βœ…'}")
837
+
838
+ if color_only_failures:
839
+ log(" β”‚")
840
+ log(" β”‚ ⚠️ FAILING COLORS (vs white/black):")
841
+ for i, f in enumerate(color_only_failures[:5]):
842
+ fix_info = f" β†’ πŸ’‘ Fix: {f.suggested_fix} ({f.suggested_fix_contrast:.1f}:1)" if f.suggested_fix else ""
843
+ log(f" β”‚ β”œβ”€ {f.name}: {f.hex_color} ({f.contrast_on_white:.1f}:1 on white){fix_info}")
844
+ if len(color_only_failures) > 5:
845
+ log(f" β”‚ └─ ... and {len(color_only_failures) - 5} more")
846
+
847
+ if pair_failures:
848
+ log(" β”‚")
849
+ log(" β”‚ ❌ FAILING FG/BG PAIRS (actual on-page combinations):")
850
+ for i, f in enumerate(pair_failures[:5]):
851
+ fix_info = f" β†’ πŸ’‘ Fix: {f.suggested_fix} ({f.suggested_fix_contrast:.1f}:1)" if f.suggested_fix else ""
852
+ log(f" β”‚ β”œβ”€ {f.name}{fix_info}")
853
+ if len(pair_failures) > 5:
854
+ log(f" β”‚ └─ ... and {len(pair_failures) - 5} more")
855
+
856
+ log("")
857
+
858
+ # ─────────────────────────────────────────────────────────────
859
+ # Spacing Grid Analysis
860
+ # ─────────────────────────────────────────────────────────────
861
+ log(" πŸ“ SPACING GRID ANALYSIS")
862
+ log(" " + "─" * 40)
863
+ spacing = analyze_spacing_grid(spacing_tokens)
864
+
865
+ alignment_icon = "βœ…" if spacing.is_aligned else "⚠️"
866
+ log(f" β”œβ”€ Detected Base: {spacing.detected_base}px")
867
+ log(f" β”œβ”€ Grid Aligned: {alignment_icon} {spacing.alignment_percentage:.0f}%")
868
+
869
+ if spacing.misaligned_values:
870
+ log(f" β”œβ”€ Misaligned Values: {spacing.misaligned_values[:8]}{'...' if len(spacing.misaligned_values) > 8 else ''}")
871
+
872
+ log(f" β”œβ”€ Suggested Scale: {spacing.suggested_scale[:10]}...")
873
+ log(f" └─ πŸ’‘ Recommendation: {spacing.recommendation}px ({spacing.recommendation_reason})")
874
+ log("")
875
+
876
+ # ─────────────────────────────────────────────────────────────
877
+ # Color Statistics
878
+ # ─────────────────────────────────────────────────────────────
879
+ log(" 🎨 COLOR PALETTE STATISTICS")
880
+ log(" " + "─" * 40)
881
+ color_stats = analyze_color_statistics(color_tokens)
882
+
883
+ dup_icon = "⚠️" if color_stats.duplicate_count > 10 else "βœ…"
884
+ unique_icon = "⚠️" if color_stats.unique_count > 30 else "βœ…"
885
+
886
+ log(f" β”œβ”€ Total Colors: {color_stats.total_count}")
887
+ log(f" β”œβ”€ Unique Colors: {color_stats.unique_count} {unique_icon}")
888
+ log(f" β”œβ”€ Exact Duplicates: {color_stats.duplicate_count} {dup_icon}")
889
+ log(f" β”œβ”€ Near-Duplicates: {len(color_stats.near_duplicates)}")
890
+ log(f" β”œβ”€ Grays: {color_stats.gray_count} | Saturated: {color_stats.saturated_count}")
891
+ log(f" └─ Hue Distribution: {dict(list(color_stats.hue_distribution.items())[:5])}...")
892
+ log("")
893
+
894
+ # ─────────────────────────────────────────────────────────────
895
+ # Calculate Summary Scores
896
+ # ─────────────────────────────────────────────────────────────
897
+
898
+ # Consistency score (0-100)
899
+ type_score = 25 if typography.is_consistent else 10
900
+ aa_score = 25 * (passes / max(len(accessibility), 1))
901
+ spacing_score = 25 * (spacing.alignment_percentage / 100)
902
+ color_score = 25 * (1 - min(color_stats.duplicate_count / max(color_stats.total_count, 1), 1))
903
+
904
+ consistency_score = int(type_score + aa_score + spacing_score + color_score)
905
+
906
+ log(" " + "─" * 40)
907
+ log(f" πŸ“Š RULE ENGINE SUMMARY")
908
+ log(f" β”œβ”€ Consistency Score: {consistency_score}/100")
909
+ log(f" β”œβ”€ AA Failures: {len(failures)}")
910
+ log(f" └─ Cost: $0.00 (free)")
911
+ log("")
912
+
913
+ return RuleEngineResults(
914
+ typography=typography,
915
+ accessibility=accessibility,
916
+ spacing=spacing,
917
+ color_stats=color_stats,
918
+ aa_failures=len(failures),
919
+ consistency_score=consistency_score,
920
+ )