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

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:
2026-07-30 21:52:16 -04:00
parent 46d88f9e7e
commit 0f80b790c7
12 changed files with 203 additions and 11 deletions
+37
View File
@@ -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;
}