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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user