feat(design-systems): formulas — derived tokens that follow their source
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 42s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 42s
Operator: "build in a way to support formulas like this so that the colors shift
as expected and have less to clean up when testing color changes."
The storage needed no change at all, which is the good news. A formula is just a
value:
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent)
It passes the value sanitiser untouched (verified, and now pinned by a test —
had `color-mix(... var(...) ...)` been rejected as unsafe, derivation would have
needed a storage shape of its own), and the browser resolves the `var()` at use
time. Change `--fs-accent` and everything derived from it shifts.
**One declaration covers every mode**, and that is the "less to clean up" part.
A derived token written once in the base layer follows its source through dark
mode automatically, because `var()` resolves where it is USED rather than where
it is written. A stored computed literal would need a row per mode and would
silently stop tracking the source the moment the source changed — the whole
problem this avoids.
What derivation DID need is the check. A formula pointing at a token that does
not exist is invalid-at-computed-value-time: the browser drops the declaration
outright and the token has no value. No error, no warning, nothing in the
toolchain notices — the same family as `--color-accent`, `_parent_map`, and the
scripted edit whose anchor matched nothing.
So `derivation_report` returns three things alongside the sheet: which tokens are
computed and from what, which formulas point at nothing, and which derive from
each other in a loop. CSS resolves a loop to nothing rather than hanging, so the
cycle check is about telling the operator, not protecting the renderer — but a
token that quietly resolves to nothing is exactly what is worth being told.
A self-reference with a fallback (`var(--fs-x, 8px)`) is deliberately not a
dependency; counting it would report every such token as a one-node loop.
The UI leads with broken formulas, then loops, then the healthy derived set —
the first two are unambiguously wrong, where a duplicate value is a judgement
call.
This commit is contained in:
@@ -299,3 +299,89 @@ def check_code_against_tokens(code: str, tokens) -> dict:
|
||||
"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),
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ from scribe.models.project import Project
|
||||
from scribe.services import access
|
||||
from scribe.services.design_stylesheet import (
|
||||
check_code_against_tokens,
|
||||
derivation_report,
|
||||
duplicate_values,
|
||||
render_stylesheet,
|
||||
)
|
||||
@@ -373,6 +374,10 @@ async def stylesheet_for_system(
|
||||
"token_count": len(resolved),
|
||||
"valueless": [t.name for t in resolved if not t.value_by_mode],
|
||||
"duplicates": duplicate_values(resolved),
|
||||
# Formulas: which tokens are computed from others, and which of those
|
||||
# point at nothing. A broken formula is dropped by the browser without
|
||||
# any error, so the sheet cannot show it for itself.
|
||||
"derivation": derivation_report(resolved),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user