feat(design-systems): give a design system a way to reach the session
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 31s

Storing a design system never made a session aware of one. Rules get pushed
into every session by the SessionStart hook and returned by enter_project; a
design system had neither, so its standards were reachable only by an agent
that already knew to call resolve_design_system — the same silent failure as a
token nobody declares.

That gap was invisible while the operator's visual standards also lived in a
rulebook. Retiring that rulebook (which is what this unblocks) would have
deleted design guidance from every session with nothing to say so.

- services/design_systems.design_context() — the delivery side. Guidance is
  chain-merged ANCESTOR-FIRST: a child system holds only what it CHANGES, so
  its own guidance describes a departure from a house style it never restates,
  and the leaf alone is a fragment. Tokens are summarised (count + group
  names), not listed — a hundred declarations would crowd out the context they
  are meant to inform.
- enter_project returns `design_system`, null when the project has none.
- The SessionStart context gains a Design system block with pointers to the
  values, alongside the always-on rules.
- server.py's entity list gains Design system, including the negative: do NOT
  record one as a rulebook, because a token kept as prose cannot be resolved,
  inherited, rendered or checked.
- The rulebook-tier passage used "a design-system rulebook" as its worked
  example of a subscribed rulebook — it now teaches the opposite, plus a new
  "is this a rule at all?" test pointing at design systems, processes and
  snippets.
- using-scribe gains a section on building UI against the project's system.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
2026-07-31 22:10:09 -04:00
co-authored by Claude Opus 5
parent 1e139d0d18
commit 731ca284c3
8 changed files with 356 additions and 6 deletions
+62
View File
@@ -282,6 +282,68 @@ async def resolve_design_system(
return resolve_tokens(design_system_id, parents, tokens_by_system)
async def design_context(user_id: int, design_system_id: int) -> dict | None:
"""What a session needs to know about a design system, before it writes UI.
This is the DELIVERY side of a design system, and it exists because storing
one does not make a session aware of it. Rules get pushed into every session
by the plugin's SessionStart hook; a design system had no such channel, so
the standards were reachable only by an agent that already knew to go
looking — which is the same silent failure as a token nobody declares.
Guidance is chain-merged, ANCESTOR-FIRST, and that is the point rather than
a convenience. A child system holds only what it CHANGES, so its own
guidance describes a departure from a house style it never restates. Hand an
agent the leaf alone and it builds against a fragment, with no signal that
the rest exists.
Tokens are summarised, not listed: the count and the group names are enough
to know what the system covers, and the full set is one call away. Sending
a hundred token values into every session start would crowd out the context
it is meant to inform.
None when the caller may not read the system.
"""
tokens = await resolve_design_system(user_id, design_system_id)
if tokens is None:
return None
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return None
parents = await _parent_map(session, system.owner_user_id)
chain = ancestry(design_system_id, parents)
rows = (
await session.execute(
select(DesignSystem).where(DesignSystem.id.in_(chain))
)
).scalars().all()
by_id = {s.id: s for s in rows}
return {
"id": system.id,
"title": system.title,
"description": system.description or "",
# Outermost ancestor first, so the reader meets the house style before
# the app's departures from it.
"inherits_from": [
by_id[sid].title for sid in reversed(chain[1:]) if sid in by_id
],
"guidance": [
{
"design_system_id": sid,
"title": by_id[sid].title,
"guidance": (by_id[sid].guidance or "").strip(),
}
for sid in reversed(chain)
if sid in by_id and (by_id[sid].guidance or "").strip()
],
"token_count": len(tokens),
"token_groups": sorted({t.group_name for t in tokens if t.group_name}),
}
async def update_token(
user_id: int, token_id: int, **fields: object
) -> DesignToken | None:
+32
View File
@@ -22,6 +22,7 @@ from sqlalchemy import select
from scribe.models import async_session
from scribe.models.rulebook import RulebookTopic
from scribe.services import design_systems as design_systems_svc
from scribe.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
@@ -837,6 +838,37 @@ async def build_session_context(
f"Goal: {goal[:200]}" if goal else "",
f"Open todo tasks: {open_count}",
]
# A design system binds the same way a rule does, and until this
# existed it had no push channel — the standards were reachable only
# by an agent that already knew to look for them. Summary only: the
# token VALUES are a tool call away, and pasting a hundred of them
# into every session would crowd out the context they inform.
if project.design_system_id:
design = await design_systems_svc.design_context(
user_id, project.design_system_id,
)
if design:
inherits = (
" (inherits " + " ".join(design["inherits_from"]) + ")"
if design["inherits_from"] else ""
)
groups = ", ".join(design["token_groups"])
lines += [
"",
f"## Design system: {design['title']} "
f"(id {design['id']}){inherits}",
f"{design['token_count']} tokens"
+ (f" across {groups}" if groups else "")
+ ". This project's UI is built from these, not from "
"literals — reach for a token before writing a colour, "
"size, radius or duration by hand.",
f"Values: `resolve_design_system({design['id']})` · "
f"stylesheet: `get_design_system_stylesheet({design['id']})` "
f"· the prose (aesthetic, voice, where the accent may "
f"appear): `enter_project` returns it, or "
f"`get_design_system({design['id']})`.",
]
elif unbound_repo:
lines += [
"",