CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Last piece of the architecture in #2296. The operator: "the prose doesn't have to live as one offs, there's a central system for managing it." Two fields, both free-form: design_systems.guidance the narrative a token table cannot hold — aesthetic, voice and tone, what is deliberately out of scope. design_tokens.rationale WHY a token is this value, which is a different question from `purpose` (what it is FOR). "Success equals Moss, aligned by design" is a rationale; "page bg, deepest surface" is a purpose. Rules carry the first routinely and a token row had nowhere to put it. Free-form rather than a column per category, deliberately. A schema with `voice`, `aesthetic` and `scope` columns would bake one rulebook's table of contents into every install (rule #115), leaving the next install three empty columns and nowhere for what it actually cares about. Both nullable: a design system with no prose at all is complete, not a draft. `rationale` cascades like `purpose` — deepest non-empty wins — so an app overriding a colour keeps the family's reasoning rather than blanking it. Same argument as `supersedes`: the override was about the value, not the meaning. In the generated sheet the inline comment prefers `purpose` and falls back to `rationale`, so a token carrying only the why still says something instead of rendering bare.
252 lines
11 KiB
Python
252 lines
11 KiB
Python
"""The design-system cascade, as pure functions over already-loaded rows.
|
|
|
|
Deliberately free of every database and service import — including
|
|
`services/access.py`, which needs `ancestry` to answer "can this caller read
|
|
this system?" and would otherwise form an import cycle with the service that
|
|
needs `access` back. A module that imports nothing can be imported by both.
|
|
|
|
Being pure is also what makes the cascade rule testable without a database:
|
|
these take a plain `{id: parent_id}` map, so a test states the shape of a
|
|
hierarchy in one literal instead of building one.
|
|
"""
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
|
|
# The mode key that applies when no more specific one does. Emitted CSS puts it
|
|
# on the base selector and every other key on a mode selector, mirroring how a
|
|
# stylesheet is actually written: light on `:root`, dark layered over it.
|
|
BASE_MODE = "base"
|
|
|
|
|
|
def ancestry(system_id: int, parents: Mapping[int, int | None]) -> list[int]:
|
|
"""The inheritance chain from `system_id` up to its root, nearest first.
|
|
|
|
Includes `system_id` itself at index 0, because resolution wants
|
|
deepest-to-shallowest and the system being resolved is the deepest link.
|
|
|
|
A system missing from `parents` terminates the chain rather than raising: a
|
|
parent whose row was soft-deleted or filtered out is a truncated chain, not
|
|
a failed request.
|
|
|
|
The visited-set is defensive, not the primary guard — writes already refuse
|
|
to create a cycle (`would_cycle`). It is here because a loop introduced by a
|
|
direct DB edit or a future bug must degrade to a truncated chain instead of
|
|
spinning forever. Truncation shows up in the result; a hang shows up as an
|
|
outage.
|
|
"""
|
|
chain: list[int] = []
|
|
seen: set[int] = set()
|
|
current: int | None = system_id
|
|
while current is not None and current not in seen:
|
|
seen.add(current)
|
|
chain.append(current)
|
|
current = parents.get(current)
|
|
return chain
|
|
|
|
|
|
def would_cycle(
|
|
system_id: int,
|
|
proposed_parent_id: int | None,
|
|
parents: Mapping[int, int | None],
|
|
) -> bool:
|
|
"""Would making `proposed_parent_id` the parent of `system_id` close a loop?
|
|
|
|
True when the proposed parent IS the system, or already inherits from it.
|
|
|
|
The walk goes UP from the proposed parent, which is the cheap direction —
|
|
each system has at most one parent, so the chain is a line. Asking the
|
|
equivalent downward question ("is the proposed parent among my
|
|
descendants?") would mean searching a whole forest for the same answer.
|
|
|
|
`proposed_parent_id=None` clears the parent and can never cycle.
|
|
"""
|
|
if proposed_parent_id is None:
|
|
return False
|
|
if proposed_parent_id == system_id:
|
|
return True
|
|
return system_id in ancestry(proposed_parent_id, parents)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resolution — flattening a chain into an effective token set
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class Contribution:
|
|
"""One system's offer for one token in one mode."""
|
|
system_id: int
|
|
value: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResolvedToken:
|
|
"""A token after the cascade, carrying the whole argument rather than the verdict.
|
|
|
|
`contributions` holds every system that supplied a value, per mode, DEEPEST
|
|
FIRST — so `[0]` is the winner and `[1:]` are what it shadowed. Storing the
|
|
contest rather than a winner plus a separate provenance field means there is
|
|
nothing to keep in sync: "which system supplied this?" and "what did it
|
|
override?" are both reads of the same list, and they cannot disagree.
|
|
|
|
Provenance is per MODE, not per token, because overriding is. A system that
|
|
deepens one accent for light backgrounds while leaving dark alone owns the
|
|
base value and inherits the dark one, and a token-level "overridden here"
|
|
flag would have to lie about one of them.
|
|
"""
|
|
name: str
|
|
contributions: dict[str, tuple[Contribution, ...]]
|
|
group_name: str | None
|
|
purpose: str | None
|
|
rationale: str | None
|
|
supersedes: tuple[str, ...]
|
|
order_index: int
|
|
|
|
@property
|
|
def value_by_mode(self) -> dict[str, str]:
|
|
"""The effective value for each mode — the winner of each contest."""
|
|
return {mode: entries[0].value for mode, entries in self.contributions.items()}
|
|
|
|
@property
|
|
def origin_by_mode(self) -> dict[str, int]:
|
|
"""Which system supplied each mode's effective value."""
|
|
return {mode: entries[0].system_id for mode, entries in self.contributions.items()}
|
|
|
|
def value_for(self, mode: str) -> str | None:
|
|
"""The value to render in `mode`, falling back to the base mode.
|
|
|
|
This is the read rule the storage shape implies: a token that is not
|
|
mode-dependent carries only `base`, and asking it for "dark" must yield
|
|
the base value rather than nothing.
|
|
"""
|
|
entries = self.contributions.get(mode) or self.contributions.get(BASE_MODE)
|
|
return entries[0].value if entries else None
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Payload shape for both surfaces — and it carries the SHADOWED entries.
|
|
|
|
Serialising only the winner would throw away the provenance at the last
|
|
step, which is the one thing this type exists to preserve. `contributions`
|
|
is the audit trail; `value_by_mode` / `origin_by_mode` are alongside it so
|
|
a client renders without re-deriving anything, and cannot derive it
|
|
differently.
|
|
"""
|
|
return {
|
|
"name": self.name,
|
|
"group_name": self.group_name,
|
|
"purpose": self.purpose,
|
|
"rationale": self.rationale,
|
|
"supersedes": list(self.supersedes),
|
|
"order_index": self.order_index,
|
|
"value_by_mode": self.value_by_mode,
|
|
"origin_by_mode": self.origin_by_mode,
|
|
"contributions": {
|
|
mode: [
|
|
{"system_id": c.system_id, "value": c.value} for c in entries
|
|
]
|
|
for mode, entries in self.contributions.items()
|
|
},
|
|
}
|
|
|
|
def is_overridden_in(self, system_id: int) -> bool:
|
|
"""Does `system_id` win any mode of this token AND shadow something?
|
|
|
|
The distinction the UI needs: a token this system introduced is not an
|
|
override, and a token it merely inherits is not either.
|
|
"""
|
|
return any(
|
|
len(entries) > 1 and entries[0].system_id == system_id
|
|
for entries in self.contributions.values()
|
|
)
|
|
|
|
|
|
def _sort_key(token: ResolvedToken) -> tuple:
|
|
# Ungrouped tokens sort last rather than first: a design system that has
|
|
# started grouping should read as its groups, with the not-yet-filed
|
|
# remainder at the end.
|
|
return (token.group_name is None, token.group_name or "", token.order_index, token.name)
|
|
|
|
|
|
def resolve_tokens(
|
|
system_id: int,
|
|
parents: Mapping[int, int | None],
|
|
tokens_by_system: Mapping[int, Sequence],
|
|
) -> list[ResolvedToken]:
|
|
"""Flatten a system's inheritance chain into its effective token set.
|
|
|
|
Walks from `system_id` up to the root and applies tokens by name, deepest
|
|
winning. That is the CSS cascade — precedence by name along a parent chain —
|
|
rather than an analogy to it, which is why the storage model and the
|
|
stylesheet model came out the same shape.
|
|
|
|
`tokens_by_system` maps a system id to its own token rows. Any object with
|
|
`.name`, `.value_by_mode`, `.group_name`, `.purpose` and `.order_index` will
|
|
do, so a test can state a hierarchy in literals and the service can pass ORM
|
|
rows to the same function.
|
|
|
|
The result includes tokens the system never mentions — inheriting one is
|
|
what puts it in the effective set. A system with no tokens of its own
|
|
resolves to its parent's set entire, which is the correct answer for an app
|
|
that has not departed from the family yet.
|
|
|
|
Merging is per (name, MODE): a child that supplies only a dark value
|
|
overrides only dark and keeps inheriting base. Metadata (`group_name`,
|
|
`purpose`, `order_index`) cascades separately by the same deepest-wins rule,
|
|
since a child overriding a value routinely leaves the family's description
|
|
of what the token is FOR untouched — and inheriting it beats blanking it.
|
|
"""
|
|
chain = ancestry(system_id, parents)
|
|
|
|
contributions: dict[str, dict[str, list[Contribution]]] = {}
|
|
metadata: dict[str, dict[str, object]] = {}
|
|
|
|
# Deepest first, so the first contribution seen for a (name, mode) wins and
|
|
# every later one is a shadowed ancestor appended behind it.
|
|
for depth_system_id in chain:
|
|
for token in tokens_by_system.get(depth_system_id) or ():
|
|
per_mode = contributions.setdefault(token.name, {})
|
|
for mode, value in (token.value_by_mode or {}).items():
|
|
per_mode.setdefault(mode, []).append(
|
|
Contribution(system_id=depth_system_id, value=value)
|
|
)
|
|
|
|
meta = metadata.setdefault(
|
|
token.name,
|
|
{
|
|
"group_name": None, "purpose": None, "rationale": None,
|
|
"supersedes": None, "order_index": None,
|
|
},
|
|
)
|
|
for field in ("group_name", "purpose", "rationale"):
|
|
if meta[field] is None:
|
|
meta[field] = getattr(token, field, None)
|
|
# order_index alone treats 0 as UNSTATED rather than "first",
|
|
# because 0 is the column default. Reading it as a real value would
|
|
# let any child override drag its token to the top of the group and
|
|
# lose the family's ordering — a visible reshuffle in return for a
|
|
# change that only touched a colour.
|
|
if not meta["order_index"]:
|
|
meta["order_index"] = getattr(token, "order_index", 0) or None
|
|
# `supersedes` cascades on EMPTINESS, not on None: a child that
|
|
# overrides a colour and says nothing about which literals it
|
|
# replaces should keep the family's declaration, and an empty list
|
|
# is what "said nothing" looks like once the column is NOT NULL.
|
|
# A child that states its own list replaces the whole thing.
|
|
if not meta["supersedes"]:
|
|
meta["supersedes"] = tuple(getattr(token, "supersedes", None) or ()) or None
|
|
|
|
resolved = [
|
|
ResolvedToken(
|
|
name=name,
|
|
contributions={
|
|
mode: tuple(entries) for mode, entries in per_mode.items()
|
|
},
|
|
group_name=metadata[name]["group_name"],
|
|
purpose=metadata[name]["purpose"],
|
|
rationale=metadata[name]["rationale"],
|
|
supersedes=metadata[name]["supersedes"] or (),
|
|
order_index=metadata[name]["order_index"] or 0,
|
|
)
|
|
for name, per_mode in contributions.items()
|
|
]
|
|
return sorted(resolved, key=_sort_key)
|