Design systems as records — the stylesheet Scribe holds, plus two live bug fixes #88

Merged
bvandeusen merged 16 commits from dev into main 2026-07-30 23:35:26 -04:00
5 changed files with 1313 additions and 5 deletions
Showing only changes of commit 0937b1761e - Show all commits
+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>