CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 34s
The acceptance greps found what the per-area passes missed: - src/scribe: "Phase 7/8" removal notes in projects, settings and user_profile; a paragraph promising an auto-summary side effect that no longer exists; user_profile's claim that "upcoming MCP profile tools" read the profile, which nothing does; an instance count and instance rule titles in plugin_context; "MILESTONE 323 DECIDED" in rulebooks. - scripts/: check_plugin's header and inline comments were a history of the incidents behind each guard, with issue numbers in two failure messages; check_dangling_styles named this app's classes as its examples; check_design_tokens, measure_duplication and mint_plugin_version cited issues and checklists. - frontend/src: forty-odd comments with four-digit issue numbers that the earlier frontend scan filtered out as hex colours, plus "used to" and "Phase N" notes. Kept: illustrative ids (`#4181` as a bare-id example, `PR #198`), migration Create Date headers and one migration's dated data floor, and the plugin skills' worked examples of how to cite a record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
11 KiB
Python
260 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Check the frontend's CSS against the tokens its stylesheet declares.
|
|
|
|
`services/design_stylesheet.check_code_against_tokens` answers "does this code
|
|
use the sheet correctly?", but inside the app it is fed only recorded SNIPPETS.
|
|
The app's own components, where unresolvable references live quietly, need a
|
|
check of their own.
|
|
|
|
That is structural. 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 `theme.css` is a
|
|
generated artifact — so the source of truth is a local file, with no network
|
|
and no credentials.
|
|
|
|
INSTANCE-AGNOSTIC ON PURPOSE. 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())
|