diff --git a/alembic/versions/0074_design_prose.py b/alembic/versions/0074_design_prose.py new file mode 100644 index 0000000..b48971c --- /dev/null +++ b/alembic/versions/0074_design_prose.py @@ -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") diff --git a/frontend/src/api/designSystems.ts b/frontend/src/api/designSystems.ts index c857d73..f9333fe 100644 --- a/frontend/src/api/designSystems.ts +++ b/frontend/src/api/designSystems.ts @@ -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; 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; @@ -72,13 +77,19 @@ export const fetchDesignSystem = (id: number) => export const createDesignSystem = (body: { title: string; description?: string; + guidance?: string; parent_id?: number | null; }) => apiPost("/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(`/api/design-systems/${id}`, body); export const deleteDesignSystem = (id: number) => @@ -101,6 +112,7 @@ export const createDesignToken = ( value_by_mode?: Record; group_name?: string | null; purpose?: string | null; + rationale?: string | null; supersedes?: string[]; order_index?: number; }, diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue index 9736eca..f62ece5 100644 --- a/frontend/src/views/DesignSystemsView.vue +++ b/frontend/src/views/DesignSystemsView.vue @@ -179,6 +179,7 @@ async function submitCreate() { const editTitle = ref(""); const editDescription = ref(""); +const editGuidance = ref(""); const editParentId = ref(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 { +
+ + +

+ The prose a token table can't hold. Kept here rather than + scattered, so the system carries its own reasoning. +

+
+

+ Distinct from purpose: purpose is what the token is FOR, this is + why it is this value. +

+
+
{{ token.name }} {{ token.purpose }} + {{ token.rationale }} instead of {{ token.supersedes.join(", ") }} @@ -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; } diff --git a/src/scribe/mcp/tools/design_systems.py b/src/scribe/mcp/tools/design_systems.py index 3620a0f..f8777bb 100644 --- a/src/scribe/mcp/tools/design_systems.py +++ b/src/scribe/mcp/tools/design_systems.py @@ -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: diff --git a/src/scribe/models/design_system.py b/src/scribe/models/design_system.py index a64e557..8aa2c6e 100644 --- a/src/scribe/models/design_system.py +++ b/src/scribe/models/design_system.py @@ -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, diff --git a/src/scribe/routes/design_systems.py b/src/scribe/routes/design_systems.py index 8a672a8..4c0e015 100644 --- a/src/scribe/routes/design_systems.py +++ b/src/scribe/routes/design_systems.py @@ -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) diff --git a/src/scribe/services/design_cascade.py b/src/scribe/services/design_cascade.py index 1fd894c..89ca254 100644 --- a/src/scribe/services/design_cascade.py +++ b/src/scribe/services/design_cascade.py @@ -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, ) diff --git a/src/scribe/services/design_stylesheet.py b/src/scribe/services/design_stylesheet.py index c734ec9..6ead872 100644 --- a/src/scribe/services/design_stylesheet.py +++ b/src/scribe/services/design_stylesheet.py @@ -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: diff --git a/src/scribe/services/design_systems.py b/src/scribe/services/design_systems.py index e985380..de6a1af 100644 --- a/src/scribe/services/design_systems.py +++ b/src/scribe/services/design_systems.py @@ -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) diff --git a/tests/test_design_cascade.py b/tests/test_design_cascade.py index f8bea91..9083c33 100644 --- a/tests/test_design_cascade.py +++ b/tests/test_design_cascade.py @@ -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 diff --git a/tests/test_design_stylesheet.py b/tests/test_design_stylesheet.py index a9bb674..56e9e62 100644 --- a/tests/test_design_stylesheet.py +++ b/tests/test_design_stylesheet.py @@ -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 diff --git a/tests/test_services_design_systems.py b/tests/test_services_design_systems.py index d4c00ea..387781a 100644 --- a/tests/test_services_design_systems.py +++ b/tests/test_services_design_systems.py @@ -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()