Files
FabledScribe/src/scribe/routes/design_systems.py
T
bvandeusen 23a385e2db
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) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s
revert(design-systems): drop the rulebook import — a migration, not a feature
Operator's call, and it corrects a scope error rather than a bug:

  "this is a path for a user to go from a rulebook to a design system. we don't
   need to build this path in the app itself ... you should be the one that does
   the import ... going forward no one else should have to do such a migration."

Right. Nobody starting from a design system will ever go rulebook -> system, so
the whole path was permanent product code serving a single act on one install.
Rule #22: remove it, don't flag it off. Gone from the service, the REST route,
the MCP tool, the UI panel, the API client and its tests.

There is a second consequence I had missed, and it is the better argument. The
parser was WORSE at this than doing it by hand. `propose_tokens` leaves radius
steps and type sizes valueless because "Small 4px" is not a hex and nothing here
parses it — a limitation I documented carefully and shipped anyway. But that
limitation only exists because the importer had to run unattended. Done as work
rather than as a feature, those values are just read and written, and the result
is a complete design system instead of one with a dozen blanks and a count
explaining them.

Scaffolding built around my own absence from the loop, when I am the loop.

KEPT: `extract_expectations` and `design_expectations` in
services/design_rulebook_import.py. The live drift panel still reads them until
it is repointed at a resolved design system (#2295), and removing them now would
take the /design page's only content with it. They go with that change, not this
one.
2026-07-31 09:46:12 -04:00

237 lines
8.9 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_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"),
)
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/<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")})