riazmo commited on
Commit
ba4c870
Β·
verified Β·
1 Parent(s): a6c864a

Upload rule_engine.py

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