#!/usr/bin/env python3 """Whole-swap theme asset + CSS self-containment (BACKLOG #62). waybar.nix applies themes//waybar.css *raw* — no palette @define-color block is prepended. A whole-swap that references @text without defining it is broken at apply time (neon-glass, 2026-07-09). This gate fails that class of bug before it ships. Also asserts every themes/*.json has preview.png + non-empty backgrounds/. Wired as `checks.theme-wholeswap` in flake.nix; runnable directly: python3 tools/check-theme-wholeswap.py themes/ """ from __future__ import annotations import re import sys from pathlib import Path # CSS at-rules that look like @name but are not GTK/CSS color names. AT_RULES = frozenset( { "keyframes", "media", "import", "supports", "font-face", "charset", "page", "define-color", "layer", "container", "property", "scope", } ) COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) DEFINE_RE = re.compile(r"@define-color\s+([A-Za-z_][\w-]*)") # Color uses: @name, alpha(@name, …) — same token shape as defines. AT_REF_RE = re.compile(r"@([A-Za-z_][\w-]*)") def strip_comments(css: str) -> str: return COMMENT_RE.sub("", css) def check_waybar_css(path: Path, failures: list[str]) -> None: raw = path.read_text(encoding="utf-8") css = strip_comments(raw) defs = set(DEFINE_RE.findall(css)) refs = set(AT_REF_RE.findall(css)) - AT_RULES - defs # define-color lines also match AT_REF_RE for the rule name only via # @define-color — already in AT_RULES. Color names on define lines # appear as the capture of DEFINE_RE only when after define-color; # AT_REF_RE on "@define-color foo" yields "define-color", not "foo". # On "color: @text" yields "text". Good. if refs: missing = ", ".join(sorted(refs)) failures.append(f"{path}: undefined color refs: {missing}") def check_assets(themes_dir: Path, failures: list[str]) -> None: jsons = sorted(themes_dir.glob("*.json")) if not jsons: failures.append(f"{themes_dir}: no themes/*.json found") return for jp in jsons: slug = jp.stem tdir = themes_dir / slug preview = tdir / "preview.png" if not preview.is_file(): failures.append(f"{slug}: missing {preview.relative_to(themes_dir)}") bg = tdir / "backgrounds" if not bg.is_dir(): failures.append(f"{slug}: missing backgrounds/") elif not any(bg.iterdir()): failures.append(f"{slug}: backgrounds/ is empty") def main() -> int: if len(sys.argv) != 2: print(f"usage: {sys.argv[0]} ", file=sys.stderr) return 2 themes_dir = Path(sys.argv[1]) if not themes_dir.is_dir(): print(f"not a directory: {themes_dir}", file=sys.stderr) return 2 failures: list[str] = [] check_assets(themes_dir, failures) for css in sorted(themes_dir.glob("*/waybar.css")): check_waybar_css(css, failures) if failures: for f in failures: print(f"theme-wholeswap: {f}", file=sys.stderr) print(f"theme-wholeswap: {len(failures)} failure(s)", file=sys.stderr) return 1 n_css = len(list(themes_dir.glob("*/waybar.css"))) n_json = len(list(themes_dir.glob("*.json"))) print( f"theme-wholeswap: ok — {n_json} presets (preview+backgrounds), " f"{n_css} whole-swap waybar.css self-contained" ) return 0 if __name__ == "__main__": sys.exit(main())