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) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Last piece of the architecture in #2296. The operator: "the prose doesn't have to live as one offs, there's a central system for managing it." Two fields, both free-form: design_systems.guidance the narrative a token table cannot hold — aesthetic, voice and tone, what is deliberately out of scope. design_tokens.rationale WHY a token is this value, which is a different question from `purpose` (what it is FOR). "Success equals Moss, aligned by design" is a rationale; "page bg, deepest surface" is a purpose. Rules carry the first routinely and a token row had nowhere to put it. Free-form rather than a column per category, deliberately. A schema with `voice`, `aesthetic` and `scope` columns would bake one rulebook's table of contents into every install (rule #115), leaving the next install three empty columns and nowhere for what it actually cares about. Both nullable: a design system with no prose at all is complete, not a draft. `rationale` cascades like `purpose` — deepest non-empty wins — so an app overriding a colour keeps the family's reasoning rather than blanking it. Same argument as `supersedes`: the override was about the value, not the meaning. In the generated sheet the inline comment prefers `purpose` and falls back to `rationale`, so a token carrying only the why still says something instead of rendering bare.
261 lines
9.9 KiB
Python
261 lines
9.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)
|
|
|
|
|
|
@design_systems_bp.post("/design-systems/<int:design_system_id>/import")
|
|
@login_required
|
|
async def import_design_system(design_system_id: int):
|
|
"""Seed a design system from a rulebook's prose.
|
|
|
|
`{"rulebook_id": N}` previews; add `"apply": true` to write. Preview is the
|
|
default because an import is a PROPOSAL — rulebooks are written
|
|
aspirationally and some of what they describe was never built.
|
|
|
|
Existing token names are never overwritten, so a second run fills gaps and
|
|
reports the rest rather than undoing corrections.
|
|
"""
|
|
data = await request.get_json() or {}
|
|
rulebook_id = data.get("rulebook_id")
|
|
if not isinstance(rulebook_id, int) or rulebook_id <= 0:
|
|
return jsonify({"error": "rulebook_id is required"}), 400
|
|
report = await ds_svc.import_from_rulebook(
|
|
_uid(), design_system_id, rulebook_id, apply=bool(data.get("apply")),
|
|
)
|
|
if report is None:
|
|
return _not_found("design system or rulebook")
|
|
return jsonify(report)
|
|
|
|
|
|
# ── 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")})
|