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
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.
237 lines
8.9 KiB
Python
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")})
|