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
+91
View File
@@ -321,3 +321,94 @@ async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself():
assert result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
assert "--fs-moss: #4a5d3f;" in result["css"]
assert "FabledSword" in result["css"]
# --- design_context: the delivery side --------------------------------------
def _ctx_token(name: str, group: str | None):
from scribe.services.design_cascade import ResolvedToken
return ResolvedToken(
name=name, contributions={}, group_name=group,
purpose=None, rationale=None, supersedes=(), order_index=0,
)
@pytest.mark.asyncio
async def test_design_context_merges_guidance_ANCESTOR_FIRST():
"""LOAD-BEARING. A child system holds only what it CHANGES, so its own
guidance describes a departure from a house style it never restates. An
agent handed the leaf alone builds against a fragment with no signal that
the rest exists — which is exactly the failure retiring the design rulebook
would otherwise have caused.
"""
family = MagicMock(id=1, deleted_at=None, owner_user_id=42,
description="the house", guidance="Dark-mode-first.")
family.title = "House"
app = MagicMock(id=3, deleted_at=None, owner_user_id=42,
description="one app", guidance="Accent on the wordmark.")
app.title = "App"
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=app)
mock_session.execute = AsyncMock(return_value=MagicMock(
scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family]))
))
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems._parent_map",
AsyncMock(return_value={3: 1, 1: None})), \
patch("scribe.services.design_systems.resolve_design_system",
AsyncMock(return_value=[
_ctx_token("--a", "surface"), _ctx_token("--b", "type"),
_ctx_token("--c", "surface"), _ctx_token("--d", None),
])):
mock_cls.return_value = mock_session
from scribe.services.design_systems import design_context
out = await design_context(user_id=42, design_system_id=3)
assert [g["title"] for g in out["guidance"]] == ["House", "App"]
assert out["inherits_from"] == ["House"]
assert out["token_count"] == 4
# Group names deduped and sorted; an ungrouped token contributes nothing.
assert out["token_groups"] == ["surface", "type"]
@pytest.mark.asyncio
async def test_design_context_denied_returns_none():
"""It rides on resolve_design_system's ACL rather than re-deriving one —
a second permission path is a second thing to get wrong."""
with patch("scribe.services.design_systems.resolve_design_system",
AsyncMock(return_value=None)):
from scribe.services.design_systems import design_context
assert await design_context(user_id=1, design_system_id=3) is None
@pytest.mark.asyncio
async def test_design_context_omits_systems_with_no_guidance():
"""A system that overrides one token and says nothing about it should not
contribute an empty section — a heading with nothing under it reads as
missing content rather than as an absence of content."""
family = MagicMock(id=1, deleted_at=None, owner_user_id=42,
description="", guidance="Dark-mode-first.")
family.title = "House"
app = MagicMock(id=3, deleted_at=None, owner_user_id=42,
description="", guidance=" ")
app.title = "App"
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=app)
mock_session.execute = AsyncMock(return_value=MagicMock(
scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family]))
))
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems._parent_map",
AsyncMock(return_value={3: 1, 1: None})), \
patch("scribe.services.design_systems.resolve_design_system",
AsyncMock(return_value=[])):
mock_cls.return_value = mock_session
from scribe.services.design_systems import design_context
out = await design_context(user_id=42, design_system_id=3)
assert [g["title"] for g in out["guidance"]] == ["House"]
assert out["token_count"] == 0