Files
FabledGrimoire/src/grimoire/services/design_starter_roles.py
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

185 lines
7.2 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.
A house style with no "text on a filled colour" role gets a pure-white literal
at every call site that needs one — not out of defiance, but because nothing
exists to write instead. The correction is not a better ban list. It is
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
----------------------------
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, 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
---------------------------
A house prefix such as `--fs-` is one family'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 one in would put one family's naming into
every install.
FLAT, NOT PRESET
----------------
One list, every group individually skippable, all on by default. Presets
keyed to app shape — web / CLI / docs — are not offered: 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()
]