refactor(design): retire /design — a surface that could only inspect itself
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 40s

The design surface is for the projects an install tracks. /design read the
running app's own stylesheet — names out of a bundled theme.css, values out of
getComputedStyle(document.documentElement) — so it could only ever describe the
instance serving the page. Scribe is one project among the projects Scribe
tracks; it gets no view hardcoded into every install.

The mechanism that makes this a tool rather than a mirror already existed and
already covers Scribe: scripts/check_design_tokens.py runs in CI against a
sheet path it knows nothing about, using check_code_against_tokens — the same
engine behind check_snippets_against_system. /design was redundant even here.

Removed: DesignView, DesignTabs (nothing left to tab between), api/design.ts,
routes/design.py and its blueprint, the /design route, ui_design_system() and
its setting, and the Settings picker that designated "this app's UI".

utils/designTokens.ts and utils/designDrift.ts go with it — between them they
were the browser-reading half. What survives is utils/designValues.ts, which
works on a record rather than a document: valueForMode, modesPresent, and
resolveDeclared.

resolveDeclared gained real isolation in the move. Custom properties inherit
and `all: initial` does not reset them, so a probe sitting in this page would
resolve any reference a record leaves undeclared against the SURROUNDING app's
tokens — previewing another project's system would quietly borrow this one's
palette wherever that system was incomplete, and a token already reported under
unknown_refs would render as though it were fine. Undeclared references are now
blanked on the probe first, so they resolve to nothing, which is what the record
says they are.

Migration 0075 absorbs ui_design_system_id alongside design_rulebook_id rather
than an 0076 undoing it: 0075 has not run anywhere, since dev is unmerged and
deploys come from main. Both keys named a design source for the running
install, and a project already carries its own pointer.

This retires the agreement panel shipped yesterday. It asked whether the sheet
was actually loaded and applied — the one question a record cannot answer about
itself — but only ever about the app you are already inside. Nothing replaces
it; recorded in #2430 rather than quietly dropped.

Step 1 of milestone #274.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
2026-08-04 10:35:52 -04:00
co-authored by Claude Opus 5
parent 4d2be27935
commit dcd4efcea0
14 changed files with 123 additions and 1428 deletions
-235
View File
@@ -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<string, string>,
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>): string[] {
const families = new Set<string>();
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<string, string>,
live: DesignToken[],
): Agreement[] {
const liveByName = new Map<string, string>();
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<AgreementStatus, number> = {
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);
});
}
-243
View File
@@ -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<string>();
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<string> {
const bare = css.replace(COMMENT, "");
const names = new Set<string>();
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<string, string>): Map<string, string> {
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<string, string>();
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<string, string> = new Map(),
): Map<string, DesignToken[]> {
const out = new Map<string, DesignToken[]>();
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}`));
}
+88
View File
@@ -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<string, string>,
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, string> }[],
): string[] {
const modes = new Set<string>();
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<string, string>): Map<string, string> {
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<string, string>();
for (const name of declared.keys()) {
out.set(name, computed.getPropertyValue(name).trim());
}
return out;
} finally {
probe.remove();
}
}