sweedworks

← all sources

checka11y.py

WCAG contrast ratios, computed from the stylesheet

140 lines. This is the file the build actually runs, copied verbatim at build time.

  1. 1"""Compute WCAG contrast ratios from the stylesheet, in both themes.
  2. 2 
  3. 3The site asserts it is readable. That is measurable, so it should be measured
  4. 4rather than asserted — same rule as every other claim here. This parses the real
  5. 5custom properties out of style.css, so it cannot drift from what ships.
  6. 6 
  7. 7Thresholds (WCAG 2.1 AA):
  8. 8 4.5:1 body text
  9. 9 3.0:1 large text (>= 24px, or >= 18.66px bold) and UI component boundaries
  10. 10"""
  11. 11 
  12. 12import os
  13. 13import re
  14. 14import sys
  15. 15 
  16. 16HERE = os.path.dirname(os.path.abspath(__file__))
  17. 17ROOT = os.path.abspath(os.path.join(HERE, ".."))
  18. 18 
  19. 19CSS = os.path.join(ROOT, "style.css")
  20. 20 
  21. 21AA_TEXT = 4.5
  22. 22AA_LARGE = 3.0
  23. 23 
  24. 24 
  25. 25def parse_hex(value):
  26. 26 v = value.strip().lstrip("#")
  27. 27 if len(v) == 3:
  28. 28 v = "".join(c * 2 for c in v)
  29. 29 return tuple(int(v[i:i + 2], 16) for i in (0, 2, 4))
  30. 30 
  31. 31 
  32. 32def luminance(rgb):
  33. 33 out = []
  34. 34 for c in rgb:
  35. 35 s = c / 255
  36. 36 out.append(s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4)
  37. 37 return 0.2126 * out[0] + 0.7152 * out[1] + 0.0722 * out[2]
  38. 38 
  39. 39 
  40. 40def ratio(a, b):
  41. 41 la, lb = luminance(a), luminance(b)
  42. 42 hi, lo = max(la, lb), min(la, lb)
  43. 43 return (hi + 0.05) / (lo + 0.05)
  44. 44 
  45. 45 
  46. 46def variables():
  47. 47 """(light, dark) maps of custom property -> rgb tuple."""
  48. 48 css = open(CSS, encoding="utf-8").read()
  49. 49 
  50. 50 root = re.search(r":root\s*\{(.*?)\}", css, re.S)
  51. 51 light = {}
  52. 52 for name, val in re.findall(r"--([\w-]+):\s*(#[0-9a-fA-F]{3,6})\s*;",
  53. 53 root.group(1)):
  54. 54 light[name] = parse_hex(val)
  55. 55 
  56. 56 dark = dict(light)
  57. 57 block = re.search(r'prefers-color-scheme:\s*dark.*?\{(.*?)\n \}\n\}', css, re.S)
  58. 58 if block:
  59. 59 for name, val in re.findall(r"--([\w-]+):\s*(#[0-9a-fA-F]{3,6})\s*;",
  60. 60 block.group(1)):
  61. 61 dark[name] = parse_hex(val)
  62. 62 return light, dark
  63. 63 
  64. 64 
  65. 65# (foreground, background, minimum, what it is)
  66. 66PAIRS = [
  67. 67 ("ink", "bg", AA_TEXT, "body text"),
  68. 68 ("ink", "panel", AA_TEXT, "text inside figures"),
  69. 69 ("ink-soft", "bg", AA_TEXT, "standfirst and secondary prose"),
  70. 70 ("ink-soft", "panel", AA_TEXT, "button labels"),
  71. 71 ("ink-faint", "bg", AA_TEXT, "captions, footer, status lines"),
  72. 72 ("ink-faint", "panel", AA_TEXT, "figure captions"),
  73. 73 ("link", "bg", AA_TEXT, "links in prose"),
  74. 74 ("accent", "bg", AA_LARGE, "big statistics and headings"),
  75. 75 ("accent", "accent-soft", AA_TEXT, "pressed buttons, highlighted rows"),
  76. 76 ("ink", "bg-sunk", AA_TEXT, "inline code"),
  77. 77 ("rule-strong", "bg", AA_LARGE, "form control borders"),
  78. 78 ("rule-strong", "panel", AA_LARGE, "form control borders on panels"),
  79. 79 # --rule itself is deliberately not checked. It draws hairline dividers
  80. 80 # between rows and around figures: decoration that carries no information
  81. 81 # and is exempt from WCAG 1.4.11. Anything whose boundary is an affordance
  82. 82 # — a text box, a button — uses --rule-strong, which is checked.
  83. 83]
  84. 84 
  85. 85# Token chips: the text is --ink on each chip background.
  86. 86CHIPS = [f"tok-{i}" for i in range(1, 7)]
  87. 87 
  88. 88 
  89. 89def main():
  90. 90 light, dark = variables()
  91. 91 problems, rows = [], []
  92. 92 
  93. 93 for theme_name, theme in (("light", light), ("dark", dark)):
  94. 94 for fg, bg, minimum, what in PAIRS:
  95. 95 if fg not in theme or bg not in theme:
  96. 96 problems.append(f"{theme_name}: missing variable {fg} or {bg}")
  97. 97 continue
  98. 98 r = ratio(theme[fg], theme[bg])
  99. 99 ok = r >= minimum
  100. 100 rows.append((theme_name, f"--{fg} on --{bg}", r, minimum, ok, what))
  101. 101 if not ok:
  102. 102 problems.append(
  103. 103 f"{theme_name}: --{fg} on --{bg} is {r:.2f}:1, "
  104. 104 f"needs {minimum}:1 ({what})")
  105. 105 
  106. 106 for chip in CHIPS:
  107. 107 if chip not in theme:
  108. 108 continue
  109. 109 r = ratio(theme["ink"], theme[chip])
  110. 110 ok = r >= AA_TEXT
  111. 111 rows.append((theme_name, f"--ink on --{chip}", r, AA_TEXT, ok,
  112. 112 "token chip text"))
  113. 113 if not ok:
  114. 114 problems.append(f"{theme_name}: --ink on --{chip} is {r:.2f}:1, "
  115. 115 f"needs {AA_TEXT}:1 (token chip text)")
  116. 116 
  117. 117 worst = min(rows, key=lambda r: r[2])
  118. 118 for theme_name in ("light", "dark"):
  119. 119 sub = [r for r in rows if r[0] == theme_name]
  120. 120 bad = [r for r in sub if not r[4]]
  121. 121 status = "PASS" if not bad else "FAIL"
  122. 122 print(f"{status} {theme_name:<6} {len(sub)} pairs, "
  123. 123 f"lowest {min(r[2] for r in sub):.2f}:1")
  124. 124 for r in sub:
  125. 125 if not r[4]:
  126. 126 print(f" {r[1]:<28} {r[2]:.2f}:1 needs {r[3]} ({r[5]})")
  127. 127 
  128. 128 print(f" tightest overall: {worst[1]} in {worst[0]} at {worst[2]:.2f}:1"
  129. 129 f" ({worst[5]})")
  130. 130 print()
  131. 131 if problems:
  132. 132 print(f"{len(problems)} contrast problem(s).")
  133. 133 return 1
  134. 134 print(f"All {len(rows)} colour pairs meet WCAG AA in both themes.")
  135. 135 return 0
  136. 136 
  137. 137 
  138. 138if __name__ == "__main__":
  139. 139 sys.exit(main())