feat(design-systems): central prose — guidance on the system, rationale on tokens
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Last piece of the architecture in #2296. The operator: "the prose doesn't have to live as one offs, there's a central system for managing it." Two fields, both free-form: design_systems.guidance the narrative a token table cannot hold — aesthetic, voice and tone, what is deliberately out of scope. design_tokens.rationale WHY a token is this value, which is a different question from `purpose` (what it is FOR). "Success equals Moss, aligned by design" is a rationale; "page bg, deepest surface" is a purpose. Rules carry the first routinely and a token row had nowhere to put it. Free-form rather than a column per category, deliberately. A schema with `voice`, `aesthetic` and `scope` columns would bake one rulebook's table of contents into every install (rule #115), leaving the next install three empty columns and nowhere for what it actually cares about. Both nullable: a design system with no prose at all is complete, not a draft. `rationale` cascades like `purpose` — deepest non-empty wins — so an app overriding a colour keeps the family's reasoning rather than blanking it. Same argument as `supersedes`: the override was about the value, not the meaning. In the generated sheet the inline comment prefers `purpose` and falls back to `rationale`, so a token carrying only the why still says something instead of rendering bare.
This commit is contained in:
@@ -12,6 +12,8 @@ export interface DesignSystem {
|
||||
owner_user_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Narrative a token table cannot hold: aesthetic, voice, what's out of scope. */
|
||||
guidance: string;
|
||||
parent_id: number | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
@@ -26,6 +28,8 @@ export interface DesignToken {
|
||||
value_by_mode: Record<string, string>;
|
||||
group_name: string | null;
|
||||
purpose: string | null;
|
||||
/** WHY it is this value — distinct from `purpose`, which is what it is FOR. */
|
||||
rationale: string | null;
|
||||
/** Literal values this token should be used INSTEAD OF, e.g. ["#fff"].
|
||||
*
|
||||
* How a design system records what a prohibition was trying to say: not
|
||||
@@ -56,6 +60,7 @@ export interface ResolvedToken {
|
||||
name: string;
|
||||
group_name: string | null;
|
||||
purpose: string | null;
|
||||
rationale: string | null;
|
||||
supersedes: string[];
|
||||
order_index: number;
|
||||
value_by_mode: Record<string, string>;
|
||||
@@ -72,13 +77,19 @@ export const fetchDesignSystem = (id: number) =>
|
||||
export const createDesignSystem = (body: {
|
||||
title: string;
|
||||
description?: string;
|
||||
guidance?: 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 },
|
||||
body: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
guidance?: string;
|
||||
parent_id?: number | null;
|
||||
},
|
||||
) => apiPatch<DesignSystem>(`/api/design-systems/${id}`, body);
|
||||
|
||||
export const deleteDesignSystem = (id: number) =>
|
||||
@@ -101,6 +112,7 @@ export const createDesignToken = (
|
||||
value_by_mode?: Record<string, string>;
|
||||
group_name?: string | null;
|
||||
purpose?: string | null;
|
||||
rationale?: string | null;
|
||||
supersedes?: string[];
|
||||
order_index?: number;
|
||||
},
|
||||
|
||||
@@ -179,6 +179,7 @@ async function submitCreate() {
|
||||
|
||||
const editTitle = ref("");
|
||||
const editDescription = ref("");
|
||||
const editGuidance = ref("");
|
||||
const editParentId = ref<number | null>(null);
|
||||
const savingSystem = ref(false);
|
||||
|
||||
@@ -187,6 +188,7 @@ watch(
|
||||
(system) => {
|
||||
editTitle.value = system?.title ?? "";
|
||||
editDescription.value = system?.description ?? "";
|
||||
editGuidance.value = system?.guidance ?? "";
|
||||
editParentId.value = system?.parent_id ?? null;
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -198,6 +200,7 @@ const systemDirty = computed(() => {
|
||||
return (
|
||||
editTitle.value !== system.title ||
|
||||
editDescription.value !== (system.description ?? "") ||
|
||||
editGuidance.value !== (system.guidance ?? "") ||
|
||||
editParentId.value !== system.parent_id
|
||||
);
|
||||
});
|
||||
@@ -212,6 +215,7 @@ async function saveSystem() {
|
||||
await updateDesignSystem(system.id, {
|
||||
title: editTitle.value.trim(),
|
||||
description: editDescription.value.trim(),
|
||||
guidance: editGuidance.value.trim(),
|
||||
parent_id: editParentId.value,
|
||||
});
|
||||
await loadSystems();
|
||||
@@ -263,6 +267,7 @@ function resetTokenForm() {
|
||||
tokenName.value = "";
|
||||
tokenGroup.value = "";
|
||||
tokenPurpose.value = "";
|
||||
tokenRationale.value = "";
|
||||
tokenModes.value = [{ mode: "base", value: "" }];
|
||||
tokenSupersedes.value = "";
|
||||
editingTokenId.value = null;
|
||||
@@ -274,6 +279,7 @@ function startEditToken(token: DesignToken) {
|
||||
tokenName.value = token.name;
|
||||
tokenGroup.value = token.group_name ?? "";
|
||||
tokenPurpose.value = token.purpose ?? "";
|
||||
tokenRationale.value = token.rationale ?? "";
|
||||
tokenSupersedes.value = (token.supersedes ?? []).join(", ");
|
||||
const entries = Object.entries(token.value_by_mode);
|
||||
tokenModes.value = entries.length
|
||||
@@ -301,6 +307,7 @@ async function submitToken() {
|
||||
value_by_mode: collectModes(),
|
||||
group_name: tokenGroup.value.trim() || null,
|
||||
purpose: tokenPurpose.value.trim() || null,
|
||||
rationale: tokenRationale.value.trim() || null,
|
||||
supersedes: tokenSupersedes.value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
@@ -629,6 +636,17 @@ function isColourish(value: string): boolean {
|
||||
<label class="field-label" for="edit-desc">Description</label>
|
||||
<input id="edit-desc" v-model="editDescription" class="input" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="edit-guidance">Guidance</label>
|
||||
<textarea
|
||||
id="edit-guidance" v-model="editGuidance" class="input" rows="5"
|
||||
placeholder="Aesthetic, voice and tone, what's deliberately out of scope…"
|
||||
></textarea>
|
||||
<p class="field-hint">
|
||||
The prose a token table can't hold. Kept here rather than
|
||||
scattered, so the system carries its own reasoning.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="edit-parent">Inherits from</label>
|
||||
<select id="edit-parent" v-model="editParentId" class="input">
|
||||
@@ -918,6 +936,18 @@ function isColourish(value: string): boolean {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-rationale">Why this value</label>
|
||||
<input
|
||||
id="token-rationale" v-model="tokenRationale" class="input" type="text"
|
||||
placeholder="Success equals Moss, aligned by design"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Distinct from purpose: purpose is what the token is FOR, this is
|
||||
why it is this value.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-supersedes">Use instead of</label>
|
||||
<input
|
||||
@@ -1029,6 +1059,7 @@ function isColourish(value: string): boolean {
|
||||
<div class="resolved-head">
|
||||
<code class="token-name">{{ token.name }}</code>
|
||||
<span v-if="token.purpose" class="token-meta">{{ token.purpose }}</span>
|
||||
<span v-if="token.rationale" class="supersedes">{{ token.rationale }}</span>
|
||||
<span v-if="token.supersedes.length" class="supersedes">
|
||||
instead of {{ token.supersedes.join(", ") }}
|
||||
</span>
|
||||
@@ -1286,6 +1317,12 @@ function isColourish(value: string): boolean {
|
||||
}
|
||||
|
||||
|
||||
textarea.input {
|
||||
resize: vertical;
|
||||
min-height: 5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user