feat(design-systems): the editing surface — overrides, effective set, provenance
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Canceled after 15s
CI & Build / Python tests (push) Canceled after 15s
CI & Build / integration (push) Canceled after 15s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Canceled after 15s
CI & Build / Python tests (push) Canceled after 15s
CI & Build / integration (push) Canceled after 15s
CI & Build / Build & push image (push) Canceled after 0s
Milestone #254 step 5 (#2294). /design-systems is the editable half of the surface /design already showed: that page is what the browser renders, this one is the record that ought to decide it. Each links to the other. The layout follows the model rather than decorating it. Two token lists, and they are deliberately different questions: Overrides — the system's own rows. Short by design, and EMPTY is the correct state for an app that hasn't departed from its family yet, so that empty state says so rather than looking unfinished. Effective — what it resolves to with inheritance applied, each row labelled with where its value came from. Provenance renders PER MODE when the modes disagree. A system can own `base` and inherit `dark` at once — that is the case the value column is a map for — and a single badge per row would have to lie about one of them. Rows whose modes agree (the common case) keep the single badge. "Defined here" and "overridden here" are distinct labels. Introducing a token and shadowing an ancestor's are different acts, and `is_overridden_in` is already false for the first. The parent picker filters out the selected system's descendants. The server refuses those anyway with a message naming the loop — but a refusal you cannot trigger beats a refusal explained well. Cycles that arrive some other way still render a truncated chain rather than freezing the tab: the client keeps the same defensive visited-set the server has. Three drift bugs caught while writing the styles, all of the shape this milestone exists to surface: - `--color-accent` does not exist. I had used it for every focus ring and active border; it would have rendered as nothing at all, silently. The brand token is `--color-primary`. - focus rings are ALREADY global in theme.css (`button:focus-visible` et al). My per-element rules would have overridden the house ring with a different one — the exact "bypassed abstraction" shape from #253. - every existing `.btn-primary` copy uses `color: #fff`, which is rule 52's prohibition and 67 live violations (#2275). This one uses Parchment and says why in a comment, rather than becoming the 68th. Also wires the project pointer into ProjectView's details panel, hidden entirely when no design systems exist (rule #115 — that is the ordinary state, not a degraded one) and saved through its own PUT, since clearing it is a real outcome rather than an omission.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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; 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;
|
||||
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 },
|
||||
);
|
||||
Reference in New Issue
Block a user