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

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:
2026-07-31 09:51:54 -04:00
parent 23a385e2db
commit 1fde646c60
5 changed files with 242 additions and 0 deletions
+8
View File
@@ -145,6 +145,14 @@ export interface StylesheetResult {
valueless: string[]; valueless: string[];
/** Values declared under more than one name — alias, or one idea twice. */ /** Values declared under more than one name — alias, or one idea twice. */
duplicates: Record<string, string[]>; duplicates: Record<string, string[]>;
derivation: {
/** Tokens computed from others, mapped to what they're computed from. */
derived: Record<string, string[]>;
/** Formulas pointing at tokens that don't exist — the browser drops these. */
unknown_refs: Record<string, string[]>;
/** Derivation loops, which resolve to nothing for the same reason. */
cycles: string[][];
};
} }
/** The master CSS sheet a design system generates. /** The master CSS sheet a design system generates.
+50
View File
@@ -442,6 +442,16 @@ watch([selectedId, ownTokens], () => {
const duplicateEntries = computed(() => Object.entries(sheet.value?.duplicates ?? {})); const duplicateEntries = computed(() => Object.entries(sheet.value?.duplicates ?? {}));
/** Formulas whose source token doesn't exist. The browser drops the whole
* declaration — invalid at computed-value time — so nothing errors and the
* token simply has no value. Led with, because it is the only entry here that
* is unambiguously broken rather than a judgement call. */
const brokenFormulas = computed(() =>
Object.entries(sheet.value?.derivation.unknown_refs ?? {}),
);
const derivedEntries = computed(() => Object.entries(sheet.value?.derivation.derived ?? {}));
const derivationCycles = computed(() => sheet.value?.derivation.cycles ?? []);
// --- do the snippets use the sheet? ----------------------------------------- // --- do the snippets use the sheet? -----------------------------------------
const snippetCheck = ref<SnippetCheck | null>(null); const snippetCheck = ref<SnippetCheck | null>(null);
@@ -691,6 +701,46 @@ function isColourish(value: string): boolean {
under more than one name under more than one name
</p> </p>
<div v-if="brokenFormulas.length" class="notice notice-warn">
<strong>Some formulas point at tokens that don't exist.</strong>
<p>
The browser drops these declarations entirely — no error, no
warning, the token just has no value. Either the source token
was renamed or the reference is a typo.
</p>
<ul class="dupe-list">
<li v-for="[name, refs] in brokenFormulas" :key="name">
<code>{{ name }}</code> → <code>{{ refs.join(", ") }}</code>
</li>
</ul>
</div>
<div v-if="derivationCycles.length" class="notice notice-warn">
<strong>Some tokens derive from each other in a loop.</strong>
<p>
CSS resolves a loop to nothing rather than looping forever, so
every token in the cycle ends up with no value.
</p>
<ul class="dupe-list">
<li v-for="(cycle, i) in derivationCycles" :key="i">
<code>{{ cycle.join(" ") }} → {{ cycle[0] }}</code>
</li>
</ul>
</div>
<div v-if="derivedEntries.length" class="notice">
<strong>{{ derivedEntries.length }} tokens are computed from others.</strong>
<p>
These follow their source automatically, in every mode, from a
single declaration — change the source and they shift with it.
</p>
<ul class="dupe-list">
<li v-for="[name, refs] in derivedEntries" :key="name">
<code>{{ name }}</code> from <code>{{ refs.join(", ") }}</code>
</li>
</ul>
</div>
<div v-if="duplicateEntries.length" class="notice notice-warn"> <div v-if="duplicateEntries.length" class="notice notice-warn">
<strong>Some values are declared twice.</strong> <strong>Some values are declared twice.</strong>
<p> <p>
+86
View File
@@ -299,3 +299,89 @@ def check_code_against_tokens(code: str, tokens) -> dict:
"superseded_literals": superseded, "superseded_literals": superseded,
"local_definitions": sorted(defined_tokens(code)), "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),
}
+5
View File
@@ -23,6 +23,7 @@ from scribe.models.project import Project
from scribe.services import access from scribe.services import access
from scribe.services.design_stylesheet import ( from scribe.services.design_stylesheet import (
check_code_against_tokens, check_code_against_tokens,
derivation_report,
duplicate_values, duplicate_values,
render_stylesheet, render_stylesheet,
) )
@@ -373,6 +374,10 @@ async def stylesheet_for_system(
"token_count": len(resolved), "token_count": len(resolved),
"valueless": [t.name for t in resolved if not t.value_by_mode], "valueless": [t.name for t in resolved if not t.value_by_mode],
"duplicates": duplicate_values(resolved), "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),
} }
+93
View File
@@ -9,6 +9,7 @@ from types import SimpleNamespace
from scribe.services.design_stylesheet import ( from scribe.services.design_stylesheet import (
check_code_against_tokens, check_code_against_tokens,
derivation_report,
duplicate_values, duplicate_values,
is_valid_token_name, is_valid_token_name,
render_stylesheet, render_stylesheet,
@@ -340,3 +341,95 @@ def test_purpose_wins_over_rationale_in_the_comment():
css = render_stylesheet([token]) css = render_stylesheet([token])
assert "hairline borders" in css assert "hairline borders" in css
assert "because thin" not in css assert "because thin" not in css
# --- derivation -------------------------------------------------------------
#
# A formula needs no special storage: `color-mix(..., var(--fs-accent) 15%, ...)`
# is a value, and the browser resolves it live. What it needs is a check, because
# a formula pointing at a missing token is dropped silently.
def _dtok(name, base):
return SimpleNamespace(
name=name, value_by_mode={"base": base},
group_name=None, purpose=None, supersedes=[],
)
ACCENT = "color-mix(in srgb, var(--fs-accent) 15%, transparent)"
def test_a_formula_survives_the_value_sanitiser_untouched():
"""LOAD-BEARING for the whole approach. If `color-mix(... var(...) ...)` were
rejected as unsafe, derivation would need a storage shape of its own."""
assert safe_value(ACCENT) == ACCENT
assert ACCENT in render_stylesheet([_dtok("--fs-accent-soft", ACCENT)])
def test_a_derived_token_reports_what_it_is_computed_from():
report = derivation_report([
_dtok("--fs-accent", "#5b4a8a"),
_dtok("--fs-accent-soft", ACCENT),
])
assert report["derived"] == {"--fs-accent-soft": ["--fs-accent"]}
assert report["unknown_refs"] == {}
def test_a_formula_pointing_at_a_token_that_does_not_exist_is_reported():
"""The browser drops the whole declaration — invalid at computed-value time —
and nothing errors. Exactly the failure mode this system exists to end."""
report = derivation_report([_dtok("--fs-accent-soft", ACCENT)])
assert report["unknown_refs"] == {"--fs-accent-soft": ["--fs-accent"]}
def test_a_plain_value_is_not_reported_as_derived():
report = derivation_report([_dtok("--fs-obsidian", "#14171a")])
assert report["derived"] == {}
def test_a_derivation_loop_is_reported_once():
"""CSS resolves a loop to nothing rather than hanging, so this is about
telling the operator — but a token that quietly resolves to nothing is
precisely the thing worth being told."""
report = derivation_report([
_dtok("--fs-a", "var(--fs-b)"),
_dtok("--fs-b", "var(--fs-a)"),
])
assert len(report["cycles"]) == 1
assert set(report["cycles"][0]) == {"--fs-a", "--fs-b"}
def test_a_chain_of_derivations_is_not_a_cycle():
"""a <- b <- c is ordinary and must not trip the loop check."""
report = derivation_report([
_dtok("--fs-a", "#000"),
_dtok("--fs-b", "var(--fs-a)"),
_dtok("--fs-c", "var(--fs-b)"),
])
assert report["cycles"] == []
assert report["derived"] == {"--fs-b": ["--fs-a"], "--fs-c": ["--fs-b"]}
def test_a_token_referencing_itself_is_not_treated_as_a_dependency():
"""`--fs-x: var(--fs-x, fallback)` is a self-reference with a fallback, not a
derivation — counting it would report every such token as a one-node loop."""
report = derivation_report([_dtok("--fs-x", "var(--fs-x, 8px)")])
assert report["cycles"] == []
assert report["derived"] == {}
def test_a_derived_token_needs_only_a_base_value_to_follow_every_mode():
"""The reason formulas beat computed literals. One declaration in the base
layer tracks its source through dark mode too, because `var()` resolves where
it is USED, not where it is written — so there is nothing to re-derive when a
colour changes."""
css = render_stylesheet([
SimpleNamespace(
name="--fs-accent", value_by_mode={"base": "#5b4a8a", "dark": "#7a68b0"},
group_name=None, purpose=None,
),
_dtok("--fs-accent-soft", ACCENT),
])
dark_block = css.split('[data-theme="dark"] {')[1]
assert "--fs-accent:" in dark_block
assert "--fs-accent-soft" not in dark_block # stated once, follows anyway