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
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
256 lines
9.5 KiB
Python
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")})
|