From b0a7d9e89b61c0670068ab4533f4b6358c02cb2d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 30 Jul 2026 21:42:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(design-systems):=20the=20master=20sheet=20?= =?UTF-8?q?=E2=80=94=20purpose=20tokens,=20not=20per-element=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator's new requirement (#2299, architecture in #2296): a design system does not just hold tokens, it generates and manages a master CSS sheet. That settles the milestone's open "authority mechanism" question — the record is authoritative because the stylesheet comes out of it. **The sheet is shaped by purpose and styles no elements.** It declares custom properties, grouped by what they mean, and contains no `.btn-primary`, no `table`, no `input`. That is the design, not a shortcut: a sheet that styled elements would restate the same handful of values once per element and grow with the UI, where purpose-named values are stated once and reused. Components live as SNIPPETS that reference these names — a surface that already exists and already carries prose, locations, drift checks, merge and write-path recall. A token named after an element (`--fs-button-bg`) is the smell that the two have been mixed; a purpose name (`--fs-action-primary`) is reused across all of them. Alongside the CSS the endpoint returns what the text cannot say for itself: which tokens are still valueless, and which VALUES are declared under more than one name. The second is the operator's "reuse consistent values" constraint made checkable — and it reports rather than refuses, because a design system legitimately aligns colours on purpose ("Success = Moss, by design") and only a human knows which case it is. Mode maps to selector the way the codebase already does it: base on the root selector, every other mode layered on `[data-theme="…"]`. The root selector is a PARAMETER — #251 recorded that a container-scoped preview cannot use `:root`, so hardcoding it would have made the generator useless to the preview surface. A token the rulebook names but states no value for is emitted as a commented-out declaration IN ITS GROUP rather than dropped. Its absence is the finding, and a comment puts that finding where the reader already is. Values are validated, not escaped, and this is a real boundary rather than tidiness: design systems are shareable records (rule #47), so `red; } body { display: none` in a system shared with you would otherwise inject CSS into your page. A value containing `{ } ; @ < >`, a comment delimiter or a newline is REFUSED and rendered as a comment saying so — rejecting beats stripping, since a partially-sanitised value is one the operator never wrote and the sheet's whole claim is that it is the record. Not in scope, and deliberately: serving this as the app's actual stylesheet. Generating and exposing a sheet is reversible; swapping theme.css for a generated one is not, and it should be an explicit call rather than a side effect. --- frontend/src/api/designSystems.ts | 19 ++ frontend/src/views/DesignSystemsView.vue | 139 ++++++++++++++ src/scribe/mcp/tools/design_systems.py | 29 +++ src/scribe/routes/design_systems.py | 21 +++ src/scribe/services/design_stylesheet.py | 213 +++++++++++++++++++++ src/scribe/services/design_systems.py | 35 ++++ tests/test_design_stylesheet.py | 230 +++++++++++++++++++++++ tests/test_routes_design_systems.py | 6 +- tests/test_services_design_systems.py | 46 +++++ 9 files changed, 737 insertions(+), 1 deletion(-) create mode 100644 src/scribe/services/design_stylesheet.py create mode 100644 tests/test_design_stylesheet.py diff --git a/frontend/src/api/designSystems.ts b/frontend/src/api/designSystems.ts index ffae9f2..0576ce1 100644 --- a/frontend/src/api/designSystems.ts +++ b/frontend/src/api/designSystems.ts @@ -159,3 +159,22 @@ export const importFromRulebook = ( rulebook_id: rulebookId, apply, }); + +export interface StylesheetResult { + design_system_id: number; + /** The master sheet: purpose tokens only, no element or class rules. */ + css: string; + token_count: number; + /** Tokens the system names but has no value for yet. */ + valueless: string[]; + /** Values declared under more than one name — alias, or one idea twice. */ + duplicates: Record; +} + +/** The master CSS sheet a design system generates. + * + * Purpose tokens only. Components (buttons, tables, input schemes) are + * snippets that reference these names, so a value is stated once and reused + * rather than restated per element. */ +export const fetchStylesheet = (id: number) => + apiGet(`/api/design-systems/${id}/stylesheet`); diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue index 2688b13..85c491f 100644 --- a/frontend/src/views/DesignSystemsView.vue +++ b/frontend/src/views/DesignSystemsView.vue @@ -29,6 +29,7 @@ import { fetchDesignSystems, fetchDesignTokens, fetchResolvedTokens, + fetchStylesheet, importFromRulebook, updateDesignSystem, updateDesignToken, @@ -36,6 +37,7 @@ import { type DesignToken, type ImportReport, type ResolvedToken, + type StylesheetResult, } from "@/api/designSystems"; import { listRulebooks, type Rulebook } from "@/api/rulebooks"; import { ApiError } from "@/api/client"; @@ -385,6 +387,52 @@ const overrideCount = computed( () => resolved.value.filter((t) => Object.values(t.origin_by_mode).some((id) => id === selectedId.value)).length, ); +// --- the master sheet ------------------------------------------------------- + +const sheet = ref(null); +const sheetLoading = ref(false); +const showSheet = ref(false); + +async function loadSheet() { + if (selectedId.value === null) return; + sheetLoading.value = true; + try { + sheet.value = await fetchStylesheet(selectedId.value); + } catch { + sheet.value = null; + toast.show("Failed to render the stylesheet", "error"); + } finally { + sheetLoading.value = false; + } +} + +/** Toggle, and render on first open rather than on mount — the sheet is a + * derived view most visits don't need, and rendering it unasked would cost a + * round trip per system selection. */ +function toggleSheet() { + showSheet.value = !showSheet.value; + if (showSheet.value && !sheet.value) loadSheet(); +} + +async function copySheet() { + if (!sheet.value) return; + try { + await navigator.clipboard.writeText(sheet.value.css); + toast.show("Stylesheet copied"); + } catch { + toast.show("Couldn't copy — select the text instead", "error"); + } +} + +/** Re-render whenever the tokens behind it change, so the sheet on screen is + * never a stale answer to a question the operator has already moved past. */ +watch([selectedId, ownTokens], () => { + sheet.value = null; + if (showSheet.value) loadSheet(); +}); + +const duplicateEntries = computed(() => Object.entries(sheet.value?.duplicates ?? {})); + // --- import from a rulebook ------------------------------------------------- const rulebooks = ref([]); @@ -582,6 +630,69 @@ function isColourish(value: string): boolean { + +
+
+

Master stylesheet

+ +
+ + +
+
@@ -1219,6 +1330,34 @@ function isColourish(value: string): boolean { gap: 0.35rem; } +.sheet { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 0.75rem 1rem; + margin-top: 0.75rem; + overflow-x: auto; + font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; + font-size: 0.8rem; + line-height: 1.6; + max-height: 30rem; + overflow-y: auto; +} + +.dupe-list { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + font-size: 0.85rem; +} + +.dupe-list li { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.15rem 0; +} + .import-summary { margin-top: 0.75rem; } diff --git a/src/scribe/mcp/tools/design_systems.py b/src/scribe/mcp/tools/design_systems.py index 91f014d..b6a1122 100644 --- a/src/scribe/mcp/tools/design_systems.py +++ b/src/scribe/mcp/tools/design_systems.py @@ -139,6 +139,34 @@ async def delete_design_system(design_system_id: int) -> dict: return {"message": f"Design system {design_system_id} deleted."} +async def get_design_system_stylesheet( + design_system_id: int, + root_selector: str = ":root", +) -> dict: + """The master CSS sheet a design system generates. + + Purpose tokens only — this sheet declares what values MEAN and styles no + elements. Components (buttons, tables, input schemes) are SNIPPETS that + reference these names, so a value is stated once and reused rather than + restated per element. Reach for this when you need the tokens a snippet is + allowed to use. + + Also returns `valueless` (tokens the system names but has no value for) and + `duplicates` (values declared under more than one name — a deliberate alias, + or one idea recorded twice). + + Args: + design_system_id: The system to render. + root_selector: Selector for the base layer. Defaults to `:root`; pass a + container selector to scope the sheet to a preview region. + """ + uid = current_user_id() + result = await ds_svc.stylesheet_for_system(uid, design_system_id, root_selector) + if result is None: + raise ValueError(f"design system {design_system_id} not found") + return result + + async def import_design_system_from_rulebook( design_system_id: int, rulebook_id: int, @@ -307,6 +335,7 @@ def register(mcp) -> None: resolve_design_system, update_design_system, delete_design_system, + get_design_system_stylesheet, import_design_system_from_rulebook, create_design_token, list_design_tokens, diff --git a/src/scribe/routes/design_systems.py b/src/scribe/routes/design_systems.py index c4cd9ea..48706ea 100644 --- a/src/scribe/routes/design_systems.py +++ b/src/scribe/routes/design_systems.py @@ -114,6 +114,27 @@ async def resolve_design_system(design_system_id: int): }) +@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.post("/design-systems//import") @login_required async def import_design_system(design_system_id: int): diff --git a/src/scribe/services/design_stylesheet.py b/src/scribe/services/design_stylesheet.py new file mode 100644 index 0000000..b6f4e5a --- /dev/null +++ b/src/scribe/services/design_stylesheet.py @@ -0,0 +1,213 @@ +"""Render a design system's resolved tokens as its master CSS sheet. + +Pure, like `design_cascade` and for the same reasons: no database import, so the +rendering rule is testable without one and the preview surface can reuse it. + +WHAT THIS SHEET IS, AND DELIBERATELY IS NOT +------------------------------------------- +It declares **purpose tokens only** — custom properties, grouped by what they +mean. It contains no rules for elements or classes: no `.btn-primary`, no +`table`, no `input`. + +That is the point rather than a limitation. A sheet that styled every element +would restate the same handful of values once per element and grow with the UI; +a sheet of purpose-named values states each once and is reused. Components — +buttons, tables, input schemes — live as SNIPPETS that reference these names and +carry prose about the idea, which is a surface that already exists and already +has recall, locations, drift checks and merge. + +So the division is: this sheet says what the values MEAN; snippets say what +things LOOK LIKE, in terms of those values. A token named after an element +(`--fs-button-bg`) is the smell that the two have been mixed — it multiplies +with every new element, where a purpose name (`--fs-action-primary`) is reused. + +SAFETY +------ +Values are interpolated into a stylesheet, and design systems are shareable +records (rule #47). A value of `red; } body { display: none` in a system someone +shared with you would otherwise inject CSS into your page. Everything rendered +here is validated or dropped — never escaped-and-hoped. +""" +from __future__ import annotations + +import re +from collections.abc import Sequence + +BASE_MODE = "base" + +# A custom property name, strictly. Anything else is dropped rather than +# sanitised: a name is an identifier, and a "cleaned up" identifier is a +# different token than the one the operator recorded. +_VALID_NAME = re.compile(r"^--[A-Za-z0-9_-]+$") + +# Characters that would end the declaration, open a block, start an at-rule, or +# begin a tag. A value containing any of them is not a value. +_UNSAFE_VALUE = re.compile(r"[{};@<>]|\*/|/\*|\n|\r") + + +def is_valid_token_name(name: str) -> bool: + return bool(_VALID_NAME.match((name or "").strip())) + + +def safe_value(value: str) -> str | None: + """A CSS value that cannot escape its declaration, or None. + + Rejects rather than strips. A partially-sanitised value is a value the + operator did not write, and silently rendering a different colour than the + record holds is worse than rendering none — the sheet's whole claim is that + it IS the record. + """ + candidate = (value or "").strip() + if not candidate or _UNSAFE_VALUE.search(candidate): + return None + return candidate + + +def safe_comment(text: str) -> str: + """Comment text that cannot close its comment or break the line.""" + return re.sub(r"\*/|/\*|[\n\r]", " ", (text or "")).strip() + + +def selector_for_mode(mode: str, root_selector: str = ":root") -> str: + """Which selector a mode's declarations belong under. + + `base` gets the caller's root selector; every other mode gets a bare + attribute selector, matching the convention already in the codebase. + + The root selector is a PARAMETER because a container-scoped preview cannot + use `:root` — #251 recorded that mode scoping is one-way (light lives on + `:root`, dark layers over it), so a generator that hardcoded `:root` could + not serve a preview at all. + """ + if mode == BASE_MODE: + return root_selector + safe_mode = re.sub(r"[^A-Za-z0-9_-]", "", mode) + return f'[data-theme="{safe_mode}"]' if safe_mode else root_selector + + +def _grouped(tokens: Sequence) -> list[tuple[str | None, list]]: + """Tokens by group, preserving the order they arrive in (already sorted).""" + groups: dict[str | None, list] = {} + for token in tokens: + groups.setdefault(getattr(token, "group_name", None), []).append(token) + return list(groups.items()) + + +def _modes_present(tokens: Sequence) -> list[str]: + """Every mode any token declares, base first then the rest alphabetically.""" + modes = { + mode + for token in tokens + for mode in (getattr(token, "value_by_mode", None) or {}) + } + rest = sorted(modes - {BASE_MODE}) + return ([BASE_MODE] if BASE_MODE in modes else []) + rest + + +def render_stylesheet( + tokens: Sequence, + *, + root_selector: str = ":root", + title: str = "", + design_system_id: int | None = None, +) -> str: + """The master sheet for a resolved token set. + + One block per mode. Within a block, only the tokens that declare a value for + that mode — so a mode block is an override layer, exactly as the storage + model has it. + + Tokens with no value at all are emitted as COMMENTED-OUT declarations in + their group, not dropped. The rulebook named them, so their absence is a + finding, and a commented line puts that finding where the reader is already + looking. + """ + header = [ + "/*", + f" * {safe_comment(title) or 'Design system'} — generated stylesheet", + ] + if design_system_id is not None: + header.append(f" * Source: design system {int(design_system_id)}.") + header += [ + " *", + " * Purpose tokens only. This sheet declares what values MEAN; it styles", + " * no elements. Buttons, tables and input schemes live as snippets that", + " * reference these names, so each value is stated once and reused rather", + " * than restated per element.", + " *", + " * Generated — edit the design system, not this file.", + " */", + "", + ] + + lines = list(header) + valueless = [ + t for t in tokens + if is_valid_token_name(getattr(t, "name", "")) + and not (getattr(t, "value_by_mode", None) or {}) + ] + + for mode in _modes_present(tokens): + block: list[str] = [] + for group, group_tokens in _grouped(tokens): + entries: list[str] = [] + for token in group_tokens: + name = (getattr(token, "name", "") or "").strip() + if not is_valid_token_name(name): + continue + raw = (getattr(token, "value_by_mode", None) or {}).get(mode) + if raw is None: + continue + value = safe_value(str(raw)) + if value is None: + entries.append( + f" /* {name}: value rejected — not a safe CSS value */" + ) + continue + purpose = safe_comment(getattr(token, "purpose", "") or "") + comment = f" /* {purpose} */" if purpose and mode == BASE_MODE else "" + entries.append(f" {name}: {value};{comment}") + if entries: + if group: + block.append(f" /* {safe_comment(group)} */") + block.extend(entries) + block.append("") + + # Declared-but-valueless tokens belong to the base layer: they have no + # mode to sit under, and repeating them per mode would triple the noise. + if mode == BASE_MODE and valueless: + block.append(" /* Declared by the design system, no value set yet: */") + block.extend(f" /* {t.name}: ; */" for t in valueless) + block.append("") + + if not block: + continue + lines.append(f"{selector_for_mode(mode, root_selector)} {{") + lines.extend(block[:-1] if block[-1] == "" else block) + lines.append("}") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def duplicate_values(tokens: Sequence) -> dict[str, list[str]]: + """Values declared by more than one token, mapped to the names declaring them. + + Two names for one value are either a deliberate alias or the same idea + recorded twice — the token-level form of the duplicated-definition shape. + Reported rather than refused: a design system legitimately aligns colours on + purpose ("Success = Moss, by design"), and a generator that rejected that + would be wrong about the operator's intent. + + Compares the BASE value only. Two tokens agreeing in one mode and diverging + in another are not duplicates of each other — they are a near-miss, which is + a different and less interesting finding. + """ + by_value: dict[str, list[str]] = {} + for token in tokens: + name = getattr(token, "name", "") + base = (getattr(token, "value_by_mode", None) or {}).get(BASE_MODE) + if not name or not base: + continue + by_value.setdefault(str(base).strip().lower(), []).append(name) + return {value: names for value, names in by_value.items() if len(names) > 1} diff --git a/src/scribe/services/design_systems.py b/src/scribe/services/design_systems.py index be8ea78..bcb2757 100644 --- a/src/scribe/services/design_systems.py +++ b/src/scribe/services/design_systems.py @@ -21,6 +21,7 @@ from scribe.models import async_session from scribe.models.design_system import DesignSystem, DesignToken from scribe.models.project import Project from scribe.services import access +from scribe.services.design_stylesheet import duplicate_values, render_stylesheet from scribe.services.design_cascade import ( ResolvedToken, ancestry, @@ -402,3 +403,37 @@ async def import_from_rulebook( "created": created, "skipped": skipped, } + + +# --- the master sheet ------------------------------------------------------- + +async def stylesheet_for_system( + user_id: int, design_system_id: int, root_selector: str = ":root" +) -> dict | None: + """The master CSS sheet a design system generates, plus its reuse report. + + Purpose tokens only — the sheet styles no elements. See + `services/design_stylesheet.py` for why that split is the design rather than + a shortcut. + + Returns None if the caller may not read the system. The `duplicates` half is + advisory: two tokens sharing a value are either a deliberate alias or one + idea recorded twice, and only the operator knows which. + """ + resolved = await resolve_design_system(user_id, design_system_id) + if resolved is None: + return None + system = await get_design_system(user_id, design_system_id) + + return { + "design_system_id": design_system_id, + "css": render_stylesheet( + resolved, + root_selector=root_selector, + title=system.title if system else "", + design_system_id=design_system_id, + ), + "token_count": len(resolved), + "valueless": [t.name for t in resolved if not t.value_by_mode], + "duplicates": duplicate_values(resolved), + } diff --git a/tests/test_design_stylesheet.py b/tests/test_design_stylesheet.py new file mode 100644 index 0000000..df18171 --- /dev/null +++ b/tests/test_design_stylesheet.py @@ -0,0 +1,230 @@ +"""Rendering a design system as its master CSS sheet. + +The generator is pure, so a whole sheet is one literal of tokens in and a string +out. Two things here are load-bearing rather than cosmetic: what the sheet +deliberately does NOT contain, and the fact that a value cannot escape its +declaration. +""" +from types import SimpleNamespace + +from scribe.services.design_stylesheet import ( + duplicate_values, + is_valid_token_name, + render_stylesheet, + safe_comment, + safe_value, + selector_for_mode, +) + + +def _token(name, value_by_mode, group_name=None, purpose=None): + return SimpleNamespace( + name=name, value_by_mode=value_by_mode, + group_name=group_name, purpose=purpose, + ) + + +# --- safety ----------------------------------------------------------------- +# +# Design systems are shareable records. A value that can close its declaration +# can inject arbitrary CSS into the page of anyone the system was shared with, +# which makes this a real boundary rather than tidiness. + +def test_a_value_that_would_escape_its_declaration_is_refused(): + """`red; } body { display: none` is the whole attack: end the declaration, + close the block, open your own.""" + assert safe_value("red; } body { display: none") is None + assert safe_value("#fff}") is None + assert safe_value("#fff;") is None + + +def test_at_rules_and_tags_are_refused(): + assert safe_value("@import url(evil.css)") is None + assert safe_value("