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
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:
@@ -0,0 +1,55 @@
|
|||||||
|
"""design_tokens.supersedes — the literals a token should be used instead of
|
||||||
|
|
||||||
|
Revision ID: 0073
|
||||||
|
Revises: 0072
|
||||||
|
Create Date: 2026-07-30
|
||||||
|
|
||||||
|
Records what a prohibition was actually trying to say.
|
||||||
|
|
||||||
|
A design rulebook writes "pure white #FFFFFF is NEVER used as text color". That
|
||||||
|
sentence has no row in a table of tokens, because a design system stores what
|
||||||
|
things ARE — which looked like a gap in the model and was really a sentence
|
||||||
|
written backwards. The positive fact is "text is Parchment", and the useful
|
||||||
|
record is the mapping from the literal someone would otherwise write to the
|
||||||
|
token they should write instead.
|
||||||
|
|
||||||
|
So `supersedes` is a JSONB array of literal values, e.g. `["#fff", "#ffffff"]`
|
||||||
|
on a text-on-action token. A finding built from it can say what to write, not
|
||||||
|
merely what not to.
|
||||||
|
|
||||||
|
It must be DECLARED rather than derived. `#fff` and Parchment `#E8E4D8` are
|
||||||
|
different colours, so no value-matching rule could ever have connected them —
|
||||||
|
which is exactly why the prohibition felt unrepresentable until it was turned
|
||||||
|
around.
|
||||||
|
|
||||||
|
Consumed by the source lint that reads component CSS, not by the drift panel:
|
||||||
|
these literals are in the components, which the panel cannot see.
|
||||||
|
|
||||||
|
NOT NULL with a `'[]'` default, matching `value_by_mode` — a nullable JSONB
|
||||||
|
column has two empty states and every reader has to test for both.
|
||||||
|
|
||||||
|
Downgrade drops the column; the declarations are lost, which costs the lint its
|
||||||
|
input and nothing else.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0073"
|
||||||
|
down_revision = "0072"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"design_tokens",
|
||||||
|
sa.Column(
|
||||||
|
"supersedes", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("design_tokens", "supersedes")
|
||||||
@@ -26,6 +26,13 @@ export interface DesignToken {
|
|||||||
value_by_mode: Record<string, string>;
|
value_by_mode: Record<string, string>;
|
||||||
group_name: string | null;
|
group_name: string | null;
|
||||||
purpose: string | null;
|
purpose: string | null;
|
||||||
|
/** Literal values this token should be used INSTEAD OF, e.g. ["#fff"].
|
||||||
|
*
|
||||||
|
* How a design system records what a prohibition was trying to say: not
|
||||||
|
* "white is banned" but "write this token instead". Declared rather than
|
||||||
|
* inferred, because a superseded literal and the token's own value are
|
||||||
|
* usually different values and nothing could connect them by matching. */
|
||||||
|
supersedes: string[];
|
||||||
order_index: number;
|
order_index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +56,7 @@ export interface ResolvedToken {
|
|||||||
name: string;
|
name: string;
|
||||||
group_name: string | null;
|
group_name: string | null;
|
||||||
purpose: string | null;
|
purpose: string | null;
|
||||||
|
supersedes: string[];
|
||||||
order_index: number;
|
order_index: number;
|
||||||
value_by_mode: Record<string, string>;
|
value_by_mode: Record<string, string>;
|
||||||
origin_by_mode: Record<string, number>;
|
origin_by_mode: Record<string, number>;
|
||||||
@@ -93,6 +101,7 @@ export const createDesignToken = (
|
|||||||
value_by_mode?: Record<string, string>;
|
value_by_mode?: Record<string, string>;
|
||||||
group_name?: string | null;
|
group_name?: string | null;
|
||||||
purpose?: string | null;
|
purpose?: string | null;
|
||||||
|
supersedes?: string[];
|
||||||
order_index?: number;
|
order_index?: number;
|
||||||
},
|
},
|
||||||
) => apiPost<DesignToken>(`/api/design-systems/${designSystemId}/tokens`, body);
|
) => apiPost<DesignToken>(`/api/design-systems/${designSystemId}/tokens`, body);
|
||||||
|
|||||||
@@ -245,6 +245,10 @@ const tokenGroup = ref("");
|
|||||||
const tokenPurpose = ref("");
|
const tokenPurpose = ref("");
|
||||||
/** Mode/value pairs, edited as a list so a token can carry any number of modes. */
|
/** Mode/value pairs, edited as a list so a token can carry any number of modes. */
|
||||||
const tokenModes = ref<{ mode: string; value: string }[]>([{ mode: "base", value: "" }]);
|
const tokenModes = ref<{ mode: string; value: string }[]>([{ mode: "base", value: "" }]);
|
||||||
|
/** Literals this token should be written instead of, comma-separated in the
|
||||||
|
* form. Kept as one text field rather than a repeater: it is a short list of
|
||||||
|
* literals, and a row-per-value UI would cost more than it explains. */
|
||||||
|
const tokenSupersedes = ref("");
|
||||||
const savingToken = ref(false);
|
const savingToken = ref(false);
|
||||||
const editingTokenId = ref<number | null>(null);
|
const editingTokenId = ref<number | null>(null);
|
||||||
|
|
||||||
@@ -253,6 +257,7 @@ function resetTokenForm() {
|
|||||||
tokenGroup.value = "";
|
tokenGroup.value = "";
|
||||||
tokenPurpose.value = "";
|
tokenPurpose.value = "";
|
||||||
tokenModes.value = [{ mode: "base", value: "" }];
|
tokenModes.value = [{ mode: "base", value: "" }];
|
||||||
|
tokenSupersedes.value = "";
|
||||||
editingTokenId.value = null;
|
editingTokenId.value = null;
|
||||||
showAddToken.value = false;
|
showAddToken.value = false;
|
||||||
}
|
}
|
||||||
@@ -262,6 +267,7 @@ function startEditToken(token: DesignToken) {
|
|||||||
tokenName.value = token.name;
|
tokenName.value = token.name;
|
||||||
tokenGroup.value = token.group_name ?? "";
|
tokenGroup.value = token.group_name ?? "";
|
||||||
tokenPurpose.value = token.purpose ?? "";
|
tokenPurpose.value = token.purpose ?? "";
|
||||||
|
tokenSupersedes.value = (token.supersedes ?? []).join(", ");
|
||||||
const entries = Object.entries(token.value_by_mode);
|
const entries = Object.entries(token.value_by_mode);
|
||||||
tokenModes.value = entries.length
|
tokenModes.value = entries.length
|
||||||
? entries.map(([mode, value]) => ({ mode, value }))
|
? entries.map(([mode, value]) => ({ mode, value }))
|
||||||
@@ -288,6 +294,10 @@ async function submitToken() {
|
|||||||
value_by_mode: collectModes(),
|
value_by_mode: collectModes(),
|
||||||
group_name: tokenGroup.value.trim() || null,
|
group_name: tokenGroup.value.trim() || null,
|
||||||
purpose: tokenPurpose.value.trim() || null,
|
purpose: tokenPurpose.value.trim() || null,
|
||||||
|
supersedes: tokenSupersedes.value
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean),
|
||||||
};
|
};
|
||||||
if (editingTokenId.value !== null) {
|
if (editingTokenId.value !== null) {
|
||||||
await updateDesignToken(editingTokenId.value, body);
|
await updateDesignToken(editingTokenId.value, body);
|
||||||
@@ -555,6 +565,20 @@ function isColourish(value: string): boolean {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="token-supersedes">Use instead of</label>
|
||||||
|
<input
|
||||||
|
id="token-supersedes" v-model="tokenSupersedes" class="input mono" type="text"
|
||||||
|
placeholder="#fff, #ffffff"
|
||||||
|
/>
|
||||||
|
<p class="field-hint">
|
||||||
|
Literal values that should be written as this token instead —
|
||||||
|
comma separated. This is where a rule like "pure white is never
|
||||||
|
text" belongs: not as a prohibition, but as the thing to write
|
||||||
|
in its place. Checked by the source lint, not by this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset class="modes">
|
<fieldset class="modes">
|
||||||
<legend class="field-label">Values by mode</legend>
|
<legend class="field-label">Values by mode</legend>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
@@ -613,7 +637,12 @@ function isColourish(value: string): boolean {
|
|||||||
<code>{{ value }}</code>
|
<code>{{ value }}</code>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="token-meta">{{ token.purpose || token.group_name || "" }}</span>
|
<span class="token-meta">
|
||||||
|
{{ token.purpose || token.group_name || "" }}
|
||||||
|
<span v-if="token.supersedes.length" class="supersedes">
|
||||||
|
instead of {{ token.supersedes.join(", ") }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<span class="token-actions">
|
<span class="token-actions">
|
||||||
<button class="btn-ghost btn-small" @click="startEditToken(token)">Edit</button>
|
<button class="btn-ghost btn-small" @click="startEditToken(token)">Edit</button>
|
||||||
<button class="btn-ghost btn-small" @click="removeToken(token)">
|
<button class="btn-ghost btn-small" @click="removeToken(token)">
|
||||||
@@ -647,6 +676,9 @@ function isColourish(value: string): boolean {
|
|||||||
<div class="resolved-head">
|
<div class="resolved-head">
|
||||||
<code class="token-name">{{ token.name }}</code>
|
<code class="token-name">{{ token.name }}</code>
|
||||||
<span v-if="token.purpose" class="token-meta">{{ token.purpose }}</span>
|
<span v-if="token.purpose" class="token-meta">{{ token.purpose }}</span>
|
||||||
|
<span v-if="token.supersedes.length" class="supersedes">
|
||||||
|
instead of {{ token.supersedes.join(", ") }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- One badge only when every mode agrees on its source. -->
|
<!-- One badge only when every mode agrees on its source. -->
|
||||||
@@ -1034,6 +1066,12 @@ function isColourish(value: string): boolean {
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.supersedes {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.mode-chip {
|
.mode-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ async def create_design_token(
|
|||||||
value_by_mode: dict | None = None,
|
value_by_mode: dict | None = None,
|
||||||
group_name: str = "",
|
group_name: str = "",
|
||||||
purpose: str = "",
|
purpose: str = "",
|
||||||
|
supersedes: list | None = None,
|
||||||
order_index: int = 0,
|
order_index: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Add a token to a design system.
|
"""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
|
group_name: Free-text grouping — "surface", "text", "radius", whatever
|
||||||
this system's own vocabulary is.
|
this system's own vocabulary is.
|
||||||
purpose: What the token is for, e.g. "page bg, deepest surface".
|
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.
|
order_index: Display position within its group.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
@@ -172,6 +179,7 @@ async def create_design_token(
|
|||||||
value_by_mode=value_by_mode,
|
value_by_mode=value_by_mode,
|
||||||
group_name=group_name or None,
|
group_name=group_name or None,
|
||||||
purpose=purpose or None,
|
purpose=purpose or None,
|
||||||
|
supersedes=supersedes,
|
||||||
order_index=order_index,
|
order_index=order_index,
|
||||||
)
|
)
|
||||||
if token is None:
|
if token is None:
|
||||||
@@ -192,12 +200,14 @@ async def update_design_token(
|
|||||||
value_by_mode: dict | None = None,
|
value_by_mode: dict | None = None,
|
||||||
group_name: str = "",
|
group_name: str = "",
|
||||||
purpose: str = "",
|
purpose: str = "",
|
||||||
|
supersedes: list | None = None,
|
||||||
order_index: int = -1,
|
order_index: int = -1,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a token. Empty/None args leave a field unchanged.
|
"""Update a token. Empty/None args leave a field unchanged.
|
||||||
|
|
||||||
`value_by_mode` REPLACES the whole map rather than merging into it, so send
|
`value_by_mode` and `supersedes` REPLACE their whole value rather than
|
||||||
every mode you want the token to keep.
|
merging into it, so send every entry you want the token to keep. Pass `[]`
|
||||||
|
to clear `supersedes` entirely.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
@@ -209,6 +219,10 @@ async def update_design_token(
|
|||||||
fields["group_name"] = group_name
|
fields["group_name"] = group_name
|
||||||
if purpose:
|
if purpose:
|
||||||
fields["purpose"] = 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:
|
if order_index >= 0:
|
||||||
fields["order_index"] = order_index
|
fields["order_index"] = order_index
|
||||||
token = await ds_svc.update_token(uid, token_id, **fields)
|
token = await ds_svc.update_token(uid, token_id, **fields)
|
||||||
|
|||||||
@@ -109,6 +109,24 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
# vocabulary, and a whitelist would bake one install's kit into the schema.
|
# vocabulary, and a whitelist would bake one install's kit into the schema.
|
||||||
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
purpose: 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")
|
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
@@ -119,6 +137,7 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"value_by_mode": self.value_by_mode or {},
|
"value_by_mode": self.value_by_mode or {},
|
||||||
"group_name": self.group_name,
|
"group_name": self.group_name,
|
||||||
"purpose": self.purpose,
|
"purpose": self.purpose,
|
||||||
|
"supersedes": self.supersedes or [],
|
||||||
"order_index": self.order_index,
|
"order_index": self.order_index,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ async def create_design_token(design_system_id: int):
|
|||||||
value_by_mode=data.get("value_by_mode"),
|
value_by_mode=data.get("value_by_mode"),
|
||||||
group_name=data.get("group_name") or None,
|
group_name=data.get("group_name") or None,
|
||||||
purpose=data.get("purpose") or None,
|
purpose=data.get("purpose") or None,
|
||||||
|
supersedes=data.get("supersedes"),
|
||||||
order_index=data.get("order_index") or 0,
|
order_index=data.get("order_index") or 0,
|
||||||
)
|
)
|
||||||
if token is None:
|
if token is None:
|
||||||
@@ -153,7 +154,10 @@ async def update_design_token(token_id: int):
|
|||||||
data = await request.get_json() or {}
|
data = await request.get_json() or {}
|
||||||
fields = {
|
fields = {
|
||||||
k: v for k, v in data.items()
|
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)
|
token = await ds_svc.update_token(_uid(), token_id, **fields)
|
||||||
if token is None:
|
if token is None:
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class ResolvedToken:
|
|||||||
contributions: dict[str, tuple[Contribution, ...]]
|
contributions: dict[str, tuple[Contribution, ...]]
|
||||||
group_name: str | None
|
group_name: str | None
|
||||||
purpose: str | None
|
purpose: str | None
|
||||||
|
supersedes: tuple[str, ...]
|
||||||
order_index: int
|
order_index: int
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -132,6 +133,7 @@ class ResolvedToken:
|
|||||||
"name": self.name,
|
"name": self.name,
|
||||||
"group_name": self.group_name,
|
"group_name": self.group_name,
|
||||||
"purpose": self.purpose,
|
"purpose": self.purpose,
|
||||||
|
"supersedes": list(self.supersedes),
|
||||||
"order_index": self.order_index,
|
"order_index": self.order_index,
|
||||||
"value_by_mode": self.value_by_mode,
|
"value_by_mode": self.value_by_mode,
|
||||||
"origin_by_mode": self.origin_by_mode,
|
"origin_by_mode": self.origin_by_mode,
|
||||||
@@ -206,7 +208,11 @@ def resolve_tokens(
|
|||||||
)
|
)
|
||||||
|
|
||||||
meta = metadata.setdefault(
|
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"):
|
for field in ("group_name", "purpose"):
|
||||||
if meta[field] is None:
|
if meta[field] is None:
|
||||||
@@ -218,6 +224,13 @@ def resolve_tokens(
|
|||||||
# change that only touched a colour.
|
# change that only touched a colour.
|
||||||
if not meta["order_index"]:
|
if not meta["order_index"]:
|
||||||
meta["order_index"] = getattr(token, "order_index", 0) or None
|
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 = [
|
resolved = [
|
||||||
ResolvedToken(
|
ResolvedToken(
|
||||||
@@ -227,6 +240,7 @@ def resolve_tokens(
|
|||||||
},
|
},
|
||||||
group_name=metadata[name]["group_name"],
|
group_name=metadata[name]["group_name"],
|
||||||
purpose=metadata[name]["purpose"],
|
purpose=metadata[name]["purpose"],
|
||||||
|
supersedes=metadata[name]["supersedes"] or (),
|
||||||
order_index=metadata[name]["order_index"] or 0,
|
order_index=metadata[name]["order_index"] or 0,
|
||||||
)
|
)
|
||||||
for name, per_mode in contributions.items()
|
for name, per_mode in contributions.items()
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ async def create_token(
|
|||||||
value_by_mode: dict | None = None,
|
value_by_mode: dict | None = None,
|
||||||
group_name: str | None = None,
|
group_name: str | None = None,
|
||||||
purpose: str | None = None,
|
purpose: str | None = None,
|
||||||
|
supersedes: list | None = None,
|
||||||
order_index: int = 0,
|
order_index: int = 0,
|
||||||
) -> DesignToken | None:
|
) -> DesignToken | None:
|
||||||
if not await access.can_write_design_system(user_id, design_system_id):
|
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 {},
|
value_by_mode=value_by_mode or {},
|
||||||
group_name=group_name,
|
group_name=group_name,
|
||||||
purpose=purpose,
|
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,
|
order_index=order_index,
|
||||||
)
|
)
|
||||||
session.add(token)
|
session.add(token)
|
||||||
@@ -270,7 +275,10 @@ async def resolve_design_system(
|
|||||||
async def update_token(
|
async def update_token(
|
||||||
user_id: int, token_id: int, **fields: object
|
user_id: int, token_id: int, **fields: object
|
||||||
) -> DesignToken | None:
|
) -> 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:
|
async with async_session() as session:
|
||||||
token = await session.get(DesignToken, token_id)
|
token = await session.get(DesignToken, token_id)
|
||||||
if token is None or token.deleted_at is not None:
|
if token is None or token.deleted_at is not None:
|
||||||
|
|||||||
@@ -102,10 +102,11 @@ def test_the_guard_survives_a_hierarchy_that_is_already_corrupt():
|
|||||||
# because resolve_tokens is pure and duck-typed — which is the whole reason it
|
# because resolve_tokens is pure and duck-typed — which is the whole reason it
|
||||||
# lives here rather than inside the service.
|
# lives here rather than inside the service.
|
||||||
|
|
||||||
def _token(name, value_by_mode, group_name=None, purpose=None, order_index=0):
|
def _token(name, value_by_mode, group_name=None, purpose=None, order_index=0,
|
||||||
|
supersedes=None):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
name=name, value_by_mode=value_by_mode, group_name=group_name,
|
name=name, value_by_mode=value_by_mode, group_name=group_name,
|
||||||
purpose=purpose, order_index=order_index,
|
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -310,3 +311,79 @@ def test_resolution_terminates_on_a_corrupt_hierarchy():
|
|||||||
assert set(resolved) == {"--fs-a", "--fs-b"}
|
assert set(resolved) == {"--fs-a", "--fs-b"}
|
||||||
# Each system contributes exactly once, not endlessly.
|
# Each system contributes exactly once, not endlessly.
|
||||||
assert len(resolved["--fs-a"].contributions["base"]) == 1
|
assert len(resolved["--fs-a"].contributions["base"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- supersedes -------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The declaration that replaces a prohibition. A design system stores what things
|
||||||
|
# ARE, so "pure white is never text" has no row — but "write this token instead
|
||||||
|
# of #fff" does, and it is the same fact stated forwards.
|
||||||
|
|
||||||
|
def test_supersedes_is_inherited_when_the_override_is_silent_about_it():
|
||||||
|
"""LOAD-BEARING. 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."""
|
||||||
|
resolved = _by_name(resolve_tokens(
|
||||||
|
APP, PARENTS,
|
||||||
|
{
|
||||||
|
FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"])],
|
||||||
|
APP: [_token("--fs-text", {"base": "#f0ece0"})],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
text = resolved["--fs-text"]
|
||||||
|
assert text.supersedes == ("#fff", "#ffffff")
|
||||||
|
assert text.value_by_mode == {"base": "#f0ece0"} # the value still overrode
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_override_that_states_its_own_supersedes_replaces_the_list():
|
||||||
|
"""Whole-list replacement, not a merge — an app that means "only #fff" must
|
||||||
|
be able to say so without inheriting entries it deliberately dropped."""
|
||||||
|
resolved = _by_name(resolve_tokens(
|
||||||
|
APP, PARENTS,
|
||||||
|
{
|
||||||
|
FAMILY: [_token("--fs-text", {"base": "a"}, supersedes=["#fff", "#ffffff"])],
|
||||||
|
APP: [_token("--fs-text", {"base": "b"}, supersedes=["#fff"])],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
assert resolved["--fs-text"].supersedes == ("#fff",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_token_that_supersedes_nothing_resolves_to_an_empty_tuple():
|
||||||
|
"""Most tokens replace nothing. That has to be an empty sequence rather than
|
||||||
|
None, so no caller has to test for two kinds of nothing."""
|
||||||
|
resolved = _by_name(resolve_tokens(
|
||||||
|
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]},
|
||||||
|
))
|
||||||
|
assert resolved["--fs-radius-md"].supersedes == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_supersedes_survives_serialisation_as_a_list():
|
||||||
|
resolved = _by_name(resolve_tokens(
|
||||||
|
FAMILY, PARENTS,
|
||||||
|
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
|
||||||
|
))
|
||||||
|
assert resolved["--fs-text"].to_dict()["supersedes"] == ["#fff"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_superseded_literal_need_not_match_the_tokens_own_value():
|
||||||
|
"""The whole reason this is DECLARED rather than derived. `#fff` and
|
||||||
|
Parchment are different colours, so a value-matching rule could never have
|
||||||
|
connected them — which is why the prohibition looked unrepresentable until
|
||||||
|
it was turned around."""
|
||||||
|
resolved = _by_name(resolve_tokens(
|
||||||
|
FAMILY, PARENTS,
|
||||||
|
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
|
||||||
|
))
|
||||||
|
text = resolved["--fs-text"]
|
||||||
|
assert text.value_by_mode["base"] not in text.supersedes
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_without_a_supersedes_attribute_at_all_still_resolve():
|
||||||
|
"""Duck-typed input: a caller passing rows from before the column existed
|
||||||
|
must not crash the cascade."""
|
||||||
|
legacy = SimpleNamespace(
|
||||||
|
name="--fs-x", value_by_mode={"base": "a"},
|
||||||
|
group_name=None, purpose=None, order_index=0,
|
||||||
|
)
|
||||||
|
resolved = _by_name(resolve_tokens(FAMILY, PARENTS, {FAMILY: [legacy]}))
|
||||||
|
assert resolved["--fs-x"].supersedes == ()
|
||||||
|
|||||||
@@ -189,3 +189,24 @@ async def test_resolve_returns_serialised_tokens_with_their_provenance():
|
|||||||
{"system_id": 2, "value": "#5b4a8a"},
|
{"system_id": 2, "value": "#5b4a8a"},
|
||||||
{"system_id": 1, "value": "#6b2118"},
|
{"system_id": 1, "value": "#6b2118"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_token_can_clear_supersedes_with_an_empty_list():
|
||||||
|
"""`[]` means "this token replaces nothing after all" — a real edit. Guarded
|
||||||
|
on `is not None` so it isn't swallowed as "unchanged", the same trap #2077
|
||||||
|
recorded for update_snippet."""
|
||||||
|
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||||
|
svc.update_token = AsyncMock(return_value=_fake_token())
|
||||||
|
from scribe.mcp.tools.design_systems import update_design_token
|
||||||
|
await update_design_token(token_id=9, supersedes=[])
|
||||||
|
assert svc.update_token.await_args.kwargs["supersedes"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_token_leaves_supersedes_alone_when_omitted():
|
||||||
|
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||||
|
svc.update_token = AsyncMock(return_value=_fake_token())
|
||||||
|
from scribe.mcp.tools.design_systems import update_design_token
|
||||||
|
await update_design_token(token_id=9, purpose="text on action surfaces")
|
||||||
|
assert "supersedes" not in svc.update_token.await_args.kwargs
|
||||||
|
|||||||
@@ -236,3 +236,41 @@ async def test_resolve_scopes_the_hierarchy_to_the_systems_OWNER_not_the_caller(
|
|||||||
|
|
||||||
assert result == [] # empty chain, not None
|
assert result == [] # empty chain, not None
|
||||||
assert parent_map.await_args.args[1] == owner # NOT `caller`
|
assert parent_map.await_args.args[1] == owner # NOT `caller`
|
||||||
|
|
||||||
|
|
||||||
|
# --- supersedes (step 6's reframe) ------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_token_supersedes_defaults_to_an_empty_list_not_json_null():
|
||||||
|
"""Same NOT NULL reasoning as value_by_mode: absence gets one spelling."""
|
||||||
|
mock_session = _make_mock_session()
|
||||||
|
captured = {}
|
||||||
|
mock_session.add = MagicMock(
|
||||||
|
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
|
||||||
|
)
|
||||||
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||||
|
patch("scribe.services.design_systems.access") as acc:
|
||||||
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||||
|
mock_cls.return_value = mock_session
|
||||||
|
from scribe.services.design_systems import create_token
|
||||||
|
await create_token(user_id=1, design_system_id=3, name="--fs-x", supersedes=None)
|
||||||
|
assert captured["supersedes"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_token_records_the_literals_it_replaces():
|
||||||
|
mock_session = _make_mock_session()
|
||||||
|
captured = {}
|
||||||
|
mock_session.add = MagicMock(
|
||||||
|
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
|
||||||
|
)
|
||||||
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||||
|
patch("scribe.services.design_systems.access") as acc:
|
||||||
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||||
|
mock_cls.return_value = mock_session
|
||||||
|
from scribe.services.design_systems import create_token
|
||||||
|
await create_token(
|
||||||
|
user_id=1, design_system_id=3, name="--fs-text-on-action",
|
||||||
|
value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"],
|
||||||
|
)
|
||||||
|
assert captured["supersedes"] == ["#fff", "#ffffff"]
|
||||||
|
|||||||
Reference in New Issue
Block a user