Upload rule_engine.py
Browse files- core/rule_engine.py +93 -21
core/rule_engine.py
CHANGED
|
@@ -40,6 +40,7 @@ class TypeScaleAnalysis:
|
|
| 40 |
ratios_between_sizes: list[float]
|
| 41 |
recommendation: float
|
| 42 |
recommendation_name: str
|
|
|
|
| 43 |
|
| 44 |
def to_dict(self) -> dict:
|
| 45 |
return {
|
|
@@ -49,6 +50,7 @@ class TypeScaleAnalysis:
|
|
| 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 |
}
|
|
@@ -381,6 +383,7 @@ def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis:
|
|
| 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,
|
|
@@ -391,6 +394,7 @@ def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis:
|
|
| 391 |
ratios_between_sizes=[],
|
| 392 |
recommendation=1.25,
|
| 393 |
recommendation_name="Major Third",
|
|
|
|
| 394 |
)
|
| 395 |
|
| 396 |
# Calculate ratios between consecutive sizes
|
|
@@ -402,6 +406,9 @@ def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis:
|
|
| 402 |
ratios.append(ratio)
|
| 403 |
|
| 404 |
if not ratios:
|
|
|
|
|
|
|
|
|
|
| 405 |
return TypeScaleAnalysis(
|
| 406 |
detected_ratio=1.0,
|
| 407 |
closest_standard_ratio=1.25,
|
|
@@ -412,6 +419,7 @@ def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis:
|
|
| 412 |
ratios_between_sizes=[],
|
| 413 |
recommendation=1.25,
|
| 414 |
recommendation_name="Major Third",
|
|
|
|
| 415 |
)
|
| 416 |
|
| 417 |
# Average ratio
|
|
@@ -425,6 +433,21 @@ def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis:
|
|
| 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
|
|
@@ -445,6 +468,7 @@ def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis:
|
|
| 445 |
ratios_between_sizes=ratios,
|
| 446 |
recommendation=recommendation,
|
| 447 |
recommendation_name=recommendation_name,
|
|
|
|
| 448 |
)
|
| 449 |
|
| 450 |
|
|
@@ -452,45 +476,48 @@ def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis:
|
|
| 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,
|
|
@@ -505,7 +532,36 @@ def analyze_accessibility(color_tokens: dict) -> list[ColorAccessibility]:
|
|
| 505 |
))
|
| 506 |
except Exception:
|
| 507 |
continue
|
| 508 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
return results
|
| 510 |
|
| 511 |
|
|
@@ -668,6 +724,7 @@ def run_rule_engine(
|
|
| 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.
|
|
@@ -716,23 +773,38 @@ def run_rule_engine(
|
|
| 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(
|
| 727 |
-
|
| 728 |
-
|
|
|
|
| 729 |
log(" β")
|
| 730 |
-
log(" β β οΈ FAILING COLORS:")
|
| 731 |
-
for i, f in enumerate(
|
| 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(
|
| 735 |
-
log(f" β ββ ... and {len(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
|
| 737 |
log("")
|
| 738 |
|
|
|
|
| 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 {
|
|
|
|
| 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 |
}
|
|
|
|
| 383 |
sizes_px = sorted(set(sizes))
|
| 384 |
|
| 385 |
if len(sizes_px) < 2:
|
| 386 |
+
base_size = sizes_px[0] if sizes_px else 16.0
|
| 387 |
return TypeScaleAnalysis(
|
| 388 |
detected_ratio=1.0,
|
| 389 |
closest_standard_ratio=1.25,
|
|
|
|
| 394 |
ratios_between_sizes=[],
|
| 395 |
recommendation=1.25,
|
| 396 |
recommendation_name="Major Third",
|
| 397 |
+
base_size=base_size,
|
| 398 |
)
|
| 399 |
|
| 400 |
# Calculate ratios between consecutive sizes
|
|
|
|
| 406 |
ratios.append(ratio)
|
| 407 |
|
| 408 |
if not ratios:
|
| 409 |
+
# Detect base size even if no valid ratios
|
| 410 |
+
base_candidates = [s for s in sizes_px if 14 <= s <= 18]
|
| 411 |
+
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)
|
| 412 |
return TypeScaleAnalysis(
|
| 413 |
detected_ratio=1.0,
|
| 414 |
closest_standard_ratio=1.25,
|
|
|
|
| 419 |
ratios_between_sizes=[],
|
| 420 |
recommendation=1.25,
|
| 421 |
recommendation_name="Major Third",
|
| 422 |
+
base_size=base_size,
|
| 423 |
)
|
| 424 |
|
| 425 |
# Average ratio
|
|
|
|
| 433 |
closest_scale = min(STANDARD_SCALES.keys(), key=lambda x: abs(x - avg_ratio))
|
| 434 |
scale_name = STANDARD_SCALES[closest_scale]
|
| 435 |
|
| 436 |
+
# Detect base size (closest to 16px, or 14-18px range typical for body)
|
| 437 |
+
# The base size is typically the most common body text size
|
| 438 |
+
base_candidates = [s for s in sizes_px if 14 <= s <= 18]
|
| 439 |
+
if base_candidates:
|
| 440 |
+
# Prefer 16px if present, otherwise closest to 16
|
| 441 |
+
if 16 in base_candidates:
|
| 442 |
+
base_size = 16.0
|
| 443 |
+
else:
|
| 444 |
+
base_size = min(base_candidates, key=lambda x: abs(x - 16))
|
| 445 |
+
elif sizes_px:
|
| 446 |
+
# Fallback: find size closest to 16px
|
| 447 |
+
base_size = min(sizes_px, key=lambda x: abs(x - 16))
|
| 448 |
+
else:
|
| 449 |
+
base_size = 16.0
|
| 450 |
+
|
| 451 |
# Recommendation
|
| 452 |
if is_consistent and abs(avg_ratio - closest_scale) < 0.05:
|
| 453 |
# Already using a standard scale
|
|
|
|
| 468 |
ratios_between_sizes=ratios,
|
| 469 |
recommendation=recommendation,
|
| 470 |
recommendation_name=recommendation_name,
|
| 471 |
+
base_size=base_size,
|
| 472 |
)
|
| 473 |
|
| 474 |
|
|
|
|
| 476 |
# ACCESSIBILITY ANALYSIS
|
| 477 |
# =============================================================================
|
| 478 |
|
| 479 |
+
def analyze_accessibility(color_tokens: dict, fg_bg_pairs: list[dict] = None) -> list[ColorAccessibility]:
|
| 480 |
"""
|
| 481 |
Analyze all colors for WCAG accessibility compliance.
|
| 482 |
+
|
| 483 |
Args:
|
| 484 |
color_tokens: Dict of color tokens with value/hex
|
| 485 |
+
fg_bg_pairs: Optional list of actual foreground/background pairs
|
| 486 |
+
extracted from the DOM (each dict has 'foreground',
|
| 487 |
+
'background', 'element' keys).
|
| 488 |
+
|
| 489 |
Returns:
|
| 490 |
List of ColorAccessibility results
|
| 491 |
"""
|
| 492 |
results = []
|
| 493 |
+
|
| 494 |
for name, token in color_tokens.items():
|
| 495 |
if isinstance(token, dict):
|
| 496 |
hex_color = token.get("value") or token.get("hex") or token.get("color")
|
| 497 |
else:
|
| 498 |
hex_color = getattr(token, "value", None)
|
| 499 |
+
|
| 500 |
if not hex_color or not hex_color.startswith("#"):
|
| 501 |
continue
|
| 502 |
+
|
| 503 |
try:
|
| 504 |
contrast_white = get_contrast_ratio(hex_color, "#ffffff")
|
| 505 |
contrast_black = get_contrast_ratio(hex_color, "#000000")
|
| 506 |
+
|
| 507 |
passes_aa_normal = contrast_white >= 4.5 or contrast_black >= 4.5
|
| 508 |
passes_aa_large = contrast_white >= 3.0 or contrast_black >= 3.0
|
| 509 |
passes_aaa_normal = contrast_white >= 7.0 or contrast_black >= 7.0
|
| 510 |
+
|
| 511 |
best_text = "#ffffff" if contrast_white > contrast_black else "#000000"
|
| 512 |
+
|
| 513 |
# Generate fix suggestion if needed
|
| 514 |
suggested_fix = None
|
| 515 |
suggested_fix_contrast = None
|
| 516 |
+
|
| 517 |
if not passes_aa_normal:
|
| 518 |
suggested_fix = find_aa_compliant_color(hex_color, "#ffffff", 4.5)
|
| 519 |
suggested_fix_contrast = get_contrast_ratio(suggested_fix, "#ffffff")
|
| 520 |
+
|
| 521 |
results.append(ColorAccessibility(
|
| 522 |
hex_color=hex_color,
|
| 523 |
name=name,
|
|
|
|
| 532 |
))
|
| 533 |
except Exception:
|
| 534 |
continue
|
| 535 |
+
|
| 536 |
+
# --- Real foreground-background pair checks ---
|
| 537 |
+
if fg_bg_pairs:
|
| 538 |
+
for pair in fg_bg_pairs:
|
| 539 |
+
fg = pair.get("foreground", "")
|
| 540 |
+
bg = pair.get("background", "")
|
| 541 |
+
element = pair.get("element", "")
|
| 542 |
+
if not (fg.startswith("#") and bg.startswith("#")):
|
| 543 |
+
continue
|
| 544 |
+
try:
|
| 545 |
+
ratio = get_contrast_ratio(fg, bg)
|
| 546 |
+
if ratio < 4.5:
|
| 547 |
+
# This pair fails AA β record it
|
| 548 |
+
fix = find_aa_compliant_color(fg, bg, 4.5)
|
| 549 |
+
fix_contrast = get_contrast_ratio(fix, bg)
|
| 550 |
+
results.append(ColorAccessibility(
|
| 551 |
+
hex_color=fg,
|
| 552 |
+
name=f"fg:{fg} on bg:{bg} ({element})",
|
| 553 |
+
contrast_on_white=get_contrast_ratio(fg, "#ffffff"),
|
| 554 |
+
contrast_on_black=get_contrast_ratio(fg, "#000000"),
|
| 555 |
+
passes_aa_normal=False,
|
| 556 |
+
passes_aa_large=ratio >= 3.0,
|
| 557 |
+
passes_aaa_normal=False,
|
| 558 |
+
best_text_color="#ffffff" if get_contrast_ratio(fg, "#ffffff") > get_contrast_ratio(fg, "#000000") else "#000000",
|
| 559 |
+
suggested_fix=fix,
|
| 560 |
+
suggested_fix_contrast=fix_contrast,
|
| 561 |
+
))
|
| 562 |
+
except Exception:
|
| 563 |
+
continue
|
| 564 |
+
|
| 565 |
return results
|
| 566 |
|
| 567 |
|
|
|
|
| 724 |
radius_tokens: dict = None,
|
| 725 |
shadow_tokens: dict = None,
|
| 726 |
log_callback: Optional[callable] = None,
|
| 727 |
+
fg_bg_pairs: list[dict] = None,
|
| 728 |
) -> RuleEngineResults:
|
| 729 |
"""
|
| 730 |
Run complete rule-based analysis on design tokens.
|
|
|
|
| 773 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 774 |
log(" βΏ ACCESSIBILITY CHECK (WCAG AA/AAA)")
|
| 775 |
log(" " + "β" * 40)
|
| 776 |
+
accessibility = analyze_accessibility(color_tokens, fg_bg_pairs=fg_bg_pairs)
|
| 777 |
|
| 778 |
+
# Separate individual-color failures from real FG/BG pair failures
|
| 779 |
+
pair_failures = [a for a in accessibility if not a.passes_aa_normal and a.name.startswith("fg:")]
|
| 780 |
+
color_only_failures = [a for a in accessibility if not a.passes_aa_normal and not a.name.startswith("fg:")]
|
| 781 |
failures = [a for a in accessibility if not a.passes_aa_normal]
|
| 782 |
passes = len(accessibility) - len(failures)
|
| 783 |
+
|
| 784 |
+
pair_count = len(fg_bg_pairs) if fg_bg_pairs else 0
|
| 785 |
log(f" ββ Colors Analyzed: {len(accessibility)}")
|
| 786 |
+
log(f" ββ FG/BG Pairs Checked: {pair_count}")
|
| 787 |
log(f" ββ AA Pass: {passes} β
")
|
| 788 |
+
log(f" ββ AA Fail (color vs white/black): {len(color_only_failures)} {'β' if color_only_failures else 'β
'}")
|
| 789 |
+
log(f" ββ AA Fail (real FG/BG pairs): {len(pair_failures)} {'β' if pair_failures else 'β
'}")
|
| 790 |
+
|
| 791 |
+
if color_only_failures:
|
| 792 |
log(" β")
|
| 793 |
+
log(" β β οΈ FAILING COLORS (vs white/black):")
|
| 794 |
+
for i, f in enumerate(color_only_failures[:5]):
|
| 795 |
fix_info = f" β π‘ Fix: {f.suggested_fix} ({f.suggested_fix_contrast:.1f}:1)" if f.suggested_fix else ""
|
| 796 |
log(f" β ββ {f.name}: {f.hex_color} ({f.contrast_on_white:.1f}:1 on white){fix_info}")
|
| 797 |
+
if len(color_only_failures) > 5:
|
| 798 |
+
log(f" β ββ ... and {len(color_only_failures) - 5} more")
|
| 799 |
+
|
| 800 |
+
if pair_failures:
|
| 801 |
+
log(" β")
|
| 802 |
+
log(" β β FAILING FG/BG PAIRS (actual on-page combinations):")
|
| 803 |
+
for i, f in enumerate(pair_failures[:5]):
|
| 804 |
+
fix_info = f" β π‘ Fix: {f.suggested_fix} ({f.suggested_fix_contrast:.1f}:1)" if f.suggested_fix else ""
|
| 805 |
+
log(f" β ββ {f.name}{fix_info}")
|
| 806 |
+
if len(pair_failures) > 5:
|
| 807 |
+
log(f" β ββ ... and {len(pair_failures) - 5} more")
|
| 808 |
|
| 809 |
log("")
|
| 810 |
|