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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ from scribe.services.design_systems import DesignSystemCycle
|
||||
async def create_design_system(
|
||||
title: str,
|
||||
description: str = "",
|
||||
guidance: str = "",
|
||||
parent_id: int = 0,
|
||||
) -> dict:
|
||||
"""Create a design system, optionally inheriting from another.
|
||||
@@ -34,6 +35,8 @@ async def create_design_system(
|
||||
Args:
|
||||
title: What this system is, e.g. "FabledSword" or "Scribe" (required).
|
||||
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
|
||||
overrides. Omit (0) for a top-level "family" system, which is what
|
||||
a first design system usually is.
|
||||
@@ -43,6 +46,7 @@ async def create_design_system(
|
||||
uid,
|
||||
title=title,
|
||||
description=description or None,
|
||||
guidance=guidance or None,
|
||||
parent_id=parent_id or None,
|
||||
)
|
||||
if system is None:
|
||||
@@ -98,6 +102,7 @@ async def update_design_system(
|
||||
design_system_id: int,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
guidance: str = "",
|
||||
parent_id: int = 0,
|
||||
) -> dict:
|
||||
"""Update a design system.
|
||||
@@ -106,6 +111,7 @@ async def update_design_system(
|
||||
design_system_id: The system to update.
|
||||
title: New title, 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
|
||||
family system), positive = inherit from that system. A parent that
|
||||
already inherits from this system is refused — that would be a loop.
|
||||
@@ -116,6 +122,8 @@ async def update_design_system(
|
||||
fields["title"] = title
|
||||
if description:
|
||||
fields["description"] = description
|
||||
if guidance:
|
||||
fields["guidance"] = guidance
|
||||
if parent_id:
|
||||
fields["parent_id"] = None if parent_id == -1 else parent_id
|
||||
try:
|
||||
@@ -246,6 +254,7 @@ async def create_design_token(
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str = "",
|
||||
purpose: str = "",
|
||||
rationale: str = "",
|
||||
supersedes: list | None = None,
|
||||
order_index: int = 0,
|
||||
) -> dict:
|
||||
@@ -262,6 +271,9 @@ async def create_design_token(
|
||||
group_name: Free-text grouping — "surface", "text", "radius", whatever
|
||||
this system's own vocabulary is.
|
||||
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.
|
||||
["#fff", "#ffffff"]. This is how a design system records what a
|
||||
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,
|
||||
group_name=group_name or None,
|
||||
purpose=purpose or None,
|
||||
rationale=rationale or None,
|
||||
supersedes=supersedes,
|
||||
order_index=order_index,
|
||||
)
|
||||
@@ -299,6 +312,7 @@ async def update_design_token(
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str = "",
|
||||
purpose: str = "",
|
||||
rationale: str = "",
|
||||
supersedes: list | None = None,
|
||||
order_index: int = -1,
|
||||
) -> dict:
|
||||
@@ -318,6 +332,8 @@ async def update_design_token(
|
||||
fields["group_name"] = group_name
|
||||
if purpose:
|
||||
fields["purpose"] = purpose
|
||||
if rationale:
|
||||
fields["rationale"] = rationale
|
||||
# `is not None`, not truthiness: `[]` is a meaningful edit (drop every
|
||||
# superseded literal) and would otherwise be unreachable.
|
||||
if supersedes is not None:
|
||||
|
||||
@@ -32,6 +32,11 @@ class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
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
|
||||
# system that inherited from it. Orphaning turns each child into a root that
|
||||
# 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,
|
||||
"title": self.title,
|
||||
"description": self.description or "",
|
||||
"guidance": self.guidance or "",
|
||||
"parent_id": self.parent_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_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.
|
||||
group_name: 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",
|
||||
# "#ffffff"] on a text-on-action token.
|
||||
#
|
||||
@@ -137,6 +148,7 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"value_by_mode": self.value_by_mode or {},
|
||||
"group_name": self.group_name,
|
||||
"purpose": self.purpose,
|
||||
"rationale": self.rationale,
|
||||
"supersedes": self.supersedes or [],
|
||||
"order_index": self.order_index,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
|
||||
@@ -54,6 +54,7 @@ async def create_design_system():
|
||||
user_id=_uid(),
|
||||
title=title,
|
||||
description=data.get("description") or None,
|
||||
guidance=data.get("guidance") or None,
|
||||
parent_id=data.get("parent_id"),
|
||||
)
|
||||
if system is None:
|
||||
@@ -74,7 +75,9 @@ async def get_design_system(design_system_id: int):
|
||||
@login_required
|
||||
async def update_design_system(design_system_id: int):
|
||||
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",
|
||||
# which a `if data.get("parent_id")` filter would silently drop.
|
||||
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"),
|
||||
group_name=data.get("group_name") or None,
|
||||
purpose=data.get("purpose") or None,
|
||||
rationale=data.get("rationale") or None,
|
||||
supersedes=data.get("supersedes"),
|
||||
order_index=data.get("order_index") or 0,
|
||||
)
|
||||
@@ -218,8 +222,8 @@ async def update_design_token(token_id: int):
|
||||
fields = {
|
||||
k: v for k, v in data.items()
|
||||
if k in (
|
||||
"name", "value_by_mode", "group_name", "purpose", "supersedes",
|
||||
"order_index",
|
||||
"name", "value_by_mode", "group_name", "purpose", "rationale",
|
||||
"supersedes", "order_index",
|
||||
)
|
||||
}
|
||||
token = await ds_svc.update_token(_uid(), token_id, **fields)
|
||||
|
||||
@@ -97,6 +97,7 @@ class ResolvedToken:
|
||||
contributions: dict[str, tuple[Contribution, ...]]
|
||||
group_name: str | None
|
||||
purpose: str | None
|
||||
rationale: str | None
|
||||
supersedes: tuple[str, ...]
|
||||
order_index: int
|
||||
|
||||
@@ -133,6 +134,7 @@ class ResolvedToken:
|
||||
"name": self.name,
|
||||
"group_name": self.group_name,
|
||||
"purpose": self.purpose,
|
||||
"rationale": self.rationale,
|
||||
"supersedes": list(self.supersedes),
|
||||
"order_index": self.order_index,
|
||||
"value_by_mode": self.value_by_mode,
|
||||
@@ -210,11 +212,11 @@ def resolve_tokens(
|
||||
meta = metadata.setdefault(
|
||||
token.name,
|
||||
{
|
||||
"group_name": None, "purpose": None,
|
||||
"group_name": None, "purpose": None, "rationale": None,
|
||||
"supersedes": None, "order_index": None,
|
||||
},
|
||||
)
|
||||
for field in ("group_name", "purpose"):
|
||||
for field in ("group_name", "purpose", "rationale"):
|
||||
if meta[field] is None:
|
||||
meta[field] = getattr(token, field, None)
|
||||
# order_index alone treats 0 as UNSTATED rather than "first",
|
||||
@@ -240,6 +242,7 @@ def resolve_tokens(
|
||||
},
|
||||
group_name=metadata[name]["group_name"],
|
||||
purpose=metadata[name]["purpose"],
|
||||
rationale=metadata[name]["rationale"],
|
||||
supersedes=metadata[name]["supersedes"] or (),
|
||||
order_index=metadata[name]["order_index"] or 0,
|
||||
)
|
||||
|
||||
@@ -164,7 +164,14 @@ def render_stylesheet(
|
||||
f" /* {name}: value rejected — not a safe CSS value */"
|
||||
)
|
||||
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 ""
|
||||
entries.append(f" {name}: {value};{comment}")
|
||||
if entries:
|
||||
|
||||
@@ -76,6 +76,7 @@ async def create_design_system(
|
||||
user_id: int,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
guidance: str | None = None,
|
||||
parent_id: int | None = None,
|
||||
) -> DesignSystem | None:
|
||||
"""Create a system, with or without a parent.
|
||||
@@ -92,6 +93,7 @@ async def create_design_system(
|
||||
owner_user_id=user_id,
|
||||
title=title.strip(),
|
||||
description=description,
|
||||
guidance=guidance,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
session.add(system)
|
||||
@@ -158,7 +160,7 @@ async def update_design_system(
|
||||
system.parent_id = parent_id
|
||||
|
||||
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)
|
||||
system.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
@@ -188,6 +190,7 @@ async def create_token(
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str | None = None,
|
||||
purpose: str | None = None,
|
||||
rationale: str | None = None,
|
||||
supersedes: list | None = None,
|
||||
order_index: int = 0,
|
||||
) -> DesignToken | None:
|
||||
@@ -203,6 +206,7 @@ async def create_token(
|
||||
value_by_mode=value_by_mode or {},
|
||||
group_name=group_name,
|
||||
purpose=purpose,
|
||||
rationale=rationale,
|
||||
# `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
|
||||
# null instead of an empty array.
|
||||
@@ -281,8 +285,8 @@ async def update_token(
|
||||
user_id: int, token_id: int, **fields: object
|
||||
) -> DesignToken | None:
|
||||
allowed = {
|
||||
"name", "value_by_mode", "group_name", "purpose", "supersedes",
|
||||
"order_index",
|
||||
"name", "value_by_mode", "group_name", "purpose", "rationale",
|
||||
"supersedes", "order_index",
|
||||
}
|
||||
async with async_session() as session:
|
||||
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]}))
|
||||
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["local_definitions"] == []
|
||||
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=(
|
||||
{"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()
|
||||
|
||||
Reference in New Issue
Block a user