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

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:
2026-07-30 17:17:43 -04:00
parent 143b968c5d
commit 0937b1761e
5 changed files with 1313 additions and 5 deletions
+116
View File
@@ -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 },
);
+1
View File
@@ -106,6 +106,7 @@ router.afterEach(() => {
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/design" class="nav-link">Design</router-link>
<router-link to="/design-systems" class="nav-link">Design systems</router-link>
<router-link to="/trash" class="nav-link">Trash</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link>
<div class="mobile-divider"></div>
+7
View File
@@ -116,6 +116,13 @@ const router = createRouter({
name: "design",
component: () => import("@/views/DesignView.vue"),
},
{
// The editable half of the same surface: /design is what the browser
// renders, /design-systems is the record that ought to decide it.
path: "/design-systems",
name: "design-systems",
component: () => import("@/views/DesignSystemsView.vue"),
},
{
path: "/tasks",
redirect: "/",
File diff suppressed because it is too large Load Diff
+48 -5
View File
@@ -9,6 +9,11 @@ import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import {
fetchDesignSystems,
setProjectDesignSystem,
type DesignSystem,
} from "@/api/designSystems";
import {
LayoutGrid,
Clock,
@@ -41,6 +46,7 @@ interface Project {
goal: string | null;
status: "active" | "paused" | "completed" | "archived";
color: string | null;
design_system_id: number | null;
permission?: string;
created_at: string;
updated_at: string;
@@ -69,6 +75,12 @@ const toast = useToastStore();
const tasksStore = useTasksStore();
const project = ref<Project | null>(null);
// Design system the project is styled from. Loaded separately because an
// install with none is the ordinary case (rule #115) and the picker simply
// doesn't render — a failed fetch must not take the project page with it.
const designSystems = ref<DesignSystem[]>([]);
const editDesignSystemId = ref<number | null>(null);
const loading = ref(false);
const showStartPlanning = ref(false);
@@ -175,6 +187,7 @@ async function loadProject() {
editDescription.value = data.description ?? "";
editGoal.value = data.goal ?? "";
editStatus.value = data.status;
editDesignSystemId.value = data.design_system_id ?? null;
editDirty.value = false;
milestones.value = data.summary?.milestone_summary ?? [];
autoCollapseCompleted(milestones.value);
@@ -336,8 +349,20 @@ onMounted(async () => {
await loadProject();
loadTasks();
loadNotes();
loadDesignSystems();
});
/** Populate the design-system picker. Swallows failure on purpose: with no
* design systems the picker doesn't render at all, which is the ordinary state
* for most installs — so this must never be able to break the project page. */
async function loadDesignSystems() {
try {
designSystems.value = (await fetchDesignSystems()).design_systems;
} catch {
designSystems.value = [];
}
}
watch(projectId, async () => {
await loadProject();
loadTasks();
@@ -345,28 +370,39 @@ watch(projectId, async () => {
});
watch(
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value],
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value, editDesignSystemId.value],
() => {
if (!project.value) return;
editDirty.value =
editTitle.value !== project.value.title ||
editDescription.value !== (project.value.description ?? "") ||
editGoal.value !== (project.value.goal ?? "") ||
editStatus.value !== project.value.status;
editStatus.value !== project.value.status ||
editDesignSystemId.value !== (project.value.design_system_id ?? null);
}
);
async function saveProject() {
if (!project.value || saving.value) return;
// Bound once rather than re-read: the checks below straddle two awaits, and
// `project.value` is a ref whose narrowing doesn't survive them.
const current = project.value;
if (!current || saving.value) return;
saving.value = true;
try {
const updated = await apiPatch<Project>(`/api/projects/${project.value.id}`, {
const updated = await apiPatch<Project>(`/api/projects/${current.id}`, {
title: editTitle.value.trim(),
description: editDescription.value.trim() || null,
goal: editGoal.value.trim() || null,
status: editStatus.value,
});
project.value = { ...project.value, ...updated };
// The design-system pointer is its own endpoint (PUT, because clearing it
// is a real outcome rather than an omission), so it saves separately —
// only when it actually changed, to keep the common save at one request.
if (editDesignSystemId.value !== (current.design_system_id ?? null)) {
await setProjectDesignSystem(current.id, editDesignSystemId.value);
updated.design_system_id = editDesignSystemId.value;
}
project.value = { ...current, ...updated };
editDirty.value = false;
toast.show("Project saved");
} catch {
@@ -505,6 +541,13 @@ async function confirmDelete() {
<option value="archived">Archived</option>
</select>
</div>
<div v-if="designSystems.length" class="edit-field">
<label class="edit-label" for="project-design-system">Design system</label>
<select id="project-design-system" v-model="editDesignSystemId" class="edit-select">
<option :value="null">None</option>
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select>
</div>
<button class="btn-save-panel" @click="saveProject" :disabled="!editDirty || saving">
{{ saving ? "Saving..." : "Save Changes" }}
</button>