diff --git a/frontend/src/api/designSystems.ts b/frontend/src/api/designSystems.ts new file mode 100644 index 0000000..af057f4 --- /dev/null +++ b/frontend/src/api/designSystems.ts @@ -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; + 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; + origin_by_mode: Record; + contributions: Record; +} + +export const fetchDesignSystems = () => + apiGet<{ design_systems: DesignSystem[] }>("/api/design-systems"); + +export const fetchDesignSystem = (id: number) => + apiGet(`/api/design-systems/${id}`); + +export const createDesignSystem = (body: { + title: string; + description?: string; + parent_id?: number | null; +}) => apiPost("/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(`/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; + group_name?: string | null; + purpose?: string | null; + order_index?: number; + }, +) => apiPost(`/api/design-systems/${designSystemId}/tokens`, body); + +export const updateDesignToken = ( + tokenId: number, + body: Partial>, +) => apiPatch(`/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 }, + ); diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 2af5cbc..9afa991 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -106,6 +106,7 @@ router.afterEach(() => { Shared
Design + Design systems Trash Settings
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bedd06c..5e9bf81 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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: "/", diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue new file mode 100644 index 0000000..0d02177 --- /dev/null +++ b/frontend/src/views/DesignSystemsView.vue @@ -0,0 +1,1141 @@ + + + + + diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 4e2d9ea..14aeca2 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -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(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([]); +const editDesignSystemId = ref(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(`/api/projects/${project.value.id}`, { + const updated = await apiPatch(`/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() { +
+ + +