# =============================================================================
# check_theme_completeness.py — every token must be defined in every theme
# -----------------------------------------------------------------------------
# Why this exists: a theme works by REDECLARING the same custom properties with
# different values. If a token lives in :root but a theme block forgets to
# override it, that theme silently inherits the :root value — usually the light
# value bleeding into a dark theme, which looks like a half-broken page. This
# tool parses the token blocks out of a CSS file (or files) and verifies that
# every property in the base block is also present in each theme block, and
# flags any token that exists only in a theme.
# It deliberately does NOT measure contrast or values — that is check_contrast's
# job. This tool only answers "is the same SET of tokens present everywhere?".
# Stdlib only (Python 3) — no pip installs, so it runs anywhere and in CI.
# python3 check_theme_completeness.py <css-file> [more...] [--base SELECTOR] [--quiet]
# --base SELECTOR Selector that opens the BASE token block. Default ":root".
# Override when the base set lives somewhere else.
# --quiet Print only the per-theme summary lines and the verdict,
# not every individual missing/extra token.
# Exit: 1 if any theme is missing a base token (ERROR), else 0. Extras are
# WARNINGs and do not fail the run on their own.
# =============================================================================
# =============================================================================
# CSS parsing — pull custom-property NAMES out, grouped by their block
# -----------------------------------------------------------------------------
# We track brace depth so each `--name: value;` is attributed to the selector
# that opened its block (mirrors check_contrast.py). Comments are stripped first
# so a `/* --x: y; */` never registers as a real declaration. We only keep the
# NAMES — values are irrelevant to a presence check.
# =============================================================================
_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
# A `--name:` declaration. We capture only the name; the value is ignored.
_DECL_RE = re.compile(r"\s*--([A-Za-z0-9_-]+)\s*:")
def parse_blocks(css, base_selector):
"""Return [(label, is_base, ordered_token_names)] for every token block found.
A "token block" is any block whose opening selector we recognize as the base
or as a theme. We walk the CSS tracking brace depth so each `--name:` is
attributed to the selector that opened its enclosing block. Order is
preserved (insertion order of dict) so reports read top-to-bottom.
css = _COMMENT_RE.sub("", css)
# {label: {"is_base": bool, "names": dict-as-ordered-set}}
buf = "" # text since the last brace/semicolon — selector or declaration
decl_buf = "" # same text, kept separately so a declaration survives nested {}
selector_stack.append(buf.strip())
_flush_decl(decl_buf, selector_stack, blocks, base_selector)
_flush_decl(decl_buf, selector_stack, blocks, base_selector)
(label, info["is_base"], list(info["names"]))
for label, info in blocks.items()
def _flush_decl(decl_text, selector_stack, blocks, base_selector):
"""If decl_text is a `--name:` inside a base/theme block, record the name."""
m = _DECL_RE.match(decl_text)
# Attribute to the nearest enclosing block we recognize as base or theme.
for selector in reversed(selector_stack):
label, is_base = _classify(selector, base_selector)
entry = blocks.setdefault(label, {"is_base": is_base, "names": {}})
entry["names"][name] = True # dict used as an insertion-ordered set
def _classify(selector, base_selector):
"""Return (label, is_base) for a selector, or (None, False) if not a token block.
Base: the configured base selector (default ":root"), matched when it is NOT
also carrying a theme attribute (so `:root[data-theme="dark"]` is a theme).
Themes: [data-theme="X"] / [data-theme=X], .dark, .theme-X, html[...] etc.
has_theme_attr = "[data-theme" in sel
# Base block: the configured selector, when no theme attribute rides along.
if base_selector in sel and not has_theme_attr:
return f"base ({base_selector})", True
# [data-theme="dark"] / [data-theme=dark] / html[data-theme=...] → name it.
m = re.search(r'\[data-theme\s*=\s*["\']?([A-Za-z0-9_-]+)["\']?\]', sel)
return f'{m.group(1)} ([data-theme="{m.group(1)}"])', False
# .theme-X convenience class.
m = re.search(r"\.theme-([A-Za-z0-9_-]+)", sel)
return f"{m.group(1)} (.theme-{m.group(1)})", False
# .dark convenience class.
if re.search(r"(^|[\s,>+~])\.dark(\b|[\s,>+~{])", sel):
return "dark (.dark)", False
# =============================================================================
# =============================================================================
# Primitives shared verbatim across themes (radii, font stacks, raw scales)
# legitimately live only in the base block. We can't reliably tell semantic
# tokens from primitives without guessing, so we report everything and let the
# author silence intentional cases. This note rides along in the WARNING text.
"primitives shared across all themes can stay base-only — "
"exclude with a comment if intentional"
parser = argparse.ArgumentParser(
description="Verify every base token is redefined in every theme block.",
parser.add_argument("css_files", nargs="+",
help="One or more CSS files containing the token blocks.")
parser.add_argument("--base", default=":root", metavar="SELECTOR",
help='Selector that opens the base token block (default ":root").')
parser.add_argument("--quiet", action="store_true",
help="Print only per-theme summaries and the verdict.")
args = parser.parse_args(argv)
# Read and concatenate every input file. Themes and the base may be split
# across files (e.g. tokens.css + dark.css), so we treat the inputs as one
# combined stylesheet for the presence check.
for path in args.css_files:
with open(path, "r", encoding="utf-8") as fh:
css_parts.append(fh.read())
print(f"error: cannot read {path}: {exc}", file=sys.stderr)
css = "\n".join(css_parts)
blocks = parse_blocks(css, args.base)
base = next((names for label, is_base, names in blocks if is_base), None)
themes = [(label, names) for label, is_base, names in blocks if not is_base]
print(f"No base block found (looked for the selector '{args.base}').")
print(f"Base '{args.base}' found with {len(base)} token(s), "
"but no theme blocks to compare against (looked for "
"[data-theme=...], .theme-*, .dark).")
for label, names in themes:
missing = [n for n in base if n not in theme_set] # base order
extra = [n for n in names if n not in base_set] # theme order
print(f"\nTheme {label}:")
f" ERROR token --{name} missing from theme {label} — "
"it will fall back to the base value and may break that theme"
f" WARNING --{name} defined in theme {label} but not in "
f"{args.base} ({_SILENCE_HINT})"
if not missing and not extra:
print(" ok — token set matches the base")
print(f" summary: theme {label}: {len(missing)} missing, {len(extra)} extra")
total_missing += len(missing)
total_extra += len(extra)
f"\nChecked {len(themes)} theme(s) against {len(base_set)} base token(s); "
f"{total_missing} missing, {total_extra} extra."
return 1 if total_missing else 0
if __name__ == "__main__":