feat(design-systems): declare what to write instead, rather than what not to
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 38s

Milestone #254 step 6, first half (#2295) — and this reframes the task rather
than answering it. The operator's call:

  "in this case we should declare what should be used in place of pure white,
   it's not a prohibition it's what should be used in its place."

None of the three options on the table (a constraints record / the panel reads
both sources / negative token rows) was right, because all three kept the
prohibition as a KIND OF THING. It isn't one. "Pure white is never text" is the
shadow cast by a positive fact — text is Parchment — and a design system that
stores what things ARE has no row for a ban because it never needed one.

So `design_tokens` gains `supersedes`: the literal values this token should be
written instead of. `--color-text-on-action` supersedes `#fff` / `#ffffff`. Same
fact as the rule, stated forwards, and now actionable — a finding can say what
to write rather than only objecting.

It has to be DECLARED, not derived, and that is the crux: `#fff` and Parchment
`#E8E4D8` are different colours, so no value-matching check could ever have
connected them. That mismatch is precisely why the prohibition looked
unrepresentable until it was turned around.

`supersedes` cascades on EMPTINESS rather than on None. A child overriding a
colour says nothing about which literals it replaces, and blanking the family's
declaration there would silently disarm the check for every app that customises
the token — while a child that states its own list replaces it wholesale.

Two things this deliberately does NOT do:

  - It does not feed the drift panel. Superseded literals live in component CSS,
    which `designDrift.ts` cannot see and already documents as a blind spot.
    This is input for the source lint (#2277). Declaring it with nothing
    consuming it yet is honest; wiring it to a panel that cannot check it would
    not be.
  - It does not remove the panel's `prohibited_color` arm yet — that happens
    when the panel is repointed at a resolved system, which needs #2288 first.

The declaration also exposes a missing token: most of the 67 hardcoded
`color: #fff` (#2275) are text on a coloured action button, and the system has
no token for that role at all. Every view hardcodes it. Declaring the token that
was never there is the first real output of the operator's framing.
This commit is contained in:
2026-07-30 21:16:45 -04:00
parent 78489308b8
commit 3da40abcb8
11 changed files with 305 additions and 8 deletions
+16 -2
View File
@@ -147,6 +147,7 @@ async def create_design_token(
value_by_mode: dict | None = None,
group_name: str = "",
purpose: str = "",
supersedes: list | None = None,
order_index: int = 0,
) -> dict:
"""Add a token to a design system.
@@ -162,6 +163,12 @@ async def create_design_token(
group_name: Free-text grouping — "surface", "text", "radius", whatever
this system's own vocabulary is.
purpose: What the token is for, e.g. "page bg, deepest surface".
supersedes: Literal values this token should be used INSTEAD OF, e.g.
["#fff", "#ffffff"]. This is how a design system records what a
prohibition was trying to say — not "white is banned" but "write
this token instead". Declare it rather than expecting it to be
inferred: a superseded literal and the token's own value are
usually different values, so nothing can connect them by matching.
order_index: Display position within its group.
"""
uid = current_user_id()
@@ -172,6 +179,7 @@ async def create_design_token(
value_by_mode=value_by_mode,
group_name=group_name or None,
purpose=purpose or None,
supersedes=supersedes,
order_index=order_index,
)
if token is None:
@@ -192,12 +200,14 @@ async def update_design_token(
value_by_mode: dict | None = None,
group_name: str = "",
purpose: str = "",
supersedes: list | None = None,
order_index: int = -1,
) -> dict:
"""Update a token. Empty/None args leave a field unchanged.
`value_by_mode` REPLACES the whole map rather than merging into it, so send
every mode you want the token to keep.
`value_by_mode` and `supersedes` REPLACE their whole value rather than
merging into it, so send every entry you want the token to keep. Pass `[]`
to clear `supersedes` entirely.
"""
uid = current_user_id()
fields: dict = {}
@@ -209,6 +219,10 @@ async def update_design_token(
fields["group_name"] = group_name
if purpose:
fields["purpose"] = purpose
# `is not None`, not truthiness: `[]` is a meaningful edit (drop every
# superseded literal) and would otherwise be unreachable.
if supersedes is not None:
fields["supersedes"] = supersedes
if order_index >= 0:
fields["order_index"] = order_index
token = await ds_svc.update_token(uid, token_id, **fields)
+19
View File
@@ -109,6 +109,24 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
# vocabulary, and a whitelist would bake one install's kit into the schema.
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
# Literal values this token should be used INSTEAD OF, e.g. ["#fff",
# "#ffffff"] on a text-on-action token.
#
# This is how a design system records the thing a prohibition was trying to
# say. "Pure white is never text" is the shadow of a positive fact — text is
# Parchment — and a system that stores what things ARE has no row for a ban.
# Recording the replacement keeps the check and makes it actionable: a
# finding can name what to write instead of merely objecting.
#
# It has to be DECLARED rather than inferred, because the superseded literal
# and the token's own value are usually different colours (#fff is not
# #E8E4D8). No value-matching rule could ever connect them.
#
# Consumed by the source lint (#2277), not by the drift panel: these
# literals live in component CSS, which the panel cannot see and says so.
supersedes: Mapped[list] = mapped_column(
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
)
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
def to_dict(self) -> dict:
@@ -119,6 +137,7 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
"value_by_mode": self.value_by_mode or {},
"group_name": self.group_name,
"purpose": self.purpose,
"supersedes": self.supersedes or [],
"order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
+5 -1
View File
@@ -140,6 +140,7 @@ async def create_design_token(design_system_id: int):
value_by_mode=data.get("value_by_mode"),
group_name=data.get("group_name") or None,
purpose=data.get("purpose") or None,
supersedes=data.get("supersedes"),
order_index=data.get("order_index") or 0,
)
if token is None:
@@ -153,7 +154,10 @@ async def update_design_token(token_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items()
if k in ("name", "value_by_mode", "group_name", "purpose", "order_index")
if k in (
"name", "value_by_mode", "group_name", "purpose", "supersedes",
"order_index",
)
}
token = await ds_svc.update_token(_uid(), token_id, **fields)
if token is None:
+15 -1
View File
@@ -97,6 +97,7 @@ class ResolvedToken:
contributions: dict[str, tuple[Contribution, ...]]
group_name: str | None
purpose: str | None
supersedes: tuple[str, ...]
order_index: int
@property
@@ -132,6 +133,7 @@ class ResolvedToken:
"name": self.name,
"group_name": self.group_name,
"purpose": self.purpose,
"supersedes": list(self.supersedes),
"order_index": self.order_index,
"value_by_mode": self.value_by_mode,
"origin_by_mode": self.origin_by_mode,
@@ -206,7 +208,11 @@ def resolve_tokens(
)
meta = metadata.setdefault(
token.name, {"group_name": None, "purpose": None, "order_index": None}
token.name,
{
"group_name": None, "purpose": None,
"supersedes": None, "order_index": None,
},
)
for field in ("group_name", "purpose"):
if meta[field] is None:
@@ -218,6 +224,13 @@ def resolve_tokens(
# 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(
@@ -227,6 +240,7 @@ def resolve_tokens(
},
group_name=metadata[name]["group_name"],
purpose=metadata[name]["purpose"],
supersedes=metadata[name]["supersedes"] or (),
order_index=metadata[name]["order_index"] or 0,
)
for name, per_mode in contributions.items()
+9 -1
View File
@@ -183,6 +183,7 @@ async def create_token(
value_by_mode: dict | None = None,
group_name: str | None = None,
purpose: str | None = None,
supersedes: list | None = None,
order_index: int = 0,
) -> DesignToken | None:
if not await access.can_write_design_system(user_id, design_system_id):
@@ -197,6 +198,10 @@ async def create_token(
value_by_mode=value_by_mode or {},
group_name=group_name,
purpose=purpose,
# `or []` for the same reason as value_by_mode above: the column is
# NOT NULL so absence has one spelling, and None would store JSON
# null instead of an empty array.
supersedes=supersedes or [],
order_index=order_index,
)
session.add(token)
@@ -270,7 +275,10 @@ async def resolve_design_system(
async def update_token(
user_id: int, token_id: int, **fields: object
) -> DesignToken | None:
allowed = {"name", "value_by_mode", "group_name", "purpose", "order_index"}
allowed = {
"name", "value_by_mode", "group_name", "purpose", "supersedes",
"order_index",
}
async with async_session() as session:
token = await session.get(DesignToken, token_id)
if token is None or token.deleted_at is not None: