diff --git a/alembic/versions/0075_retire_design_rulebook_setting.py b/alembic/versions/0075_retire_design_rulebook_setting.py
index 933b59b..6a3250a 100644
--- a/alembic/versions/0075_retire_design_rulebook_setting.py
+++ b/alembic/versions/0075_retire_design_rulebook_setting.py
@@ -1,28 +1,29 @@
-"""retire the design_rulebook_id setting
+"""retire the two settings that designated a design source for the app itself
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:
+Two keys, retired for the same reason a week apart, so they go in one change
+rather than one migration each:
- design_rulebook_id -> ui_design_system_id
+ design_rulebook_id which rulebook described how this app should look
+ ui_design_system_id which design system this app's own UI was built from
-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.
+Both named a design source for THE RUNNING INSTALL. The design surface is for
+the projects an install tracks, and a project already carries its own pointer
+(`projects.design_system_id`) — so an install-wide designation had nothing left
+to mean. `ui_design_system_id` was introduced by this same migration's first
+draft and never reached a deployed database; it is listed here rather than
+undone by an 0076 that would reverse a change nobody ran.
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.
+lie: the pointer lives on the project now, and always did for anyone who set it
+there.
"""
from alembic import op
import sqlalchemy as sa
@@ -33,10 +34,12 @@ down_revision = "0074"
branch_labels = None
depends_on = None
-
def upgrade() -> None:
op.execute(
- sa.text("DELETE FROM settings WHERE key = 'design_rulebook_id'")
+ sa.text(
+ "DELETE FROM settings "
+ "WHERE key IN ('design_rulebook_id', 'ui_design_system_id')"
+ )
)
diff --git a/frontend/src/api/design.ts b/frontend/src/api/design.ts
deleted file mode 100644
index d8d7d04..0000000
--- a/frontend/src/api/design.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { apiGet } from "@/api/client";
-
-export interface UiSystemResponse {
- design_system_id: number | null;
- title: string | null;
-}
-
-/** The design system this install says its own UI is built from.
- *
- * 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/components/DesignTabs.vue b/frontend/src/components/DesignTabs.vue
deleted file mode 100644
index b00e14d..0000000
--- a/frontend/src/components/DesignTabs.vue
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-
-
-
-
-
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 5e9bf81..0d590f7 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -110,15 +110,11 @@ const router = createRouter({
component: () => import("@/views/RulesView.vue"),
},
{
- // Meta-surface, same family as /rules: it describes the app rather than
- // holding the operator's records.
- path: "/design",
- name: "design",
- component: () => import("@/views/DesignView.vue"),
- },
- {
- // The editable half of the same surface: /design is what the browser
- // renders, /design-systems is the record that ought to decide it.
+ // The design systems this install RECORDS — for the projects it tracks,
+ // not for the install itself. There was a sibling `/design` that read the
+ // running app's own stylesheet out of the browser; it could only ever
+ // inspect the instance it was served from, which made it a mirror rather
+ // than a tool (#274).
path: "/design-systems",
name: "design-systems",
component: () => import("@/views/DesignSystemsView.vue"),
diff --git a/frontend/src/utils/designDrift.ts b/frontend/src/utils/designDrift.ts
deleted file mode 100644
index 5bd1332..0000000
--- a/frontend/src/utils/designDrift.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-/**
- * Agreement — does the running app match the design system it was built from?
- *
- * 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
- * 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";
-
-/** The base mode's key in a token's `value_by_mode`, mirroring services/design_stylesheet. */
-export const BASE_MODE = "base";
-
-/** 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;
- groupName: string | null;
-}
-
-export type AgreementStatus = "ok" | "absent" | "differs" | "unrecorded";
-
-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;
-}
-
-/**
- * Which declared value applies when the page is in `mode`.
- *
- * 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();
-
- const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
- if (hex) {
- let digits = hex[1];
- if (digits.length === 3 || digits.length === 4) {
- digits = digits.split("").map((c) => c + c).join("");
- }
- return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
- }
-
- // 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);
- if (parts.length < 3) return null;
- const channels = parts.slice(0, 3).map((p) => Number(p));
- if (channels.some((n) => !Number.isFinite(n))) return null;
- const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
- const base = `#${channels.map(hexOf).join("")}`;
- if (parts.length === 3) return base;
- const alpha = Number(parts[3]);
- if (!Number.isFinite(alpha) || alpha >= 1) return base;
- return `${base}${hexOf(alpha * 255)}`;
- }
-
- return null;
-}
-
-/**
- * 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);
-}
-
-/**
- * The families the record claims, as name prefixes.
- *
- * 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 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];
-}
-
-/**
- * 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;
- absent: number;
- differs: number;
- unrecorded: number;
- total: number;
-}
-
-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. `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 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];
- return byStatus !== 0 ? byStatus : a.name.localeCompare(b.name);
- });
-}
diff --git a/frontend/src/utils/designTokens.ts b/frontend/src/utils/designTokens.ts
deleted file mode 100644
index 802ccc8..0000000
--- a/frontend/src/utils/designTokens.ts
+++ /dev/null
@@ -1,243 +0,0 @@
-/**
- * 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 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
- * its VALUE is not: values contain nested parens, commas inside rgba(),
- * `var()` references to other tokens, multi-part shadows, and gradients — and
- * `theme.css` has all of those today. So we take the names from the source and
- * ask the BROWSER for every value.
- *
- * That is not just easier, it is more correct. getComputedStyle reports what
- * actually won the cascade, resolves `var()` chains, and — critically for this
- * milestone — reflects live overrides set on a container, which is exactly what
- * the preview surface needs (see #2261). Parsing the source would report what
- * the file says rather than what the user is looking at.
- *
- * It also means this module needs no unit tests to be trustworthy: the only
- * logic here is a name regex and a group lookup. The frontend has no test
- * runner today (`vue-tsc --noEmit` is the whole check), so keeping the
- * error-prone half in the browser rather than in our code is deliberate.
- */
-import themeCss from "@/assets/theme.css?raw";
-
-export type TokenGroup =
- | "color"
- | "radius"
- | "gradient"
- | "glow"
- | "focus"
- | "layout"
- | "other";
-
-export type ThemeMode = "light" | "dark";
-
-export interface DesignToken {
- /** Full custom-property name, including the leading `--`. */
- name: string;
- /** Coarse family, derived from the name prefix. */
- group: TokenGroup;
- /** Resolved value in the requested context, straight from the browser. */
- value: string;
- /** True when the token is re-declared under a mode selector in source. */
- modeAware: boolean;
-}
-
-/**
- * Matches a custom-property DECLARATION, and never a `var(--name)` use.
- *
- * The discriminator is the COLON, not the preceding character. A declaration is
- * `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
- * by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
- * actively wrong: an earlier version required the match to follow `{` or `;`,
- * which silently dropped every declaration that came after a comment —
- * including `--color-bg`, the first and most-used token in the file.
- */
-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;
-
-/**
- * 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"],
- ["--radius-", "radius"],
- ["--gradient-", "gradient"],
- ["--glow-", "glow"],
- ["--focus-", "focus"],
- ["--page-", "layout"],
- ["--sidebar-", "layout"],
- ["--chat-", "layout"],
-];
-
-export function groupFor(name: string): TokenGroup {
- for (const [prefix, group] of GROUP_PREFIXES) {
- if (name.startsWith(prefix)) return group;
- }
- return "other";
-}
-
-/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
-export function tokenNames(css: string = themeCss): string[] {
- const seen = new Set();
- const out: string[] = [];
- for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
- const name = match[1];
- if (!seen.has(name)) {
- seen.add(name);
- out.push(name);
- }
- }
- return out;
-}
-
-/** 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 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;
-}
-
-/**
- * Read the resolved value of every token in `host`'s context.
- *
- * Pass a container to read the tokens as they apply INSIDE it — which is how
- * the preview surface reads a scoped override without disturbing the page.
- * Defaults to the document root, i.e. the app-wide values.
- */
-export function readTokens(host: Element = document.documentElement): DesignToken[] {
- const computed = getComputedStyle(host);
- const modal = modeOverriddenNames();
- return tokenNames().map((name) => ({
- name,
- group: groupFor(name),
- value: computed.getPropertyValue(name).trim(),
- 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.
- *
- * Uses an offscreen probe carrying the mode attribute, so the live UI is never
- * mutated to take a reading.
- *
- * KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
- * 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.
- *
- * 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");
- probe.setAttribute("data-theme", mode);
- probe.style.display = "none";
- document.body.appendChild(probe);
- try {
- return readTokens(probe);
- } finally {
- probe.remove();
- }
-}
-
-/**
- * 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 group = overrides.get(token.name) ?? token.group;
- const bucket = out.get(group);
- if (bucket) bucket.push(token);
- else out.set(group, [token]);
- }
- return out;
-}
-
-/**
- * Tokens declared in the stylesheet that nothing references with `var()`.
- *
- * Dead tokens are drift too: `--chat-reading-width` and
- * `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
- * in the MCP-first pivot, and nothing has referenced them since. Takes the
- * corpus of source files to search as an argument so the caller decides what
- * "used" means — this module has no opinion about the project layout.
- */
-export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
- const haystack = sources.join("\n");
- return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
-}
diff --git a/frontend/src/utils/designValues.ts b/frontend/src/utils/designValues.ts
new file mode 100644
index 0000000..6745bb0
--- /dev/null
+++ b/frontend/src/utils/designValues.ts
@@ -0,0 +1,88 @@
+/**
+ * Turning a design system's RECORDED values into ones you can look at.
+ *
+ * Replaces `designTokens.ts` and `designDrift.ts`, which between them read the
+ * running app's own stylesheet — names out of a bundled `theme.css`, values out
+ * of `getComputedStyle(document.documentElement)`. That could only ever describe
+ * the install serving the page, and the design surface is for the projects an
+ * install TRACKS (#274). What is left here works on any system's record,
+ * including one for an app this browser has never loaded.
+ *
+ * Nothing in this module reads the document's own tokens or mutates the page.
+ */
+
+/** The base mode's key in `value_by_mode`, mirroring services/design_stylesheet. */
+export const BASE_MODE = "base";
+
+/**
+ * Which declared value applies in `mode`.
+ *
+ * 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 generated 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] ?? "";
+}
+
+/** Every mode any token in the set declares, base first then the rest by name. */
+export function modesPresent(
+ tokens: { value_by_mode: Record }[],
+): string[] {
+ const modes = new Set();
+ for (const token of tokens) {
+ for (const [mode, value] of Object.entries(token.value_by_mode)) {
+ if (value) modes.add(mode);
+ }
+ }
+ const rest = [...modes].filter((m) => m !== BASE_MODE).sort();
+ return modes.has(BASE_MODE) ? [BASE_MODE, ...rest] : rest;
+}
+
+/**
+ * Resolve declared values the way a browser would, without applying them.
+ *
+ * A record holds `color-mix(in srgb, var(--fs-accent) 15%, transparent)`. Shown
+ * as text that is a string; shown as a swatch it needs `var()` substituted and
+ * the mix evaluated. Rather than write a CSS parser, set the declarations on an
+ * offscreen probe and read them back — the substitution is done by the
+ * implementation that would do it for real.
+ *
+ * Custom properties INHERIT, and `all: initial` does not reset them — so a probe
+ * sitting in this page would resolve any reference the record leaves undeclared
+ * against the surrounding app's own tokens. Previewing another project's system
+ * would then quietly borrow this one's palette wherever that system was
+ * incomplete, and a token the record already knows is broken (it shows up under
+ * `unknown_refs`) would render as though it were fine.
+ *
+ * So every name referenced but not declared is blanked on the probe first. It
+ * resolves to nothing, which is what the record says it is.
+ */
+const VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
+
+export function resolveDeclared(declared: Map): Map {
+ const probe = document.createElement("div");
+ probe.style.display = "none";
+ for (const value of declared.values()) {
+ for (const match of value.matchAll(VAR_REFERENCE)) {
+ if (!declared.has(match[1])) probe.style.setProperty(match[1], " ");
+ }
+ }
+ 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();
+ }
+}
diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue
index ff50efd..856957a 100644
--- a/frontend/src/views/DesignSystemsView.vue
+++ b/frontend/src/views/DesignSystemsView.vue
@@ -1,9 +1,13 @@
-
-
-
-
-
-
-
Live tokens
-
- The system as it actually is. Token values are read from the browser at
- runtime, so this page reflects the live cascade rather than what the
- stylesheet says. Components shown are the real ones — where a piece of
- the system has no shared implementation, it is marked missing rather
- than mocked up.
-
-
-
-
-
-
Agreement with the record
-
-
- Checking the running app against its design system…
-
-
-
- The design system couldn't be read.
-
- 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.
-
-
-
-
-
- {{ 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 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".
-
-
-
-
- The record declares no valued tokens, so there is nothing to compare
- yet. Give its roles values and this panel starts reporting.
-
Imported from the app. What you see is what ships.
-
-
- Status badge
-
-
-
-
-
-
-
-
-
- Priority badge
-
-
-
-
- (none renders nothing, by design)
-
-
-
-
- Tag pill
-
-
-
-
-
-
-
-
-
-
Buttons
-
-
-
-
-
-
-
-
- Three sizes, because the app has three kinds of button: a page action, a
- row action, and an affordance that sits inside a card without disturbing
- its rhythm.
-
-
-
-
-
-
-
-
- {{ b.name }}
- {{ b.spec }}
- shared
-
-
-
-
-
-
-
Type scale
-
- The scale has no tokens.
-
- Nothing in the stylesheet declares a size token, so sizes are being set
- ad hoc per component. There is nothing live to show here.
-
-
-
-
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index a291711..fd8615f 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -4,7 +4,6 @@ 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 { fetchDesignSystems } from "@/api/designSystems";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
@@ -32,12 +31,6 @@ 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 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,9 +100,6 @@ async function saveKbInject() {
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
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 system" — absent rather than zero.
- ui_design_system_id: uiDesignSystemId.value,
});
kbInjectSaved.value = true;
setTimeout(() => (kbInjectSaved.value = false), 2000);
@@ -500,16 +490,6 @@ onMounted(async () => {
if (allSettings.kb_duplicate_threshold !== undefined) {
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
}
- uiDesignSystemId.value = allSettings.ui_design_system_id ?? "";
- // Best-effort: the picker degrades to "none available" rather than blocking
- // the whole settings page if design systems can't be listed.
- try {
- designSystems.value = (await fetchDesignSystems()).design_systems.map(
- (s) => ({ id: s.id, title: s.title }),
- );
- } catch {
- designSystems.value = [];
- }
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -1280,25 +1260,12 @@ function formatUserDate(iso: string): string {
location, not by resemblance.
-
-
-
-
- Which design system this interface is supposed to be built from. Once
- set, the Design page compares
- every token the system declares against what the browser has actually
- resolved, and reports the ones the app is missing, renders differently,
- or has never heard of. That catches a stylesheet that was regenerated
- but never shipped — which the record alone cannot tell you, since the
- sheet is generated from it. Leave it as None if this install's
- interface isn't described by one of your design systems.
-
-
+
+
Quart:
app.register_blueprint(knowledge_bp)
app.register_blueprint(rulebooks_bp)
app.register_blueprint(plugin_bp)
- app.register_blueprint(design_bp)
app.register_blueprint(design_systems_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
diff --git a/src/scribe/routes/design.py b/src/scribe/routes/design.py
deleted file mode 100644
index e54dc15..0000000
--- a/src/scribe/routes/design.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""This install's UI surface — which design system it claims to be built from.
-
-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_systems as ds_svc
-
-design_bp = Blueprint("design", __name__, url_prefix="/api/design")
-
-
-@design_bp.get("/ui-system")
-@login_required
-async def get_ui_system():
- """The design system this install designated as the source of its own UI.
-
- Returns `{"design_system_id": int|null, "title": str|null}`.
-
- 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()
- 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_systems.py b/src/scribe/services/design_systems.py
index 8e2a4ae..522829d 100644
--- a/src/scribe/services/design_systems.py
+++ b/src/scribe/services/design_systems.py
@@ -37,7 +37,6 @@ from scribe.services.design_cascade import (
resolve_tokens,
would_cycle,
)
-from scribe.services.settings import get_setting
logger = logging.getLogger(__name__)
@@ -135,40 +134,6 @@ 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 `