feat(design-systems): the master sheet — purpose tokens, not per-element values
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 34s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 34s
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.
This commit is contained in:
@@ -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<string, string[]>;
|
||||
}
|
||||
|
||||
/** 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<StylesheetResult>(`/api/design-systems/${id}/stylesheet`);
|
||||
|
||||
@@ -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<StylesheetResult | null>(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<Rulebook[]>([]);
|
||||
@@ -582,6 +630,69 @@ function isColourish(value: string): boolean {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The master sheet -->
|
||||
<section class="ds-section">
|
||||
<div class="section-head">
|
||||
<h2>Master stylesheet</h2>
|
||||
<button
|
||||
class="btn-ghost btn-small"
|
||||
@click="toggleSheet"
|
||||
>
|
||||
{{ showSheet ? "Hide" : "Show" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="showSheet">
|
||||
<p class="section-note">
|
||||
What this system generates. <strong>Purpose tokens only</strong> —
|
||||
it declares what values mean and 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.
|
||||
</p>
|
||||
|
||||
<p v-if="sheetLoading" class="muted">Rendering…</p>
|
||||
|
||||
<template v-else-if="sheet">
|
||||
<p class="section-note">
|
||||
<strong>{{ sheet.token_count }}</strong> tokens ·
|
||||
<strong>{{ sheet.valueless.length }}</strong> still without a value ·
|
||||
<strong>{{ duplicateEntries.length }}</strong> values declared
|
||||
under more than one name
|
||||
</p>
|
||||
|
||||
<div v-if="duplicateEntries.length" class="notice notice-warn">
|
||||
<strong>Some values are declared twice.</strong>
|
||||
<p>
|
||||
Either a deliberate alias or one idea recorded under two
|
||||
names — only you can tell which, so nothing is changed here.
|
||||
</p>
|
||||
<ul class="dupe-list">
|
||||
<li v-for="[value, names] in duplicateEntries" :key="value">
|
||||
<span
|
||||
v-if="isColourish(value)" class="swatch"
|
||||
:style="{ background: value }" aria-hidden="true"
|
||||
/>
|
||||
<code>{{ value }}</code> — {{ names.join(", ") }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="row-actions">
|
||||
<button class="btn-ghost btn-small" @click="copySheet">Copy</button>
|
||||
<a
|
||||
class="btn-ghost btn-small"
|
||||
:href="`/api/design-systems/${selectedId}/stylesheet?format=css`"
|
||||
download
|
||||
>Download</a>
|
||||
<button class="btn-ghost btn-small" @click="loadSheet">Re-render</button>
|
||||
</div>
|
||||
|
||||
<pre class="sheet"><code>{{ sheet.css }}</code></pre>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Import from a rulebook -->
|
||||
<section class="ds-section">
|
||||
<div class="section-head">
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -114,6 +114,27 @@ async def resolve_design_system(design_system_id: int):
|
||||
})
|
||||
|
||||
|
||||
@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.post("/design-systems/<int:design_system_id>/import")
|
||||
@login_required
|
||||
async def import_design_system(design_system_id: int):
|
||||
|
||||
@@ -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}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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("</style><script>") is None
|
||||
|
||||
|
||||
def test_comment_delimiters_in_a_value_are_refused():
|
||||
"""A value is not rendered inside a comment, but `/*` would comment out
|
||||
every declaration after it — silently blanking the rest of the sheet."""
|
||||
assert safe_value("red /* ") is None
|
||||
assert safe_value("*/ red") is None
|
||||
|
||||
|
||||
def test_a_newline_in_a_value_is_refused():
|
||||
assert safe_value("red\n color: blue") is None
|
||||
|
||||
|
||||
def test_ordinary_values_survive_untouched():
|
||||
for value in ("#14171a", "8px", "cubic-bezier(0.2, 0.6, 0.2, 1)",
|
||||
"0 4px 12px rgba(0,0,0,0.35)", "color-mix(in srgb, red 15%, transparent)"):
|
||||
assert safe_value(value) == value
|
||||
|
||||
|
||||
def test_a_rejected_value_is_dropped_not_cleaned_up():
|
||||
"""Rejecting beats stripping. A partially-sanitised value is one the operator
|
||||
never wrote, and the sheet's entire claim is that it IS the record — quietly
|
||||
rendering a different colour would break that claim invisibly."""
|
||||
css = render_stylesheet([_token("--fs-x", {"base": "red; } body { color: blue"})])
|
||||
assert "body" not in css
|
||||
assert "value rejected" in css
|
||||
|
||||
|
||||
def test_comment_text_cannot_close_its_comment():
|
||||
"""`purpose` is operator prose rendered into a comment — `*/` in it would
|
||||
end the comment and spill the rest into the stylesheet as code."""
|
||||
assert "*/" not in safe_comment("ends the comment */ then color: red")
|
||||
|
||||
|
||||
def test_a_malformed_token_name_is_dropped():
|
||||
"""A name is an identifier. A 'cleaned up' identifier is a different token
|
||||
than the one recorded, so it is dropped rather than repaired."""
|
||||
assert is_valid_token_name("--fs-obsidian")
|
||||
assert not is_valid_token_name("--fs obsidian")
|
||||
assert not is_valid_token_name("color: red")
|
||||
assert not is_valid_token_name("fs-obsidian") # no leading --
|
||||
|
||||
css = render_stylesheet([_token("--bad name", {"base": "red"})])
|
||||
assert "bad name" not in css
|
||||
|
||||
|
||||
def test_a_mode_name_cannot_break_out_of_its_selector():
|
||||
assert selector_for_mode('dark"] body {') == '[data-theme="darkbody"]'
|
||||
|
||||
|
||||
# --- what the sheet is ------------------------------------------------------
|
||||
|
||||
def test_the_sheet_declares_properties_and_styles_no_elements():
|
||||
"""THE shape decision. A sheet that styled elements would restate the same
|
||||
handful of values once per element and grow with the UI. Purpose tokens are
|
||||
stated once and reused; components are snippets that reference them."""
|
||||
css = render_stylesheet([
|
||||
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"),
|
||||
_token("--fs-moss", {"base": "#4a5d3f"}, group_name="action"),
|
||||
])
|
||||
assert "--fs-obsidian: #14171a;" in css
|
||||
# No element or class rules — the sheet has exactly one block here, and
|
||||
# every declaration in it is a custom property.
|
||||
assert css.count("{") == 1
|
||||
declarations = [
|
||||
line.strip() for line in css.splitlines()
|
||||
if ":" in line and line.strip().endswith(";")
|
||||
]
|
||||
assert declarations and all(d.startswith("--") for d in declarations)
|
||||
|
||||
|
||||
def test_the_header_says_what_the_sheet_is_for():
|
||||
"""A generated file with no explanation gets hand-edited, and then it has
|
||||
diverged from the record it claims to be."""
|
||||
css = render_stylesheet([_token("--fs-x", {"base": "1px"})], title="FabledSword")
|
||||
assert "FabledSword" in css
|
||||
assert "Generated" in css
|
||||
assert "snippets" in css
|
||||
|
||||
|
||||
# --- modes ------------------------------------------------------------------
|
||||
|
||||
def test_base_goes_on_the_root_selector_and_other_modes_layer_over_it():
|
||||
"""Matches the convention already in the codebase, and the one-way scoping
|
||||
#251 recorded: light on `:root`, dark layered on an attribute selector."""
|
||||
css = render_stylesheet([
|
||||
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}),
|
||||
])
|
||||
assert ":root {" in css
|
||||
assert '[data-theme="dark"] {' in css
|
||||
assert css.index(":root {") < css.index('[data-theme="dark"] {')
|
||||
|
||||
|
||||
def test_a_mode_block_contains_only_what_that_mode_declares():
|
||||
"""A mode block is an OVERRIDE layer, exactly as the storage model has it.
|
||||
Repeating every token in every block would make the sheet claim each mode
|
||||
redefines the whole system."""
|
||||
css = render_stylesheet([
|
||||
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}),
|
||||
_token("--fs-radius-md", {"base": "8px"}),
|
||||
])
|
||||
dark_block = css.split('[data-theme="dark"] {')[1]
|
||||
assert "--fs-bg" in dark_block
|
||||
assert "--fs-radius-md" not in dark_block
|
||||
|
||||
|
||||
def test_the_root_selector_is_caller_chosen():
|
||||
"""A container-scoped preview cannot use `:root`. A generator that hardcoded
|
||||
it could not serve the preview surface at all."""
|
||||
css = render_stylesheet(
|
||||
[_token("--fs-x", {"base": "1px"})], root_selector="[data-preview]"
|
||||
)
|
||||
assert "[data-preview] {" in css
|
||||
assert ":root {" not in css
|
||||
|
||||
|
||||
# --- grouping and honesty ---------------------------------------------------
|
||||
|
||||
def test_tokens_are_grouped_by_purpose_with_the_group_named():
|
||||
css = render_stylesheet([
|
||||
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"),
|
||||
_token("--fs-radius-md", {"base": "8px"}, group_name="radius"),
|
||||
])
|
||||
assert "/* surface */" in css
|
||||
assert "/* radius */" in css
|
||||
|
||||
|
||||
def test_a_purpose_becomes_an_inline_comment_on_the_base_layer_only():
|
||||
"""Repeating the same prose in every mode block is noise: the token means
|
||||
the same thing in dark mode."""
|
||||
css = render_stylesheet([
|
||||
_token("--fs-obsidian", {"base": "#14171a", "dark": "#000000"},
|
||||
purpose="page bg, deepest surface"),
|
||||
])
|
||||
assert css.count("page bg, deepest surface") == 1
|
||||
|
||||
|
||||
def test_a_declared_token_with_no_value_appears_as_a_comment_not_a_silence():
|
||||
"""The rulebook named it, so its absence is a FINDING. A commented line puts
|
||||
that finding where the reader is already looking; dropping it would make the
|
||||
sheet look complete."""
|
||||
css = render_stylesheet([
|
||||
_token("--fs-obsidian", {"base": "#14171a"}),
|
||||
_token("--fs-radius-sm", {}),
|
||||
])
|
||||
assert "--fs-radius-sm" in css
|
||||
assert "no value set yet" in css
|
||||
# Commented, so it cannot be mistaken for a live declaration.
|
||||
assert " --fs-radius-sm:" not in css
|
||||
|
||||
|
||||
def test_an_empty_system_renders_a_sheet_with_no_blocks_rather_than_failing():
|
||||
"""A system with no tokens is an ordinary state (rule #115), including one
|
||||
that was just created."""
|
||||
css = render_stylesheet([])
|
||||
assert "{" not in css
|
||||
assert "Generated" in css
|
||||
|
||||
|
||||
# --- the reuse report -------------------------------------------------------
|
||||
|
||||
def test_two_tokens_sharing_a_value_are_reported_not_refused():
|
||||
"""The operator's constraint, made checkable: reuse consistent values rather
|
||||
than restating them. But a design system legitimately aligns colours on
|
||||
purpose — "Success = Moss, by design" — so this reports and lets a human
|
||||
decide which it is."""
|
||||
dupes = duplicate_values([
|
||||
_token("--fs-moss", {"base": "#4A5D3F"}),
|
||||
_token("--fs-success", {"base": "#4a5d3f"}),
|
||||
_token("--fs-obsidian", {"base": "#14171a"}),
|
||||
])
|
||||
assert dupes == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
|
||||
|
||||
|
||||
def test_tokens_that_agree_in_one_mode_but_differ_in_another_are_not_duplicates():
|
||||
"""A near-miss is a different, weaker finding, and calling it a duplicate
|
||||
would send someone to merge two tokens that genuinely diverge."""
|
||||
assert duplicate_values([
|
||||
_token("--fs-a", {"base": "#fff", "dark": "#000"}),
|
||||
_token("--fs-b", {"base": "#fff", "dark": "#111"}),
|
||||
]) == {"#fff": ["--fs-a", "--fs-b"]}
|
||||
|
||||
|
||||
def test_valueless_tokens_never_count_as_duplicates_of_each_other():
|
||||
"""Otherwise every unfilled token would collide with every other one and the
|
||||
report would be nothing but noise on a fresh import."""
|
||||
assert duplicate_values([_token("--fs-a", {}), _token("--fs-b", {})]) == {}
|
||||
@@ -24,7 +24,8 @@ def test_route_handlers_callable():
|
||||
for name in (
|
||||
"list_design_systems", "create_design_system", "get_design_system",
|
||||
"update_design_system", "delete_design_system", "resolve_design_system",
|
||||
"import_design_system", "list_design_tokens", "create_design_token",
|
||||
"import_design_system", "get_design_system_stylesheet",
|
||||
"list_design_tokens", "create_design_token",
|
||||
"update_design_token", "delete_design_token", "set_project_design_system",
|
||||
):
|
||||
assert callable(getattr(routes, name))
|
||||
@@ -45,6 +46,7 @@ def test_every_endpoint_is_reachable_on_the_app():
|
||||
"/api/design-systems/<int:design_system_id>",
|
||||
"/api/design-systems/<int:design_system_id>/resolved",
|
||||
"/api/design-systems/<int:design_system_id>/import",
|
||||
"/api/design-systems/<int:design_system_id>/stylesheet",
|
||||
"/api/design-systems/<int:design_system_id>/tokens",
|
||||
"/api/design-tokens/<int:token_id>",
|
||||
"/api/projects/<int:project_id>/design-system",
|
||||
@@ -59,6 +61,7 @@ def test_service_functions_take_user_id():
|
||||
"update_design_system", "delete_design_system", "resolve_design_system",
|
||||
"create_token", "list_tokens", "update_token", "delete_token",
|
||||
"set_project_design_system", "import_from_rulebook",
|
||||
"stylesheet_for_system",
|
||||
):
|
||||
fn = getattr(svc, fn_name)
|
||||
assert callable(fn)
|
||||
@@ -81,6 +84,7 @@ def test_agent_and_web_surfaces_stay_at_parity():
|
||||
"resolve_design_system", "update_design_system", "delete_design_system",
|
||||
"create_design_token", "list_design_tokens", "update_design_token",
|
||||
"delete_design_token", "set_project_design_system",
|
||||
"get_design_system_stylesheet",
|
||||
):
|
||||
assert callable(getattr(tools, name)), f"MCP tool missing: {name}"
|
||||
assert callable(getattr(routes, name)), f"REST route missing: {name}"
|
||||
|
||||
@@ -373,3 +373,49 @@ async def test_an_unreadable_or_empty_rulebook_reports_nothing_rather_than_faili
|
||||
from scribe.services.design_systems import import_from_rulebook
|
||||
report = await import_from_rulebook(1, 3, 9, apply=True)
|
||||
assert report == {"rulebook_id": 9, "proposed": [], "created": [], "skipped": []}
|
||||
|
||||
|
||||
# --- the master sheet -------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stylesheet_denied_returns_none():
|
||||
with patch("scribe.services.design_systems.resolve_design_system",
|
||||
AsyncMock(return_value=None)):
|
||||
from scribe.services.design_systems import stylesheet_for_system
|
||||
assert await stylesheet_for_system(1, 3) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself():
|
||||
"""The CSS alone hides two things a reviewer needs: which tokens are still
|
||||
valueless, and which values are declared twice. Both ride alongside rather
|
||||
than being left for the reader to derive from the text."""
|
||||
from scribe.services.design_cascade import Contribution, ResolvedToken
|
||||
|
||||
def _resolved(name, base=None):
|
||||
return ResolvedToken(
|
||||
name=name,
|
||||
contributions=(
|
||||
{"base": (Contribution(system_id=1, value=base),)} if base else {}
|
||||
),
|
||||
group_name=None, purpose=None, supersedes=(), order_index=0,
|
||||
)
|
||||
|
||||
system = MagicMock()
|
||||
system.title = "FabledSword"
|
||||
with patch("scribe.services.design_systems.resolve_design_system",
|
||||
AsyncMock(return_value=[
|
||||
_resolved("--fs-moss", "#4a5d3f"),
|
||||
_resolved("--fs-success", "#4a5d3f"),
|
||||
_resolved("--fs-radius-sm"),
|
||||
])), \
|
||||
patch("scribe.services.design_systems.get_design_system",
|
||||
AsyncMock(return_value=system)):
|
||||
from scribe.services.design_systems import stylesheet_for_system
|
||||
result = await stylesheet_for_system(1, 3)
|
||||
|
||||
assert result["token_count"] == 3
|
||||
assert result["valueless"] == ["--fs-radius-sm"]
|
||||
assert result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
|
||||
assert "--fs-moss: #4a5d3f;" in result["css"]
|
||||
assert "FabledSword" in result["css"]
|
||||
|
||||
Reference in New Issue
Block a user