"""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() ]