CI & Build / Python lint (push) Successful in 7s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 34s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Build & push image (push) Successful in 41s
Closes the gap the theme.css repoint exposed (#2319, part of #2277). `check_code_against_tokens` could always answer "does this code use the sheet correctly?" — it was only ever fed recorded SNIPPETS. The app's own components, where sixteen unresolvable references were living quietly, 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 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 committed file with no network and no credentials. **The sheet now carries its own SUPERSEDES block.** That is what keeps the checker instance-agnostic (rule #115): it knows nothing about any palette, and reads both the declarations and the discouraged literals out of whatever stylesheet it is pointed at. Hardcoding "#fff means use the text token" would have baked one install's kit into the tool. Two severities, split on whether the count is already zero: FAIL an unresolvable var() reference — zero today, so this is a ratchet holding a line already reached. It cannot false-positive either: the name is declared or it is not. REPORT superseded literals (32 files) and raw colour literals (246). Gating those means a permanently-red job, and a check nobody reads is worse than no check. **Comments are stripped before scanning, and that fired on the first real run.** A comment explaining why a literal is avoided necessarily contains that literal — DesignSystemsView's stylesheet documents exactly that about `#fff`, and the checker reported the explanation as a violation. A checker that flags the documentation of a rule teaches people to stop documenting rules. Also narrowed `--fs-weight-medium`'s supersedes to the keywords. `600` and `700` are real violations of the two-weight rule, but a bare number matches too much to find by literal scan — `z-index: 600` is not a font weight. That needs a property-aware check, which is a different tool. Verified end to end: the generator's output parses back through the checker's reader, so the two halves cannot drift into disagreeing about the format.
410 lines
17 KiB
Python
410 lines
17 KiB
Python
"""Render a design system's resolved tokens as its master CSS sheet.
|
|
|
|
Pure, like `design_cascade` and for the same reasons: no database import, so the
|
|
rendering rule is testable without one and the preview surface can reuse it.
|
|
|
|
WHAT THIS SHEET IS, AND DELIBERATELY IS NOT
|
|
-------------------------------------------
|
|
It declares **purpose tokens only** — custom properties, grouped by what they
|
|
mean. It contains no rules for elements or classes: no `.btn-primary`, no
|
|
`table`, no `input`.
|
|
|
|
That is the point rather than a limitation. A sheet that styled every element
|
|
would restate the same handful of values once per element and grow with the UI;
|
|
a sheet of purpose-named values states each once and is reused. Components —
|
|
buttons, tables, input schemes — live as SNIPPETS that reference these names and
|
|
carry prose about the idea, which is a surface that already exists and already
|
|
has recall, locations, drift checks and merge.
|
|
|
|
So the division is: this sheet says what the values MEAN; snippets say what
|
|
things LOOK LIKE, in terms of those values. A token named after an element
|
|
(`--button-bg`) is the smell that the two have been mixed — it multiplies
|
|
with every new element, where a purpose name (`--action-primary`) is reused.
|
|
|
|
SAFETY
|
|
------
|
|
Values are interpolated into a stylesheet, and design systems are shareable
|
|
records (rule #47). A value of `red; } body { display: none` in a system someone
|
|
shared with you would otherwise inject CSS into your page. Everything rendered
|
|
here is validated or dropped — never escaped-and-hoped.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Sequence
|
|
|
|
BASE_MODE = "base"
|
|
|
|
# A custom property name, strictly. Anything else is dropped rather than
|
|
# sanitised: a name is an identifier, and a "cleaned up" identifier is a
|
|
# different token than the one the operator recorded.
|
|
_VALID_NAME = re.compile(r"^--[A-Za-z0-9_-]+$")
|
|
|
|
# Characters that would end the declaration, open a block, start an at-rule, or
|
|
# begin a tag. A value containing any of them is not a value.
|
|
_UNSAFE_VALUE = re.compile(r"[{};@<>]|\*/|/\*|\n|\r")
|
|
|
|
|
|
def is_valid_token_name(name: str) -> bool:
|
|
return bool(_VALID_NAME.match((name or "").strip()))
|
|
|
|
|
|
def safe_value(value: str) -> str | None:
|
|
"""A CSS value that cannot escape its declaration, or None.
|
|
|
|
Rejects rather than strips. A partially-sanitised value is a value the
|
|
operator did not write, and silently rendering a different colour than the
|
|
record holds is worse than rendering none — the sheet's whole claim is that
|
|
it IS the record.
|
|
"""
|
|
candidate = (value or "").strip()
|
|
if not candidate or _UNSAFE_VALUE.search(candidate):
|
|
return None
|
|
return candidate
|
|
|
|
|
|
def safe_comment(text: str) -> str:
|
|
"""Comment text that cannot close its comment or break the line."""
|
|
return re.sub(r"\*/|/\*|[\n\r]", " ", (text or "")).strip()
|
|
|
|
|
|
def selector_for_mode(mode: str, root_selector: str = ":root") -> str:
|
|
"""Which selector a mode's declarations belong under.
|
|
|
|
`base` gets the caller's root selector; every other mode gets a bare
|
|
attribute selector, matching the convention already in the codebase.
|
|
|
|
The root selector is a PARAMETER because a container-scoped preview cannot
|
|
use `:root` — #251 recorded that mode scoping is one-way (light lives on
|
|
`:root`, dark layers over it), so a generator that hardcoded `:root` could
|
|
not serve a preview at all.
|
|
"""
|
|
if mode == BASE_MODE:
|
|
return root_selector
|
|
safe_mode = re.sub(r"[^A-Za-z0-9_-]", "", mode)
|
|
return f'[data-theme="{safe_mode}"]' if safe_mode else root_selector
|
|
|
|
|
|
def _grouped(tokens: Sequence) -> list[tuple[str | None, list]]:
|
|
"""Tokens by group, preserving the order they arrive in (already sorted)."""
|
|
groups: dict[str | None, list] = {}
|
|
for token in tokens:
|
|
groups.setdefault(getattr(token, "group_name", None), []).append(token)
|
|
return list(groups.items())
|
|
|
|
|
|
def _modes_present(tokens: Sequence) -> list[str]:
|
|
"""Every mode any token declares, base first then the rest alphabetically."""
|
|
modes = {
|
|
mode
|
|
for token in tokens
|
|
for mode in (getattr(token, "value_by_mode", None) or {})
|
|
}
|
|
rest = sorted(modes - {BASE_MODE})
|
|
return ([BASE_MODE] if BASE_MODE in modes else []) + rest
|
|
|
|
|
|
def render_stylesheet(
|
|
tokens: Sequence,
|
|
*,
|
|
root_selector: str = ":root",
|
|
title: str = "",
|
|
design_system_id: int | None = None,
|
|
) -> str:
|
|
"""The master sheet for a resolved token set.
|
|
|
|
One block per mode. Within a block, only the tokens that declare a value for
|
|
that mode — so a mode block is an override layer, exactly as the storage
|
|
model has it.
|
|
|
|
Tokens with no value at all are emitted as COMMENTED-OUT declarations in
|
|
their group, not dropped. The rulebook named them, so their absence is a
|
|
finding, and a commented line puts that finding where the reader is already
|
|
looking.
|
|
"""
|
|
header = [
|
|
"/*",
|
|
f" * {safe_comment(title) or 'Design system'} — generated stylesheet",
|
|
]
|
|
if design_system_id is not None:
|
|
header.append(f" * Source: design system {int(design_system_id)}.")
|
|
header += [
|
|
" *",
|
|
" * Purpose tokens only. This sheet declares what values MEAN; it styles",
|
|
" * no elements. Buttons, tables and input schemes live as snippets that",
|
|
" * reference these names, so each value is stated once and reused rather",
|
|
" * than restated per element.",
|
|
" *",
|
|
" * Generated — edit the design system, not this file.",
|
|
" */",
|
|
"",
|
|
]
|
|
|
|
lines = list(header)
|
|
valueless = [
|
|
t for t in tokens
|
|
if is_valid_token_name(getattr(t, "name", ""))
|
|
and not (getattr(t, "value_by_mode", None) or {})
|
|
]
|
|
|
|
for mode in _modes_present(tokens):
|
|
block: list[str] = []
|
|
for group, group_tokens in _grouped(tokens):
|
|
entries: list[str] = []
|
|
for token in group_tokens:
|
|
name = (getattr(token, "name", "") or "").strip()
|
|
if not is_valid_token_name(name):
|
|
continue
|
|
raw = (getattr(token, "value_by_mode", None) or {}).get(mode)
|
|
if raw is None:
|
|
continue
|
|
value = safe_value(str(raw))
|
|
if value is None:
|
|
entries.append(
|
|
f" /* {name}: value rejected — not a safe CSS value */"
|
|
)
|
|
continue
|
|
# Purpose first — what the token is FOR is what a reader of the
|
|
# stylesheet needs. Rationale is the fallback so a token that
|
|
# only carries the why still says something.
|
|
purpose = safe_comment(
|
|
getattr(token, "purpose", "")
|
|
or getattr(token, "rationale", "")
|
|
or ""
|
|
)
|
|
comment = f" /* {purpose} */" if purpose and mode == BASE_MODE else ""
|
|
entries.append(f" {name}: {value};{comment}")
|
|
if entries:
|
|
if group:
|
|
block.append(f" /* {safe_comment(group)} */")
|
|
block.extend(entries)
|
|
block.append("")
|
|
|
|
# Declared-but-valueless tokens belong to the base layer: they have no
|
|
# mode to sit under, and repeating them per mode would triple the noise.
|
|
if mode == BASE_MODE and valueless:
|
|
block.append(" /* Declared by the design system, no value set yet: */")
|
|
block.extend(f" /* {t.name}: ; */" for t in valueless)
|
|
block.append("")
|
|
|
|
if not block:
|
|
continue
|
|
lines.append(f"{selector_for_mode(mode, root_selector)} {{")
|
|
lines.extend(block[:-1] if block[-1] == "" else block)
|
|
lines.append("}")
|
|
lines.append("")
|
|
|
|
# A trailing, machine-readable record of what this system says to write
|
|
# INSTEAD of a given literal.
|
|
#
|
|
# The sheet carries its own supersedes declarations so that any consumer has
|
|
# them — notably a CI check, which has the component sources but no database.
|
|
# Hardcoding the mapping in a checker would bake one install's palette into
|
|
# the tool; reading it from the sheet keeps the checker instance-agnostic and
|
|
# keeps this file the single source.
|
|
replacements = [
|
|
(literal, getattr(token, "name", ""))
|
|
for token in tokens
|
|
for literal in (getattr(token, "supersedes", None) or ())
|
|
if is_valid_token_name(getattr(token, "name", ""))
|
|
]
|
|
if replacements:
|
|
lines.append("/* SUPERSEDES — write the token, not the literal.")
|
|
for literal, name in replacements:
|
|
lines.append(f" * {safe_comment(str(literal))} -> {name}")
|
|
lines.append(" */")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def duplicate_values(tokens: Sequence) -> dict[str, list[str]]:
|
|
"""Values declared by more than one token, mapped to the names declaring them.
|
|
|
|
Two names for one value are either a deliberate alias or the same idea
|
|
recorded twice — the token-level form of the duplicated-definition shape.
|
|
Reported rather than refused: a design system legitimately aligns colours on
|
|
purpose (one palette entry defined as equal to another), and a generator
|
|
that rejected that
|
|
would be wrong about the operator's intent.
|
|
|
|
Compares the BASE value only. Two tokens agreeing in one mode and diverging
|
|
in another are not duplicates of each other — they are a near-miss, which is
|
|
a different and less interesting finding.
|
|
"""
|
|
by_value: dict[str, list[str]] = {}
|
|
for token in tokens:
|
|
name = getattr(token, "name", "")
|
|
base = (getattr(token, "value_by_mode", None) or {}).get(BASE_MODE)
|
|
if not name or not base:
|
|
continue
|
|
by_value.setdefault(str(base).strip().lower(), []).append(name)
|
|
return {value: names for value, names in by_value.items() if len(names) > 1}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reading a sheet from the other side: does this code use it correctly?
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# "The snippets use the tags from the sheet" is a verifiable relation, and
|
|
# nothing checked it before. Three questions, each a different failure:
|
|
#
|
|
# var(--x) where no --x exists -> renders as NOTHING. No error, no test
|
|
# failure, no visual clue beyond the thing
|
|
# silently not being styled.
|
|
# a superseded literal in code -> the value the sheet said to stop writing,
|
|
# and the sheet knows what to write instead.
|
|
# --x: declared inside a snippet -> a component minting its own token is the
|
|
# bloat a shared sheet exists to prevent.
|
|
#
|
|
# The first is not hypothetical: `--color-accent` was used throughout a new view
|
|
# in this codebase and does not exist.
|
|
|
|
_VAR_REFERENCE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)")
|
|
_LOCAL_DEFINITION = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")
|
|
|
|
|
|
def referenced_tokens(code: str) -> set[str]:
|
|
"""Every custom property the code reads through `var()`."""
|
|
return set(_VAR_REFERENCE.findall(code or ""))
|
|
|
|
|
|
def defined_tokens(code: str) -> set[str]:
|
|
"""Every custom property the code declares itself.
|
|
|
|
Excludes names it also reads: `--x: var(--x, fallback)` is a redeclaration
|
|
of something the sheet owns, which the unknown-reference check already
|
|
covers more precisely.
|
|
"""
|
|
return set(_LOCAL_DEFINITION.findall(code or "")) - referenced_tokens(code or "")
|
|
|
|
|
|
def _literal_pattern(literal: str) -> re.Pattern:
|
|
"""Match a literal value without matching a longer one that contains it.
|
|
|
|
`#fff` must not match inside `#ffffff` — they are different colours, and a
|
|
finding that fired on the wrong one would send someone to change code that
|
|
was already correct.
|
|
"""
|
|
escaped = re.escape(literal)
|
|
lead = r"(?<![0-9A-Za-z_#-])"
|
|
trail = r"(?![0-9A-Za-z_-])" if literal.startswith("#") else r"(?![0-9A-Za-z_-])"
|
|
return re.compile(lead + escaped + trail, re.IGNORECASE)
|
|
|
|
|
|
def check_code_against_tokens(code: str, tokens) -> dict:
|
|
"""What this code gets wrong about that token set.
|
|
|
|
`tokens` is any sequence with `.name`, `.value_by_mode` and `.supersedes` —
|
|
resolved tokens, or stored ones.
|
|
|
|
Reports rather than scores. Every finding here has a legitimate exception:
|
|
a snippet may target a system it isn't being checked against, and a literal
|
|
may be deliberate in a context the token doesn't cover. What it removes is
|
|
the SILENCE — all three currently fail with no signal at all.
|
|
"""
|
|
known = {getattr(t, "name", "") for t in tokens}
|
|
referenced = referenced_tokens(code)
|
|
|
|
superseded: list[dict] = []
|
|
for token in tokens:
|
|
for literal in getattr(token, "supersedes", None) or ():
|
|
if _literal_pattern(str(literal)).search(code or ""):
|
|
superseded.append({
|
|
"literal": literal,
|
|
"use_instead": getattr(token, "name", ""),
|
|
})
|
|
|
|
return {
|
|
"used": sorted(referenced & known),
|
|
"unknown": sorted(referenced - known),
|
|
"superseded_literals": superseded,
|
|
"local_definitions": sorted(defined_tokens(code)),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Derivation — tokens whose value is a formula over other tokens
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# `--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent)` needs
|
|
# NO special storage: it is a value like any other, and the browser resolves the
|
|
# `var()` at use time. Change `--fs-accent` and every derived token shifts with
|
|
# it, in every mode, from one declaration.
|
|
#
|
|
# That last part is the real win. A derived token declared once in the base layer
|
|
# follows its source through dark mode automatically, because `var()` resolves in
|
|
# whatever context it is used rather than where it is written. Storing a computed
|
|
# literal instead would need one row per mode AND would silently stop tracking
|
|
# the source the moment the source changed.
|
|
#
|
|
# What derivation DOES need is the check below. A formula pointing at a token
|
|
# that does not exist is invalid-at-computed-value-time: the browser drops the
|
|
# declaration and the element falls back to inheritance or nothing. Silent, like
|
|
# everything else in this family.
|
|
|
|
|
|
def token_dependencies(tokens) -> dict[str, set[str]]:
|
|
"""Each token name mapped to the token names its own values reference."""
|
|
deps: dict[str, set[str]] = {}
|
|
for token in tokens:
|
|
name = getattr(token, "name", "")
|
|
if not name:
|
|
continue
|
|
refs: set[str] = set()
|
|
for value in (getattr(token, "value_by_mode", None) or {}).values():
|
|
refs |= referenced_tokens(str(value))
|
|
deps[name] = refs - {name}
|
|
return deps
|
|
|
|
|
|
def _find_cycles(deps: dict[str, set[str]]) -> list[list[str]]:
|
|
"""Derivation loops, each reported once as the names involved.
|
|
|
|
CSS degrades a loop to invalid-at-computed-value-time rather than hanging, so
|
|
this is about telling the operator, not about protecting the renderer. A
|
|
token that quietly resolves to nothing is the failure worth naming.
|
|
"""
|
|
cycles: list[list[str]] = []
|
|
seen_cycles: set[frozenset] = set()
|
|
|
|
def walk(node: str, path: list[str], visiting: set[str]) -> None:
|
|
for dep in sorted(deps.get(node, ())):
|
|
if dep in visiting:
|
|
loop = path[path.index(dep):]
|
|
key = frozenset(loop)
|
|
if loop and key not in seen_cycles:
|
|
seen_cycles.add(key)
|
|
cycles.append(loop)
|
|
continue
|
|
if dep in deps:
|
|
walk(dep, path + [dep], visiting | {dep})
|
|
|
|
for name in sorted(deps):
|
|
walk(name, [name], {name})
|
|
return cycles
|
|
|
|
|
|
def derivation_report(tokens) -> dict:
|
|
"""Which tokens are formulas, and which of those are broken.
|
|
|
|
`derived` name -> the tokens it is computed from
|
|
`unknown_refs` name -> references that resolve to no token in this system.
|
|
The browser drops such a declaration entirely; nothing errors.
|
|
`cycles` derivation loops, which resolve to nothing for the same reason
|
|
"""
|
|
deps = token_dependencies(tokens)
|
|
known = set(deps)
|
|
|
|
derived = {name: sorted(refs) for name, refs in deps.items() if refs}
|
|
unknown = {
|
|
name: sorted(refs - known)
|
|
for name, refs in deps.items()
|
|
if refs - known
|
|
}
|
|
return {
|
|
"derived": derived,
|
|
"unknown_refs": unknown,
|
|
"cycles": _find_cycles(deps),
|
|
}
|