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:
@@ -0,0 +1,44 @@
|
|||||||
|
"""central prose on a design system, and rationale on a token
|
||||||
|
|
||||||
|
Revision ID: 0074
|
||||||
|
Revises: 0073
|
||||||
|
Create Date: 2026-07-31
|
||||||
|
|
||||||
|
The operator's shape for #254: prose doesn't live as one-offs, it lives centrally
|
||||||
|
on the system. Two fields rather than one per category:
|
||||||
|
|
||||||
|
design_systems.guidance the narrative a token table cannot hold — aesthetic,
|
||||||
|
voice and tone, what is deliberately out of scope.
|
||||||
|
Markdown, free-form.
|
||||||
|
design_tokens.rationale WHY this 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.
|
||||||
|
|
||||||
|
Free-form rather than a column per category on purpose. A schema with `voice`,
|
||||||
|
`aesthetic` and `scope` columns would bake one rulebook's table of contents into
|
||||||
|
every install (rule #115), and the next install's design system would have three
|
||||||
|
empty columns and nowhere to put what it actually cares about.
|
||||||
|
|
||||||
|
Both nullable: a design system with no prose at all is complete, not a draft.
|
||||||
|
|
||||||
|
Downgrade drops both columns and the prose with them.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0074"
|
||||||
|
down_revision = "0073"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("design_systems", sa.Column("guidance", sa.Text(), nullable=True))
|
||||||
|
op.add_column("design_tokens", sa.Column("rationale", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("design_tokens", "rationale")
|
||||||
|
op.drop_column("design_systems", "guidance")
|
||||||
@@ -12,6 +12,8 @@ export interface DesignSystem {
|
|||||||
owner_user_id: number;
|
owner_user_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
/** Narrative a token table cannot hold: aesthetic, voice, what's out of scope. */
|
||||||
|
guidance: string;
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
@@ -26,6 +28,8 @@ export interface DesignToken {
|
|||||||
value_by_mode: Record<string, string>;
|
value_by_mode: Record<string, string>;
|
||||||
group_name: string | null;
|
group_name: string | null;
|
||||||
purpose: 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"].
|
/** 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
|
* How a design system records what a prohibition was trying to say: not
|
||||||
@@ -56,6 +60,7 @@ export interface ResolvedToken {
|
|||||||
name: string;
|
name: string;
|
||||||
group_name: string | null;
|
group_name: string | null;
|
||||||
purpose: string | null;
|
purpose: string | null;
|
||||||
|
rationale: string | null;
|
||||||
supersedes: string[];
|
supersedes: string[];
|
||||||
order_index: number;
|
order_index: number;
|
||||||
value_by_mode: Record<string, string>;
|
value_by_mode: Record<string, string>;
|
||||||
@@ -72,13 +77,19 @@ export const fetchDesignSystem = (id: number) =>
|
|||||||
export const createDesignSystem = (body: {
|
export const createDesignSystem = (body: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
guidance?: string;
|
||||||
parent_id?: number | null;
|
parent_id?: number | null;
|
||||||
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
||||||
|
|
||||||
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
||||||
export const updateDesignSystem = (
|
export const updateDesignSystem = (
|
||||||
id: number,
|
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);
|
) => apiPatch<DesignSystem>(`/api/design-systems/${id}`, body);
|
||||||
|
|
||||||
export const deleteDesignSystem = (id: number) =>
|
export const deleteDesignSystem = (id: number) =>
|
||||||
@@ -101,6 +112,7 @@ export const createDesignToken = (
|
|||||||
value_by_mode?: Record<string, string>;
|
value_by_mode?: Record<string, string>;
|
||||||
group_name?: string | null;
|
group_name?: string | null;
|
||||||
purpose?: string | null;
|
purpose?: string | null;
|
||||||
|
rationale?: string | null;
|
||||||
supersedes?: string[];
|
supersedes?: string[];
|
||||||
order_index?: number;
|
order_index?: number;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ async function submitCreate() {
|
|||||||
|
|
||||||
const editTitle = ref("");
|
const editTitle = ref("");
|
||||||
const editDescription = ref("");
|
const editDescription = ref("");
|
||||||
|
const editGuidance = ref("");
|
||||||
const editParentId = ref<number | null>(null);
|
const editParentId = ref<number | null>(null);
|
||||||
const savingSystem = ref(false);
|
const savingSystem = ref(false);
|
||||||
|
|
||||||
@@ -187,6 +188,7 @@ watch(
|
|||||||
(system) => {
|
(system) => {
|
||||||
editTitle.value = system?.title ?? "";
|
editTitle.value = system?.title ?? "";
|
||||||
editDescription.value = system?.description ?? "";
|
editDescription.value = system?.description ?? "";
|
||||||
|
editGuidance.value = system?.guidance ?? "";
|
||||||
editParentId.value = system?.parent_id ?? null;
|
editParentId.value = system?.parent_id ?? null;
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -198,6 +200,7 @@ const systemDirty = computed(() => {
|
|||||||
return (
|
return (
|
||||||
editTitle.value !== system.title ||
|
editTitle.value !== system.title ||
|
||||||
editDescription.value !== (system.description ?? "") ||
|
editDescription.value !== (system.description ?? "") ||
|
||||||
|
editGuidance.value !== (system.guidance ?? "") ||
|
||||||
editParentId.value !== system.parent_id
|
editParentId.value !== system.parent_id
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -212,6 +215,7 @@ async function saveSystem() {
|
|||||||
await updateDesignSystem(system.id, {
|
await updateDesignSystem(system.id, {
|
||||||
title: editTitle.value.trim(),
|
title: editTitle.value.trim(),
|
||||||
description: editDescription.value.trim(),
|
description: editDescription.value.trim(),
|
||||||
|
guidance: editGuidance.value.trim(),
|
||||||
parent_id: editParentId.value,
|
parent_id: editParentId.value,
|
||||||
});
|
});
|
||||||
await loadSystems();
|
await loadSystems();
|
||||||
@@ -263,6 +267,7 @@ function resetTokenForm() {
|
|||||||
tokenName.value = "";
|
tokenName.value = "";
|
||||||
tokenGroup.value = "";
|
tokenGroup.value = "";
|
||||||
tokenPurpose.value = "";
|
tokenPurpose.value = "";
|
||||||
|
tokenRationale.value = "";
|
||||||
tokenModes.value = [{ mode: "base", value: "" }];
|
tokenModes.value = [{ mode: "base", value: "" }];
|
||||||
tokenSupersedes.value = "";
|
tokenSupersedes.value = "";
|
||||||
editingTokenId.value = null;
|
editingTokenId.value = null;
|
||||||
@@ -274,6 +279,7 @@ function startEditToken(token: DesignToken) {
|
|||||||
tokenName.value = token.name;
|
tokenName.value = token.name;
|
||||||
tokenGroup.value = token.group_name ?? "";
|
tokenGroup.value = token.group_name ?? "";
|
||||||
tokenPurpose.value = token.purpose ?? "";
|
tokenPurpose.value = token.purpose ?? "";
|
||||||
|
tokenRationale.value = token.rationale ?? "";
|
||||||
tokenSupersedes.value = (token.supersedes ?? []).join(", ");
|
tokenSupersedes.value = (token.supersedes ?? []).join(", ");
|
||||||
const entries = Object.entries(token.value_by_mode);
|
const entries = Object.entries(token.value_by_mode);
|
||||||
tokenModes.value = entries.length
|
tokenModes.value = entries.length
|
||||||
@@ -301,6 +307,7 @@ async function submitToken() {
|
|||||||
value_by_mode: collectModes(),
|
value_by_mode: collectModes(),
|
||||||
group_name: tokenGroup.value.trim() || null,
|
group_name: tokenGroup.value.trim() || null,
|
||||||
purpose: tokenPurpose.value.trim() || null,
|
purpose: tokenPurpose.value.trim() || null,
|
||||||
|
rationale: tokenRationale.value.trim() || null,
|
||||||
supersedes: tokenSupersedes.value
|
supersedes: tokenSupersedes.value
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
@@ -629,6 +636,17 @@ function isColourish(value: string): boolean {
|
|||||||
<label class="field-label" for="edit-desc">Description</label>
|
<label class="field-label" for="edit-desc">Description</label>
|
||||||
<input id="edit-desc" v-model="editDescription" class="input" type="text" />
|
<input id="edit-desc" v-model="editDescription" class="input" type="text" />
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label class="field-label" for="edit-parent">Inherits from</label>
|
<label class="field-label" for="edit-parent">Inherits from</label>
|
||||||
<select id="edit-parent" v-model="editParentId" class="input">
|
<select id="edit-parent" v-model="editParentId" class="input">
|
||||||
@@ -918,6 +936,18 @@ function isColourish(value: string): boolean {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="field">
|
||||||
<label class="field-label" for="token-supersedes">Use instead of</label>
|
<label class="field-label" for="token-supersedes">Use instead of</label>
|
||||||
<input
|
<input
|
||||||
@@ -1029,6 +1059,7 @@ function isColourish(value: string): boolean {
|
|||||||
<div class="resolved-head">
|
<div class="resolved-head">
|
||||||
<code class="token-name">{{ token.name }}</code>
|
<code class="token-name">{{ token.name }}</code>
|
||||||
<span v-if="token.purpose" class="token-meta">{{ token.purpose }}</span>
|
<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">
|
<span v-if="token.supersedes.length" class="supersedes">
|
||||||
instead of {{ token.supersedes.join(", ") }}
|
instead of {{ token.supersedes.join(", ") }}
|
||||||
</span>
|
</span>
|
||||||
@@ -1286,6 +1317,12 @@ function isColourish(value: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
textarea.input {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
|
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from scribe.services.design_systems import DesignSystemCycle
|
|||||||
async def create_design_system(
|
async def create_design_system(
|
||||||
title: str,
|
title: str,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
|
guidance: str = "",
|
||||||
parent_id: int = 0,
|
parent_id: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a design system, optionally inheriting from another.
|
"""Create a design system, optionally inheriting from another.
|
||||||
@@ -34,6 +35,8 @@ async def create_design_system(
|
|||||||
Args:
|
Args:
|
||||||
title: What this system is, e.g. "FabledSword" or "Scribe" (required).
|
title: What this system is, e.g. "FabledSword" or "Scribe" (required).
|
||||||
description: What it covers and when it applies.
|
description: What it covers and when it applies.
|
||||||
|
guidance: The narrative a token table cannot hold — aesthetic, voice and
|
||||||
|
tone, what is deliberately out of scope. Markdown, free-form.
|
||||||
parent_id: Inherit from this system — it holds the defaults this one
|
parent_id: Inherit from this system — it holds the defaults this one
|
||||||
overrides. Omit (0) for a top-level "family" system, which is what
|
overrides. Omit (0) for a top-level "family" system, which is what
|
||||||
a first design system usually is.
|
a first design system usually is.
|
||||||
@@ -43,6 +46,7 @@ async def create_design_system(
|
|||||||
uid,
|
uid,
|
||||||
title=title,
|
title=title,
|
||||||
description=description or None,
|
description=description or None,
|
||||||
|
guidance=guidance or None,
|
||||||
parent_id=parent_id or None,
|
parent_id=parent_id or None,
|
||||||
)
|
)
|
||||||
if system is None:
|
if system is None:
|
||||||
@@ -98,6 +102,7 @@ async def update_design_system(
|
|||||||
design_system_id: int,
|
design_system_id: int,
|
||||||
title: str = "",
|
title: str = "",
|
||||||
description: str = "",
|
description: str = "",
|
||||||
|
guidance: str = "",
|
||||||
parent_id: int = 0,
|
parent_id: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a design system.
|
"""Update a design system.
|
||||||
@@ -106,6 +111,7 @@ async def update_design_system(
|
|||||||
design_system_id: The system to update.
|
design_system_id: The system to update.
|
||||||
title: New title, or "" to leave unchanged.
|
title: New title, or "" to leave unchanged.
|
||||||
description: New description, or "" to leave unchanged.
|
description: New description, or "" to leave unchanged.
|
||||||
|
guidance: New guidance prose, or "" to leave unchanged.
|
||||||
parent_id: 0 = leave unchanged, -1 = clear (make this a top-level
|
parent_id: 0 = leave unchanged, -1 = clear (make this a top-level
|
||||||
family system), positive = inherit from that system. A parent that
|
family system), positive = inherit from that system. A parent that
|
||||||
already inherits from this system is refused — that would be a loop.
|
already inherits from this system is refused — that would be a loop.
|
||||||
@@ -116,6 +122,8 @@ async def update_design_system(
|
|||||||
fields["title"] = title
|
fields["title"] = title
|
||||||
if description:
|
if description:
|
||||||
fields["description"] = description
|
fields["description"] = description
|
||||||
|
if guidance:
|
||||||
|
fields["guidance"] = guidance
|
||||||
if parent_id:
|
if parent_id:
|
||||||
fields["parent_id"] = None if parent_id == -1 else parent_id
|
fields["parent_id"] = None if parent_id == -1 else parent_id
|
||||||
try:
|
try:
|
||||||
@@ -246,6 +254,7 @@ async def create_design_token(
|
|||||||
value_by_mode: dict | None = None,
|
value_by_mode: dict | None = None,
|
||||||
group_name: str = "",
|
group_name: str = "",
|
||||||
purpose: str = "",
|
purpose: str = "",
|
||||||
|
rationale: str = "",
|
||||||
supersedes: list | None = None,
|
supersedes: list | None = None,
|
||||||
order_index: int = 0,
|
order_index: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -262,6 +271,9 @@ async def create_design_token(
|
|||||||
group_name: Free-text grouping — "surface", "text", "radius", whatever
|
group_name: Free-text grouping — "surface", "text", "radius", whatever
|
||||||
this system's own vocabulary is.
|
this system's own vocabulary is.
|
||||||
purpose: What the token is for, e.g. "page bg, deepest surface".
|
purpose: What the token is for, e.g. "page bg, deepest surface".
|
||||||
|
rationale: WHY it is this value — a different question from purpose.
|
||||||
|
"Success equals Moss, aligned by design" is a rationale; "page bg,
|
||||||
|
deepest surface" is a purpose.
|
||||||
supersedes: Literal values this token should be used INSTEAD OF, e.g.
|
supersedes: Literal values this token should be used INSTEAD OF, e.g.
|
||||||
["#fff", "#ffffff"]. This is how a design system records what a
|
["#fff", "#ffffff"]. This is how a design system records what a
|
||||||
prohibition was trying to say — not "white is banned" but "write
|
prohibition was trying to say — not "white is banned" but "write
|
||||||
@@ -278,6 +290,7 @@ async def create_design_token(
|
|||||||
value_by_mode=value_by_mode,
|
value_by_mode=value_by_mode,
|
||||||
group_name=group_name or None,
|
group_name=group_name or None,
|
||||||
purpose=purpose or None,
|
purpose=purpose or None,
|
||||||
|
rationale=rationale or None,
|
||||||
supersedes=supersedes,
|
supersedes=supersedes,
|
||||||
order_index=order_index,
|
order_index=order_index,
|
||||||
)
|
)
|
||||||
@@ -299,6 +312,7 @@ async def update_design_token(
|
|||||||
value_by_mode: dict | None = None,
|
value_by_mode: dict | None = None,
|
||||||
group_name: str = "",
|
group_name: str = "",
|
||||||
purpose: str = "",
|
purpose: str = "",
|
||||||
|
rationale: str = "",
|
||||||
supersedes: list | None = None,
|
supersedes: list | None = None,
|
||||||
order_index: int = -1,
|
order_index: int = -1,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -318,6 +332,8 @@ async def update_design_token(
|
|||||||
fields["group_name"] = group_name
|
fields["group_name"] = group_name
|
||||||
if purpose:
|
if purpose:
|
||||||
fields["purpose"] = purpose
|
fields["purpose"] = purpose
|
||||||
|
if rationale:
|
||||||
|
fields["rationale"] = rationale
|
||||||
# `is not None`, not truthiness: `[]` is a meaningful edit (drop every
|
# `is not None`, not truthiness: `[]` is a meaningful edit (drop every
|
||||||
# superseded literal) and would otherwise be unreachable.
|
# superseded literal) and would otherwise be unreachable.
|
||||||
if supersedes is not None:
|
if supersedes is not None:
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(Text)
|
title: Mapped[str] = mapped_column(Text)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# The narrative a token table cannot hold: aesthetic, voice and tone, what
|
||||||
|
# is deliberately out of scope. Free-form markdown rather than a column per
|
||||||
|
# category — a schema with `voice`/`aesthetic`/`scope` columns would bake one
|
||||||
|
# rulebook's table of contents into every install.
|
||||||
|
guidance: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
# SET NULL, not CASCADE: deleting a family system must not delete every app
|
# SET NULL, not CASCADE: deleting a family system must not delete every app
|
||||||
# system that inherited from it. Orphaning turns each child into a root that
|
# system that inherited from it. Orphaning turns each child into a root that
|
||||||
# still holds its own overrides — recoverable. A cascade would destroy data
|
# still holds its own overrides — recoverable. A cascade would destroy data
|
||||||
@@ -49,6 +54,7 @@ class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"owner_user_id": self.owner_user_id,
|
"owner_user_id": self.owner_user_id,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"description": self.description or "",
|
"description": self.description or "",
|
||||||
|
"guidance": self.guidance or "",
|
||||||
"parent_id": self.parent_id,
|
"parent_id": self.parent_id,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
@@ -109,6 +115,11 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
# vocabulary, and a whitelist would bake one install's kit into the schema.
|
# vocabulary, and a whitelist would bake one install's kit into the schema.
|
||||||
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
|
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# WHY this token is this value — a different question from `purpose`, which
|
||||||
|
# is 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.
|
||||||
|
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
# Literal values this token should be used INSTEAD OF, e.g. ["#fff",
|
# Literal values this token should be used INSTEAD OF, e.g. ["#fff",
|
||||||
# "#ffffff"] on a text-on-action token.
|
# "#ffffff"] on a text-on-action token.
|
||||||
#
|
#
|
||||||
@@ -137,6 +148,7 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"value_by_mode": self.value_by_mode or {},
|
"value_by_mode": self.value_by_mode or {},
|
||||||
"group_name": self.group_name,
|
"group_name": self.group_name,
|
||||||
"purpose": self.purpose,
|
"purpose": self.purpose,
|
||||||
|
"rationale": self.rationale,
|
||||||
"supersedes": self.supersedes or [],
|
"supersedes": self.supersedes or [],
|
||||||
"order_index": self.order_index,
|
"order_index": self.order_index,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ async def create_design_system():
|
|||||||
user_id=_uid(),
|
user_id=_uid(),
|
||||||
title=title,
|
title=title,
|
||||||
description=data.get("description") or None,
|
description=data.get("description") or None,
|
||||||
|
guidance=data.get("guidance") or None,
|
||||||
parent_id=data.get("parent_id"),
|
parent_id=data.get("parent_id"),
|
||||||
)
|
)
|
||||||
if system is None:
|
if system is None:
|
||||||
@@ -74,7 +75,9 @@ async def get_design_system(design_system_id: int):
|
|||||||
@login_required
|
@login_required
|
||||||
async def update_design_system(design_system_id: int):
|
async def update_design_system(design_system_id: int):
|
||||||
data = await request.get_json() or {}
|
data = await request.get_json() or {}
|
||||||
fields = {k: v for k, v in data.items() if k in ("title", "description")}
|
fields = {
|
||||||
|
k: v for k, v in data.items() if k in ("title", "description", "guidance")
|
||||||
|
}
|
||||||
# Presence, not truthiness: `{"parent_id": null}` means "make this a root",
|
# Presence, not truthiness: `{"parent_id": null}` means "make this a root",
|
||||||
# which a `if data.get("parent_id")` filter would silently drop.
|
# which a `if data.get("parent_id")` filter would silently drop.
|
||||||
if "parent_id" in data:
|
if "parent_id" in data:
|
||||||
@@ -203,6 +206,7 @@ async def create_design_token(design_system_id: int):
|
|||||||
value_by_mode=data.get("value_by_mode"),
|
value_by_mode=data.get("value_by_mode"),
|
||||||
group_name=data.get("group_name") or None,
|
group_name=data.get("group_name") or None,
|
||||||
purpose=data.get("purpose") or None,
|
purpose=data.get("purpose") or None,
|
||||||
|
rationale=data.get("rationale") or None,
|
||||||
supersedes=data.get("supersedes"),
|
supersedes=data.get("supersedes"),
|
||||||
order_index=data.get("order_index") or 0,
|
order_index=data.get("order_index") or 0,
|
||||||
)
|
)
|
||||||
@@ -218,8 +222,8 @@ async def update_design_token(token_id: int):
|
|||||||
fields = {
|
fields = {
|
||||||
k: v for k, v in data.items()
|
k: v for k, v in data.items()
|
||||||
if k in (
|
if k in (
|
||||||
"name", "value_by_mode", "group_name", "purpose", "supersedes",
|
"name", "value_by_mode", "group_name", "purpose", "rationale",
|
||||||
"order_index",
|
"supersedes", "order_index",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
token = await ds_svc.update_token(_uid(), token_id, **fields)
|
token = await ds_svc.update_token(_uid(), token_id, **fields)
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class ResolvedToken:
|
|||||||
contributions: dict[str, tuple[Contribution, ...]]
|
contributions: dict[str, tuple[Contribution, ...]]
|
||||||
group_name: str | None
|
group_name: str | None
|
||||||
purpose: str | None
|
purpose: str | None
|
||||||
|
rationale: str | None
|
||||||
supersedes: tuple[str, ...]
|
supersedes: tuple[str, ...]
|
||||||
order_index: int
|
order_index: int
|
||||||
|
|
||||||
@@ -133,6 +134,7 @@ class ResolvedToken:
|
|||||||
"name": self.name,
|
"name": self.name,
|
||||||
"group_name": self.group_name,
|
"group_name": self.group_name,
|
||||||
"purpose": self.purpose,
|
"purpose": self.purpose,
|
||||||
|
"rationale": self.rationale,
|
||||||
"supersedes": list(self.supersedes),
|
"supersedes": list(self.supersedes),
|
||||||
"order_index": self.order_index,
|
"order_index": self.order_index,
|
||||||
"value_by_mode": self.value_by_mode,
|
"value_by_mode": self.value_by_mode,
|
||||||
@@ -210,11 +212,11 @@ def resolve_tokens(
|
|||||||
meta = metadata.setdefault(
|
meta = metadata.setdefault(
|
||||||
token.name,
|
token.name,
|
||||||
{
|
{
|
||||||
"group_name": None, "purpose": None,
|
"group_name": None, "purpose": None, "rationale": None,
|
||||||
"supersedes": None, "order_index": None,
|
"supersedes": None, "order_index": None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
for field in ("group_name", "purpose"):
|
for field in ("group_name", "purpose", "rationale"):
|
||||||
if meta[field] is None:
|
if meta[field] is None:
|
||||||
meta[field] = getattr(token, field, None)
|
meta[field] = getattr(token, field, None)
|
||||||
# order_index alone treats 0 as UNSTATED rather than "first",
|
# order_index alone treats 0 as UNSTATED rather than "first",
|
||||||
@@ -240,6 +242,7 @@ def resolve_tokens(
|
|||||||
},
|
},
|
||||||
group_name=metadata[name]["group_name"],
|
group_name=metadata[name]["group_name"],
|
||||||
purpose=metadata[name]["purpose"],
|
purpose=metadata[name]["purpose"],
|
||||||
|
rationale=metadata[name]["rationale"],
|
||||||
supersedes=metadata[name]["supersedes"] or (),
|
supersedes=metadata[name]["supersedes"] or (),
|
||||||
order_index=metadata[name]["order_index"] or 0,
|
order_index=metadata[name]["order_index"] or 0,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -164,7 +164,14 @@ def render_stylesheet(
|
|||||||
f" /* {name}: value rejected — not a safe CSS value */"
|
f" /* {name}: value rejected — not a safe CSS value */"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
purpose = safe_comment(getattr(token, "purpose", "") or "")
|
# Purpose first — what the token is FOR is what a reader of the
|
||||||
|
# stylesheet needs. Rationale is the fallback so a token that
|
||||||
|
# only carries the why still says something.
|
||||||
|
purpose = safe_comment(
|
||||||
|
getattr(token, "purpose", "")
|
||||||
|
or getattr(token, "rationale", "")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
comment = f" /* {purpose} */" if purpose and mode == BASE_MODE else ""
|
comment = f" /* {purpose} */" if purpose and mode == BASE_MODE else ""
|
||||||
entries.append(f" {name}: {value};{comment}")
|
entries.append(f" {name}: {value};{comment}")
|
||||||
if entries:
|
if entries:
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ async def create_design_system(
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
title: str,
|
title: str,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
|
guidance: str | None = None,
|
||||||
parent_id: int | None = None,
|
parent_id: int | None = None,
|
||||||
) -> DesignSystem | None:
|
) -> DesignSystem | None:
|
||||||
"""Create a system, with or without a parent.
|
"""Create a system, with or without a parent.
|
||||||
@@ -92,6 +93,7 @@ async def create_design_system(
|
|||||||
owner_user_id=user_id,
|
owner_user_id=user_id,
|
||||||
title=title.strip(),
|
title=title.strip(),
|
||||||
description=description,
|
description=description,
|
||||||
|
guidance=guidance,
|
||||||
parent_id=parent_id,
|
parent_id=parent_id,
|
||||||
)
|
)
|
||||||
session.add(system)
|
session.add(system)
|
||||||
@@ -158,7 +160,7 @@ async def update_design_system(
|
|||||||
system.parent_id = parent_id
|
system.parent_id = parent_id
|
||||||
|
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
if key in ("title", "description") and value is not None:
|
if key in ("title", "description", "guidance") and value is not None:
|
||||||
setattr(system, key, value)
|
setattr(system, key, value)
|
||||||
system.updated_at = datetime.now(timezone.utc)
|
system.updated_at = datetime.now(timezone.utc)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -188,6 +190,7 @@ async def create_token(
|
|||||||
value_by_mode: dict | None = None,
|
value_by_mode: dict | None = None,
|
||||||
group_name: str | None = None,
|
group_name: str | None = None,
|
||||||
purpose: str | None = None,
|
purpose: str | None = None,
|
||||||
|
rationale: str | None = None,
|
||||||
supersedes: list | None = None,
|
supersedes: list | None = None,
|
||||||
order_index: int = 0,
|
order_index: int = 0,
|
||||||
) -> DesignToken | None:
|
) -> DesignToken | None:
|
||||||
@@ -203,6 +206,7 @@ async def create_token(
|
|||||||
value_by_mode=value_by_mode or {},
|
value_by_mode=value_by_mode or {},
|
||||||
group_name=group_name,
|
group_name=group_name,
|
||||||
purpose=purpose,
|
purpose=purpose,
|
||||||
|
rationale=rationale,
|
||||||
# `or []` for the same reason as value_by_mode above: the column is
|
# `or []` for the same reason as value_by_mode above: the column is
|
||||||
# NOT NULL so absence has one spelling, and None would store JSON
|
# NOT NULL so absence has one spelling, and None would store JSON
|
||||||
# null instead of an empty array.
|
# null instead of an empty array.
|
||||||
@@ -281,8 +285,8 @@ async def update_token(
|
|||||||
user_id: int, token_id: int, **fields: object
|
user_id: int, token_id: int, **fields: object
|
||||||
) -> DesignToken | None:
|
) -> DesignToken | None:
|
||||||
allowed = {
|
allowed = {
|
||||||
"name", "value_by_mode", "group_name", "purpose", "supersedes",
|
"name", "value_by_mode", "group_name", "purpose", "rationale",
|
||||||
"order_index",
|
"supersedes", "order_index",
|
||||||
}
|
}
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
token = await session.get(DesignToken, token_id)
|
token = await session.get(DesignToken, token_id)
|
||||||
|
|||||||
@@ -387,3 +387,34 @@ def test_rows_without_a_supersedes_attribute_at_all_still_resolve():
|
|||||||
)
|
)
|
||||||
resolved = _by_name(resolve_tokens(FAMILY, PARENTS, {FAMILY: [legacy]}))
|
resolved = _by_name(resolve_tokens(FAMILY, PARENTS, {FAMILY: [legacy]}))
|
||||||
assert resolved["--fs-x"].supersedes == ()
|
assert resolved["--fs-x"].supersedes == ()
|
||||||
|
|
||||||
|
|
||||||
|
# --- rationale --------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_rationale_cascades_like_purpose_and_is_a_different_question():
|
||||||
|
"""`purpose` is what the token is FOR; `rationale` is why it is this value.
|
||||||
|
Rules carry the second routinely ("Success = Moss, aligned by design") and a
|
||||||
|
token row had nowhere to put it until now."""
|
||||||
|
resolved = _by_name(resolve_tokens(
|
||||||
|
APP, PARENTS,
|
||||||
|
{
|
||||||
|
FAMILY: [SimpleNamespace(
|
||||||
|
name="--fs-success", value_by_mode={"base": "#4a5d3f"},
|
||||||
|
group_name="semantic", purpose="success states",
|
||||||
|
rationale="equals Moss, aligned by design",
|
||||||
|
order_index=0, supersedes=[],
|
||||||
|
)],
|
||||||
|
APP: [_token("--fs-success", {"base": "#3f5236"})],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
token = resolved["--fs-success"]
|
||||||
|
assert token.rationale == "equals Moss, aligned by design"
|
||||||
|
assert token.purpose == "success states"
|
||||||
|
assert token.value_by_mode == {"base": "#3f5236"} # the value still overrode
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_token_without_a_rationale_resolves_to_none():
|
||||||
|
resolved = _by_name(resolve_tokens(
|
||||||
|
FAMILY, PARENTS, {FAMILY: [_token("--fs-x", {"base": "1px"})]},
|
||||||
|
))
|
||||||
|
assert resolved["--fs-x"].rationale is None
|
||||||
|
|||||||
@@ -319,3 +319,24 @@ def test_clean_code_reports_nothing_to_act_on():
|
|||||||
assert report["superseded_literals"] == []
|
assert report["superseded_literals"] == []
|
||||||
assert report["local_definitions"] == []
|
assert report["local_definitions"] == []
|
||||||
assert report["used"] == ["--fs-obsidian", "--fs-parchment"]
|
assert report["used"] == ["--fs-obsidian", "--fs-parchment"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_inline_comment_falls_back_to_rationale_when_there_is_no_purpose():
|
||||||
|
"""A token carrying only the why still says something in the sheet, rather
|
||||||
|
than rendering bare because the other field happened to be empty."""
|
||||||
|
token = SimpleNamespace(
|
||||||
|
name="--fs-success", value_by_mode={"base": "#4a5d3f"},
|
||||||
|
group_name=None, purpose=None, rationale="equals Moss, by design",
|
||||||
|
)
|
||||||
|
assert "equals Moss, by design" in render_stylesheet([token])
|
||||||
|
|
||||||
|
|
||||||
|
def test_purpose_wins_over_rationale_in_the_comment():
|
||||||
|
"""What a token is FOR is what a reader of the stylesheet needs first."""
|
||||||
|
token = SimpleNamespace(
|
||||||
|
name="--fs-x", value_by_mode={"base": "1px"},
|
||||||
|
group_name=None, purpose="hairline borders", rationale="because thin",
|
||||||
|
)
|
||||||
|
css = render_stylesheet([token])
|
||||||
|
assert "hairline borders" in css
|
||||||
|
assert "because thin" not in css
|
||||||
|
|||||||
@@ -398,7 +398,8 @@ async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself():
|
|||||||
contributions=(
|
contributions=(
|
||||||
{"base": (Contribution(system_id=1, value=base),)} if base else {}
|
{"base": (Contribution(system_id=1, value=base),)} if base else {}
|
||||||
),
|
),
|
||||||
group_name=None, purpose=None, supersedes=(), order_index=0,
|
group_name=None, purpose=None, rationale=None,
|
||||||
|
supersedes=(), order_index=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
system = MagicMock()
|
system = MagicMock()
|
||||||
|
|||||||
Reference in New Issue
Block a user