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
+44
View File
@@ -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")
+13 -1
View File
@@ -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;
}, },
+37
View File
@@ -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;
} }
+16
View File
@@ -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:
+12
View File
@@ -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,
+7 -3
View File
@@ -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)
+5 -2
View File
@@ -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,
) )
+8 -1
View File
@@ -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:
+7 -3
View File
@@ -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)
+31
View File
@@ -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
+21
View File
@@ -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
+2 -1
View File
@@ -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()