diff --git a/alembic/versions/0075_retire_design_rulebook_setting.py b/alembic/versions/0075_retire_design_rulebook_setting.py
new file mode 100644
index 0000000..933b59b
--- /dev/null
+++ b/alembic/versions/0075_retire_design_rulebook_setting.py
@@ -0,0 +1,44 @@
+"""retire the design_rulebook_id setting
+
+Revision ID: 0075
+Revises: 0074
+Create Date: 2026-08-03
+
+The /design panel used to compare a design RULEBOOK's prose claims against the
+live tokens. That rulebook was imported into the design system and retired, and
+the panel now compares the running app against the design system it was
+generated from (#2419). Its designation moved with it:
+
+ design_rulebook_id -> ui_design_system_id
+
+Nothing reads the old key any more, so this deletes the row rather than leaving
+an inert one behind (rule #22 — remove the old path, including the setting it
+read from). The values are not translatable: a rulebook id and a design system
+id are ids in different tables, and guessing a mapping would silently point the
+new panel at the wrong system.
+
+Deleting settings rows by key is safe in a way dropping a column is not — the
+table is free-form key/value, so an install that never designated one simply has
+no row to delete.
+
+Downgrade cannot restore what it never recorded, so it is a no-op rather than a
+lie: the operator re-designates in Settings.
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "0075"
+down_revision = "0074"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute(
+ sa.text("DELETE FROM settings WHERE key = 'design_rulebook_id'")
+ )
+
+
+def downgrade() -> None:
+ pass
diff --git a/frontend/src/api/design.ts b/frontend/src/api/design.ts
index cbdfe09..d8d7d04 100644
--- a/frontend/src/api/design.ts
+++ b/frontend/src/api/design.ts
@@ -1,9 +1,15 @@
import { apiGet } from "@/api/client";
-import type { ExpectationResponse } from "@/utils/designDrift";
-/** Checkable claims from the rulebook this install designated as its design system.
+export interface UiSystemResponse {
+ design_system_id: number | null;
+ title: string | null;
+}
+
+/** The design system this install says its own UI is built from.
*
- * `rulebook_id: null` means none has been designated — the normal state for a
- * fresh install, not an error. The caller shows an explanatory empty state. */
-export const fetchDesignExpectations = () =>
- apiGet("/api/design/expectations");
+ * Both nulls means none designated — the normal state for a fresh install, not
+ * an error; the caller shows an explanatory empty state. An id with a null
+ * title means designated but deleted or unreadable, which is a
+ * misconfiguration and must not be rendered as "none". */
+export const fetchUiDesignSystem = () =>
+ apiGet("/api/design/ui-system");
diff --git a/frontend/src/utils/designDrift.ts b/frontend/src/utils/designDrift.ts
index 453e84f..5bd1332 100644
--- a/frontend/src/utils/designDrift.ts
+++ b/frontend/src/utils/designDrift.ts
@@ -1,58 +1,80 @@
/**
- * Drift comparison — what the rulebook claims vs what the stylesheet does.
+ * Agreement — does the running app match the design system it was built from?
*
- * Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
- * prose into claims) is server-side in `services/design_system.py`, where pytest
- * can assert on it. What's left here is set arithmetic over live token values,
- * which is the one thing the browser knows and the server doesn't.
+ * This panel used to compare a design RULEBOOK's prose claims against the live
+ * tokens. That rulebook was retired into the design system on 2026-08-01, and
+ * the feature spent two days rendering a reassuring empty state instead of
+ * failing (#2419). Its replacement is not the same question re-aimed: comparing
+ * a design system against a stylesheet generated from that same design system
+ * would be a tautology.
+ *
+ * The question that survives is the one no server can answer. A sheet still has
+ * to be LOADED and APPLIED, and until now nothing checked that it was. Three
+ * failures live in that gap:
+ *
+ * absent the app has no such token at all — the sheet was never
+ * regenerated after the record changed, or never loaded
+ * differs the app has the token with another value — a stale copy of the
+ * sheet, or a later rule that overrode it
+ * unrecorded the app declares a token in the record's own family that the
+ * record has never heard of — hand-editing that outlived its reason
*
* SCOPE, and it is a real limit rather than an omission. This compares the
- * rulebook against the TOKENS. It cannot see the third category of drift — a
- * literal hardcoded in a component where a token should be referenced (#2275,
- * 67 occurrences of `color: #fff` against a rule that forbids pure white). That
- * drift isn't in the tokens at all, so no amount of inspecting them finds it.
- *
- * Catching it needs the component sources, which would mean bundling every SFC
- * into the app to read at runtime — a large cost for a panel. It belongs in CI,
- * as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
- * matters: a drift report that silently omits a category invites the reader to
- * conclude the category is clean.
+ * record against the TOKENS. A literal hardcoded in a component where a token
+ * should be referenced is invisible here, because the drift isn't in the tokens
+ * at all — that check has the component sources and belongs in CI (#2277).
+ * Saying so in the panel matters: a report that silently omits a category
+ * invites the reader to conclude the category is clean.
*/
import type { DesignToken } from "@/utils/designTokens";
-export type ExpectationKind = "token" | "color" | "prohibited_color";
+/** The base mode's key in a token's `value_by_mode`, mirroring services/design_stylesheet. */
+export const BASE_MODE = "base";
-export interface Expectation {
- kind: ExpectationKind;
+/** One token as the RECORD has it, already narrowed to the mode being checked. */
+export interface RecordedToken {
+ name: string;
+ /** Declared value for this mode, or "" when the role is named but unvalued. */
value: string;
- rule_id: number;
- rule_title: string;
- context: string;
+ groupName: string | null;
}
-export interface ExpectationResponse {
- rulebook_id: number | null;
- expectations: Expectation[];
-}
+export type AgreementStatus = "ok" | "absent" | "differs" | "unrecorded";
-export type FindingStatus = "ok" | "missing" | "violated";
-
-export interface Finding {
- expectation: Expectation;
- status: FindingStatus;
- /** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
- matches: string[];
+export interface Agreement {
+ name: string;
+ groupName: string | null;
+ /** What the record declares, resolved. Empty for an `unrecorded` row. */
+ recorded: string;
+ /** What the browser resolved. Empty for an `absent` row. */
+ live: string;
+ status: AgreementStatus;
}
/**
- * Normalise a colour for comparison — the client-side twin of
- * `normalize_hex` in services/design_system.py.
+ * Which declared value applies when the page is in `mode`.
*
- * These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
- * `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
- * spellings of one colour, and a comparison that misses any of them under-reports
- * rather than erroring. The rgb() case is browser-specific and therefore has no
- * server-side counterpart, which is exactly why it is handled here.
+ * Falls back to base, which is the storage model rather than a convenience: a
+ * mode block is an OVERRIDE layer, so a token with no entry for the current
+ * mode is not missing — it is inheriting, exactly as the sheet has it.
+ */
+export function valueForMode(
+ valueByMode: Record,
+ mode: string,
+): string {
+ const own = valueByMode[mode];
+ if (own !== undefined && own !== "") return own;
+ return valueByMode[BASE_MODE] ?? "";
+}
+
+/**
+ * Normalise a colour for comparison.
+ *
+ * A record writes `#FFFFFF`, a sheet writes `#fff`, and
+ * getComputedStyle can hand back `rgb(255, 255, 255)` — three spellings of one
+ * colour, and a comparison that misses any of them over-reports drift, which is
+ * the failure that gets a panel ignored. The rgb() case is browser-specific and
+ * therefore has no server-side counterpart, which is exactly why it is here.
*/
export function normalizeColour(value: string): string | null {
const raw = value.trim().toLowerCase();
@@ -66,7 +88,9 @@ export function normalizeColour(value: string): string | null {
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
}
- // getComputedStyle always reports colours as rgb()/rgba(), never as authored.
+ // getComputedStyle reports real colour properties as rgb()/rgba(), never as
+ // authored. Custom properties are token streams and usually come back as
+ // written, so this arm is insurance rather than the common path.
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
if (rgb) {
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
@@ -84,86 +108,128 @@ export function normalizeColour(value: string): string | null {
return null;
}
-/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
-export function colourIndex(tokens: DesignToken[]): Map {
- const index = new Map();
- for (const token of tokens) {
- const colour = normalizeColour(token.value);
- if (!colour) continue;
- const names = index.get(colour);
- if (names) names.push(token.name);
- else index.set(colour, [token.name]);
- }
- return index;
+/**
+ * Compare two CSS values for sameness, not for identical text.
+ *
+ * Whitespace inside a compound value is not meaningful — `0 2px 10px` and
+ * `0 2px 10px` are one shadow — and neither is case, since a custom property
+ * carries no font names or content strings that would be changed by folding it.
+ * Colours go through the normaliser first so spelling differences don't read as
+ * drift.
+ */
+export function sameValue(a: string, b: string): boolean {
+ const canon = (v: string) => {
+ const trimmed = v.trim();
+ return normalizeColour(trimmed) ?? trimmed.replace(/\s+/g, " ").toLowerCase();
+ };
+ return canon(a) === canon(b);
}
/**
- * Compare claims against the live tokens.
+ * The families the record claims, as name prefixes.
*
- * A `token` claim asks whether a custom property of that name exists.
- * A `color` claim asks whether any token resolves to that value.
- * A `prohibited_color` claim INVERTS the test — present is the failure.
+ * Used to decide which live tokens count as `unrecorded`. An app's stylesheet
+ * legitimately carries names the record never owned — Scribe's own sheet keeps
+ * a `--color-*` alias layer over the design system's `--fs-*` block — and
+ * reporting those as drift would bury the real findings under a compatibility
+ * shim. So the record is treated as owning a FAMILY, identified by the prefix
+ * up to the first separator, and nothing outside it is judged.
+ *
+ * Derived from the data rather than configured, because the prefix is the
+ * install's choice (see design_starter_roles) and hardcoding one would put a
+ * single operator's naming into every install (rule #115).
*/
-export function compareToTokens(
- expectations: Expectation[],
- tokens: DesignToken[],
-): Finding[] {
- const names = new Set(tokens.map((t) => t.name));
- const colours = colourIndex(tokens);
-
- return expectations.map((expectation) => {
- if (expectation.kind === "token") {
- const present = names.has(expectation.value);
- return {
- expectation,
- status: present ? "ok" : "missing",
- matches: present ? [expectation.value] : [],
- };
- }
-
- const matches = colours.get(expectation.value) ?? [];
- if (expectation.kind === "prohibited_color") {
- return {
- expectation,
- status: matches.length ? "violated" : "ok",
- matches,
- };
- }
- return {
- expectation,
- status: matches.length ? "ok" : "missing",
- matches,
- };
- });
+export function recordedFamilies(names: Iterable): string[] {
+ const families = new Set();
+ for (const name of names) {
+ const match = /^(--[A-Za-z0-9]+-)/.exec(name);
+ if (match) families.add(match[1]);
+ }
+ return [...families];
}
-export interface DriftSummary {
+/**
+ * Compare the record against the running app.
+ *
+ * `resolved` is the record's declared values after the browser has substituted
+ * `var()` in them (see `resolveDeclared`) — the same treatment the live values
+ * already received, which is what makes the two comparable.
+ *
+ * Tokens the record names but has no value for are SKIPPED, not reported. A
+ * valueless token is a role awaiting a decision, and the stylesheet already
+ * reports those under `valueless`; counting them as drift would mean a system
+ * created with starter roles opens this panel red on day one.
+ */
+export function compareToApp(
+ recorded: RecordedToken[],
+ resolved: Map,
+ live: DesignToken[],
+): Agreement[] {
+ const liveByName = new Map();
+ for (const token of live) liveByName.set(token.name, token.value);
+ const out: Agreement[] = [];
+
+ for (const token of recorded) {
+ if (!token.value) continue;
+ const declared = resolved.get(token.name) ?? token.value;
+ const actual = liveByName.get(token.name) ?? "";
+ out.push({
+ name: token.name,
+ groupName: token.groupName,
+ recorded: declared,
+ live: actual,
+ status: !actual ? "absent" : sameValue(declared, actual) ? "ok" : "differs",
+ });
+ }
+
+ const known = new Set(recorded.map((t) => t.name));
+ const families = recordedFamilies(known);
+ for (const token of live) {
+ if (known.has(token.name)) continue;
+ if (!families.some((prefix) => token.name.startsWith(prefix))) continue;
+ out.push({
+ name: token.name,
+ groupName: null,
+ recorded: "",
+ live: token.value,
+ status: "unrecorded",
+ });
+ }
+
+ return out;
+}
+
+export interface AgreementSummary {
ok: number;
- missing: number;
- violated: number;
+ absent: number;
+ differs: number;
+ unrecorded: number;
total: number;
}
-export function summarise(findings: Finding[]): DriftSummary {
- const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
- for (const finding of findings) summary[finding.status] += 1;
+export function summarise(agreements: Agreement[]): AgreementSummary {
+ const summary: AgreementSummary = {
+ ok: 0, absent: 0, differs: 0, unrecorded: 0, total: agreements.length,
+ };
+ for (const a of agreements) summary[a.status] += 1;
return summary;
}
/**
* Findings worth leading with.
*
- * A panel that opens with every row gets closed and never reopened — the same
- * principle the auto-inject menu is built on: a short list that gets read beats
- * a complete one that doesn't. Violations first (something is actively wrong),
- * then missing (something was never built), and `ok` rows are not "findings" at
- * all — they belong behind an expansion.
+ * A panel that opens with every row gets closed and never reopened. `absent`
+ * leads because it is the one status that can mean the whole sheet is missing;
+ * `differs` next, because a wrong value is being rendered right now;
+ * `unrecorded` last, since it is a bookkeeping gap rather than a visible fault.
+ * `ok` rows are not findings at all and belong behind an expansion.
*/
-export function rankFindings(findings: Finding[]): Finding[] {
- const order: Record = { violated: 0, missing: 1, ok: 2 };
- return [...findings].sort((a, b) => {
+export function rankAgreements(agreements: Agreement[]): Agreement[] {
+ const order: Record = {
+ absent: 0, differs: 1, unrecorded: 2, ok: 3,
+ };
+ return [...agreements].sort((a, b) => {
const byStatus = order[a.status] - order[b.status];
- if (byStatus !== 0) return byStatus;
- return a.expectation.rule_id - b.expectation.rule_id;
+ return byStatus !== 0 ? byStatus : a.name.localeCompare(b.name);
});
}
diff --git a/frontend/src/utils/designTokens.ts b/frontend/src/utils/designTokens.ts
index 029d475..802ccc8 100644
--- a/frontend/src/utils/designTokens.ts
+++ b/frontend/src/utils/designTokens.ts
@@ -2,7 +2,8 @@
* Design-token inventory — what tokens exist, and what they actually resolve to.
*
* Foundation for the design explorer (milestone #251): the gallery renders
- * against these, and the drift panel compares them to the design rulebook.
+ * against these, and the agreement panel compares them to the design system the
+ * install says its UI is built from (#2419).
*
* DESIGN NOTE — why this parses NAMES but never VALUES.
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
@@ -42,8 +43,8 @@ export interface DesignToken {
group: TokenGroup;
/** Resolved value in the requested context, straight from the browser. */
value: string;
- /** True when the declaration appears inside the dark block in source. */
- overriddenInDark: boolean;
+ /** True when the token is re-declared under a mode selector in source. */
+ modeAware: boolean;
}
/**
@@ -61,8 +62,21 @@ const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
/** Comments are stripped first so a commented-out declaration isn't counted. */
const COMMENT = /\/\*[\s\S]*?\*\//g;
-/** The dark block's selector, as written in theme.css. */
-const DARK_SELECTOR = '[data-theme="dark"]';
+/**
+ * Any mode-override block, whichever mode it names.
+ *
+ * This used to hardcode `[data-theme="dark"]`, and that stopped being true the
+ * day the sheet went dark-first: `:root` now carries dark and
+ * `[data-theme="light"]` overrides it. The hardcoded selector matched nothing,
+ * `overriddenInDark` was false for all 186 tokens, and the "mode-aware" flag
+ * silently vanished from the gallery — a UI that kept rendering, wrongly.
+ *
+ * Matching the SHAPE rather than one mode name is what makes that unrepeatable,
+ * and it is also the only version that holds for an install whose modes aren't
+ * light and dark (rule #115). `selector_for_mode` in services/design_stylesheet
+ * emits exactly this shape, so the two ends agree by construction.
+ */
+const MODE_SELECTOR = /\[data-theme=["']?[\w-]+["']?\]/g;
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
["--color-", "color"],
@@ -96,15 +110,21 @@ export function tokenNames(css: string = themeCss): string[] {
return out;
}
-/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
-export function darkOverriddenNames(css: string = themeCss): Set {
+/** The subset re-declared under a mode selector — i.e. tokens that change with mode. */
+export function modeOverriddenNames(css: string = themeCss): Set {
const bare = css.replace(COMMENT, "");
- const start = bare.indexOf(DARK_SELECTOR);
- if (start === -1) return new Set();
- const open = bare.indexOf("{", start);
- const close = bare.indexOf("}", open);
- if (open === -1 || close === -1) return new Set();
- return new Set(tokenNames(bare.slice(open, close)));
+ const names = new Set();
+ for (const match of bare.matchAll(MODE_SELECTOR)) {
+ if (match.index === undefined) continue;
+ const open = bare.indexOf("{", match.index + match[0].length);
+ if (open === -1) continue;
+ // A custom-property block is flat, so the first `}` closes it. Anything
+ // nested would be a rule, not a declaration, and has no tokens to find.
+ const close = bare.indexOf("}", open);
+ if (close === -1) continue;
+ for (const name of tokenNames(bare.slice(open, close))) names.add(name);
+ }
+ return names;
}
/**
@@ -116,15 +136,46 @@ export function darkOverriddenNames(css: string = themeCss): Set {
*/
export function readTokens(host: Element = document.documentElement): DesignToken[] {
const computed = getComputedStyle(host);
- const dark = darkOverriddenNames();
+ const modal = modeOverriddenNames();
return tokenNames().map((name) => ({
name,
group: groupFor(name),
value: computed.getPropertyValue(name).trim(),
- overriddenInDark: dark.has(name),
+ modeAware: modal.has(name),
}));
}
+/**
+ * Read a set of DECLARED values as the browser would resolve them.
+ *
+ * The point is to compare like with like. A design system records
+ * `color-mix(in srgb, var(--fs-accent) 15%, transparent)`; the browser reports
+ * the same token with `var()` already substituted. Comparing those two strings
+ * marks every derived token as drift, which is a report nobody can read.
+ *
+ * So both sides go through the same engine: set the declarations on an
+ * offscreen probe, read them back, and the substitution is done by the
+ * implementation that will do it for real rather than by a parser of ours.
+ * Undeclared references fall through to the page's own values, which is what
+ * the cascade would do anyway.
+ */
+export function resolveDeclared(declared: Map): Map {
+ const probe = document.createElement("div");
+ probe.style.display = "none";
+ for (const [name, value] of declared) probe.style.setProperty(name, value);
+ document.body.appendChild(probe);
+ try {
+ const computed = getComputedStyle(probe);
+ const out = new Map();
+ for (const name of declared.keys()) {
+ out.set(name, computed.getPropertyValue(name).trim());
+ }
+ return out;
+ } finally {
+ probe.remove();
+ }
+}
+
/**
* Read tokens as they would resolve in a given mode, without touching the page.
*
@@ -132,16 +183,15 @@ export function readTokens(host: Element = document.documentElement): DesignToke
* mutated to take a reading.
*
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
- * function: light is declared on `:root` while dark is declared on
- * `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
- * subtree, but there is no `[data-theme="light"]` block to add the light ones
- * back. So reading "light" from inside a dark page returns the dark values —
- * the probe has nothing to match.
+ * function: mode scoping is one-way. Whichever mode the sheet treats as its
+ * BASE lives on `:root` and has no attribute selector of its own, so a probe
+ * can add an overriding mode to a subtree but can never add the base mode back.
*
- * Concretely: dark-inside-light previews work, light-inside-dark previews do
- * not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
- * (milestone #251 step 6) is what makes this symmetric, and until then callers
- * should treat a cross-mode read as best-effort.
+ * The sheet is dark-first today — `:root` carries dark, `[data-theme="light"]`
+ * overrides it — so light-inside-dark previews work and dark-inside-light ones
+ * return the light values. That direction flipped when the sheet did, which is
+ * why this says "the base mode" rather than naming one: callers should treat a
+ * cross-mode read as best-effort either way.
*/
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
const probe = document.createElement("div");
@@ -155,13 +205,25 @@ export function readTokensForMode(mode: ThemeMode): DesignToken[] {
}
}
-/** Tokens grouped by family, preserving source order within each group. */
-export function groupTokens(tokens: DesignToken[]): Map {
- const out = new Map();
+/**
+ * Tokens grouped by family, preserving source order within each group.
+ *
+ * `overrides` maps a token name to the group it should sit under, and exists
+ * because the prefix table above can only know the families that shipped with
+ * the product. An install's own design system knows the groups it authored, so
+ * a caller holding the record passes them here rather than the taxonomy growing
+ * one operator's prefixes (rule #115).
+ */
+export function groupTokens(
+ tokens: DesignToken[],
+ overrides: Map = new Map(),
+): Map {
+ const out = new Map();
for (const token of tokens) {
- const bucket = out.get(token.group);
+ const group = overrides.get(token.name) ?? token.group;
+ const bucket = out.get(group);
if (bucket) bucket.push(token);
- else out.set(token.group, [token]);
+ else out.set(group, [token]);
}
return out;
}
diff --git a/frontend/src/views/DesignView.vue b/frontend/src/views/DesignView.vue
index 9a6d4e4..f5b7a49 100644
--- a/frontend/src/views/DesignView.vue
+++ b/frontend/src/views/DesignView.vue
@@ -6,7 +6,7 @@
* runtime rather than parsed from source, so what you see here is what the app
* is using right now.
*
- * HONESTY RULE, and the reason parts of this page say "not implemented":
+ * HONESTY RULE, and the reason parts of this page can say "not implemented":
* a gallery of hand-written look-alikes drifts from the app within a month and
* then lies — which is the same failure this whole surface exists to catch. So
* every specimen below is either a REAL component imported from the app, or a
@@ -18,87 +18,179 @@
* `assets/components.css` is now the single definition (#2273), so the
* specimens below are the app's real classes — they cannot drift from the app
* without drifting the app itself.
+ *
+ * The panel at the top applies the same rule one level up: the sheet the app
+ * loads is generated from a design system, and nothing checked that the app
+ * ever loaded it (#2419).
*/
-import { computed, onMounted, ref } from "vue";
+import { computed, onMounted, ref, watch } from "vue";
-import { fetchDesignExpectations } from "@/api/design";
+import { fetchUiDesignSystem } from "@/api/design";
+import { fetchResolvedTokens, type ResolvedToken } from "@/api/designSystems";
import DesignTabs from "@/components/DesignTabs.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import TagPill from "@/components/TagPill.vue";
+import { useTheme } from "@/composables/useTheme";
import {
- compareToTokens,
- rankFindings,
+ compareToApp,
+ rankAgreements,
summarise,
- type Expectation,
- type Finding,
+ valueForMode,
+ type Agreement,
+ type RecordedToken,
} from "@/utils/designDrift";
-import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
+import {
+ groupTokens,
+ readTokens,
+ resolveDeclared,
+ type DesignToken,
+ type TokenGroup,
+} from "@/utils/designTokens";
+
+const { theme } = useTheme();
const tokens = ref([]);
-const expectations = ref([]);
-const designRulebookId = ref(null);
-const driftLoaded = ref(false);
-const showCleanRows = ref(false);
+
+/** The designation, and the two ways it can be absent — see api/design.ts. */
+const systemId = ref(null);
+const systemTitle = ref(null);
+const checkLoaded = ref(false);
+const checkFailed = ref(false);
+const showAgreeingRows = ref(false);
+
+/** The record, as fetched. Kept raw because the mode narrowing has to be redone
+ * whenever the theme changes — a comparison against the wrong mode's values
+ * would report every mode-aware token as drift. */
+const records = ref([]);
+const recorded = ref([]);
+const resolved = ref
-
+
-
Rulebook drift
+
Agreement with the record
-
Checking against the design rulebook…
+
+ Checking the running app against its design system…
+
-
- No design rulebook designated.
+
+ The design system couldn't be read.
- This install hasn't said which rulebook describes its design system, so
- there is nothing to check the tokens against. Designate one in
- Settings and this panel will
- compare every colour and token the rulebook names against what the
- stylesheet actually resolves to.
+ The gallery below is still live — it comes from the browser, not the
+ server — but nothing is being compared against the record right now.
+ This is a failure, not an empty result.
+
+
+
+
+ No design system designated for this UI.
+
+ This install hasn't said which design system its own interface is
+ built from, so there is nothing to check the running app against.
+ Designate one in Settings
+ and this panel will report every token the record declares that the
+ app doesn't have, renders differently, or has never heard of.
+
+
+
+
+ Design system #{{ systemId }} could not be read.
+
+ It is designated in Settings,
+ but it has since been deleted or is no longer shared with you. Nothing
+ is being checked — this is a misconfiguration, not a clean result.
- {{ driftSummary.violated }} violated ·
- {{ driftSummary.missing }} missing ·
- {{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
- claims in rulebook #{{ designRulebookId }}.
+ {{ summary.absent }} not in the app ·
+ {{ summary.differs }} rendering another value ·
+ {{ summary.unrecorded }} not in the record ·
+ {{ summary.ok }} agreeing, across {{ summary.total }} tokens checked
+ against {{ systemTitle }} in
+ {{ theme }} mode.
+
+ {{ unvaluedCount }} recorded {{ unvaluedCount === 1 ? "role has" : "roles have" }}
+ no value yet and {{ unvaluedCount === 1 ? "was" : "were" }} not checked.
+
- This compares the rulebook against the TOKENS only.
+ This compares the record against the TOKENS only.
A value hardcoded in a component — where a token should have been
- referenced — is invisible here, because the drift isn't in the tokens
- at all. Reading it would mean bundling every component's source into
- the app. That check belongs in CI and is tracked separately, so treat
- a clean panel as "the tokens agree", not "the app agrees".
+ referenced — is invisible here, because the drift isn't in the
+ tokens at all. Reading it would mean bundling every component's
+ source into the app. That check belongs in CI and is tracked
+ separately, so treat a clean panel as "the tokens agree", not "the
+ app agrees".
-
- The rulebook names nothing this panel can check. Rules that state values
- — colours, token names — produce claims; rules that state judgement
- don't, by design.
+
+ The record declares no valued tokens, so there is nothing to compare
+ yet. Give its roles values and this panel starts reporting.
- Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
- scale is not expressed as custom properties, so sizes and weights are
- set ad hoc per component. Listed here as specification, not as a live
- specimen — there is nothing to read.
+ Nothing in the stylesheet declares a size token, so sizes are being set
+ ad hoc per component. There is nothing live to show here.
@@ -430,6 +550,7 @@ const TYPE_SPECIMENS = [
color: var(--color-text-secondary);
font-size: 0.85rem;
flex: 1;
+ word-break: break-word;
}
.spec-status {
@@ -438,6 +559,25 @@ const TYPE_SPECIMENS = [
letter-spacing: 0.08em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
+ white-space: nowrap;
+}
+
+/* `absent` is the one status that can mean the whole sheet failed to load, so
+ it carries the same weight as a high-priority finding; `differs` is a wrong
+ value on screen right now; `unrecorded` is bookkeeping and reads quietest. */
+.spec-status.absent {
+ background: var(--color-priority-high-bg);
+ color: var(--color-priority-high);
+}
+
+.spec-status.differs {
+ background: var(--color-priority-medium-bg);
+ color: var(--color-priority-medium);
+}
+
+.spec-status.unrecorded {
+ background: var(--color-surface);
+ color: var(--color-text-muted);
}
.spec-status.missing {
@@ -445,11 +585,6 @@ const TYPE_SPECIMENS = [
color: var(--color-priority-medium);
}
-.spec-status.violated {
- background: var(--color-priority-high-bg);
- color: var(--color-priority-high);
-}
-
.spec-status.ok {
background: var(--color-status-done-bg);
color: var(--color-status-done);
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index afe40c8..a291711 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -4,7 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
-import { listRulebooks } from "@/api/rulebooks";
+import { fetchDesignSystems } from "@/api/designSystems";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
@@ -32,11 +32,12 @@ const kbWritePathThreshold = ref("0.68");
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
// suggests a merge the operator reviews (services/dedup.py).
const kbDuplicateThreshold = ref("0.82");
-// Which rulebook describes this install's design system, for the /design drift
-// panel. Empty = none designated, which is the normal state for a fresh install
-// rather than a misconfiguration — the panel explains itself when unset.
-const designRulebookId = ref("");
-const designRulebooks = ref<{ id: number; title: string }[]>([]);
+// Which design system this install's own UI is built from, for the /design
+// agreement panel. Empty = none designated, which is the normal state for a
+// fresh install rather than a misconfiguration — the panel explains itself when
+// unset. Replaced design_rulebook_id when the rulebook was retired (#2419).
+const uiDesignSystemId = ref("");
+const designSystems = ref<{ id: number; title: string }[]>([]);
const savingKbInject = ref(false);
const kbInjectSaved = ref(false);
@@ -107,8 +108,8 @@ async function saveKbInject() {
kb_writepath_threshold: String(wpT),
kb_duplicate_threshold: String(dupT),
// Empty string DELETES the setting (see routes/settings.py), which is
- // exactly right for "no design rulebook" — absent rather than zero.
- design_rulebook_id: designRulebookId.value,
+ // exactly right for "no design system" — absent rather than zero.
+ ui_design_system_id: uiDesignSystemId.value,
});
kbInjectSaved.value = true;
setTimeout(() => (kbInjectSaved.value = false), 2000);
@@ -499,13 +500,15 @@ onMounted(async () => {
if (allSettings.kb_duplicate_threshold !== undefined) {
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
}
- designRulebookId.value = allSettings.design_rulebook_id ?? "";
+ uiDesignSystemId.value = allSettings.ui_design_system_id ?? "";
// Best-effort: the picker degrades to "none available" rather than blocking
- // the whole settings page if rulebooks can't be listed.
+ // the whole settings page if design systems can't be listed.
try {
- designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title }));
+ designSystems.value = (await fetchDesignSystems()).design_systems.map(
+ (s) => ({ id: s.id, title: s.title }),
+ );
} catch {
- designRulebooks.value = [];
+ designSystems.value = [];
}
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
@@ -1278,19 +1281,22 @@ function formatUserDate(iso: string): string {
-
-
diff --git a/src/scribe/routes/design.py b/src/scribe/routes/design.py
index 630e710..e54dc15 100644
--- a/src/scribe/routes/design.py
+++ b/src/scribe/routes/design.py
@@ -1,29 +1,45 @@
-"""Design-system surface — what the rulebook expects of the stylesheet.
+"""This install's UI surface — which design system it claims to be built from.
-The client owns the other half of the comparison: it reads live token values from
-the browser (see `utils/designTokens.ts`), which is the only place they exist
-resolved. This endpoint supplies the claims to check them against.
+Kept separate from the design-systems CRUD blueprint on purpose. That one is
+the RECORD: create a system, move a token, read the cascade. This one answers a
+question about the RUNNING APP, and it exists because those are not the same
+question. A design system can be a perfect record of a stylesheet the app never
+loaded.
+
+The client owns the other half. `utils/designTokens.ts` reads what the browser
+actually resolved, which is the one thing no server can report, and compares it
+to what this endpoint's system declares. So the comparison is
+"does the app agree with its own sheet?" rather than "is the record
+self-consistent?", which would be a tautology — the sheet is generated from the
+record (#2419).
"""
from quart import Blueprint, jsonify
from scribe.auth import get_current_user_id, login_required
-from scribe.services import design_rulebook_import as design_svc
+from scribe.services import design_systems as ds_svc
design_bp = Blueprint("design", __name__, url_prefix="/api/design")
-@design_bp.get("/expectations")
+@design_bp.get("/ui-system")
@login_required
-async def get_expectations():
- """Checkable claims from the rulebook this install designated as its design system.
+async def get_ui_system():
+ """The design system this install designated as the source of its own UI.
- Returns `{"rulebook_id": int|null, "expectations": [...]}`.
+ Returns `{"design_system_id": int|null, "title": str|null}`.
- `rulebook_id: null` is the NORMAL case, not an error — an install that has
- not designated a design rulebook has nothing to compare against, and the
- client shows an explanatory empty state (rule #115). Distinguishing it from
- "designated but empty" is why the id is returned alongside the list.
+ Both nulls is the NORMAL case, not an error — an install that has not
+ designated one has nothing to check the running app against, and the client
+ shows an explanatory empty state (rule #115).
+
+ An id with a null title is the third case and the reason the id is returned
+ separately: designated, but deleted or not readable by this caller. Folding
+ that into "none designated" is precisely how a feature comes to render a
+ reassuring empty state forever.
"""
uid = get_current_user_id()
- result = await design_svc.design_expectations(uid)
- return jsonify(result.as_dict())
+ system_id, system = await ds_svc.ui_design_system(uid)
+ return jsonify({
+ "design_system_id": system_id,
+ "title": system.title if system else None,
+ })
diff --git a/src/scribe/services/design_rulebook_import.py b/src/scribe/services/design_rulebook_import.py
deleted file mode 100644
index 7f637fd..0000000
--- a/src/scribe/services/design_rulebook_import.py
+++ /dev/null
@@ -1,232 +0,0 @@
-"""Design-system expectations — turning rulebook prose into checkable claims.
-
-Milestone #251 step 2. The drift panel compares what the design rulebook SAYS
-against what the stylesheet and components actually DO. This module owns the
-first half: reading a rulebook's rules and extracting the claims that can be
-mechanically checked.
-
-WHY THIS LIVES SERVER-SIDE. The frontend has no test runner — `vue-tsc --noEmit`
-is the entire check — and this is the one genuinely fiddly piece of the feature.
-Extraction happens here where pytest can assert on it; the comparison itself is
-set arithmetic and stays in the browser, where the live token values are.
-
-WHY NOT NLP. Rule statements are prose written for humans, and they should stay
-that way — they are read by people far more often than they are parsed. So this
-extracts only what is unambiguous in ANY prose: the hex colours and CSS custom
-property names a rule mentions. Everything subtler (padding scales, type ramps)
-needs a rule author to opt into a structured form, which is deliberately left for
-when someone wants it rather than invented up front.
-
-RULE #115. Nothing here assumes a design rulebook exists, or that it is this
-operator's. An install designates one; an install that hasn't gets an empty
-result and a panel that explains itself.
-"""
-from __future__ import annotations
-
-import logging
-import re
-from dataclasses import dataclass, field
-
-from scribe.models.rulebook import Rule
-from scribe.services.settings import get_setting
-
-logger = logging.getLogger(__name__)
-
-# Which rulebook describes this install's design system. A plain setting rather
-# than a column: no migration, discoverable in the Settings UI (rule #25), and
-# honest about being a per-install choice rather than a property of the rulebook.
-DESIGN_RULEBOOK_SETTING = "design_rulebook_id"
-
-# `#abc` and `#aabbcc`, plus the 4/8-digit alpha forms.
-_HEX = re.compile(r"#([0-9a-fA-F]{3,8})\b")
-
-# A custom-property name as written in prose, including the slash shorthand the
-# rulebook uses: `--fs-radius-sm/md/lg/xl`, `--fs-obsidian/iron/slate/pewter`.
-_TOKEN = re.compile(r"(--[a-zA-Z][\w-]*(?:/[\w-]+)*)")
-
-# Sentence-ish split. Rules use semicolons as hard breaks as often as periods.
-_SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+|\n+")
-
-# Negation markers. Checked PER SENTENCE, which is the whole trick — see
-# _extract_from_sentence.
-_NEGATIONS = ("never", "not ", "no ", "avoid", "don't", "must not", "excluded")
-
-
-@dataclass
-class Expectation:
- """One mechanically-checkable claim a rule makes."""
-
- kind: str # "token" | "color" | "prohibited_color"
- value: str # "--fs-obsidian" | "#14171a"
- rule_id: int
- rule_title: str
- context: str # the sentence it came from, for showing your work
-
- def as_dict(self) -> dict:
- return {
- "kind": self.kind,
- "value": self.value,
- "rule_id": self.rule_id,
- "rule_title": self.rule_title,
- "context": self.context,
- }
-
-
-@dataclass
-class ExpectationSet:
- rulebook_id: int | None = None
- expectations: list[Expectation] = field(default_factory=list)
-
- def as_dict(self) -> dict:
- return {
- "rulebook_id": self.rulebook_id,
- "expectations": [e.as_dict() for e in self.expectations],
- }
-
-
-def normalize_hex(value: str) -> str | None:
- """Fold a hex colour to a comparable form, or None if it isn't one.
-
- Load-bearing for the whole comparison: the rulebook writes `#FFFFFF` and the
- code writes `#fff`, and those must compare equal or the single largest drift
- finding (#2275) reads as zero. Expands 3-digit shorthand and lowercases.
-
- Alpha forms (4 and 8 digit) keep their alpha — `#fff` and `#ffff` are not the
- same colour, and silently dropping the alpha would invent equality.
- """
- match = _HEX.fullmatch(value.strip()) or _HEX.match(value.strip())
- if not match:
- return None
- digits = match.group(1).lower()
- if len(digits) in (3, 4):
- digits = "".join(c * 2 for c in digits)
- if len(digits) not in (6, 8):
- return None
- return f"#{digits}"
-
-
-def expand_token_shorthand(raw: str) -> list[str]:
- """`--fs-radius-sm/md/lg/xl` -> the four names it stands for.
-
- The rulebook writes token families in a slash shorthand, and both forms it
- uses expand correctly under one rule: take everything up to and including the
- LAST hyphen of the first segment as the prefix, then append each alternative.
-
- --fs-radius-sm/md/lg/xl prefix `--fs-radius-` -> sm, md, lg, xl
- --fs-obsidian/iron/slate prefix `--fs-` -> obsidian, iron, slate
- --fs-dur-fast/base/slow prefix `--fs-dur-` -> fast, base, slow
-
- A name with no slash is returned as-is.
- """
- if "/" not in raw:
- return [raw]
- head, *rest = raw.split("/")
- cut = head.rfind("-")
- if cut <= 1: # no hyphen beyond the leading `--`
- return [head, *rest]
- prefix = head[: cut + 1]
- return [head, *[f"{prefix}{part}" for part in rest if part]]
-
-
-def _is_negated(sentence: str) -> bool:
- return any(marker in sentence.lower() for marker in _NEGATIONS)
-
-
-def _extract_from_sentence(sentence: str, rule: Rule) -> list[Expectation]:
- """Claims in ONE sentence, with negation scoped to that sentence.
-
- Sentence scope is what makes the prohibition detection usable. Rule 52 reads:
-
- "Text tokens: Parchment #E8E4D8 …, Vellum #C2BFB4 …, Ash #9C9A92 ….
- Pure white #FFFFFF is NEVER used as text color."
-
- Three colours the palette REQUIRES and one it FORBIDS, in one statement.
- Detecting negation across the whole statement would mark all four as
- forbidden; detecting it per sentence gets all four right.
- """
- out: list[Expectation] = []
- negated = _is_negated(sentence)
-
- for match in _HEX.finditer(sentence):
- value = normalize_hex(match.group(0))
- if not value:
- continue
- out.append(Expectation(
- kind="prohibited_color" if negated else "color",
- value=value,
- rule_id=int(rule.id),
- rule_title=rule.title,
- context=sentence.strip(),
- ))
-
- # Token names are not negated in practice — a rule says which tokens should
- # exist, never which must not — so they are recorded as expectations
- # regardless. If that ever changes, it needs its own kind rather than
- # borrowing the colour one.
- for match in _TOKEN.finditer(sentence):
- for name in expand_token_shorthand(match.group(1)):
- out.append(Expectation(
- kind="token",
- value=name,
- rule_id=int(rule.id),
- rule_title=rule.title,
- context=sentence.strip(),
- ))
-
- return out
-
-
-def extract_expectations(rules: list[Rule]) -> list[Expectation]:
- """Every checkable claim across a set of rules, deduped on (kind, value).
-
- First occurrence wins so the reported rule is the one that introduced the
- claim, which is usually the most specific place to send a reader.
- """
- seen: set[tuple[str, str]] = set()
- out: list[Expectation] = []
- for rule in rules:
- text = " ".join(filter(None, [rule.statement or "", rule.how_to_apply or ""]))
- for sentence in _SENTENCE_SPLIT.split(text):
- if not sentence.strip():
- continue
- for expectation in _extract_from_sentence(sentence, rule):
- key = (expectation.kind, expectation.value)
- if key in seen:
- continue
- seen.add(key)
- out.append(expectation)
- return out
-
-
-async def get_design_rulebook_id(user_id: int) -> int | None:
- """The rulebook this install designated as its design system, if any."""
- raw = (await get_setting(user_id, DESIGN_RULEBOOK_SETTING, "")).strip()
- if not raw:
- return None
- try:
- value = int(raw)
- except (TypeError, ValueError):
- return None
- return value if value > 0 else None
-
-
-async def design_expectations(user_id: int) -> ExpectationSet:
- """Checkable claims from the designated design rulebook.
-
- Returns an empty set when no rulebook is designated — the normal case for
- any install but the one that set it up (rule #115). The caller shows an
- explanatory empty state rather than treating this as an error.
- """
- rulebook_id = await get_design_rulebook_id(user_id)
- if rulebook_id is None:
- return ExpectationSet()
-
- from scribe.services import rulebooks as rulebooks_svc
-
- try:
- rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id)
- except Exception:
- logger.warning("Design rulebook %s could not be read", rulebook_id, exc_info=True)
- return ExpectationSet(rulebook_id=rulebook_id)
-
- return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules))
diff --git a/src/scribe/services/design_systems.py b/src/scribe/services/design_systems.py
index 522829d..8e2a4ae 100644
--- a/src/scribe/services/design_systems.py
+++ b/src/scribe/services/design_systems.py
@@ -37,6 +37,7 @@ from scribe.services.design_cascade import (
resolve_tokens,
would_cycle,
)
+from scribe.services.settings import get_setting
logger = logging.getLogger(__name__)
@@ -134,6 +135,40 @@ async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem
return system
+# Which design system this install's own UI is built from. A plain setting
+# rather than a column: no migration, discoverable in the Settings UI (rule
+# #25), and honest about being a per-install claim rather than a property of the
+# system — the same system can be the record for an app that never loads it.
+UI_DESIGN_SYSTEM_SETTING = "ui_design_system_id"
+
+
+async def ui_design_system(user_id: int) -> tuple[int | None, DesignSystem | None]:
+ """The design system this install says its UI is built from.
+
+ Returns `(id, system)`. Three outcomes, deliberately distinguishable:
+
+ - `(None, None)` — nothing designated. The NORMAL state for any install but
+ the one that set it up (rule #115), not an error.
+ - `(id, None)` — designated, but gone or not readable by this caller. A
+ misconfiguration worth naming rather than silently degrading to "none",
+ which is exactly the failure that orphaned the panel this feeds (#2419).
+ - `(id, system)` — designated and readable.
+
+ A non-numeric setting value reads as nothing designated: the value is only
+ ever written by a `