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
402 lines
16 KiB
Python
402 lines
16 KiB
Python
"""Design system + token MCP tools — wrappers over services/design_systems.py.
|
|
|
|
A design system is a stylesheet held as records: a named set of tokens with an
|
|
optional parent, so a family system carries the house style and an app system
|
|
carries only what it changes. Precedence by name along the parent chain IS the
|
|
CSS cascade, which is why "what does this app alter?" is a plain list rather
|
|
than a diff.
|
|
|
|
Parity with the REST surface is a rule, not a nicety (see
|
|
`tests/test_routes_design_systems.py`): an agent and a browser are two callers
|
|
of one service.
|
|
|
|
Sentinels, matching the milestone/task tool conventions:
|
|
- title="" / description="" / etc. -> "leave unchanged" on update
|
|
- parent_id / design_system_id: 0 = leave unchanged, -1 = clear, positive = set
|
|
(three states, because clearing a parent is a real operation and not the
|
|
same as omitting the argument)
|
|
- order_index=-1 -> "leave unchanged" (0 is a valid order_index)
|
|
"""
|
|
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(
|
|
title: str,
|
|
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.
|
|
|
|
Args:
|
|
title: What this system is — a house style, or one app within it
|
|
(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.
|
|
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()
|
|
rows = await ds_svc.list_design_systems(uid)
|
|
return {"design_systems": [s.to_dict() for s in rows]}
|
|
|
|
|
|
async def get_design_system(design_system_id: int) -> dict:
|
|
"""Fetch a design system plus its OWN tokens — i.e. what it changes.
|
|
|
|
For what it actually resolves to once inheritance is applied, use
|
|
`resolve_design_system`. The two answer different questions and a system
|
|
that overrides nothing has an empty token list but a full resolved set.
|
|
"""
|
|
uid = current_user_id()
|
|
system = await ds_svc.get_design_system(uid, design_system_id)
|
|
if system is None:
|
|
raise ValueError(f"design system {design_system_id} not found")
|
|
tokens = await ds_svc.list_tokens(uid, design_system_id)
|
|
return {
|
|
"design_system": system.to_dict(),
|
|
"tokens": [t.to_dict() for t in tokens],
|
|
}
|
|
|
|
|
|
async def resolve_design_system(design_system_id: int) -> dict:
|
|
"""The EFFECTIVE token set — everything inherited, with this system's on top.
|
|
|
|
Each token carries `origin_by_mode` (which system supplied each mode's
|
|
value) and `contributions` (every system that offered one, deepest first —
|
|
so entry 0 won and the rest were shadowed). Reach for this when you need to
|
|
know what a value actually IS; reach for `get_design_system` when you need
|
|
to know what this system CHANGES.
|
|
"""
|
|
uid = current_user_id()
|
|
resolved = await ds_svc.resolve_design_system(uid, design_system_id)
|
|
if resolved is None:
|
|
raise ValueError(f"design system {design_system_id} not found")
|
|
return {
|
|
"design_system_id": design_system_id,
|
|
"tokens": [t.to_dict() for t in resolved],
|
|
}
|
|
|
|
|
|
async def update_design_system(
|
|
design_system_id: int,
|
|
title: str = "",
|
|
description: str = "",
|
|
guidance: str = "",
|
|
parent_id: int = 0,
|
|
) -> dict:
|
|
"""Update a design system.
|
|
|
|
Args:
|
|
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.
|
|
"""
|
|
uid = current_user_id()
|
|
fields: dict = {}
|
|
if title:
|
|
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:
|
|
system = await ds_svc.update_design_system(uid, design_system_id, **fields)
|
|
except DesignSystemCycle as exc:
|
|
raise ValueError(str(exc)) from exc
|
|
if system is None:
|
|
raise ValueError(f"design system {design_system_id} not found or not writable")
|
|
return system.to_dict()
|
|
|
|
|
|
async def delete_design_system(design_system_id: int) -> dict:
|
|
"""Soft-delete a design system (recoverable).
|
|
|
|
Systems that inherited from it become top-level systems keeping their own
|
|
tokens — deleting a family does not delete the apps under it.
|
|
"""
|
|
uid = current_user_id()
|
|
if not await ds_svc.delete_design_system(uid, design_system_id):
|
|
raise ValueError(f"design system {design_system_id} not found or not writable")
|
|
return {"message": f"Design system {design_system_id} deleted."}
|
|
|
|
|
|
async def get_design_system_stylesheet(
|
|
design_system_id: int,
|
|
root_selector: str = ":root",
|
|
) -> dict:
|
|
"""The master CSS sheet a design system generates.
|
|
|
|
Purpose tokens only — this sheet declares what values MEAN and styles no
|
|
elements. Components (buttons, tables, input schemes) are SNIPPETS that
|
|
reference these names, so a value is stated once and reused rather than
|
|
restated per element. Reach for this when you need the tokens a snippet is
|
|
allowed to use.
|
|
|
|
Also returns `valueless` (tokens the system names but has no value for) and
|
|
`duplicates` (values declared under more than one name — a deliberate alias,
|
|
or one idea recorded twice).
|
|
|
|
Args:
|
|
design_system_id: The system to render.
|
|
root_selector: Selector for the base layer. Defaults to `:root`; pass a
|
|
container selector to scope the sheet to a preview region.
|
|
"""
|
|
uid = current_user_id()
|
|
result = await ds_svc.stylesheet_for_system(uid, design_system_id, root_selector)
|
|
if result is None:
|
|
raise ValueError(f"design system {design_system_id} not found")
|
|
return result
|
|
|
|
|
|
async def check_snippets_against_design_system(
|
|
design_system_id: int,
|
|
project_id: int = 0,
|
|
) -> dict:
|
|
"""Which recorded snippets disagree with a design system's sheet.
|
|
|
|
Snippets are the component layer — buttons, tables, input schemes — and they
|
|
are supposed to use the tags the sheet declares. Three findings per snippet,
|
|
each currently silent in the codebase:
|
|
|
|
unknown `var(--x)` where the system has no `--x`. Renders as
|
|
NOTHING: no error, no failing test, just an element
|
|
that quietly isn't styled.
|
|
superseded_literals a literal the sheet says to stop writing, paired with
|
|
the token to write instead.
|
|
local_definitions custom properties the snippet mints for itself rather
|
|
than using shared ones — the bloat a shared sheet
|
|
exists to prevent.
|
|
|
|
Snippets with nothing to report are omitted. Reach for this before writing
|
|
or reviewing component CSS.
|
|
|
|
Args:
|
|
design_system_id: The system whose sheet is authoritative.
|
|
project_id: Narrow to one project, or 0 for every project.
|
|
"""
|
|
uid = current_user_id()
|
|
result = await ds_svc.check_snippets_against_system(
|
|
uid, design_system_id, project_id
|
|
)
|
|
if result is None:
|
|
raise ValueError(f"design system {design_system_id} not found")
|
|
return result
|
|
|
|
|
|
# ── Tokens ──────────────────────────────────────────────────────────────
|
|
|
|
async def create_design_token(
|
|
design_system_id: int,
|
|
name: str,
|
|
value_by_mode: dict | None = None,
|
|
group_name: str = "",
|
|
purpose: str = "",
|
|
rationale: str = "",
|
|
supersedes: list | None = None,
|
|
order_index: int = 0,
|
|
) -> dict:
|
|
"""Add a token to a design system.
|
|
|
|
Args:
|
|
design_system_id: The system that owns this token.
|
|
name: The custom-property name, e.g. "--surface-page" (required).
|
|
Name it for its PURPOSE, not its value: a name like "--obsidian"
|
|
or "--button-bg" stops being true the moment the value or the
|
|
element changes.
|
|
value_by_mode: Values keyed by mode, e.g.
|
|
{"base": "#14171a", "light": "#f7f5ef"}. Use "base" for the value
|
|
that applies when no mode is more specific; a token that is not
|
|
mode-dependent needs only "base". In a system WITH a parent, an
|
|
omitted mode is inherited rather than blanked.
|
|
group_name: Free-text grouping — "surface", "text", "radius", whatever
|
|
this system's own vocabulary is.
|
|
purpose: What the token is for, e.g. "page background, deepest
|
|
surface".
|
|
rationale: WHY it is this value — a different question from purpose.
|
|
"Deliberately the same value as the primary action colour" is a
|
|
rationale; "page background, 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
|
|
this token instead". Declare it rather than expecting it to be
|
|
inferred: a superseded literal and the token's own value are
|
|
usually different values, so nothing can connect them by matching.
|
|
order_index: Display position within its group.
|
|
"""
|
|
uid = current_user_id()
|
|
token = await ds_svc.create_token(
|
|
uid,
|
|
design_system_id=design_system_id,
|
|
name=name,
|
|
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,
|
|
)
|
|
if token is None:
|
|
raise ValueError(f"design system {design_system_id} not found or not writable")
|
|
return token.to_dict()
|
|
|
|
|
|
async def list_design_tokens(design_system_id: int) -> dict:
|
|
"""A design system's OWN tokens — its override set, not its effective set."""
|
|
uid = current_user_id()
|
|
rows = await ds_svc.list_tokens(uid, design_system_id)
|
|
return {"tokens": [t.to_dict() for t in rows]}
|
|
|
|
|
|
async def update_design_token(
|
|
token_id: int,
|
|
name: str = "",
|
|
value_by_mode: dict | None = None,
|
|
group_name: str = "",
|
|
purpose: str = "",
|
|
rationale: str = "",
|
|
supersedes: list | None = None,
|
|
order_index: int = -1,
|
|
) -> dict:
|
|
"""Update a token. Empty/None args leave a field unchanged.
|
|
|
|
`value_by_mode` and `supersedes` REPLACE their whole value rather than
|
|
merging into it, so send every entry you want the token to keep. Pass `[]`
|
|
to clear `supersedes` entirely.
|
|
"""
|
|
uid = current_user_id()
|
|
fields: dict = {}
|
|
if name:
|
|
fields["name"] = name
|
|
if value_by_mode is not None:
|
|
fields["value_by_mode"] = value_by_mode
|
|
if group_name:
|
|
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:
|
|
fields["supersedes"] = supersedes
|
|
if order_index >= 0:
|
|
fields["order_index"] = order_index
|
|
token = await ds_svc.update_token(uid, token_id, **fields)
|
|
if token is None:
|
|
raise ValueError(f"design token {token_id} not found or not writable")
|
|
return token.to_dict()
|
|
|
|
|
|
async def delete_design_token(token_id: int) -> dict:
|
|
"""Soft-delete a token (recoverable).
|
|
|
|
In a system with a parent this restores inheritance: the token stops being
|
|
overridden here and resolves to the parent's value again.
|
|
"""
|
|
uid = current_user_id()
|
|
if not await ds_svc.delete_token(uid, token_id):
|
|
raise ValueError(f"design token {token_id} not found or not writable")
|
|
return {"message": f"Design token {token_id} deleted."}
|
|
|
|
|
|
async def set_project_design_system(project_id: int, design_system_id: int = 0) -> dict:
|
|
"""Point a project at a design system.
|
|
|
|
Args:
|
|
project_id: The project to style.
|
|
design_system_id: The system it uses, or -1 to clear it. Pointing at a
|
|
system only requires READ access to it — consuming a design system
|
|
is not changing it.
|
|
"""
|
|
uid = current_user_id()
|
|
target = None if design_system_id == -1 else design_system_id
|
|
ok = await ds_svc.set_project_design_system(uid, project_id, target)
|
|
if not ok:
|
|
raise ValueError(
|
|
f"project {project_id} not writable, or design system "
|
|
f"{design_system_id} not found"
|
|
)
|
|
return {"project_id": project_id, "design_system_id": target}
|
|
|
|
|
|
def register(mcp) -> None:
|
|
for fn in (
|
|
create_design_system,
|
|
list_starter_role_groups,
|
|
list_design_systems,
|
|
get_design_system,
|
|
resolve_design_system,
|
|
update_design_system,
|
|
delete_design_system,
|
|
get_design_system_stylesheet,
|
|
check_snippets_against_design_system,
|
|
create_design_token,
|
|
list_design_tokens,
|
|
update_design_token,
|
|
delete_design_token,
|
|
set_project_design_system,
|
|
):
|
|
mcp.tool(name=fn.__name__)(fn)
|