CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Last piece of the architecture in #2296. The operator: "the prose doesn't have to live as one offs, there's a central system for managing it." Two fields, both free-form: design_systems.guidance the narrative a token table cannot hold — aesthetic, voice and tone, what is deliberately out of scope. design_tokens.rationale WHY a token is this value, which is a different question from `purpose` (what it is FOR). "Success equals Moss, aligned by design" is a rationale; "page bg, deepest surface" is a purpose. Rules carry the first routinely and a token row had nowhere to put it. Free-form rather than a column per category, deliberately. A schema with `voice`, `aesthetic` and `scope` columns would bake one rulebook's table of contents into every install (rule #115), leaving the next install three empty columns and nowhere for what it actually cares about. Both nullable: a design system with no prose at all is complete, not a draft. `rationale` cascades like `purpose` — deepest non-empty wins — so an app overriding a colour keeps the family's reasoning rather than blanking it. Same argument as `supersedes`: the override was about the value, not the meaning. In the generated sheet the inline comment prefers `purpose` and falls back to `rationale`, so a token carrying only the why still says something instead of rendering bare.
399 lines
15 KiB
Python
399 lines
15 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
|
|
|
|
|
|
async def create_design_system(
|
|
title: str,
|
|
description: str = "",
|
|
guidance: str = "",
|
|
parent_id: int = 0,
|
|
) -> dict:
|
|
"""Create a design system, optionally inheriting from another.
|
|
|
|
Args:
|
|
title: What this system is, e.g. "FabledSword" or "Scribe" (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.
|
|
"""
|
|
uid = current_user_id()
|
|
system = await ds_svc.create_design_system(
|
|
uid,
|
|
title=title,
|
|
description=description or None,
|
|
guidance=guidance or None,
|
|
parent_id=parent_id or None,
|
|
)
|
|
if system is None:
|
|
raise ValueError(f"parent design system {parent_id} not found or not writable")
|
|
return system.to_dict()
|
|
|
|
|
|
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
|
|
|
|
|
|
async def import_design_system_from_rulebook(
|
|
design_system_id: int,
|
|
rulebook_id: int,
|
|
apply: bool = False,
|
|
) -> dict:
|
|
"""Seed a design system from a rulebook that describes one in prose.
|
|
|
|
Reads the rulebook's colour and token declarations and proposes the tokens
|
|
they add up to — joining "Obsidian #14171A" in one rule to `--fs-obsidian`
|
|
in another, since neither alone is a token.
|
|
|
|
Defaults to a PREVIEW. An import is a proposal: rulebooks are written
|
|
aspirationally and some of what they describe was never built, so read
|
|
`proposed` before setting apply=True. Every entry carries the rule and
|
|
sentence it came from so the claim can be checked rather than trusted.
|
|
|
|
Existing token names are never overwritten — a second run fills gaps and
|
|
lists the rest under `skipped`, so it is safe to repeat.
|
|
|
|
Args:
|
|
design_system_id: The system to seed.
|
|
rulebook_id: The rulebook to read.
|
|
apply: False (default) previews; True writes the tokens.
|
|
"""
|
|
uid = current_user_id()
|
|
report = await ds_svc.import_from_rulebook(
|
|
uid, design_system_id, rulebook_id, apply=apply,
|
|
)
|
|
if report is None:
|
|
raise ValueError(
|
|
f"design system {design_system_id} not writable, or rulebook "
|
|
f"{rulebook_id} not readable"
|
|
)
|
|
return report
|
|
|
|
|
|
# ── 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. "--fs-obsidian" (required).
|
|
value_by_mode: Values keyed by mode, e.g.
|
|
{"base": "#f7f5ef", "dark": "#14171a"}. 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 bg, deepest surface".
|
|
rationale: WHY it is this value — a different question from purpose.
|
|
"Success equals Moss, aligned by design" is a rationale; "page bg,
|
|
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_design_systems,
|
|
get_design_system,
|
|
resolve_design_system,
|
|
update_design_system,
|
|
delete_design_system,
|
|
get_design_system_stylesheet,
|
|
check_snippets_against_design_system,
|
|
import_design_system_from_rulebook,
|
|
create_design_token,
|
|
list_design_tokens,
|
|
update_design_token,
|
|
delete_design_token,
|
|
set_project_design_system,
|
|
):
|
|
mcp.tool(name=fn.__name__)(fn)
|