CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 33s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 39s
ITEM 1 — the dead canon. StatusBadge is recorded canon (#2960) and its only consumer, TaskCard, has been unreachable since 2026-04-08, when TasksListView was deleted in favour of the Knowledge view. Four and a half months of a canon that rendered nowhere, which is worse than no canon: a session pulls #2960, builds from it, and matches a component nobody has seen. TaskCard is deleted (rule 22), and the canon is made real by adoption rather than by being left as a museum piece. ITEM 2 — MY OWN ISSUE OVERSTATED THIS, and the correction is the finding. "Three scoped re-spellings" assumed one shape spelled thrice. Reading them: KnowledgeView a task-status chip, just smaller -> a real duplicate WorkspaceTaskPanel a CLICKABLE cycler: pointer, outlined, transparent background -> a control, not a chip ProjectView PROJECT lifecycle (active/paused/ completed/archived) -> a different vocabulary Only the first was ever a duplicate. The others shared a class NAME and nothing else — which is exactly what would make a future consolidation merge three unrelated things. So: KnowledgeView adopts StatusBadge/PriorityBadge via the `compact` variant the canon already anticipated ("interactive/compact re-spellings are variants of it"); the cycler becomes `.status-cycler`; and project status becomes its own vocabulary. And there was a FOURTH, in ProjectListView — the genuine duplicate of ProjectView's project pill, differing by the amounts two hands differ by: 0.68rem vs 0.7rem, a 14% tint vs 15%, one bordered and one not. Both now use one ProjectStatusBadge. `statusLabel` went with its only caller. ITEM 3 — weight. StatusBadge and PriorityBadge used font-weight 600; the house style allows 400 and 500 only. Also "In Progress" -> "In progress", which was invisible under `text-transform: uppercase` and becomes visible the moment the compact variant turns that off. THE GUARD MISSED FOUR LIVE SITES, which is the part worth keeping. The project pills painted a hue on an inline `color-mix` tint of itself — measured 1.61-2.39:1 — and the checker only knew the `--fs-X-bg` token form. Widened, it finds 48 across the app, 26 of them --fs-accent. That backlog is not this task, so the check now splits: it GATES the token form, which is clean, and REPORTS the inline form with a count and its worst offenders. A gate nobody can satisfy today gets switched off, and then it guards nothing. Gate re-verified by reintroducing a defect — exit 1 with it, exit 0 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
254 lines
11 KiB
Python
254 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 are at different stages. The token form
|
|
is CLEAN and therefore gates. The inline form has a live backlog (48 sites
|
|
when this split was written, 26 of them --fs-accent), so it reports with a
|
|
count: a gate nobody can satisfy today gets switched off, and then it
|
|
guards nothing.
|
|
"""
|
|
token_form, inline_form = [], []
|
|
for body in re.findall(r"\{([^{}]*)\}", css):
|
|
fg = set(re.findall(r"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:
|
|
by_tok: dict[str, int] = {}
|
|
for _p, tok in inline_tint_hits:
|
|
by_tok[tok] = by_tok.get(tok, 0) + 1
|
|
print(f"REPORT — {len(inline_tint_hits)} rule(s) paint text with a token on "
|
|
f"an INLINE color-mix tint of that same token.")
|
|
print(" Same defect, spelled without a -bg token so it does not gate yet.")
|
|
print(" Worst offenders: " + ", ".join(
|
|
f"{t} x{n}" for t, n in sorted(by_tok.items(), key=lambda kv: -kv[1])[:4]) + "\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())
|