feat(design): offer starter token ROLES at creation, never values
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 46s
CI & Build / Build & push image (push) Skipped

A literal gets written into a stylesheet when there is no role to reach for.
This codebase demonstrated it: the house style had no "text on a filled colour"
role, so 76 call sites wrote a pure-white literal — not out of defiance, but
because nothing existed to write instead. The correction was not a better ban
list; it was declaring the missing role (#2275, #2349).

So the useful moment is creation. A system whose roles are named on day one
never presents the occasion.

Ten groups, ~40 roles: surface, text, action, semantic, border, accent, radius,
space, motion, state. Operator's call was one flat list, every group
individually skippable — presets keyed to app shape (web / CLI / docs) were
rejected because they need the product to hold opinions about app categories,
and a wrong category is worse than a list someone prunes once.

TWO BOUNDARIES THIS HAS TO HOLD, both rule #115:

- The ROLES ship; the VALUES never do. Every seeded token has an empty
  value_by_mode, so a fresh system is a set of named, deliberately-unanswered
  questions. A test asserts no hex appears anywhere in the module — not just
  that tokens are blank, but that no palette hides in a comment waiting to be
  pasted in.
- The PREFIX is the install's. `--fs-` is FabledSword's convention, not the
  product's; the default is a neutral `--ds-` and callers pass their own.

Valueless roles are already legible downstream — render_stylesheet emits them
as commented-out declarations and stylesheet_for_system reports them under
`valueless` (#2299) — so "declared but undecided" reads correctly with nothing
new built.

Both surfaces, per rule #33: MCP gains starter_role_groups/token_prefix plus
list_starter_role_groups(); REST gains the same on POST plus
GET /api/design-systems/starter-roles. The parity enumeration is extended
rather than loosened.

Note create_design_system treats None and [] alike (seed nothing), while
starter_tokens treats None as "all". Deliberate: creation must never write 40
rows into a system whose caller never asked, and the everything-checked default
belongs in the UI where the operator can see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
2026-08-03 11:37:20 -04:00
co-authored by Claude Opus 5
parent 5795fa908a
commit 22f907c44d
6 changed files with 397 additions and 1 deletions
+35
View File
@@ -22,6 +22,11 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import design_systems as ds_svc
from scribe.services.design_systems import DesignSystemCycle
from scribe.services.design_starter_roles import (
ALL_GROUPS,
DEFAULT_TOKEN_PREFIX,
describe_groups,
)
async def create_design_system(
@@ -29,6 +34,8 @@ async def create_design_system(
description: str = "",
guidance: str = "",
parent_id: int = 0,
starter_role_groups: list[str] | None = None,
token_prefix: str = "",
) -> dict:
"""Create a design system, optionally inheriting from another.
@@ -41,20 +48,47 @@ async def create_design_system(
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.
starter_role_groups: Seed the system with named but VALUELESS token
roles, so there is something to reach for before a literal gets
written instead. Call list_starter_role_groups() for the catalogue.
Pass ["all"] for every group. Omit for none — a system with three
hand-written tokens is a legitimate design system.
token_prefix: Naming convention for the seeded roles, e.g. "--fs-".
Defaults to a neutral "--ds-"; pass the install's own if it has one.
Ignored when no starter groups are requested.
"""
uid = current_user_id()
groups = starter_role_groups
if groups and len(groups) == 1 and groups[0] == "all":
groups = list(ALL_GROUPS)
system = await ds_svc.create_design_system(
uid,
title=title,
description=description or None,
guidance=guidance or None,
parent_id=parent_id or None,
starter_role_groups=groups,
token_prefix=token_prefix or DEFAULT_TOKEN_PREFIX,
)
if system is None:
raise ValueError(f"parent design system {parent_id} not found or not writable")
return system.to_dict()
async def list_starter_role_groups() -> dict:
"""The starter token ROLES offered at design-system creation.
Roles, not values. Every group is a set of named questions — "page
background, the deepest surface" — that the operator answers with their own
palette. Nothing here carries a colour, because a default palette would be
one install's taste shipped as product.
Reach for this before create_design_system so the choice is informed, and
pass the group names you want as `starter_role_groups`.
"""
return {"groups": describe_groups(), "default_prefix": DEFAULT_TOKEN_PREFIX}
async def list_design_systems() -> dict:
"""List your design systems. An empty list is normal — most installs have none."""
uid = current_user_id()
@@ -350,6 +384,7 @@ async def set_project_design_system(project_id: int, design_system_id: int = 0)
def register(mcp) -> None:
for fn in (
create_design_system,
list_starter_role_groups,
list_design_systems,
get_design_system,
resolve_design_system,
+19
View File
@@ -19,6 +19,10 @@ from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required
from scribe.services import design_systems as ds_svc
from scribe.services.design_starter_roles import (
DEFAULT_TOKEN_PREFIX,
describe_groups,
)
from scribe.services.design_systems import DesignSystemCycle
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
@@ -56,12 +60,27 @@ async def create_design_system():
description=data.get("description") or None,
guidance=data.get("guidance") or None,
parent_id=data.get("parent_id"),
starter_role_groups=data.get("starter_role_groups"),
token_prefix=data.get("token_prefix") or DEFAULT_TOKEN_PREFIX,
)
if system is None:
return jsonify({"error": "parent design system not found"}), 404
return jsonify(system.to_dict()), 201
@design_systems_bp.get("/design-systems/starter-roles")
@login_required
async def list_starter_role_groups():
"""The starter role catalogue, for the creation form's checklist.
Roles and purposes only — no values, ever. See services/design_starter_roles.
"""
return jsonify({
"groups": describe_groups(),
"default_prefix": DEFAULT_TOKEN_PREFIX,
})
@design_systems_bp.get("/design-systems/<int:design_system_id>")
@login_required
async def get_design_system(design_system_id: int):
+185
View File
@@ -0,0 +1,185 @@
"""A starter set of token ROLES, offered when a design system is created.
WHY THIS EXISTS
---------------
A literal gets written into a stylesheet when there is no role to reach for.
That is the mechanism, and this codebase produced a clean demonstration of it:
the house style had no "text on a filled colour" role, so 76 call sites wrote
a pure-white literal — not out of defiance, but because nothing existed to write
instead (#2275). The correction was not a better ban list. It was declaring the
missing role.
So the useful moment is CREATION. A system whose roles are named on day one
never presents the occasion for a literal, and never needs a list of values it
forbids.
WHAT SHIPS AND WHAT DOES NOT (rule #115)
----------------------------------------
The ROLES ship: `surface-page`, `text-primary`, `action-destructive` are
generic CSS-design vocabulary, not one operator's kit. Every install that has a
page has a page background.
The VALUES never ship. Each token is created with an empty `value_by_mode`, so
a fresh system is a set of named, deliberately-unanswered questions. No hex
appears anywhere in this file, and none should ever be added to it — a default
palette would be this operator's palette wearing product clothes.
A valueless token is already legible downstream: `render_stylesheet` emits it as
a commented-out declaration in its group (#2299), and `stylesheet_for_system`
reports it under `valueless`. So a blank role reads as "to be decided" rather
than as breakage, without anything new.
THE PREFIX IS THE INSTALL'S
---------------------------
`--fs-` is FabledSword's convention, not the product's. The prefix is a
parameter with a neutral default; a caller that has a house convention passes
it. Baking `--fs-` in would put one family's naming into every install.
FLAT, NOT PRESET
----------------
One list, every group individually skippable, all on by default (operator's
call, 2026-08-03). Presets keyed to app shape — web / CLI / docs — were
considered and rejected: they would require the product to hold opinions about
app categories, and a wrong category is worse than a generic list someone
prunes once.
"""
from __future__ import annotations
DEFAULT_TOKEN_PREFIX = "--ds-"
# group -> (what the group is for, ((role suffix, purpose), ...))
#
# Purposes are written as the QUESTION the operator is answering, because that
# is what an unfilled role is. "Page background, the deepest surface" tells you
# what to put there; "Colour 1" does not.
STARTER_ROLE_GROUPS: dict[str, tuple[str, tuple[tuple[str, str], ...]]] = {
"surface": (
"Backgrounds, by elevation",
(
("surface-page", "Page background, the deepest surface"),
("surface-raised", "Cards and raised elements"),
("surface-hover", "Hovered surfaces, secondary elevation"),
),
),
"text": (
"Foreground colours, by emphasis",
(
("text-primary", "Primary text on a page or raised surface"),
("text-secondary", "Secondary text and captions"),
("text-tertiary", "Hints and metadata"),
# The role whose absence caused 76 literals. It is in the starter
# set deliberately: text on a filled colour is NOT the page text
# colour, because the surface under it does not change with the
# mode while the page does.
("text-on-action", "Text on a filled colour — buttons, badges"),
),
),
"action": (
"What the user can do — kept separate from the accent, which is identity",
(
("action-primary", "The confirming action: Save, Submit"),
("action-secondary", "Non-destructive alternates"),
("action-destructive", "Irreversible actions — delete, revoke"),
),
),
"semantic": (
"What the system is telling you",
(
("success", "Something worked"),
("warning", "Something needs attention"),
("error", "Something failed — distinct from destructive"),
("info", "Neutral information"),
),
),
"border": (
"Boundaries and dividers",
(
("border-color", "The line colour itself"),
("border", "The default structural border, as a shorthand"),
("border-hover", "Border on hover or emphasis"),
("border-active", "Selected or current — the one border that may carry the accent"),
),
),
"accent": (
"This install's identity — not its actions",
(
("accent", "The single signature colour"),
("accent-soft", "Tinted backgrounds — pills, tags"),
("accent-faint", "The faintest wash"),
),
),
"radius": (
"Corner rounding",
(
("radius-sm", "Pills, tags, code spans"),
("radius-md", "Buttons, inputs, small cards"),
("radius-lg", "Cards, panels, modals"),
),
),
"space": (
"The spacing scale — a gap not on the scale is a decision to justify",
tuple((f"space-{i}", f"Spacing step {i}") for i in range(1, 11)),
),
"motion": (
"Transition timing — motion supports the interaction, never performs",
(
("ease", "The one easing curve, used by every transition"),
("dur-fast", "Hovers, colour and border changes"),
("dur-base", "Most state changes"),
("dur-slow", "Larger surface or layout shifts"),
),
),
"state": (
"Cross-cutting states that are otherwise improvised per view",
(
("disabled-opacity", "Opacity for disabled controls"),
("overlay", "Scrim behind modals and dialogs"),
),
),
}
ALL_GROUPS: tuple[str, ...] = tuple(STARTER_ROLE_GROUPS)
def starter_tokens(
groups: list[str] | tuple[str, ...] | None = None,
prefix: str = DEFAULT_TOKEN_PREFIX,
) -> list[dict]:
"""Token rows for the chosen groups — names and purposes only, no values.
`groups` of None means every group; an empty list means none, which is a
real answer and not the same as None. An operator who wants three tokens
should be able to get three.
Unknown group names are ignored rather than raising: this feeds a
checkbox list, and a stale name from an older client should not fail a
creation that is otherwise fine.
"""
chosen = ALL_GROUPS if groups is None else [g for g in groups if g in STARTER_ROLE_GROUPS]
rows: list[dict] = []
for group in chosen:
_, roles = STARTER_ROLE_GROUPS[group]
for index, (suffix, purpose) in enumerate(roles, start=1):
rows.append({
"name": f"{prefix}{suffix}",
"group_name": group,
"purpose": purpose,
# Empty, not absent: the column is NOT NULL with a {} default,
# so absence has exactly one spelling here as it does there.
"value_by_mode": {},
"order_index": index,
})
return rows
def describe_groups() -> list[dict]:
"""The catalogue, for a UI to render as a checklist."""
return [
{
"group": group,
"description": description,
"token_count": len(roles),
"names": [suffix for suffix, _ in roles],
}
for group, (description, roles) in STARTER_ROLE_GROUPS.items()
]
+21
View File
@@ -27,6 +27,10 @@ from scribe.services.design_stylesheet import (
duplicate_values,
render_stylesheet,
)
from scribe.services.design_starter_roles import (
DEFAULT_TOKEN_PREFIX,
starter_tokens,
)
from scribe.services.design_cascade import (
ResolvedToken,
ancestry,
@@ -79,11 +83,23 @@ async def create_design_system(
description: str | None = None,
guidance: str | None = None,
parent_id: int | None = None,
starter_role_groups: list[str] | None = None,
token_prefix: str = DEFAULT_TOKEN_PREFIX,
) -> DesignSystem | None:
"""Create a system, with or without a parent.
Returns None when `parent_id` names a system the caller may not write —
which, per the ACL, means one they do not own.
`starter_role_groups` seeds the system with named, VALUELESS token roles
(#2349) — the moment a role is missing is the moment a literal gets written
instead, so the cheapest time to name them is now. Pass a list of group
names to choose, `[]` for none, or None for none.
None and `[]` deliberately mean the same thing here, unlike in
`starter_tokens` where None means "all": creation must not seed 40 rows
into a system whose caller never asked. Opting in is the caller's job, and
the UI's default of everything-checked lives in the UI.
"""
if parent_id is not None and not await access.can_write_design_system(
user_id, parent_id
@@ -100,6 +116,11 @@ async def create_design_system(
session.add(system)
await session.commit()
await session.refresh(system)
if starter_role_groups:
for row in starter_tokens(starter_role_groups, prefix=token_prefix):
session.add(DesignToken(design_system_id=system.id, **row))
await session.commit()
return system