From 3da40abcb8b1f675dda17426e5e8cef55f2461a2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 30 Jul 2026 21:16:45 -0400 Subject: [PATCH] feat(design-systems): declare what to write instead, rather than what not to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../versions/0073_design_token_supersedes.py | 55 +++++++++++++ frontend/src/api/designSystems.ts | 9 +++ frontend/src/views/DesignSystemsView.vue | 40 ++++++++- src/scribe/mcp/tools/design_systems.py | 18 ++++- src/scribe/models/design_system.py | 19 +++++ src/scribe/routes/design_systems.py | 6 +- src/scribe/services/design_cascade.py | 16 +++- src/scribe/services/design_systems.py | 10 ++- tests/test_design_cascade.py | 81 ++++++++++++++++++- tests/test_mcp_tool_design_systems.py | 21 +++++ tests/test_services_design_systems.py | 38 +++++++++ 11 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 alembic/versions/0073_design_token_supersedes.py diff --git a/alembic/versions/0073_design_token_supersedes.py b/alembic/versions/0073_design_token_supersedes.py new file mode 100644 index 0000000..5596bb2 --- /dev/null +++ b/alembic/versions/0073_design_token_supersedes.py @@ -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") diff --git a/frontend/src/api/designSystems.ts b/frontend/src/api/designSystems.ts index af057f4..64830ba 100644 --- a/frontend/src/api/designSystems.ts +++ b/frontend/src/api/designSystems.ts @@ -26,6 +26,13 @@ export interface DesignToken { value_by_mode: Record; group_name: 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; } @@ -49,6 +56,7 @@ export interface ResolvedToken { name: string; group_name: string | null; purpose: string | null; + supersedes: string[]; order_index: number; value_by_mode: Record; origin_by_mode: Record; @@ -93,6 +101,7 @@ export const createDesignToken = ( value_by_mode?: Record; group_name?: string | null; purpose?: string | null; + supersedes?: string[]; order_index?: number; }, ) => apiPost(`/api/design-systems/${designSystemId}/tokens`, body); diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue index 0d02177..5c1aeee 100644 --- a/frontend/src/views/DesignSystemsView.vue +++ b/frontend/src/views/DesignSystemsView.vue @@ -245,6 +245,10 @@ const tokenGroup = ref(""); const tokenPurpose = ref(""); /** 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: "" }]); +/** 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 editingTokenId = ref(null); @@ -253,6 +257,7 @@ function resetTokenForm() { tokenGroup.value = ""; tokenPurpose.value = ""; tokenModes.value = [{ mode: "base", value: "" }]; + tokenSupersedes.value = ""; editingTokenId.value = null; showAddToken.value = false; } @@ -262,6 +267,7 @@ function startEditToken(token: DesignToken) { tokenName.value = token.name; tokenGroup.value = token.group_name ?? ""; tokenPurpose.value = token.purpose ?? ""; + tokenSupersedes.value = (token.supersedes ?? []).join(", "); const entries = Object.entries(token.value_by_mode); tokenModes.value = entries.length ? entries.map(([mode, value]) => ({ mode, value })) @@ -288,6 +294,10 @@ async function submitToken() { value_by_mode: collectModes(), group_name: tokenGroup.value.trim() || null, purpose: tokenPurpose.value.trim() || null, + supersedes: tokenSupersedes.value + .split(",") + .map((v) => v.trim()) + .filter(Boolean), }; if (editingTokenId.value !== null) { await updateDesignToken(editingTokenId.value, body); @@ -555,6 +565,20 @@ function isColourish(value: string): boolean { +
+ + +

+ 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. +

+
+
Values by mode

@@ -613,7 +637,12 @@ function isColourish(value: string): boolean { {{ value }} - {{ token.purpose || token.group_name || "" }} + + {{ token.purpose || token.group_name || "" }} + + instead of {{ token.supersedes.join(", ") }} + +