From 1fde646c60ed71c443ecaa76365fb14e3bb3fed1 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
+ 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. +
+{{ name }} → {{ refs.join(", ") }}
+ + CSS resolves a loop to nothing rather than looping forever, so + every token in the cycle ends up with no value. +
+{{ cycle.join(" → ") }} → {{ cycle[0] }}
+ + These follow their source automatically, in every mode, from a + single declaration — change the source and they shift with it. +
+{{ name }} from {{ refs.join(", ") }}
+ diff --git a/src/scribe/services/design_stylesheet.py b/src/scribe/services/design_stylesheet.py index 6ead872..1a2ef27 100644 --- a/src/scribe/services/design_stylesheet.py +++ b/src/scribe/services/design_stylesheet.py @@ -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), + } diff --git a/src/scribe/services/design_systems.py b/src/scribe/services/design_systems.py index 9f8b278..4c4ef35 100644 --- a/src/scribe/services/design_systems.py +++ b/src/scribe/services/design_systems.py @@ -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), } diff --git a/tests/test_design_stylesheet.py b/tests/test_design_stylesheet.py index 56e9e62..4c2c844 100644 --- a/tests/test_design_stylesheet.py +++ b/tests/test_design_stylesheet.py @@ -9,6 +9,7 @@ from types import SimpleNamespace from scribe.services.design_stylesheet import ( check_code_against_tokens, + derivation_report, duplicate_values, is_valid_token_name, render_stylesheet, @@ -340,3 +341,95 @@ def test_purpose_wins_over_rationale_in_the_comment(): css = render_stylesheet([token]) assert "hairline borders" 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