"""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/") @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/") @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/") @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//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//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//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//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//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/") @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/") @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//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")})