Design systems as records — the stylesheet Scribe holds, plus two live bug fixes #88
@@ -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 },
|
||||||
|
);
|
||||||
@@ -106,6 +106,7 @@ router.afterEach(() => {
|
|||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
<router-link to="/design" class="nav-link">Design</router-link>
|
<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="/trash" class="nav-link">Trash</router-link>
|
||||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ const router = createRouter({
|
|||||||
name: "design",
|
name: "design",
|
||||||
component: () => import("@/views/DesignView.vue"),
|
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",
|
path: "/tasks",
|
||||||
redirect: "/",
|
redirect: "/",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,11 @@ import { renderMarkdown } from "@/utils/markdown";
|
|||||||
import ShareDialog from "@/components/ShareDialog.vue";
|
import ShareDialog from "@/components/ShareDialog.vue";
|
||||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||||
import SystemsSection from "@/components/SystemsSection.vue";
|
import SystemsSection from "@/components/SystemsSection.vue";
|
||||||
|
import {
|
||||||
|
fetchDesignSystems,
|
||||||
|
setProjectDesignSystem,
|
||||||
|
type DesignSystem,
|
||||||
|
} from "@/api/designSystems";
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -41,6 +46,7 @@ interface Project {
|
|||||||
goal: string | null;
|
goal: string | null;
|
||||||
status: "active" | "paused" | "completed" | "archived";
|
status: "active" | "paused" | "completed" | "archived";
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
design_system_id: number | null;
|
||||||
permission?: string;
|
permission?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -69,6 +75,12 @@ const toast = useToastStore();
|
|||||||
const tasksStore = useTasksStore();
|
const tasksStore = useTasksStore();
|
||||||
|
|
||||||
const project = ref<Project | null>(null);
|
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 loading = ref(false);
|
||||||
|
|
||||||
const showStartPlanning = ref(false);
|
const showStartPlanning = ref(false);
|
||||||
@@ -175,6 +187,7 @@ async function loadProject() {
|
|||||||
editDescription.value = data.description ?? "";
|
editDescription.value = data.description ?? "";
|
||||||
editGoal.value = data.goal ?? "";
|
editGoal.value = data.goal ?? "";
|
||||||
editStatus.value = data.status;
|
editStatus.value = data.status;
|
||||||
|
editDesignSystemId.value = data.design_system_id ?? null;
|
||||||
editDirty.value = false;
|
editDirty.value = false;
|
||||||
milestones.value = data.summary?.milestone_summary ?? [];
|
milestones.value = data.summary?.milestone_summary ?? [];
|
||||||
autoCollapseCompleted(milestones.value);
|
autoCollapseCompleted(milestones.value);
|
||||||
@@ -336,8 +349,20 @@ onMounted(async () => {
|
|||||||
await loadProject();
|
await loadProject();
|
||||||
loadTasks();
|
loadTasks();
|
||||||
loadNotes();
|
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 () => {
|
watch(projectId, async () => {
|
||||||
await loadProject();
|
await loadProject();
|
||||||
loadTasks();
|
loadTasks();
|
||||||
@@ -345,28 +370,39 @@ watch(projectId, async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value],
|
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value, editDesignSystemId.value],
|
||||||
() => {
|
() => {
|
||||||
if (!project.value) return;
|
if (!project.value) return;
|
||||||
editDirty.value =
|
editDirty.value =
|
||||||
editTitle.value !== project.value.title ||
|
editTitle.value !== project.value.title ||
|
||||||
editDescription.value !== (project.value.description ?? "") ||
|
editDescription.value !== (project.value.description ?? "") ||
|
||||||
editGoal.value !== (project.value.goal ?? "") ||
|
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() {
|
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;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const updated = await apiPatch<Project>(`/api/projects/${project.value.id}`, {
|
const updated = await apiPatch<Project>(`/api/projects/${current.id}`, {
|
||||||
title: editTitle.value.trim(),
|
title: editTitle.value.trim(),
|
||||||
description: editDescription.value.trim() || null,
|
description: editDescription.value.trim() || null,
|
||||||
goal: editGoal.value.trim() || null,
|
goal: editGoal.value.trim() || null,
|
||||||
status: editStatus.value,
|
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;
|
editDirty.value = false;
|
||||||
toast.show("Project saved");
|
toast.show("Project saved");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -505,6 +541,13 @@ async function confirmDelete() {
|
|||||||
<option value="archived">Archived</option>
|
<option value="archived">Archived</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<button class="btn-save-panel" @click="saveProject" :disabled="!editDirty || saving">
|
||||||
{{ saving ? "Saving..." : "Save Changes" }}
|
{{ saving ? "Saving..." : "Save Changes" }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user