#!/usr/bin/env python3 """Spry CSS regression checks — the visual contracts of the framework. Parses the shipped stylesheets as text (no browser needed) and asserts the shape the goal work established: * Airy's dialog glide-in really is the Win7 DWM Aero curve: the open ease numerically equals f(t) = 1-(1-t)^3 and the close ease equals t^3, duration 180 ms, scale-from-0.90..0.95, anchored popovers translating toward their trigger. * Core padding defaults: bare `.card` and bare `dialog`/`[popover]` carry padding by default, with :has() zero-guards so structural children stay flush to the frame. * Every theme keeps defining the tokens the core now relies on. * The demo page (css/index.html) links and switches everything it demos. * Formal's heading scale opts out of .prose on BOTH activation routes. * Brace/paren balance and no tabs across all shipped CSS. * README points at the live demo, not stale paths. Plain stdlib, no pytest: prints PASS/FAIL lines and a final summary in the spry-tests style ("---- N passed, M failed ----"), exits non-zero on any failure. Run from anywhere: python3 tests/css_check.py """ import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent THEMES = ["airy", "dwarf", "formal", "glassy", "h2o", "living-sky", "modern"] DEMO_MARKERS = [ "popover-anchor", "dialog-titlebar", "drawer", "expander-group", "otp", "table-wrap", "tabs", ] passes = 0 failures = 0 def check(condition, label, detail=None): global passes, failures if condition: passes += 1 print("PASS %s" % label) else: failures += 1 print("FAIL %s" % label) if detail: print(" %s" % detail) return bool(condition) # -------------------------------------------------------------------------- # Text helpers # -------------------------------------------------------------------------- def read(rel): return (ROOT / rel).read_text(encoding="utf-8") def strip_comments(css): return re.sub(r"/\*.*?\*/", "", css, flags=re.S) def collapse_ws(text): return re.sub(r"\s+", " ", text).strip() def iter_blocks(css): """Yield (selector, body) for every top-level `{}` block, brace-matched.""" depth = 0 selector_start = 0 body_start = 0 for i, ch in enumerate(css): if ch == "{": if depth == 0: selector = css[selector_start:i] body_start = i + 1 depth += 1 elif ch == "}": depth -= 1 if depth == 0: yield selector, css[body_start:i] selector_start = i + 1 elif depth == 0 and ch == ";": # a declaration at top level ends any pseudo-selector window selector_start = i + 1 if depth != 0: raise ValueError("unbalanced braces while scanning blocks") def find_block(css, selector_regex): """First top-level block whose selector matches; returns (selector, body).""" rx = re.compile(selector_regex) for selector, body in iter_blocks(css): if rx.search(collapse_ws(selector)): return selector, body return None def token_value(css, name, occurrence=0): """Nth definition (0-based) of a custom property, or None.""" matches = re.findall(r"%s:\s*([^;]+);" % re.escape(name), css) return matches[occurrence].strip() if len(matches) > occurrence else None # -------------------------------------------------------------------------- # a. Bezier math — Airy's glide-in must be the DWM curve # -------------------------------------------------------------------------- BEZIER_RX = re.compile( r"cubic-bezier\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)" ) def parse_bezier(value): m = BEZIER_RX.fullmatch(value.strip()) if not m: return None return tuple(float(g) for g in m.groups()) def bezier_y(x1, y1, x2, y2, t): """y(t) of a CSS cubic-bezier: solve x(s)=t for s (x is monotonic for x1,x2 in [0,1]) by bisection, then evaluate y(s).""" def coord(a, b, s): return 3 * (1 - s) ** 2 * s * a + 3 * (1 - s) * s * s * b + s ** 3 if t <= 0.0: return 0.0 if t >= 1.0: return 1.0 lo, hi = 0.0, 1.0 for _ in range(200): # far past 1e-9 tolerance mid = (lo + hi) / 2 if coord(x1, x2, mid) < t: lo = mid else: hi = mid return coord(y1, y2, (lo + hi) / 2) def parse_ms(value): m = re.fullmatch(r"(\d+(?:\.\d+)?)(ms|s)", value.strip()) if not m: return None return float(m.group(1)) * (1000.0 if m.group(2) == "s" else 1.0) def check_airy_bezier(): airy = strip_comments(read("css/themes/spry-theme-airy.css")) print("-- Airy DWM glide-in curve --") ease = parse_bezier(token_value(airy, "--spry-dialog-ease") or "") if not check(ease is not None, "airy --spry-dialog-ease is a cubic-bezier()", "got: %r" % (token_value(airy, "--spry-dialog-ease"),)): return samples = [bezier_y(*ease, t) for t in (0.25, 0.5, 0.75)] expected = [1 - (1 - t) ** 3 for t in (0.25, 0.5, 0.75)] worst = max(abs(a - b) for a, b in zip(samples, expected)) check(worst < 1e-3, "airy open ease equals 1-(1-t)^3 at t=0.25/0.5/0.75", "bezier%s sampled %s vs %s (worst delta %.2e)" % (ease, ["%.5f" % s for s in samples], ["%.5f" % e for e in expected], worst)) close = parse_bezier(token_value(airy, "--spry-dialog-close-ease") or "") if not check(close is not None, "airy --spry-dialog-close-ease is a cubic-bezier()", "got: %r" % (token_value(airy, "--spry-dialog-close-ease"),)): return samples = [bezier_y(*close, t) for t in (0.25, 0.5, 0.75)] expected = [t ** 3 for t in (0.25, 0.5, 0.75)] worst = max(abs(a - b) for a, b in zip(samples, expected)) check(worst < 1e-3, "airy close ease equals t^3 at t=0.25/0.5/0.75", "bezier%s sampled %s vs %s (worst delta %.2e)" % (close, ["%.5f" % s for s in samples], ["%.5f" % e for e in expected], worst)) check(parse_ms(token_value(airy, "--spry-dialog-duration") or "") == 180.0, "airy --spry-dialog-duration is 180ms", "got: %r (DWM window-open T ~= 180ms)" % (token_value(airy, "--spry-dialog-duration"),)) check(parse_ms(token_value(airy, "--spry-dialog-close-duration") or "") == 150.0, "airy --spry-dialog-close-duration is 150ms", "got: %r" % (token_value(airy, "--spry-dialog-close-duration"),)) dia_in = token_value(airy, "--spry-dialog-in") or "" check(re.search(r"scale\(0\.9[0-5]", dia_in) is not None, "airy --spry-dialog-in scales from the 0.90-0.95 band", "got: %r (DWM starts at 90-95%% of final size)" % dia_in) # Anchored popovers additionally translate toward their trigger. The rule # is nested inside the theme's root block (& .popover-anchor), so scan for # its declaration list directly rather than the top-level block iterator. anchor = re.search(r"\.popover-anchor\b[^{]*\{([^{}]*)\}", airy) anchor_in = token_value(anchor.group(1), "--spry-dialog-in") if anchor else "" check(anchor is not None and re.search(r"translateY\(-", anchor_in) is not None and re.search(r"scale\(0\.9[0-5]", anchor_in) is not None, "airy .popover-anchor glides in from above (translateY + 0.9x scale)", "got: %r" % anchor_in) # -------------------------------------------------------------------------- # b. Padding defaults — bare cards and dialogs must not hug their frame # -------------------------------------------------------------------------- def check_padding_defaults(): core = strip_comments(read("css/spry.css")) print("-- core padding defaults --") bare_card = find_block(core, r"^:where\(\s*\.card\s*\)$") check(bare_card is not None and re.search(r"padding:\s*var\(--spry-card-padding\)", bare_card[1]) is not None, "bare .card defaults to padding: var(--spry-card-padding)", "expected a :where(.card) rule padding bare cards") guard_card = find_block(core, r":where\(\.card:has\(") guard_sel = collapse_ws(guard_card[0]) if guard_card else "" check(guard_card is not None and re.search(r"padding:\s*0", guard_card[1]) is not None and all(c in guard_sel for c in (".card-header", ".card-body", ".card-footer")), "structured .card (has header/body/footer) zeroes the default padding", "got selector: %r" % guard_sel) root_block = find_block(core, r":where\(:root\)") check(root_block is not None and token_value(root_block[1], "--spry-popover-padding") is not None, "--spry-popover-padding is defined in :where(:root)") check(token_value(core, "--spry-card-padding") is not None, "--spry-card-padding is defined") popover = find_block(core, r"^\[popover\]$") check(popover is not None and re.search(r"padding:\s*var\(--spry-popover-padding\)", popover[1]) is not None, "[popover] pads with var(--spry-popover-padding)") popover_win = find_block(core, r"\[popover\]:has\(") win_sel = collapse_ws(popover_win[0]) if popover_win else "" check(popover_win is not None and re.search(r"padding:\s*0", popover_win[1]) is not None and all(c in win_sel for c in (".dialog-titlebar", ".dialog-body", ".dialog-footer")), "popover 'windows' (has dialog parts) drop the generic padding", "got selector: %r" % win_sel) bare_dialog = find_block(core, r"dialog:not\(:has\(") dsel = collapse_ws(bare_dialog[0]) if bare_dialog else "" check(bare_dialog is not None and re.search(r"padding:\s*var\(--spry-popover-padding\)", bare_dialog[1]) is not None and all(c in dsel for c in (".dialog-titlebar", ".dialog-body", ".dialog-footer", ".card-header", ".card-body", ".card-footer")), "bare dialog:not(:has(structural parts)) pads itself", "got selector: %r" % dsel) # -------------------------------------------------------------------------- # c. Theme token sets # -------------------------------------------------------------------------- def check_theme_tokens(): print("-- theme token sets --") for slug in THEMES: css = strip_comments(read("css/themes/spry-theme-%s.css" % slug)) check(token_value(css, "--spry-card-padding") is not None, "theme %s defines --spry-card-padding" % slug) # -------------------------------------------------------------------------- # d. Demo page coverage # -------------------------------------------------------------------------- def check_demo(): html = read("css/index.html") print("-- demo page coverage --") missing = ["spry.css"] + ["themes/spry-theme-%s.css" % s for s in THEMES] missing = [href for href in missing if ('href="%s"' % href) not in html and ("href='%s'" % href) not in html] check(not missing, "demo links spry.css and all 7 themes", "missing links: %s" % ", ".join(missing)) inputs = re.findall(r"]*>", html) missing = [slug for slug in THEMES if not any('type="radio"' in tag and ('id="spry-theme-%s"' % slug) in tag for tag in inputs)] check(not missing, "demo has a theme radio for every slug", "missing radios: %s" % ", ".join(missing)) missing = [] for marker in DEMO_MARKERS: if not re.search(r"\b%s\b" % re.escape(marker), html): missing.append(marker) classes = re.findall(r'class="([^"]*)"', html) if not any("prose" in c.split() for c in classes): missing.append("prose (as a class)") check(not missing, "demo exercises the key components (dialogs, drawers, prose, ...)", "missing markers: %s" % ", ".join(missing)) # -------------------------------------------------------------------------- # e. Formal prose guard — both activation routes # -------------------------------------------------------------------------- def check_formal_prose(): formal = strip_comments(read("css/themes/spry-theme-formal.css")) print("-- formal .prose guard --") block = find_block(formal, r'\[data-theme="formal"\]') sel = collapse_ws(block[0]) if block else "" check(block is not None and "html:has(#spry-theme-formal:checked)" in sel, "formal rules are reachable via data-theme AND the demo radio route", "got selector: %r" % sel) body = block[1] if block else "" check("h1:not(.prose" in body and "h2:not(.prose" in body, "formal h1/h2 size rules opt out of .prose") # Scan every h1/h2 rule: if its own declarations set font-size, the # selector must carry the :not(.prose *) guard. unguarded = [] for m in re.finditer(r"\bh([12])\b", formal): nxt = re.search(r"[{};]", formal[m.end():]) if not nxt or nxt.group(0) != "{": continue # not in a selector position brace = m.end() + nxt.start() prev = max(formal.rfind("}", 0, brace), formal.rfind("{", 0, brace), formal.rfind(";", 0, brace)) selector = collapse_ws(formal[prev + 1:brace]) if not re.search(r"\bh[12]\b", selector): continue # the h1/h2 belonged to an enclosing context, not this selector decls_end = re.search(r"[{}]", formal[brace + 1:]) decls = formal[brace + 1: brace + 1 + decls_end.start()] if decls_end else "" if "font-size" in decls and ":not(.prose" not in selector: unguarded.append(selector) check(not unguarded, "every formal h1/h2 font-size rule is guarded by :not(.prose ...)", "unguarded: %s" % " | ".join(unguarded)) # -------------------------------------------------------------------------- # f. Syntax sanity # -------------------------------------------------------------------------- def check_syntax(): print("-- syntax sanity --") files = ["css/spry.css"] + ["css/themes/spry-theme-%s.css" % s for s in THEMES] for rel in files: raw = read(rel) stripped = strip_comments(raw) problems = [] if stripped.count("{") != stripped.count("}"): problems.append("braces %d/%d" % (stripped.count("{"), stripped.count("}"))) if stripped.count("(") != stripped.count(")"): problems.append("parens %d/%d" % (stripped.count("("), stripped.count(")"))) if "\t" in raw: problems.append("tab characters (files are 2-space)") check(not problems, "%s is balanced and tab-free" % rel, "; ".join(problems)) # -------------------------------------------------------------------------- # g. README freshness # -------------------------------------------------------------------------- def check_readme(): print("-- README freshness --") readme = read("README.md") check("../Spry-CSS" not in readme, "README has no stale ../Spry-CSS references") check("css/index.html" in readme, "README points at the css/index.html demo") def main(): for fn in (check_airy_bezier, check_padding_defaults, check_theme_tokens, check_demo, check_formal_prose, check_syntax, check_readme): try: fn() except Exception as exc: # a crash is a failure, not a skip check(False, "%s ran to completion" % fn.__name__, "raised: %r" % exc) print("---- %d passed, %d failed ----" % (passes, failures)) return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())