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:
@@ -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