From dcd4efcea09a1cfb1926c0187c82009e1ba47a13 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 4 Aug 2026 10:35:52 -0400 Subject: [PATCH] =?UTF-8?q?refactor(design):=20retire=20/design=20?= =?UTF-8?q?=E2=80=94=20a=20surface=20that=20could=20only=20inspect=20itsel?= =?UTF-8?q?f?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- .../0075_retire_design_rulebook_setting.py | 31 +- frontend/src/api/design.ts | 15 - frontend/src/components/DesignTabs.vue | 51 -- frontend/src/router/index.ts | 14 +- frontend/src/utils/designDrift.ts | 235 ------ frontend/src/utils/designTokens.ts | 243 ------- frontend/src/utils/designValues.ts | 88 +++ frontend/src/views/DesignSystemsView.vue | 12 +- frontend/src/views/DesignView.vue | 680 ------------------ frontend/src/views/SettingsView.vue | 45 +- src/scribe/app.py | 2 - src/scribe/routes/design.py | 45 -- src/scribe/services/design_systems.py | 35 - tests/test_routes_design.py | 55 -- 14 files changed, 123 insertions(+), 1428 deletions(-) delete mode 100644 frontend/src/api/design.ts delete mode 100644 frontend/src/components/DesignTabs.vue delete mode 100644 frontend/src/utils/designDrift.ts delete mode 100644 frontend/src/utils/designTokens.ts create mode 100644 frontend/src/utils/designValues.ts delete mode 100644 frontend/src/views/DesignView.vue delete mode 100644 src/scribe/routes/design.py delete mode 100644 tests/test_routes_design.py 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 @@ - - - - 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 `