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
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:
@@ -145,6 +145,14 @@ export interface StylesheetResult {
|
||||
valueless: string[];
|
||||
/** Values declared under more than one name — alias, or one idea twice. */
|
||||
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.
|
||||
|
||||
@@ -442,6 +442,16 @@ watch([selectedId, ownTokens], () => {
|
||||
|
||||
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? -----------------------------------------
|
||||
|
||||
const snippetCheck = ref<SnippetCheck | null>(null);
|
||||
@@ -691,6 +701,46 @@ function isColourish(value: string): boolean {
|
||||
under more than one name
|
||||
</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">
|
||||
<strong>Some values are declared twice.</strong>
|
||||
<p>
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user