CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.
Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.
The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.
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 Grimoire 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())
|