CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 42s
Operator: "build in a way to support formulas like this so that the colors shift
as expected and have less to clean up when testing color changes."
The storage needed no change at all, which is the good news. A formula is just a
value:
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent)
It passes the value sanitiser untouched (verified, and now pinned by a test —
had `color-mix(... var(...) ...)` been rejected as unsafe, derivation would have
needed a storage shape of its own), and the browser resolves the `var()` at use
time. Change `--fs-accent` and everything derived from it shifts.
**One declaration covers every mode**, and that is the "less to clean up" part.
A derived token written once in the base layer follows its source through dark
mode automatically, because `var()` resolves where it is USED rather than where
it is written. A stored computed literal would need a row per mode and would
silently stop tracking the source the moment the source changed — the whole
problem this avoids.
What derivation DID need is the check. A formula pointing at a token that does
not exist is invalid-at-computed-value-time: the browser drops the declaration
outright and the token has no value. No error, no warning, nothing in the
toolchain notices — the same family as `--color-accent`, `_parent_map`, and the
scripted edit whose anchor matched nothing.
So `derivation_report` returns three things alongside the sheet: which tokens are
computed and from what, which formulas point at nothing, and which derive from
each other in a loop. CSS resolves a loop to nothing rather than hanging, so the
cycle check is about telling the operator, not protecting the renderer — but a
token that quietly resolves to nothing is exactly what is worth being told.
A self-reference with a fallback (`var(--fs-x, 8px)`) is deliberately not a
dependency; counting it would report every such token as a one-node loop.
The UI leads with broken formulas, then loops, then the healthy derived set —
the first two are unambiguously wrong, where a duplicate value is a judgement
call.
189 lines
6.4 KiB
TypeScript
189 lines
6.4 KiB
TypeScript
/**
|
|
* Design systems — the stylesheet held as records (milestone #254).
|
|
*
|
|
* A design system is a named set of tokens with an optional parent. A system
|
|
* with no parent is a "family"; one with a parent holds ONLY what it changes,
|
|
* so "what does this app alter?" is a plain list rather than a diff.
|
|
*/
|
|
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
|
|
|
|
export interface DesignSystem {
|
|
id: number;
|
|
owner_user_id: number;
|
|
title: string;
|
|
description: string;
|
|
/** Narrative a token table cannot hold: aesthetic, voice, what's out of scope. */
|
|
guidance: string;
|
|
parent_id: number | null;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
}
|
|
|
|
/** A token as STORED — one system's own row for it. */
|
|
export interface DesignToken {
|
|
id: number;
|
|
design_system_id: number;
|
|
name: string;
|
|
/** Values keyed by mode. `base` applies when no mode is more specific. */
|
|
value_by_mode: Record<string, string>;
|
|
group_name: string | null;
|
|
purpose: string | null;
|
|
/** WHY it is this value — distinct from `purpose`, which is what it is FOR. */
|
|
rationale: 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;
|
|
}
|
|
|
|
export interface Contribution {
|
|
system_id: number;
|
|
value: string;
|
|
}
|
|
|
|
/**
|
|
* A token after the cascade.
|
|
*
|
|
* `contributions` is every system that offered a value, per mode, DEEPEST
|
|
* FIRST — entry 0 won and the rest were shadowed. `value_by_mode` and
|
|
* `origin_by_mode` are the winners, provided so the client never has to derive
|
|
* them (and so it cannot derive them differently).
|
|
*
|
|
* Provenance is per MODE because overriding is: a system can own `base` and
|
|
* inherit `dark` at the same time.
|
|
*/
|
|
export interface ResolvedToken {
|
|
name: string;
|
|
group_name: string | null;
|
|
purpose: string | null;
|
|
rationale: string | null;
|
|
supersedes: string[];
|
|
order_index: number;
|
|
value_by_mode: Record<string, string>;
|
|
origin_by_mode: Record<string, number>;
|
|
contributions: Record<string, Contribution[]>;
|
|
}
|
|
|
|
export const fetchDesignSystems = () =>
|
|
apiGet<{ design_systems: DesignSystem[] }>("/api/design-systems");
|
|
|
|
export const fetchDesignSystem = (id: number) =>
|
|
apiGet<DesignSystem>(`/api/design-systems/${id}`);
|
|
|
|
export const createDesignSystem = (body: {
|
|
title: string;
|
|
description?: string;
|
|
guidance?: string;
|
|
parent_id?: number | null;
|
|
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
|
|
|
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
|
export const updateDesignSystem = (
|
|
id: number,
|
|
body: {
|
|
title?: string;
|
|
description?: string;
|
|
guidance?: string;
|
|
parent_id?: number | null;
|
|
},
|
|
) => apiPatch<DesignSystem>(`/api/design-systems/${id}`, body);
|
|
|
|
export const deleteDesignSystem = (id: number) =>
|
|
apiDelete(`/api/design-systems/${id}`);
|
|
|
|
/** The EFFECTIVE set: everything inherited, with this system's on top. */
|
|
export const fetchResolvedTokens = (id: number) =>
|
|
apiGet<{ design_system_id: number; tokens: ResolvedToken[] }>(
|
|
`/api/design-systems/${id}/resolved`,
|
|
);
|
|
|
|
/** This system's OWN tokens — its override set. */
|
|
export const fetchDesignTokens = (id: number) =>
|
|
apiGet<{ tokens: DesignToken[] }>(`/api/design-systems/${id}/tokens`);
|
|
|
|
export const createDesignToken = (
|
|
designSystemId: number,
|
|
body: {
|
|
name: string;
|
|
value_by_mode?: Record<string, string>;
|
|
group_name?: string | null;
|
|
purpose?: string | null;
|
|
rationale?: string | null;
|
|
supersedes?: string[];
|
|
order_index?: number;
|
|
},
|
|
) => apiPost<DesignToken>(`/api/design-systems/${designSystemId}/tokens`, body);
|
|
|
|
export const updateDesignToken = (
|
|
tokenId: number,
|
|
body: Partial<Omit<DesignToken, "id" | "design_system_id">>,
|
|
) => apiPatch<DesignToken>(`/api/design-tokens/${tokenId}`, body);
|
|
|
|
export const deleteDesignToken = (tokenId: number) =>
|
|
apiDelete(`/api/design-tokens/${tokenId}`);
|
|
|
|
/** Point a project at a design system. `null` clears it. */
|
|
export const setProjectDesignSystem = (
|
|
projectId: number,
|
|
designSystemId: number | null,
|
|
) =>
|
|
apiPut<{ project_id: number; design_system_id: number | null }>(
|
|
`/api/projects/${projectId}/design-system`,
|
|
{ design_system_id: designSystemId },
|
|
);
|
|
|
|
export interface StylesheetResult {
|
|
design_system_id: number;
|
|
/** The master sheet: purpose tokens only, no element or class rules. */
|
|
css: string;
|
|
token_count: number;
|
|
/** Tokens the system names but has no value for yet. */
|
|
valueless: string[];
|
|
/** Values declared under more than one name — alias, or one idea twice. */
|
|
duplicates: Record<string, string[]>;
|
|
derivation: {
|
|
/** Tokens computed from others, mapped to what they're computed from. */
|
|
derived: Record<string, string[]>;
|
|
/** Formulas pointing at tokens that don't exist — the browser drops these. */
|
|
unknown_refs: Record<string, string[]>;
|
|
/** Derivation loops, which resolve to nothing for the same reason. */
|
|
cycles: string[][];
|
|
};
|
|
}
|
|
|
|
/** The master CSS sheet a design system generates.
|
|
*
|
|
* Purpose tokens only. Components (buttons, tables, input schemes) are
|
|
* snippets that reference these names, so a value is stated once and reused
|
|
* rather than restated per element. */
|
|
export const fetchStylesheet = (id: number) =>
|
|
apiGet<StylesheetResult>(`/api/design-systems/${id}/stylesheet`);
|
|
|
|
export interface SnippetFinding {
|
|
snippet_id: number;
|
|
title: string;
|
|
/** References that resolve against the sheet. */
|
|
used: string[];
|
|
/** `var(--x)` where the system has no `--x` — renders as nothing at all. */
|
|
unknown: string[];
|
|
/** Literals the sheet says to stop writing, paired with what to write. */
|
|
superseded_literals: { literal: string; use_instead: string }[];
|
|
/** Custom properties the snippet mints for itself instead of reusing. */
|
|
local_definitions: string[];
|
|
}
|
|
|
|
export interface SnippetCheck {
|
|
design_system_id: number;
|
|
checked: number;
|
|
/** Only snippets with something to act on; clean ones are omitted. */
|
|
findings: SnippetFinding[];
|
|
}
|
|
|
|
/** Which recorded snippets disagree with this design system's sheet. */
|
|
export const checkSnippets = (id: number) =>
|
|
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
|