Design systems as records — the stylesheet Scribe holds, plus two live bug fixes #88
@@ -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