From 174ec8af46291c6311c32c1784b552bf0760be4b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 3 Aug 2026 11:45:48 -0400 Subject: [PATCH] feat(design): the starter-role checklist, in the creation UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule #27 — the backend half shipped without a surface an operator can touch, so this is the other half of #2349. StarterRolePicker is a component rather than inline markup because DesignSystemsView has TWO creation forms: the empty state is a sibling branch of the body, not a parent, so a form written into one is unreachable from the other. Inlining the checklist would have made it the next thing in this codebase defined twice and free to drift — which is what the button migration spent nine commits undoing. What it offers is names and purposes, never values. "Named now, valued later": a role you haven't filled shows as to-be-decided, while a role that doesn't exist is what gets written as a literal instead. Every group unchecks individually, and the prefix is editable because `--fs-` is one family's convention, not the product's. Three deliberate details: - All groups checked by DEFAULT, and that default lives in the UI, not the service. create_design_system treats None and [] alike (seed nothing) so it can never write 40 rows into a system whose caller never asked; a UI default is visible and reversible before the click. Different layers, different safe answers. - A failed catalogue fetch is NOT fatal and does not read as an error. Starter roles are an accelerator, not a prerequisite — the form still creates, and the operator adds tokens by hand. - The refs are not cleared after a successful create. The picker owns them and re-seeds on mount; resetting here would race that and silently create the next system with no roles. props + defineEmits rather than defineModel, matching TagInput and the rest of components/. defineModel is available (Vue 3.5) and would be shorter, but being the only file in the codebase using a different binding idiom costs more than the lines it saves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- frontend/src/api/designSystems.ts | 17 ++ frontend/src/components/StarterRolePicker.vue | 212 ++++++++++++++++++ frontend/src/views/DesignSystemsView.vue | 18 ++ 3 files changed, 247 insertions(+) create mode 100644 frontend/src/components/StarterRolePicker.vue diff --git a/frontend/src/api/designSystems.ts b/frontend/src/api/designSystems.ts index 89cb9ba..e3d7f21 100644 --- a/frontend/src/api/designSystems.ts +++ b/frontend/src/api/designSystems.ts @@ -74,11 +74,28 @@ export const fetchDesignSystems = () => export const fetchDesignSystem = (id: number) => apiGet(`/api/design-systems/${id}`); +export interface StarterRoleGroup { + group: string; + description: string; + token_count: number; + names: string[]; +} + +/** The starter token ROLES offered at creation — names and purposes, never + * values. A default palette would be one install's taste shipped as product + * (rule #115), so the values are always the operator's to fill. */ +export const listStarterRoleGroups = () => + apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>( + "/api/design-systems/starter-roles", + ); + export const createDesignSystem = (body: { title: string; description?: string; guidance?: string; parent_id?: number | null; + starter_role_groups?: string[]; + token_prefix?: string; }) => apiPost("/api/design-systems", body); /** Omit `parent_id` to leave it alone; send `null` to make the system a family. */ diff --git a/frontend/src/components/StarterRolePicker.vue b/frontend/src/components/StarterRolePicker.vue new file mode 100644 index 0000000..0011fdb --- /dev/null +++ b/frontend/src/components/StarterRolePicker.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue index 805f859..ff50efd 100644 --- a/frontend/src/views/DesignSystemsView.vue +++ b/frontend/src/views/DesignSystemsView.vue @@ -42,6 +42,7 @@ import { import DesignTabs from "@/components/DesignTabs.vue"; import { ApiError } from "@/api/client"; import { useToastStore } from "@/stores/toast"; +import StarterRolePicker from "@/components/StarterRolePicker.vue"; const toast = useToastStore(); @@ -148,6 +149,10 @@ const newTitle = ref(""); const newDescription = ref(""); const newParentId = ref(null); const creating = ref(false); +// Starter roles (#2349). The picker fills these on mount; empty means the +// operator unchecked everything, which is a real answer. +const starterGroups = ref([]); +const tokenPrefix = ref(""); async function submitCreate() { const title = newTitle.value.trim(); @@ -158,11 +163,16 @@ async function submitCreate() { title, description: newDescription.value.trim() || undefined, parent_id: newParentId.value, + starter_role_groups: starterGroups.value.length ? starterGroups.value : undefined, + token_prefix: tokenPrefix.value.trim() || undefined, }); newTitle.value = ""; newDescription.value = ""; newParentId.value = null; showCreate.value = false; + // NOT reset: the picker owns these and re-seeds on mount. Clearing them + // here would race the next mount and silently create the following system + // with no roles at all. await loadSystems(); selectedId.value = created.id; toast.show(`Created ${created.title}`); @@ -540,6 +550,10 @@ function isColourish(value: string): boolean { placeholder="What it covers" /> +
+