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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user