Files
FabledScribe/scripts/check_design_tokens.py
T
bvandeusenandClaude Opus 5 0c74dc8275
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 36s
fix(design): badge text clears AA — the ladder was painting a hue on a tint of itself (#3132)
Every status and priority badge used its raw hue as TEXT on a 12% tint of
that same hue. Measured on the dark palette, all six pairs failed the kit's
own AA floor: todo 1.60:1, in-progress 1.97:1, done 2.06:1, low 2.02:1,
high 2.92:1, medium 2.97:1, against 4.5. Four also failed in light mode.

The cause is structural, not a bad colour pick. A 12% tint sits near the
surface it composites over, so the hue as text on it has almost nowhere to
go. Strengthening the tint was measured and REJECTED: on a dark palette a
heavier tint moves the chip toward the light text and makes it worse. 12%
was already optimal.

So each pair gains a `-fg` sibling: the hue mixed toward --fs-text-primary
until it clears 4.5:1 worst-case over surface-raised AND surface-hover in
BOTH modes. Mixing toward that token rather than a literal is what makes one
declaration cover both — it inverts by mode, so the text follows.

Recorded in the DESIGN SYSTEM, not hand-written into theme.css: seven tokens
on design system 2, each carrying its measurement and its reasoning, then the
sheet regenerated. theme.css says not to hand-edit the --fs-* block and it is
right — a hand-edit would be silently reverted by the next regeneration.

The ladder keeps its shape. High priority still holds 52% saturation and
medium 31% — the rungs that need to shout still shout. Low, todo and done
wash toward neutral, which is what their own rationales ask for: status-todo
is derived from the border colour precisely so not-yet-started recedes.
Receding and illegible are different things and the old value was the second.

--fs-status-cancelled-fg was found by measuring, not by reasoning. Cancelled
derives from --fs-text-tertiary, which looks like the obviously-correct
"quiet" choice and is a HINT colour tuned for plain surfaces — 2.63:1 on a
badge tint in light mode.

StatusBadge additionally dropped a `color-mix(..., #000 15%)` that darkened
the hue: a light-mode instinct that made these worse on a near-black surface,
and a literal besides.

THE GUARD IS THE POINT. check_design_tokens.py now FAILS on any rule that
paints text with a token on a tint of that same token, and names the -fg
sibling as the fix. Verified by reintroducing the defect: exit 1 with it,
exit 0 without. Unlike a raw literal there is nothing to weigh up, so it
gates rather than reports.

Two `border-top-color` uses keep the raw hue, correctly — a border is a
non-text graphic and needs 3:1, which is what the hue is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:11:14 -04:00

218 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""Check the frontend's CSS against the tokens its stylesheet declares.
The gap this closes: `services/design_stylesheet.check_code_against_tokens` has
always been able to answer "does this code use the sheet correctly?", but the
only thing ever fed to it was recorded SNIPPETS. The app's own components — where
sixteen unresolvable references were found living quietly (#2319) — were checked
by nothing at all.
That was structural rather than an oversight. The drift panel runs in the browser
and cannot read source files, and the server has no repo access. CI is the only
place holding both the component sources and the ability to run the check, and it
only became cheap once `theme.css` became a generated artifact — so the source of
truth is a local file, with no network and no credentials.
INSTANCE-AGNOSTIC ON PURPOSE (rule #115). Nothing here knows what a token should
be called or which literals are discouraged. Both come from the stylesheet: the
declarations, and the `SUPERSEDES` block the generator emits. Point it at a
different install's sheet and it checks that install's rules.
Two severities, and the split is deliberate:
FAIL an unresolvable `var()` reference. Currently zero, so this is a ratchet
that holds a line already reached rather than a backlog that keeps CI
red. It also cannot false-positive: either the name is declared or it
is not.
REPORT superseded literals and raw colour literals. Hundreds today, so gating
on them would mean a permanently failing job that everyone learns to
ignore — which is worse than no check.
"""
from __future__ import annotations
import argparse
import pathlib
import re
import sys
# A declaration is `--name:`; a reference is `var(--name)` or `var(--name, …)`.
DECLARATION = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")
REFERENCE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)")
SUPERSEDES_LINE = re.compile(r"^\s*\*\s*(\S+)\s*->\s*(--[A-Za-z0-9_-]+)\s*$")
HEX_LITERAL = re.compile(r"#[0-9a-fA-F]{3,8}\b")
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.S)
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
def declared_tokens(sheet: str) -> set[str]:
"""Every custom property the stylesheet declares.
Anchored on the colon alone. Anchoring on `{` or `;` instead silently drops
every declaration that follows a comment — a mistake made once already, which
lost `--color-bg` and 2 others without erroring.
"""
return set(DECLARATION.findall(sheet))
def superseded_literals(sheet: str) -> dict[str, str]:
"""`{literal: token}` from the generator's SUPERSEDES block, lowercased."""
out: dict[str, str] = {}
for line in sheet.splitlines():
match = SUPERSEDES_LINE.match(line)
if match:
out[match.group(1).lower()] = match.group(2)
return out
def _literal_pattern(literal: str) -> re.Pattern:
"""Match a literal without matching a longer one containing it.
`#fff` must not fire inside `#ffffff`: different colours, and a finding on
the wrong one sends someone to change correct code.
"""
return re.compile(
r"(?<![0-9A-Za-z_#-])" + re.escape(literal) + r"(?![0-9A-Za-z_-])",
re.IGNORECASE,
)
def style_source(path: pathlib.Path) -> str:
"""The CSS in a file — `<style>` blocks for an SFC, the whole of a .css.
Comments are stripped, and that is load-bearing rather than tidy. A comment
EXPLAINING a rule mentions the very literal the rule forbids: this file's own
stylesheet documents why it avoids `#fff`, and the first run of this checker
reported that explanation as a violation. A checker that flags the
documentation of a rule teaches people to stop documenting rules.
"""
text = path.read_text(encoding="utf-8", errors="replace")
css = "\n".join(STYLE_BLOCK.findall(text)) if path.suffix == ".vue" else text
return CSS_COMMENT.sub(" ", css)
# A rule that paints text with a colour token AND its own -bg tint of the same
# token. The pair looks harmonious and is close to illegible: a 12% tint of a
# hue sits near the surface, so the hue as text on it lands around 2:1 against
# an AA floor of 4.5. Measured across the whole Scribe ladder in 2026-08:
# every one of the six pairs failed on the dark palette, worst 1.60:1.
#
# The fix is always the same and always available — the token's `-fg` sibling,
# which is the hue mixed toward --fs-text-primary far enough to clear AA. So
# this FAILS rather than reports: unlike a raw literal, there is nothing to
# weigh up.
SAME_TOKEN_PAIR = re.compile(
r"color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)" # color: var(--fs-X)
r"|background(?:-color)?\s*:\s*var\(\s*(--fs-[\w-]+?)-bg\s*\)"
)
def same_hue_text_on_tint(css: str) -> list[str]:
"""Tokens used as TEXT on a tint of themselves, within one rule block."""
hits = []
for body in re.findall(r"\{([^{}]*)\}", css):
fg = set(re.findall(r"color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)", body))
bg = set(re.findall(r"background(?:-color)?\s*:\s*var\(\s*(--fs-[\w-]+?)-bg\s*\)", body))
hits.extend(sorted(fg & bg))
return hits
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
parser.add_argument("--root", default="frontend/src")
parser.add_argument(
"--report-literals", action="store_true",
help="also list raw colour literals (advisory, never fails)",
)
args = parser.parse_args()
sheet_path = pathlib.Path(args.sheet)
if not sheet_path.is_file():
print(f"error: stylesheet not found: {sheet_path}", file=sys.stderr)
return 2
sheet = sheet_path.read_text()
declared = declared_tokens(sheet)
supersedes = superseded_literals(sheet)
print(f"{sheet_path}: {len(declared)} tokens declared, "
f"{len(supersedes)} superseded literals recorded\n")
root = pathlib.Path(args.root)
sources = sorted(
[p for p in root.rglob("*.vue")] + [p for p in root.rglob("*.css")]
)
unresolved: list[tuple[pathlib.Path, str]] = []
same_hue_hits: list[tuple[pathlib.Path, str]] = []
superseded_hits: list[tuple[pathlib.Path, str, str]] = []
literal_count = 0
for path in sources:
if path == sheet_path:
continue
css = style_source(path)
if not css.strip():
continue
# A component may legitimately declare a local custom property; a
# reference to it is not unresolved.
local = set(DECLARATION.findall(css))
for name in sorted(set(REFERENCE.findall(css))):
if name not in declared and name not in local:
unresolved.append((path, name))
for literal, token in supersedes.items():
if _literal_pattern(literal).search(css):
superseded_hits.append((path, literal, token))
literal_count += len(HEX_LITERAL.findall(css))
for tok in same_hue_text_on_tint(css):
same_hue_hits.append((path, tok))
if unresolved:
print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
print(" These render as the fallback if given one, or as nothing at all.")
print(" Either way nothing errors, which is why they survive.\n")
for path, name in unresolved:
print(f" {path}: {name}")
print()
else:
print("OK — every var() reference resolves to a declared token.\n")
if superseded_hits:
print(f"REPORT — {len(superseded_hits)} superseded literal(s). "
"The sheet says what to write instead:")
seen: set[tuple[str, str]] = set()
for path, literal, token in superseded_hits:
key = (str(path), literal)
if key in seen:
continue
seen.add(key)
print(f" {path}: {literal} -> {token}")
print()
if same_hue_hits:
print(f"FAIL — {len(same_hue_hits)} rule(s) paint text with a token on a "
f"tint of that same token.")
print(" A 12% tint sits near the surface, so the hue as text on it lands "
"around 2:1 against AA's 4.5.")
print(" Use the token's -fg sibling, which is mixed toward "
"--fs-text-primary until it clears the floor.\n")
for path, tok in same_hue_hits:
print(f" {path}: color: var({tok}) on var({tok}-bg) -> var({tok}-fg)")
print()
else:
print("OK — no text painted with a token on a tint of itself.\n")
if args.report_literals:
print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.")
print(" Advisory: a literal is a value stated outside the system, so it "
"cannot follow a palette change.\n")
return 1 if (unresolved or same_hue_hits) else 0
if __name__ == "__main__":
sys.exit(main())