CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / Build & push image (push) Successful in 1m2s
The badge fix (#3132) exposed the same defect everywhere: 48 rules painting a token as TEXT on an inline color-mix tint of that same token. Worst raw measurements, across every tint strength in use, both modes, over page/raised/hover: accent 1.53:1 · success 1.67:1 · text-tertiary 2.15:1 warning 2.32:1 · error 2.36:1 against AA's 4.5 THE DEFECT IS IN THE HOUSE, NOT IN SCRIBE. The semantic hues are shared family-wide, and the accent case was measured against every app's real accent, not assumed from Scribe's: Minstrel 1.81, Forge 1.87, Steward 1.65, Roundtable 3.01 — all failing. So the six -fg tokens are recorded on FabledSword (design system 1), where their parents live, rather than copied into each app. 45% toward --fs-text-primary clears AA for ALL FIVE accents (4.56-5.00), so this is one house token rather than five overrides, and it keeps deriving from --fs-accent — an app that overrides its accent still gets a legible tinted-text colour in its own colour, the same mechanism as --fs-accent-soft. The tokens are additive: a sibling app is unaffected until it regenerates its own stylesheet. One token is honestly redundant. --fs-text-secondary already passes at 4.82:1, and --fs-text-secondary-fg barely moves it. It exists so the rule has NO exceptions, because the alternative is a permanent allow-list entry for the one case that happens to pass — and a guard with an invisible exception is a guard that erodes. 46 substitutions across 18 files, each rewriting only the `color:` inside a block that tints its own background. THE CHECK NOW GATES BOTH SPELLINGS. It previously reported the inline form, because a gate nobody can satisfy on the day it lands gets switched off. Both are clean, so both fail the build now. And the check had a false-positive bug worth naming: its `color\s*:` regex matched the tail of `border-color`, `border-left-color` and `outline-color`, so it flagged seven rules that were already correct. A border is a non-text graphic with a 3:1 floor, not text at 4.5. A check that cries wolf on correct code is one that gets muted, so that mattered more than the noise. Verified by construction, not by passing: reintroduced each defect form (exit 1 each), and confirmed a legitimate border-only rule still exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
261 lines
11 KiB
Python
261 lines
11 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) -> tuple[list[str], list[str]]:
|
|
"""Tokens used as TEXT on a tint of themselves, within one rule block.
|
|
|
|
TWO SPELLINGS of the same background, because the first version of this
|
|
check only knew the first and missed four live instances:
|
|
|
|
background: var(--fs-X-bg) the token
|
|
background: color-mix(in srgb, var(--fs-X) N%, transparent) inline
|
|
|
|
The inline form is what the project-status pills used, and it is the more
|
|
dangerous of the two — it does not even name a `-bg` token, so nothing
|
|
about it looks like the pattern until you measure it.
|
|
|
|
Returned separately because they were paid down separately — the token
|
|
form first (7 badge pairs), then the inline form (46 sites across 18
|
|
files, 26 of them --fs-accent). Both are clean now, so BOTH gate. The
|
|
split is kept because the two spellings need different error text: one
|
|
names a -bg token you can search for, the other names nothing at all.
|
|
"""
|
|
token_form, inline_form = [], []
|
|
for body in re.findall(r"\{([^{}]*)\}", css):
|
|
# (?<![-\w]) or `border-color`, `border-left-color` and `outline-color`
|
|
# all match as if they were text. They are not: a border is a non-text
|
|
# graphic and its floor is 3:1, not 4.5. Without this the check reported
|
|
# seven rules that were already correct — and a check that cries wolf on
|
|
# correct code is one that gets muted.
|
|
fg = set(re.findall(r"(?<![-\w])color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)", body))
|
|
bg_tok = set(re.findall(r"background(?:-color)?\s*:\s*var\(\s*(--fs-[\w-]+?)-bg\s*\)", body))
|
|
bg_inl = set(re.findall(
|
|
r"background(?:-color)?\s*:\s*color-mix\([^;]*?var\(\s*(--fs-[\w-]+?)\s*\)[^;]*?\)",
|
|
body,
|
|
))
|
|
token_form.extend(sorted(fg & bg_tok))
|
|
inline_form.extend(sorted(fg & (bg_inl - bg_tok)))
|
|
return token_form, inline_form
|
|
|
|
|
|
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]] = []
|
|
inline_tint_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))
|
|
|
|
tok_hits, inl_hits = same_hue_text_on_tint(css)
|
|
for tok in tok_hits:
|
|
same_hue_hits.append((path, tok))
|
|
for tok in inl_hits:
|
|
inline_tint_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 its own -bg.\n")
|
|
|
|
if inline_tint_hits:
|
|
print(f"FAIL — {len(inline_tint_hits)} rule(s) paint text with a token on an "
|
|
f"INLINE color-mix tint of that same token.")
|
|
print(" Identical defect to the block above, spelled without a -bg token —")
|
|
print(" which is what let it hide: nothing about it LOOKS like the pattern.")
|
|
print(" Use the token's -fg sibling.\n")
|
|
for path, tok in inline_tint_hits:
|
|
print(f" {path}: color: var({tok}) on an inline tint -> var({tok}-fg)")
|
|
print()
|
|
else:
|
|
print("OK — no text painted with a token on an inline 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 or inline_tint_hits) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|