css_check.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. #!/usr/bin/env python3
  2. """Spry CSS regression checks — the visual contracts of the framework.
  3. Parses the shipped stylesheets as text (no browser needed) and asserts the
  4. shape the goal work established:
  5. * Airy's dialog glide-in really is the Win7 DWM Aero curve: the open ease
  6. numerically equals f(t) = 1-(1-t)^3 and the close ease equals t^3,
  7. duration 180 ms, scale-from-0.90..0.95, anchored popovers translating
  8. toward their trigger.
  9. * Core padding defaults: bare `.card` and bare `dialog`/`[popover]` carry
  10. padding by default, with :has() zero-guards so structural children stay
  11. flush to the frame.
  12. * Every theme keeps defining the tokens the core now relies on.
  13. * The demo page (css/index.html) links and switches everything it demos.
  14. * Formal's heading scale opts out of .prose on BOTH activation routes.
  15. * Brace/paren balance and no tabs across all shipped CSS.
  16. * README points at the live demo, not stale paths.
  17. Plain stdlib, no pytest: prints PASS/FAIL lines and a final summary in the
  18. spry-tests style ("---- N passed, M failed ----"), exits non-zero on any
  19. failure. Run from anywhere:
  20. python3 tests/css_check.py
  21. """
  22. import re
  23. import sys
  24. from pathlib import Path
  25. ROOT = Path(__file__).resolve().parent.parent
  26. THEMES = ["airy", "dwarf", "formal", "glassy", "h2o", "living-sky", "modern"]
  27. DEMO_MARKERS = [
  28. "popover-anchor",
  29. "dialog-titlebar",
  30. "drawer",
  31. "expander-group",
  32. "otp",
  33. "table-wrap",
  34. "tabs",
  35. ]
  36. passes = 0
  37. failures = 0
  38. def check(condition, label, detail=None):
  39. global passes, failures
  40. if condition:
  41. passes += 1
  42. print("PASS %s" % label)
  43. else:
  44. failures += 1
  45. print("FAIL %s" % label)
  46. if detail:
  47. print(" %s" % detail)
  48. return bool(condition)
  49. # --------------------------------------------------------------------------
  50. # Text helpers
  51. # --------------------------------------------------------------------------
  52. def read(rel):
  53. return (ROOT / rel).read_text(encoding="utf-8")
  54. def strip_comments(css):
  55. return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
  56. def collapse_ws(text):
  57. return re.sub(r"\s+", " ", text).strip()
  58. def iter_blocks(css):
  59. """Yield (selector, body) for every top-level `{}` block, brace-matched."""
  60. depth = 0
  61. selector_start = 0
  62. body_start = 0
  63. for i, ch in enumerate(css):
  64. if ch == "{":
  65. if depth == 0:
  66. selector = css[selector_start:i]
  67. body_start = i + 1
  68. depth += 1
  69. elif ch == "}":
  70. depth -= 1
  71. if depth == 0:
  72. yield selector, css[body_start:i]
  73. selector_start = i + 1
  74. elif depth == 0 and ch == ";":
  75. # a declaration at top level ends any pseudo-selector window
  76. selector_start = i + 1
  77. if depth != 0:
  78. raise ValueError("unbalanced braces while scanning blocks")
  79. def find_block(css, selector_regex):
  80. """First top-level block whose selector matches; returns (selector, body)."""
  81. rx = re.compile(selector_regex)
  82. for selector, body in iter_blocks(css):
  83. if rx.search(collapse_ws(selector)):
  84. return selector, body
  85. return None
  86. def token_value(css, name, occurrence=0):
  87. """Nth definition (0-based) of a custom property, or None."""
  88. matches = re.findall(r"%s:\s*([^;]+);" % re.escape(name), css)
  89. return matches[occurrence].strip() if len(matches) > occurrence else None
  90. # --------------------------------------------------------------------------
  91. # a. Bezier math — Airy's glide-in must be the DWM curve
  92. # --------------------------------------------------------------------------
  93. BEZIER_RX = re.compile(
  94. r"cubic-bezier\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)"
  95. )
  96. def parse_bezier(value):
  97. m = BEZIER_RX.fullmatch(value.strip())
  98. if not m:
  99. return None
  100. return tuple(float(g) for g in m.groups())
  101. def bezier_y(x1, y1, x2, y2, t):
  102. """y(t) of a CSS cubic-bezier: solve x(s)=t for s (x is monotonic for
  103. x1,x2 in [0,1]) by bisection, then evaluate y(s)."""
  104. def coord(a, b, s):
  105. return 3 * (1 - s) ** 2 * s * a + 3 * (1 - s) * s * s * b + s ** 3
  106. if t <= 0.0:
  107. return 0.0
  108. if t >= 1.0:
  109. return 1.0
  110. lo, hi = 0.0, 1.0
  111. for _ in range(200): # far past 1e-9 tolerance
  112. mid = (lo + hi) / 2
  113. if coord(x1, x2, mid) < t:
  114. lo = mid
  115. else:
  116. hi = mid
  117. return coord(y1, y2, (lo + hi) / 2)
  118. def parse_ms(value):
  119. m = re.fullmatch(r"(\d+(?:\.\d+)?)(ms|s)", value.strip())
  120. if not m:
  121. return None
  122. return float(m.group(1)) * (1000.0 if m.group(2) == "s" else 1.0)
  123. def check_airy_bezier():
  124. airy = strip_comments(read("css/themes/spry-theme-airy.css"))
  125. print("-- Airy DWM glide-in curve --")
  126. ease = parse_bezier(token_value(airy, "--spry-dialog-ease") or "")
  127. if not check(ease is not None, "airy --spry-dialog-ease is a cubic-bezier()",
  128. "got: %r" % (token_value(airy, "--spry-dialog-ease"),)):
  129. return
  130. samples = [bezier_y(*ease, t) for t in (0.25, 0.5, 0.75)]
  131. expected = [1 - (1 - t) ** 3 for t in (0.25, 0.5, 0.75)]
  132. worst = max(abs(a - b) for a, b in zip(samples, expected))
  133. check(worst < 1e-3, "airy open ease equals 1-(1-t)^3 at t=0.25/0.5/0.75",
  134. "bezier%s sampled %s vs %s (worst delta %.2e)"
  135. % (ease, ["%.5f" % s for s in samples],
  136. ["%.5f" % e for e in expected], worst))
  137. close = parse_bezier(token_value(airy, "--spry-dialog-close-ease") or "")
  138. if not check(close is not None, "airy --spry-dialog-close-ease is a cubic-bezier()",
  139. "got: %r" % (token_value(airy, "--spry-dialog-close-ease"),)):
  140. return
  141. samples = [bezier_y(*close, t) for t in (0.25, 0.5, 0.75)]
  142. expected = [t ** 3 for t in (0.25, 0.5, 0.75)]
  143. worst = max(abs(a - b) for a, b in zip(samples, expected))
  144. check(worst < 1e-3, "airy close ease equals t^3 at t=0.25/0.5/0.75",
  145. "bezier%s sampled %s vs %s (worst delta %.2e)"
  146. % (close, ["%.5f" % s for s in samples],
  147. ["%.5f" % e for e in expected], worst))
  148. check(parse_ms(token_value(airy, "--spry-dialog-duration") or "") == 180.0,
  149. "airy --spry-dialog-duration is 180ms",
  150. "got: %r (DWM window-open T ~= 180ms)" % (token_value(airy, "--spry-dialog-duration"),))
  151. check(parse_ms(token_value(airy, "--spry-dialog-close-duration") or "") == 150.0,
  152. "airy --spry-dialog-close-duration is 150ms",
  153. "got: %r" % (token_value(airy, "--spry-dialog-close-duration"),))
  154. dia_in = token_value(airy, "--spry-dialog-in") or ""
  155. check(re.search(r"scale\(0\.9[0-5]", dia_in) is not None,
  156. "airy --spry-dialog-in scales from the 0.90-0.95 band",
  157. "got: %r (DWM starts at 90-95%% of final size)" % dia_in)
  158. # Anchored popovers additionally translate toward their trigger. The rule
  159. # is nested inside the theme's root block (& .popover-anchor), so scan for
  160. # its declaration list directly rather than the top-level block iterator.
  161. anchor = re.search(r"\.popover-anchor\b[^{]*\{([^{}]*)\}", airy)
  162. anchor_in = token_value(anchor.group(1), "--spry-dialog-in") if anchor else ""
  163. check(anchor is not None
  164. and re.search(r"translateY\(-", anchor_in) is not None
  165. and re.search(r"scale\(0\.9[0-5]", anchor_in) is not None,
  166. "airy .popover-anchor glides in from above (translateY + 0.9x scale)",
  167. "got: %r" % anchor_in)
  168. # --------------------------------------------------------------------------
  169. # b. Padding defaults — bare cards and dialogs must not hug their frame
  170. # --------------------------------------------------------------------------
  171. def check_padding_defaults():
  172. core = strip_comments(read("css/spry.css"))
  173. print("-- core padding defaults --")
  174. bare_card = find_block(core, r"^:where\(\s*\.card\s*\)$")
  175. check(bare_card is not None
  176. and re.search(r"padding:\s*var\(--spry-card-padding\)", bare_card[1]) is not None,
  177. "bare .card defaults to padding: var(--spry-card-padding)",
  178. "expected a :where(.card) rule padding bare cards")
  179. guard_card = find_block(core, r":where\(\.card:has\(")
  180. guard_sel = collapse_ws(guard_card[0]) if guard_card else ""
  181. check(guard_card is not None
  182. and re.search(r"padding:\s*0", guard_card[1]) is not None
  183. and all(c in guard_sel for c in (".card-header", ".card-body", ".card-footer")),
  184. "structured .card (has header/body/footer) zeroes the default padding",
  185. "got selector: %r" % guard_sel)
  186. root_block = find_block(core, r":where\(:root\)")
  187. check(root_block is not None
  188. and token_value(root_block[1], "--spry-popover-padding") is not None,
  189. "--spry-popover-padding is defined in :where(:root)")
  190. check(token_value(core, "--spry-card-padding") is not None,
  191. "--spry-card-padding is defined")
  192. popover = find_block(core, r"^\[popover\]$")
  193. check(popover is not None
  194. and re.search(r"padding:\s*var\(--spry-popover-padding\)", popover[1]) is not None,
  195. "[popover] pads with var(--spry-popover-padding)")
  196. popover_win = find_block(core, r"\[popover\]:has\(")
  197. win_sel = collapse_ws(popover_win[0]) if popover_win else ""
  198. check(popover_win is not None
  199. and re.search(r"padding:\s*0", popover_win[1]) is not None
  200. and all(c in win_sel for c in (".dialog-titlebar", ".dialog-body", ".dialog-footer")),
  201. "popover 'windows' (has dialog parts) drop the generic padding",
  202. "got selector: %r" % win_sel)
  203. bare_dialog = find_block(core, r"dialog:not\(:has\(")
  204. dsel = collapse_ws(bare_dialog[0]) if bare_dialog else ""
  205. check(bare_dialog is not None
  206. and re.search(r"padding:\s*var\(--spry-popover-padding\)", bare_dialog[1]) is not None
  207. and all(c in dsel for c in (".dialog-titlebar", ".dialog-body",
  208. ".dialog-footer", ".card-header",
  209. ".card-body", ".card-footer")),
  210. "bare dialog:not(:has(structural parts)) pads itself",
  211. "got selector: %r" % dsel)
  212. # --------------------------------------------------------------------------
  213. # c. Theme token sets
  214. # --------------------------------------------------------------------------
  215. def check_theme_tokens():
  216. print("-- theme token sets --")
  217. for slug in THEMES:
  218. css = strip_comments(read("css/themes/spry-theme-%s.css" % slug))
  219. check(token_value(css, "--spry-card-padding") is not None,
  220. "theme %s defines --spry-card-padding" % slug)
  221. # --------------------------------------------------------------------------
  222. # d. Demo page coverage
  223. # --------------------------------------------------------------------------
  224. def check_demo():
  225. html = read("css/index.html")
  226. print("-- demo page coverage --")
  227. missing = ["spry.css"] + ["themes/spry-theme-%s.css" % s for s in THEMES]
  228. missing = [href for href in missing
  229. if ('href="%s"' % href) not in html and ("href='%s'" % href) not in html]
  230. check(not missing, "demo links spry.css and all 7 themes",
  231. "missing links: %s" % ", ".join(missing))
  232. inputs = re.findall(r"<input\b[^>]*>", html)
  233. missing = [slug for slug in THEMES
  234. if not any('type="radio"' in tag and ('id="spry-theme-%s"' % slug) in tag
  235. for tag in inputs)]
  236. check(not missing, "demo has a theme radio for every slug",
  237. "missing radios: %s" % ", ".join(missing))
  238. missing = []
  239. for marker in DEMO_MARKERS:
  240. if not re.search(r"\b%s\b" % re.escape(marker), html):
  241. missing.append(marker)
  242. classes = re.findall(r'class="([^"]*)"', html)
  243. if not any("prose" in c.split() for c in classes):
  244. missing.append("prose (as a class)")
  245. check(not missing, "demo exercises the key components (dialogs, drawers, prose, ...)",
  246. "missing markers: %s" % ", ".join(missing))
  247. # --------------------------------------------------------------------------
  248. # e. Formal prose guard — both activation routes
  249. # --------------------------------------------------------------------------
  250. def check_formal_prose():
  251. formal = strip_comments(read("css/themes/spry-theme-formal.css"))
  252. print("-- formal .prose guard --")
  253. block = find_block(formal, r'\[data-theme="formal"\]')
  254. sel = collapse_ws(block[0]) if block else ""
  255. check(block is not None and "html:has(#spry-theme-formal:checked)" in sel,
  256. "formal rules are reachable via data-theme AND the demo radio route",
  257. "got selector: %r" % sel)
  258. body = block[1] if block else ""
  259. check("h1:not(.prose" in body and "h2:not(.prose" in body,
  260. "formal h1/h2 size rules opt out of .prose")
  261. # Scan every h1/h2 rule: if its own declarations set font-size, the
  262. # selector must carry the :not(.prose *) guard.
  263. unguarded = []
  264. for m in re.finditer(r"\bh([12])\b", formal):
  265. nxt = re.search(r"[{};]", formal[m.end():])
  266. if not nxt or nxt.group(0) != "{":
  267. continue # not in a selector position
  268. brace = m.end() + nxt.start()
  269. prev = max(formal.rfind("}", 0, brace), formal.rfind("{", 0, brace),
  270. formal.rfind(";", 0, brace))
  271. selector = collapse_ws(formal[prev + 1:brace])
  272. if not re.search(r"\bh[12]\b", selector):
  273. continue # the h1/h2 belonged to an enclosing context, not this selector
  274. decls_end = re.search(r"[{}]", formal[brace + 1:])
  275. decls = formal[brace + 1: brace + 1 + decls_end.start()] if decls_end else ""
  276. if "font-size" in decls and ":not(.prose" not in selector:
  277. unguarded.append(selector)
  278. check(not unguarded, "every formal h1/h2 font-size rule is guarded by :not(.prose ...)",
  279. "unguarded: %s" % " | ".join(unguarded))
  280. # --------------------------------------------------------------------------
  281. # f. Syntax sanity
  282. # --------------------------------------------------------------------------
  283. def check_syntax():
  284. print("-- syntax sanity --")
  285. files = ["css/spry.css"] + ["css/themes/spry-theme-%s.css" % s for s in THEMES]
  286. for rel in files:
  287. raw = read(rel)
  288. stripped = strip_comments(raw)
  289. problems = []
  290. if stripped.count("{") != stripped.count("}"):
  291. problems.append("braces %d/%d" % (stripped.count("{"), stripped.count("}")))
  292. if stripped.count("(") != stripped.count(")"):
  293. problems.append("parens %d/%d" % (stripped.count("("), stripped.count(")")))
  294. if "\t" in raw:
  295. problems.append("tab characters (files are 2-space)")
  296. check(not problems, "%s is balanced and tab-free" % rel,
  297. "; ".join(problems))
  298. # --------------------------------------------------------------------------
  299. # g. README freshness
  300. # --------------------------------------------------------------------------
  301. def check_readme():
  302. print("-- README freshness --")
  303. readme = read("README.md")
  304. check("../Spry-CSS" not in readme, "README has no stale ../Spry-CSS references")
  305. check("css/index.html" in readme, "README points at the css/index.html demo")
  306. def main():
  307. for fn in (check_airy_bezier, check_padding_defaults, check_theme_tokens,
  308. check_demo, check_formal_prose, check_syntax, check_readme):
  309. try:
  310. fn()
  311. except Exception as exc: # a crash is a failure, not a skip
  312. check(False, "%s ran to completion" % fn.__name__,
  313. "raised: %r" % exc)
  314. print("---- %d passed, %d failed ----" % (passes, failures))
  315. return 1 if failures else 0
  316. if __name__ == "__main__":
  317. sys.exit(main())