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
213 lines
6.1 KiB
Vue
213 lines
6.1 KiB
Vue
<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>
|