CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Last piece of the architecture in #2296. The operator: "the prose doesn't have to live as one offs, there's a central system for managing it." Two fields, both free-form: design_systems.guidance the narrative a token table cannot hold — aesthetic, voice and tone, what is deliberately out of scope. design_tokens.rationale WHY a token is this value, which is a different question from `purpose` (what it is FOR). "Success equals Moss, aligned by design" is a rationale; "page bg, deepest surface" is a purpose. Rules carry the first routinely and a token row had nowhere to put it. Free-form rather than a column per category, deliberately. A schema with `voice`, `aesthetic` and `scope` columns would bake one rulebook's table of contents into every install (rule #115), leaving the next install three empty columns and nowhere for what it actually cares about. Both nullable: a design system with no prose at all is complete, not a draft. `rationale` cascades like `purpose` — deepest non-empty wins — so an app overriding a colour keeps the family's reasoning rather than blanking it. Same argument as `supersedes`: the override was about the value, not the meaning. In the generated sheet the inline comment prefers `purpose` and falls back to `rationale`, so a token carrying only the why still says something instead of rendering bare.
217 lines
7.1 KiB
TypeScript
217 lines
7.1 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 },
|
|
);
|
|
|
|
/** One token an import proposes, with the evidence for it. */
|
|
export interface ProposedToken {
|
|
name: string;
|
|
value_by_mode: Record<string, string>;
|
|
group_name: string | null;
|
|
purpose: string | null;
|
|
supersedes: string[];
|
|
source_rule_id: number | null;
|
|
source_rule_title: string;
|
|
source_context: string;
|
|
}
|
|
|
|
export interface ImportReport {
|
|
rulebook_id: number;
|
|
proposed: ProposedToken[];
|
|
created: DesignToken[];
|
|
/** Proposals whose name the system already defines. Never overwritten. */
|
|
skipped: string[];
|
|
}
|
|
|
|
/** Seed a design system from a rulebook that describes one in prose.
|
|
*
|
|
* Defaults to a PREVIEW: an import is a proposal, since rulebooks are written
|
|
* aspirationally and some of what they describe was never built. Pass
|
|
* `apply: true` to write. Existing token names are never overwritten, so a
|
|
* second run fills gaps and reports the rest. */
|
|
export const importFromRulebook = (
|
|
designSystemId: number,
|
|
rulebookId: number,
|
|
apply = false,
|
|
) =>
|
|
apiPost<ImportReport>(`/api/design-systems/${designSystemId}/import`, {
|
|
rulebook_id: rulebookId,
|
|
apply,
|
|
});
|
|
|
|
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[]>;
|
|
}
|
|
|
|
/** 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`);
|