Files
FabledScribe/src/scribe/routes/design_systems.py
T
bvandeusenandClaude Opus 5 22f907c44d
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
feat(design): offer starter token ROLES at creation, never values
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
2026-08-03 11:37:20 -04:00

256 lines
9.5 KiB
Python

"""Design system + token REST endpoints (milestone #254 step 4).
Wraps `services/design_systems.py`, which owns the ACL and the cycle guard.
Design systems are owner-scoped top-level records rather than project-scoped
ones, so these do NOT nest under `/api/projects/` — `routes/rulebooks.py` is the
closer shape.
Two failures this layer has to keep apart, which is why the service raises for
one and returns None for the other:
- `DesignSystemCycle` -> 400 with its message. "That parent already inherits
from this system" is a correctable mistake and the caller needs to be told
which one they made.
- None -> 404, covering both "no such system" and "not yours". Conflating
those two IS the intent: distinguishing them would confirm the existence of
records the caller may not see.
"""
from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required
from scribe.services import design_systems as ds_svc
from scribe.services.design_starter_roles import (
DEFAULT_TOKEN_PREFIX,
describe_groups,
)
from scribe.services.design_systems import DesignSystemCycle
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
def _not_found(what: str = "design system"):
return jsonify({"error": f"{what} not found"}), 404
# ── Design systems ──────────────────────────────────────────────────────
@design_systems_bp.get("/design-systems")
@login_required
async def list_design_systems():
"""The caller's design systems. An empty list is the ordinary state for an
install that has never made one, not an error."""
rows = await ds_svc.list_design_systems(_uid())
return jsonify({"design_systems": [s.to_dict() for s in rows]})
@design_systems_bp.post("/design-systems")
@login_required
async def create_design_system():
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
system = await ds_svc.create_design_system(
user_id=_uid(),
title=title,
description=data.get("description") or None,
guidance=data.get("guidance") or None,
parent_id=data.get("parent_id"),
starter_role_groups=data.get("starter_role_groups"),
token_prefix=data.get("token_prefix") or DEFAULT_TOKEN_PREFIX,
)
if system is None:
return jsonify({"error": "parent design system not found"}), 404
return jsonify(system.to_dict()), 201
@design_systems_bp.get("/design-systems/starter-roles")
@login_required
async def list_starter_role_groups():
"""The starter role catalogue, for the creation form's checklist.
Roles and purposes only — no values, ever. See services/design_starter_roles.
"""
return jsonify({
"groups": describe_groups(),
"default_prefix": DEFAULT_TOKEN_PREFIX,
})
@design_systems_bp.get("/design-systems/<int:design_system_id>")
@login_required
async def get_design_system(design_system_id: int):
system = await ds_svc.get_design_system(_uid(), design_system_id)
if system is None:
return _not_found()
return jsonify(system.to_dict())
@design_systems_bp.patch("/design-systems/<int:design_system_id>")
@login_required
async def update_design_system(design_system_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items() if k in ("title", "description", "guidance")
}
# Presence, not truthiness: `{"parent_id": null}` means "make this a root",
# which a `if data.get("parent_id")` filter would silently drop.
if "parent_id" in data:
fields["parent_id"] = data["parent_id"]
try:
system = await ds_svc.update_design_system(_uid(), design_system_id, **fields)
except DesignSystemCycle as exc:
return jsonify({"error": str(exc)}), 400
if system is None:
return _not_found()
return jsonify(system.to_dict())
@design_systems_bp.delete("/design-systems/<int:design_system_id>")
@login_required
async def delete_design_system(design_system_id: int):
if not await ds_svc.delete_design_system(_uid(), design_system_id):
return _not_found()
return "", 204
@design_systems_bp.get("/design-systems/<int:design_system_id>/resolved")
@login_required
async def resolve_design_system(design_system_id: int):
"""The EFFECTIVE token set — everything inherited, with this system's on top.
Distinct from `/tokens` on purpose: that returns what this system CHANGES,
this returns what it ends up being. Both are real questions and answering
only one would make the other a client-side computation.
"""
resolved = await ds_svc.resolve_design_system(_uid(), design_system_id)
if resolved is None:
return _not_found()
return jsonify({
"design_system_id": design_system_id,
"tokens": [t.to_dict() for t in resolved],
})
@design_systems_bp.get("/design-systems/<int:design_system_id>/stylesheet")
@login_required
async def get_design_system_stylesheet(design_system_id: int):
"""The master CSS sheet this design system generates.
JSON by default (the UI wants the reuse report alongside the CSS); add
`?format=css` for the raw stylesheet as `text/css`, which is what a build
step or a `curl` wants.
`?root=` overrides the base selector — a container-scoped preview cannot use
`:root`, so the generator takes it as a parameter.
"""
root = (request.args.get("root") or ":root").strip() or ":root"
result = await ds_svc.stylesheet_for_system(_uid(), design_system_id, root)
if result is None:
return _not_found()
if request.args.get("format") == "css":
return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"}
return jsonify(result)
@design_systems_bp.get("/design-systems/<int:design_system_id>/snippet-check")
@login_required
async def check_snippets_against_system(design_system_id: int):
"""Which recorded snippets disagree with this system's sheet.
`?project_id=` narrows to one project; omit it to check every project, which
is usually right — a component recorded elsewhere still has to use the same
tags.
"""
project_id = request.args.get("project_id", type=int) or 0
result = await ds_svc.check_snippets_against_system(
_uid(), design_system_id, project_id
)
if result is None:
return _not_found()
return jsonify(result)
# ── Tokens ──────────────────────────────────────────────────────────────
@design_systems_bp.get("/design-systems/<int:design_system_id>/tokens")
@login_required
async def list_design_tokens(design_system_id: int):
"""This system's OWN tokens — its override set, not its effective set."""
if await ds_svc.get_design_system(_uid(), design_system_id) is None:
return _not_found()
rows = await ds_svc.list_tokens(_uid(), design_system_id)
return jsonify({"tokens": [t.to_dict() for t in rows]})
@design_systems_bp.post("/design-systems/<int:design_system_id>/tokens")
@login_required
async def create_design_token(design_system_id: int):
data = await request.get_json() or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
token = await ds_svc.create_token(
user_id=_uid(),
design_system_id=design_system_id,
name=name,
value_by_mode=data.get("value_by_mode"),
group_name=data.get("group_name") or None,
purpose=data.get("purpose") or None,
rationale=data.get("rationale") or None,
supersedes=data.get("supersedes"),
order_index=data.get("order_index") or 0,
)
if token is None:
return _not_found()
return jsonify(token.to_dict()), 201
@design_systems_bp.patch("/design-tokens/<int:token_id>")
@login_required
async def update_design_token(token_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items()
if k in (
"name", "value_by_mode", "group_name", "purpose", "rationale",
"supersedes", "order_index",
)
}
token = await ds_svc.update_token(_uid(), token_id, **fields)
if token is None:
return _not_found("design token")
return jsonify(token.to_dict())
@design_systems_bp.delete("/design-tokens/<int:token_id>")
@login_required
async def delete_design_token(token_id: int):
if not await ds_svc.delete_token(_uid(), token_id):
return _not_found("design token")
return "", 204
# ── The project pointer ─────────────────────────────────────────────────
@design_systems_bp.put("/projects/<int:project_id>/design-system")
@login_required
async def set_project_design_system(project_id: int):
"""Point a project at a design system. `{"design_system_id": null}` clears it.
PUT rather than PATCH: this sets one field to exactly what is sent, and
clearing it is a first-class outcome rather than an omission.
"""
data = await request.get_json() or {}
ok = await ds_svc.set_project_design_system(
_uid(), project_id, data.get("design_system_id")
)
if not ok:
return _not_found("project or design system")
return jsonify({"project_id": project_id,
"design_system_id": data.get("design_system_id")})