CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 41s
The operator's check: this must be a system for managing design systems, not one
with the FabledSword family built into it.
No LOGIC was coupled — the audit found zero behavioural dependencies. But every
docstring example, every UI placeholder and several comments named this install's
palette, so a stranger creating their first design system was shown
"FabledSword" as the expected shape and `--fs-obsidian` as the expected token.
Examples teach, and these taught the wrong thing.
Placeholders now describe the SHAPE ("Your house style", "--surface-page")
rather than naming one instance's contents, and the token-name placeholder now
says the thing worth saying: name it for its purpose, because `--obsidian` and
`--button-bg` both stop being true the moment the value or the element changes.
Not fixed here, and it is the one real coupling left: DesignView.vue hardcodes
rule 65's button variants and rule 60's type scale as literal arrays, so a
stranger's Design page would display this family's specs. Those arrays exist
because there was no design system to read from — which there now is. They go
when the panel is repointed (#2295), not before.
Scribe's own stylesheet comments ("Moss action-primary per Hybrid") are left
alone: that is the app CONSUMING the family style, which is what dogfooding
looks like, not the tool assuming it.
367 lines
14 KiB
Python
367 lines
14 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 — 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.
|
|
"""
|
|
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
|
|
|
|
|
|
# ── 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_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)
|