Formulas — derived tokens that follow their source (#91)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 19s

This commit was merged in pull request #91.
This commit is contained in:
2026-07-31 09:54:10 -04:00
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