feat(design): the starter-role checklist, in the creation UI
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 25s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 44s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 25s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 44s
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -74,11 +74,28 @@ export const fetchDesignSystems = () =>
|
||||
export const fetchDesignSystem = (id: number) =>
|
||||
apiGet<DesignSystem>(`/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<DesignSystem>("/api/design-systems", body);
|
||||
|
||||
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The starter token ROLES offered when a design system is created (#2349).
|
||||
*
|
||||
* WHY THIS IS A COMPONENT
|
||||
* DesignSystemsView has two creation forms — the empty state and the one inside
|
||||
* the body — because the empty state is a sibling branch, not a parent. Putting
|
||||
* the checklist inline would make it the third thing in this codebase defined
|
||||
* twice and free to drift, which is what the whole button migration was about.
|
||||
*
|
||||
* WHAT IT OFFERS
|
||||
* Names and purposes, never values. A role is a question the operator answers
|
||||
* with their own palette; a default palette would be one install's taste
|
||||
* shipped as product (rule #115). Every group is individually skippable —
|
||||
* an operator who wants three tokens should get three.
|
||||
*
|
||||
* All groups are checked by default. That default lives HERE rather than in the
|
||||
* service, because the service must never seed rows into a system whose caller
|
||||
* did not ask; a UI default is visible and reversible before the click.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { listStarterRoleGroups, type StarterRoleGroup } from "@/api/designSystems";
|
||||
|
||||
// props + emit rather than defineModel, matching TagInput and the rest of
|
||||
// components/ — being the only file using a different binding idiom costs more
|
||||
// than the few lines it saves.
|
||||
const props = defineProps<{ selected: string[]; prefix: string }>();
|
||||
const emit = defineEmits<{
|
||||
"update:selected": [value: string[]];
|
||||
"update:prefix": [value: string];
|
||||
}>();
|
||||
|
||||
const groups = ref<StarterRoleGroup[]>([]);
|
||||
const defaultPrefix = ref("--ds-");
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listStarterRoleGroups();
|
||||
groups.value = data.groups;
|
||||
defaultPrefix.value = data.default_prefix;
|
||||
if (!props.prefix) emit("update:prefix", data.default_prefix);
|
||||
// Everything on by default — see the note above.
|
||||
if (!props.selected.length) {
|
||||
emit("update:selected", data.groups.map((g) => g.group));
|
||||
}
|
||||
} catch {
|
||||
// A creation form must still work when this fails. Roles are an
|
||||
// accelerator, not a prerequisite: the operator can add tokens by hand.
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggle(group: string) {
|
||||
emit(
|
||||
"update:selected",
|
||||
props.selected.includes(group)
|
||||
? props.selected.filter((g) => g !== group)
|
||||
: [...props.selected, group],
|
||||
);
|
||||
}
|
||||
|
||||
const totalTokens = () =>
|
||||
groups.value
|
||||
.filter((g) => props.selected.includes(g.group))
|
||||
.reduce((n, g) => n + g.token_count, 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="srp-note">Loading starter roles…</div>
|
||||
|
||||
<!-- Failure is not fatal and should not read as one. -->
|
||||
<div v-else-if="failed" class="srp-note">
|
||||
Starter roles unavailable — you can add tokens by hand after creating.
|
||||
</div>
|
||||
|
||||
<fieldset v-else-if="groups.length" class="srp">
|
||||
<legend class="srp-legend">Start with these token roles</legend>
|
||||
<p class="srp-intro">
|
||||
Named now, valued later. A role you haven't filled in shows as
|
||||
<em>to be decided</em>; a role that doesn't exist is what gets written as a
|
||||
literal instead. Uncheck anything this system won't have.
|
||||
</p>
|
||||
|
||||
<div class="srp-grid">
|
||||
<label v-for="g in groups" :key="g.group" class="srp-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="props.selected.includes(g.group)"
|
||||
@change="toggle(g.group)"
|
||||
/>
|
||||
<span class="srp-name">{{ g.group }}</span>
|
||||
<span class="srp-count">{{ g.token_count }}</span>
|
||||
<span class="srp-desc">{{ g.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="srp-footer">
|
||||
<label class="srp-prefix">
|
||||
<span>Prefix</span>
|
||||
<input
|
||||
:value="props.prefix" class="input srp-prefix-input" type="text"
|
||||
:placeholder="defaultPrefix"
|
||||
@input="emit('update:prefix', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<span class="srp-total">
|
||||
{{ totalTokens() }} {{ totalTokens() === 1 ? "role" : "roles" }}, no values
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.srp {
|
||||
border: var(--fs-border);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-4);
|
||||
margin: 0 0 var(--fs-space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.srp-legend {
|
||||
font-size: var(--fs-size-label);
|
||||
font-weight: var(--fs-weight-medium);
|
||||
color: var(--color-text);
|
||||
padding: 0 var(--fs-space-2);
|
||||
}
|
||||
|
||||
.srp-intro,
|
||||
.srp-note {
|
||||
margin: 0 0 var(--fs-space-3);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--fs-leading-body);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.srp-note {
|
||||
margin-bottom: var(--fs-space-4);
|
||||
}
|
||||
|
||||
.srp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: var(--fs-space-2);
|
||||
}
|
||||
|
||||
.srp-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
align-items: baseline;
|
||||
gap: var(--fs-space-2);
|
||||
padding: var(--fs-space-1) var(--fs-space-2);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
.srp-item:hover { background: var(--color-hover); }
|
||||
|
||||
.srp-name {
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.srp-count {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The description is the useful part on a wide card and the first thing worth
|
||||
dropping on a narrow one — the group name alone still identifies the row. */
|
||||
.srp-desc {
|
||||
grid-column: 1 / -1;
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--color-text-muted);
|
||||
line-height: var(--fs-leading-body);
|
||||
}
|
||||
|
||||
.srp-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--fs-space-3);
|
||||
margin-top: var(--fs-space-4);
|
||||
}
|
||||
|
||||
.srp-prefix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.srp-prefix-input {
|
||||
width: 8rem;
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: var(--fs-size-code);
|
||||
}
|
||||
|
||||
.srp-total {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -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<number | null>(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<string[]>([]);
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<StarterRolePicker
|
||||
v-model:selected="starterGroups"
|
||||
v-model:prefix="tokenPrefix"
|
||||
/>
|
||||
<div class="row-actions">
|
||||
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
|
||||
{{ creating ? "Creating…" : "Create" }}
|
||||
@@ -601,6 +615,10 @@ function isColourish(value: string): boolean {
|
||||
A system with a parent stores only its differences from it.
|
||||
</p>
|
||||
</div>
|
||||
<StarterRolePicker
|
||||
v-model:selected="starterGroups"
|
||||
v-model:prefix="tokenPrefix"
|
||||
/>
|
||||
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
|
||||
{{ creating ? "Creating…" : "Create" }}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user