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
186 lines
7.3 KiB
Python
186 lines
7.3 KiB
Python
"""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()
|
|
]
|