Files
FabledScribe/src/scribe/services/design_stylesheet.py
T
bvandeusen 0f80b790c7
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
feat(design-systems): central prose — guidance on the system, rationale on tokens
Last piece of the architecture in #2296. The operator: "the prose doesn't have
to live as one offs, there's a central system for managing it."

Two fields, both free-form:

  design_systems.guidance   the narrative a token table cannot hold — aesthetic,
                            voice and tone, what is deliberately out of scope.
  design_tokens.rationale   WHY a token is this value, which is a different
                            question from `purpose` (what it is FOR). "Success
                            equals Moss, aligned by design" is a rationale;
                            "page bg, deepest surface" is a purpose. Rules carry
                            the first routinely and a token row had nowhere to
                            put it.

Free-form rather than a column per category, deliberately. A schema with
`voice`, `aesthetic` and `scope` columns would bake one rulebook's table of
contents into every install (rule #115), leaving the next install three empty
columns and nowhere for what it actually cares about. Both nullable: a design
system with no prose at all is complete, not a draft.

`rationale` cascades like `purpose` — deepest non-empty wins — so an app
overriding a colour keeps the family's reasoning rather than blanking it. Same
argument as `supersedes`: the override was about the value, not the meaning.

In the generated sheet the inline comment prefers `purpose` and falls back to
`rationale`, so a token carrying only the why still says something instead of
rendering bare.
2026-07-30 21:52:16 -04:00

302 lines
12 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
(`--fs-button-bg`) is the smell that the two have been mixed — it multiplies
with every new element, where a purpose name (`--fs-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("")
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 ("Success = Moss, by design"), 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)),
}