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(", ") }} + +