From d0d48bcefe33afd74283711e4a596f578e4dc404 Mon Sep 17 00:00:00 2001 From: LinzLos <223187540+LinzLos@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:07:18 +0000 Subject: [PATCH] CI enforces what the docs scripts only offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checks.yml gains two jobs. docs-current runs scripts/render-docs.py in check mode, so the rendered counts and version markers can no longer drift silently — the script existed but nothing ran it. tokens-sync runs the new scripts/check-tokens.py, which resolves every var() chain in lib/tokens.css (light, then dark cascaded on top) and compares the result against the literals in lib/tokens.js: 38 primitives, 41 light and 41 dark color values. The last audit hand-checked those 82 matches; now the machine does. Two pointers still named globals.css, which has been a two-line import shim since the tokens/base split: the PR template told contributors to add tokens there (CONTRIBUTING already said tokens.css), and the tokens.js header claimed to mirror it. Both now say lib/tokens.css, as does the token-purity job's error message. --- .github/pull_request_template.md | 2 +- .github/workflows/checks.yml | 18 +++- lib/tokens.js | 5 +- scripts/check-tokens.py | 146 +++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 scripts/check-tokens.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e6240a9..2ca3351 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,7 +14,7 @@ ## Checklist - [ ] Components read **tokens only** — no hardcoded hex in `lib/components.css` (the CI check passes) -- [ ] Any new value is defined as a token in `lib/globals.css` for **both** light and dark +- [ ] Any new value is defined as a token in `lib/tokens.css` for **both** light and dark - [ ] Works in **light and dark** (`data-theme="dark"`) - [ ] Text/icon colors clear **WCAG AA** — verified in `docs/a11y.html` - [ ] Added or updated a **docs demo** so it's discoverable diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index cd657c4..8644a9c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -18,7 +18,23 @@ jobs: # in a comment doesn't false-positive the check. stripped=$(perl -0777 -pe 's{/\*.*?\*/}{ (my $c=$&) =~ s/[^\n]/ /g; $c }ges' lib/components.css) if printf '%s' "$stripped" | grep -nEi '#[0-9a-f]{6}\b|#[0-9a-f]{3}\b'; then - echo "::error::Hardcoded hex color(s) found above in lib/components.css (comments excluded). Use a var(--token) from lib/globals.css instead." + echo "::error::Hardcoded hex color(s) found above in lib/components.css (comments excluded). Use a var(--token) from lib/tokens.css instead." exit 1 fi echo "OK — lib/components.css is fully token-driven." + + docs-current: + name: Docs rendered from source + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fail if rendered counts / version markers are stale + run: python3 scripts/render-docs.py + + tokens-sync: + name: tokens.js mirrors tokens.css + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fail if lib/tokens.js values drift from lib/tokens.css + run: python3 scripts/check-tokens.py diff --git a/lib/tokens.js b/lib/tokens.js index 38a8582..a42edb3 100644 --- a/lib/tokens.js +++ b/lib/tokens.js @@ -1,11 +1,12 @@ /** * Tiny Wire — Design Tokens - * Mirrors lib/globals.css. Import to access tokens from JS. + * Mirrors lib/tokens.css. Import to access tokens from JS. * * Three tiers: `primitives` (raw ramps) → `colors` (resolved semantic values per * theme) → component aliases live in CSS only. The `colors` block is the resolved * value of each semantic token, so JS consumers and generators read the same value - * the browser computes. Keep this file in lockstep with globals.css. + * the browser computes. Keep this file in lockstep with tokens.css + * (scripts/check-tokens.py enforces it in CI). */ export const primitives = { diff --git a/scripts/check-tokens.py b/scripts/check-tokens.py new file mode 100644 index 0000000..d751ebe --- /dev/null +++ b/scripts/check-tokens.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Verify lib/tokens.js color values match lib/tokens.css. + + python3 scripts/check-tokens.py # exit 1 on any drift + +tokens.js hand-mirrors the RESOLVED value of every color token so JS +consumers read what the browser computes. Nothing ties the two files +together at runtime, so this check does: it resolves each var() chain +in tokens.css — the light `:root`, then the dark overrides cascaded on +top of it — and compares the result against the literal in tokens.js. + +Covered: `primitives` (against the light block; JS carries the light +ramps only) and `tokens.colors.light` / `tokens.colors.dark`. JS keys +map to CSS names as camelCase → kebab-case (surfaceSubtle → +--surface-subtle). Typography / spacing / radius / shadows are not +compared: they differ in shape (bare numbers vs px), and the color +blocks are where drift has actually threatened. +""" +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +VAR_RE = re.compile(r"^var\((--[a-z0-9-]+)\)$") +# key: 'value' — JS object entries whose value is a string literal. +PAIR_RE = re.compile(r"([A-Za-z0-9]+):\s*'([^']+)'") + + +def camel_to_kebab(name): + return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name).lower() + + +def strip_comments(text): + return re.sub(r"/\*.*?\*/", "", text) + + +# ------------------------------------------------------------------ tokens.css + +def css_declarations(): + """({name: value} for the light :root, {name: value} for the dark block). + Line-based, same shape as render-docs.py: several declarations can share + a line, so match anywhere.""" + css = (ROOT / "lib" / "tokens.css").read_text() + light, dark, block = {}, {}, None + for line in css.splitlines(): + if re.match(r"^:root\s*\{", line): + block = light + elif re.match(r'^:root\[data-theme="dark"\]\s*\{', line): + block = dark + elif line.startswith("}"): + block = None + if block is None: + continue + for name, value in re.findall(r"(--[a-z0-9-]+)\s*:\s*([^;]+);", strip_comments(line)): + block[name] = value.strip() + return light, dark + + +def resolve(value, env): + """Follow var(--x) chains to a literal (logo → brand → cobalt-600 → hex).""" + seen = set() + while (m := VAR_RE.match(value)): + name = m.group(1) + if name in seen or name not in env: + return None + seen.add(name) + value = env[name] + return value + + +# ------------------------------------------------------------------ tokens.js + +def js_primitives(js): + """{css name: value} from `export const primitives`. Groups nest one + level (warm.950 → --warm-950); the rest are scalars (amberChart → + --amber-chart).""" + body = re.search(r"export const primitives = \{(.*?)\n\}", js, re.S).group(1) + out = {} + + def grab_group(m): + prefix = camel_to_kebab(m.group(1)) + for key, value in PAIR_RE.findall(m.group(2)): + out[f"--{prefix}-{key}"] = value + return "" + + scalars = re.sub(r"([A-Za-z0-9]+):\s*\{([^}]*)\}", grab_group, body) + for key, value in PAIR_RE.findall(scalars): + out["--" + camel_to_kebab(key)] = value + return out + + +def js_colors(js, theme): + """{css name: value} from tokens.colors.. The first `light: {` / + `dark: {` in the file is the colors block (fontWeight's `light: 300` + and azure's `light: '#…'` open no brace).""" + block = re.search(theme + r":\s*\{(.*?)\}", js, re.S).group(1) + return {"--" + camel_to_kebab(k): v for k, v in PAIR_RE.findall(block)} + + +# ------------------------------------------------------------------ compare + +def norm(value): + return re.sub(r"\s+", " ", value).strip().upper() if value else value + + +def compare(label, js_map, env, problems): + ok = 0 + for css_name, js_value in js_map.items(): + if css_name not in env: + problems.append(f"{label}: {css_name} is in tokens.js but not declared in tokens.css") + continue + css_value = resolve(env[css_name], env) + if css_value is None: + problems.append(f"{label}: {css_name} does not resolve in tokens.css ({env[css_name]})") + elif norm(css_value) != norm(js_value): + problems.append(f"{label}: {css_name} is {js_value} in tokens.js but {css_value} in tokens.css") + else: + ok += 1 + return f"{label} {ok}/{len(js_map)}" + + +def main(): + js = strip_comments((ROOT / "lib" / "tokens.js").read_text()) + light, dark = css_declarations() + dark_env = {**light, **dark} + + problems = [] + counts = [ + compare("primitives", js_primitives(js), light, problems), + compare("colors.light", js_colors(js, "light"), light, problems), + compare("colors.dark", js_colors(js, "dark"), dark_env, problems), + ] + print(" · ".join(counts)) + if problems: + print() + for p in problems: + print(f" ! {p}") + print(f"\n{len(problems)} mismatch(es) — update lib/tokens.js to match lib/tokens.css", file=sys.stderr) + return 1 + print("tokens.js matches tokens.css") + return 0 + + +if __name__ == "__main__": + sys.exit(main())