From 5b824c1626bfe941e86eacccc74a4f1d40e2a2bd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 3 Aug 2026 20:50:03 -0400 Subject: [PATCH] feat(design): the panel now asks whether the app agrees with its own sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retiring rulebook #2 left the /design drift panel with no data source, and because its empty state was well-written the feature read as working while it could only ever render "nothing designated" (#2419). The original question is genuinely gone: theme.css is generated from design system 2, so checking the system against a sheet derived from it would be a tautology. The question that survives is the one no server can answer. A generated sheet still has to be LOADED and APPLIED, and nothing checked that it was: absent the record declares a token the app doesn't have — the sheet was never regenerated after the record changed, or never loaded differs the app has it with another value — a stale 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 Both sides go through the same engine so the comparison is honest: declared values are set on an offscreen probe and read back, which performs the same var() substitution the browser already did to the live values. Comparing raw strings would mark every derived token as drift. The designation moved with the feature — design_rulebook_id becomes ui_design_system_id, with a migration deleting the retired key rather than leaving an inert row. The prose extractor it fed goes too (#2288 said its runtime role ended when the import landed). Three orphans of the same shape, found alongside and fixed here: - darkOverriddenNames hardcoded [data-theme="dark"]. The sheet went dark-first months ago, so it matched nothing and the "mode-aware" flag silently left the gallery. Now matches the SHAPE of a mode selector, which also holds for an install whose modes aren't light and dark. - groupFor's prefix table never heard of --fs-, so 110 tokens sat under "other". Groups now come from the record where there is one; the table can only know families that shipped with the product (rule #115). - The type scale was a hand-written table of nine sizes marked "no token", true when written and false since the scale was recorded. Now rendered from whatever size tokens the sheet declares, so it can't go stale twice. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- .../0075_retire_design_rulebook_setting.py | 44 +++ frontend/src/api/design.ts | 18 +- frontend/src/utils/designDrift.ts | 270 ++++++++------ frontend/src/utils/designTokens.ts | 120 +++++-- frontend/src/views/DesignView.vue | 339 ++++++++++++------ frontend/src/views/SettingsView.vue | 50 +-- src/scribe/routes/design.py | 46 ++- src/scribe/services/design_rulebook_import.py | 232 ------------ src/scribe/services/design_systems.py | 35 ++ tests/test_design_rulebook_import.py | 161 --------- tests/test_design_stylesheet.py | 5 +- tests/test_routes_design.py | 55 +++ 12 files changed, 704 insertions(+), 671 deletions(-) create mode 100644 alembic/versions/0075_retire_design_rulebook_setting.py delete mode 100644 src/scribe/services/design_rulebook_import.py delete mode 100644 tests/test_design_rulebook_import.py create 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 new file mode 100644 index 0000000..933b59b --- /dev/null +++ b/alembic/versions/0075_retire_design_rulebook_setting.py @@ -0,0 +1,44 @@ +"""retire the design_rulebook_id setting + +Revision ID: 0075 +Revises: 0074 +Create Date: 2026-08-03 + +The /design panel used to compare a design RULEBOOK's prose claims against the +live tokens. That rulebook was imported into the design system and retired, and +the panel now compares the running app against the design system it was +generated from (#2419). Its designation moved with it: + + design_rulebook_id -> ui_design_system_id + +Nothing reads the old key any more, so this deletes the row rather than leaving +an inert one behind (rule #22 — remove the old path, including the setting it +read from). The values are not translatable: a rulebook id and a design system +id are ids in different tables, and guessing a mapping would silently point the +new panel at the wrong system. + +Deleting settings rows by key is safe in a way dropping a column is not — the +table is free-form key/value, so an install that never designated one simply has +no row to delete. + +Downgrade cannot restore what it never recorded, so it is a no-op rather than a +lie: the operator re-designates in Settings. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0075" +down_revision = "0074" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + sa.text("DELETE FROM settings WHERE key = 'design_rulebook_id'") + ) + + +def downgrade() -> None: + pass diff --git a/frontend/src/api/design.ts b/frontend/src/api/design.ts index cbdfe09..d8d7d04 100644 --- a/frontend/src/api/design.ts +++ b/frontend/src/api/design.ts @@ -1,9 +1,15 @@ import { apiGet } from "@/api/client"; -import type { ExpectationResponse } from "@/utils/designDrift"; -/** Checkable claims from the rulebook this install designated as its design system. +export interface UiSystemResponse { + design_system_id: number | null; + title: string | null; +} + +/** The design system this install says its own UI is built from. * - * `rulebook_id: null` means none has been designated — the normal state for a - * fresh install, not an error. The caller shows an explanatory empty state. */ -export const fetchDesignExpectations = () => - apiGet("/api/design/expectations"); + * Both nulls means none designated — the normal state for a fresh install, not + * an error; the caller shows an explanatory empty state. An id with a null + * title means designated but deleted or unreadable, which is a + * misconfiguration and must not be rendered as "none". */ +export const fetchUiDesignSystem = () => + apiGet("/api/design/ui-system"); diff --git a/frontend/src/utils/designDrift.ts b/frontend/src/utils/designDrift.ts index 453e84f..5bd1332 100644 --- a/frontend/src/utils/designDrift.ts +++ b/frontend/src/utils/designDrift.ts @@ -1,58 +1,80 @@ /** - * Drift comparison — what the rulebook claims vs what the stylesheet does. + * Agreement — does the running app match the design system it was built from? * - * Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook - * prose into claims) is server-side in `services/design_system.py`, where pytest - * can assert on it. What's left here is set arithmetic over live token values, - * which is the one thing the browser knows and the server doesn't. + * This panel used to compare a design RULEBOOK's prose claims against the live + * tokens. That rulebook was retired into the design system on 2026-08-01, and + * the feature spent two days rendering a reassuring empty state instead of + * failing (#2419). Its replacement is not the same question re-aimed: comparing + * a design system against a stylesheet generated from that same design system + * would be a tautology. + * + * The question that survives is the one no server can answer. A sheet still has + * to be LOADED and APPLIED, and until now nothing checked that it was. Three + * failures live in that gap: + * + * absent the app has no such token at all — the sheet was never + * regenerated after the record changed, or never loaded + * differs the app has the token with another value — a stale copy of the + * sheet, or a later rule that overrode it + * unrecorded the app declares a token in the record's own family that the + * record has never heard of — hand-editing that outlived its reason * * SCOPE, and it is a real limit rather than an omission. This compares the - * rulebook against the TOKENS. It cannot see the third category of drift — a - * literal hardcoded in a component where a token should be referenced (#2275, - * 67 occurrences of `color: #fff` against a rule that forbids pure white). That - * drift isn't in the tokens at all, so no amount of inspecting them finds it. - * - * Catching it needs the component sources, which would mean bundling every SFC - * into the app to read at runtime — a large cost for a panel. It belongs in CI, - * as a lint-shaped check, and is tracked there (#2277). Saying so in the panel - * matters: a drift report that silently omits a category invites the reader to - * conclude the category is clean. + * record against the TOKENS. A literal hardcoded in a component where a token + * should be referenced is invisible here, because the drift isn't in the tokens + * at all — that check has the component sources and belongs in CI (#2277). + * Saying so in the panel matters: a report that silently omits a category + * invites the reader to conclude the category is clean. */ import type { DesignToken } from "@/utils/designTokens"; -export type ExpectationKind = "token" | "color" | "prohibited_color"; +/** The base mode's key in a token's `value_by_mode`, mirroring services/design_stylesheet. */ +export const BASE_MODE = "base"; -export interface Expectation { - kind: ExpectationKind; +/** One token as the RECORD has it, already narrowed to the mode being checked. */ +export interface RecordedToken { + name: string; + /** Declared value for this mode, or "" when the role is named but unvalued. */ value: string; - rule_id: number; - rule_title: string; - context: string; + groupName: string | null; } -export interface ExpectationResponse { - rulebook_id: number | null; - expectations: Expectation[]; -} +export type AgreementStatus = "ok" | "absent" | "differs" | "unrecorded"; -export type FindingStatus = "ok" | "missing" | "violated"; - -export interface Finding { - expectation: Expectation; - status: FindingStatus; - /** Tokens that satisfy (or, for a prohibition, breach) the expectation. */ - matches: string[]; +export interface Agreement { + name: string; + groupName: string | null; + /** What the record declares, resolved. Empty for an `unrecorded` row. */ + recorded: string; + /** What the browser resolved. Empty for an `absent` row. */ + live: string; + status: AgreementStatus; } /** - * Normalise a colour for comparison — the client-side twin of - * `normalize_hex` in services/design_system.py. + * Which declared value applies when the page is in `mode`. * - * These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes - * `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three - * spellings of one colour, and a comparison that misses any of them under-reports - * rather than erroring. The rgb() case is browser-specific and therefore has no - * server-side counterpart, which is exactly why it is handled here. + * Falls back to base, which is the storage model rather than a convenience: a + * mode block is an OVERRIDE layer, so a token with no entry for the current + * mode is not missing — it is inheriting, exactly as the sheet has it. + */ +export function valueForMode( + valueByMode: Record, + mode: string, +): string { + const own = valueByMode[mode]; + if (own !== undefined && own !== "") return own; + return valueByMode[BASE_MODE] ?? ""; +} + +/** + * Normalise a colour for comparison. + * + * A record writes `#FFFFFF`, a sheet writes `#fff`, and + * getComputedStyle can hand back `rgb(255, 255, 255)` — three spellings of one + * colour, and a comparison that misses any of them over-reports drift, which is + * the failure that gets a panel ignored. The rgb() case is browser-specific and + * therefore has no server-side counterpart, which is exactly why it is here. */ export function normalizeColour(value: string): string | null { const raw = value.trim().toLowerCase(); @@ -66,7 +88,9 @@ export function normalizeColour(value: string): string | null { return digits.length === 6 || digits.length === 8 ? `#${digits}` : null; } - // getComputedStyle always reports colours as rgb()/rgba(), never as authored. + // getComputedStyle reports real colour properties as rgb()/rgba(), never as + // authored. Custom properties are token streams and usually come back as + // written, so this arm is insurance rather than the common path. const rgb = /^rgba?\(([^)]+)\)$/.exec(raw); if (rgb) { const parts = rgb[1].split(/[,\s/]+/).filter(Boolean); @@ -84,86 +108,128 @@ export function normalizeColour(value: string): string | null { return null; } -/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */ -export function colourIndex(tokens: DesignToken[]): Map { - const index = new Map(); - for (const token of tokens) { - const colour = normalizeColour(token.value); - if (!colour) continue; - const names = index.get(colour); - if (names) names.push(token.name); - else index.set(colour, [token.name]); - } - return index; +/** + * Compare two CSS values for sameness, not for identical text. + * + * Whitespace inside a compound value is not meaningful — `0 2px 10px` and + * `0 2px 10px` are one shadow — and neither is case, since a custom property + * carries no font names or content strings that would be changed by folding it. + * Colours go through the normaliser first so spelling differences don't read as + * drift. + */ +export function sameValue(a: string, b: string): boolean { + const canon = (v: string) => { + const trimmed = v.trim(); + return normalizeColour(trimmed) ?? trimmed.replace(/\s+/g, " ").toLowerCase(); + }; + return canon(a) === canon(b); } /** - * Compare claims against the live tokens. + * The families the record claims, as name prefixes. * - * A `token` claim asks whether a custom property of that name exists. - * A `color` claim asks whether any token resolves to that value. - * A `prohibited_color` claim INVERTS the test — present is the failure. + * Used to decide which live tokens count as `unrecorded`. An app's stylesheet + * legitimately carries names the record never owned — Scribe's own sheet keeps + * a `--color-*` alias layer over the design system's `--fs-*` block — and + * reporting those as drift would bury the real findings under a compatibility + * shim. So the record is treated as owning a FAMILY, identified by the prefix + * up to the first separator, and nothing outside it is judged. + * + * Derived from the data rather than configured, because the prefix is the + * install's choice (see design_starter_roles) and hardcoding one would put a + * single operator's naming into every install (rule #115). */ -export function compareToTokens( - expectations: Expectation[], - tokens: DesignToken[], -): Finding[] { - const names = new Set(tokens.map((t) => t.name)); - const colours = colourIndex(tokens); - - return expectations.map((expectation) => { - if (expectation.kind === "token") { - const present = names.has(expectation.value); - return { - expectation, - status: present ? "ok" : "missing", - matches: present ? [expectation.value] : [], - }; - } - - const matches = colours.get(expectation.value) ?? []; - if (expectation.kind === "prohibited_color") { - return { - expectation, - status: matches.length ? "violated" : "ok", - matches, - }; - } - return { - expectation, - status: matches.length ? "ok" : "missing", - matches, - }; - }); +export function recordedFamilies(names: Iterable): string[] { + const families = new Set(); + for (const name of names) { + const match = /^(--[A-Za-z0-9]+-)/.exec(name); + if (match) families.add(match[1]); + } + return [...families]; } -export interface DriftSummary { +/** + * Compare the record against the running app. + * + * `resolved` is the record's declared values after the browser has substituted + * `var()` in them (see `resolveDeclared`) — the same treatment the live values + * already received, which is what makes the two comparable. + * + * Tokens the record names but has no value for are SKIPPED, not reported. A + * valueless token is a role awaiting a decision, and the stylesheet already + * reports those under `valueless`; counting them as drift would mean a system + * created with starter roles opens this panel red on day one. + */ +export function compareToApp( + recorded: RecordedToken[], + resolved: Map, + live: DesignToken[], +): Agreement[] { + const liveByName = new Map(); + for (const token of live) liveByName.set(token.name, token.value); + const out: Agreement[] = []; + + for (const token of recorded) { + if (!token.value) continue; + const declared = resolved.get(token.name) ?? token.value; + const actual = liveByName.get(token.name) ?? ""; + out.push({ + name: token.name, + groupName: token.groupName, + recorded: declared, + live: actual, + status: !actual ? "absent" : sameValue(declared, actual) ? "ok" : "differs", + }); + } + + const known = new Set(recorded.map((t) => t.name)); + const families = recordedFamilies(known); + for (const token of live) { + if (known.has(token.name)) continue; + if (!families.some((prefix) => token.name.startsWith(prefix))) continue; + out.push({ + name: token.name, + groupName: null, + recorded: "", + live: token.value, + status: "unrecorded", + }); + } + + return out; +} + +export interface AgreementSummary { ok: number; - missing: number; - violated: number; + absent: number; + differs: number; + unrecorded: number; total: number; } -export function summarise(findings: Finding[]): DriftSummary { - const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length }; - for (const finding of findings) summary[finding.status] += 1; +export function summarise(agreements: Agreement[]): AgreementSummary { + const summary: AgreementSummary = { + ok: 0, absent: 0, differs: 0, unrecorded: 0, total: agreements.length, + }; + for (const a of agreements) summary[a.status] += 1; return summary; } /** * Findings worth leading with. * - * A panel that opens with every row gets closed and never reopened — the same - * principle the auto-inject menu is built on: a short list that gets read beats - * a complete one that doesn't. Violations first (something is actively wrong), - * then missing (something was never built), and `ok` rows are not "findings" at - * all — they belong behind an expansion. + * A panel that opens with every row gets closed and never reopened. `absent` + * leads because it is the one status that can mean the whole sheet is missing; + * `differs` next, because a wrong value is being rendered right now; + * `unrecorded` last, since it is a bookkeeping gap rather than a visible fault. + * `ok` rows are not findings at all and belong behind an expansion. */ -export function rankFindings(findings: Finding[]): Finding[] { - const order: Record = { violated: 0, missing: 1, ok: 2 }; - return [...findings].sort((a, b) => { +export function rankAgreements(agreements: Agreement[]): Agreement[] { + const order: Record = { + absent: 0, differs: 1, unrecorded: 2, ok: 3, + }; + return [...agreements].sort((a, b) => { const byStatus = order[a.status] - order[b.status]; - if (byStatus !== 0) return byStatus; - return a.expectation.rule_id - b.expectation.rule_id; + return byStatus !== 0 ? byStatus : a.name.localeCompare(b.name); }); } diff --git a/frontend/src/utils/designTokens.ts b/frontend/src/utils/designTokens.ts index 029d475..802ccc8 100644 --- a/frontend/src/utils/designTokens.ts +++ b/frontend/src/utils/designTokens.ts @@ -2,7 +2,8 @@ * Design-token inventory — what tokens exist, and what they actually resolve to. * * Foundation for the design explorer (milestone #251): the gallery renders - * against these, and the drift panel compares them to the design rulebook. + * against these, and the agreement panel compares them to the design system the + * install says its UI is built from (#2419). * * DESIGN NOTE — why this parses NAMES but never VALUES. * Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting @@ -42,8 +43,8 @@ export interface DesignToken { group: TokenGroup; /** Resolved value in the requested context, straight from the browser. */ value: string; - /** True when the declaration appears inside the dark block in source. */ - overriddenInDark: boolean; + /** True when the token is re-declared under a mode selector in source. */ + modeAware: boolean; } /** @@ -61,8 +62,21 @@ const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g; /** Comments are stripped first so a commented-out declaration isn't counted. */ const COMMENT = /\/\*[\s\S]*?\*\//g; -/** The dark block's selector, as written in theme.css. */ -const DARK_SELECTOR = '[data-theme="dark"]'; +/** + * Any mode-override block, whichever mode it names. + * + * This used to hardcode `[data-theme="dark"]`, and that stopped being true the + * day the sheet went dark-first: `:root` now carries dark and + * `[data-theme="light"]` overrides it. The hardcoded selector matched nothing, + * `overriddenInDark` was false for all 186 tokens, and the "mode-aware" flag + * silently vanished from the gallery — a UI that kept rendering, wrongly. + * + * Matching the SHAPE rather than one mode name is what makes that unrepeatable, + * and it is also the only version that holds for an install whose modes aren't + * light and dark (rule #115). `selector_for_mode` in services/design_stylesheet + * emits exactly this shape, so the two ends agree by construction. + */ +const MODE_SELECTOR = /\[data-theme=["']?[\w-]+["']?\]/g; const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [ ["--color-", "color"], @@ -96,15 +110,21 @@ export function tokenNames(css: string = themeCss): string[] { return out; } -/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */ -export function darkOverriddenNames(css: string = themeCss): Set { +/** The subset re-declared under a mode selector — i.e. tokens that change with mode. */ +export function modeOverriddenNames(css: string = themeCss): Set { const bare = css.replace(COMMENT, ""); - const start = bare.indexOf(DARK_SELECTOR); - if (start === -1) return new Set(); - const open = bare.indexOf("{", start); - const close = bare.indexOf("}", open); - if (open === -1 || close === -1) return new Set(); - return new Set(tokenNames(bare.slice(open, close))); + const names = new Set(); + for (const match of bare.matchAll(MODE_SELECTOR)) { + if (match.index === undefined) continue; + const open = bare.indexOf("{", match.index + match[0].length); + if (open === -1) continue; + // A custom-property block is flat, so the first `}` closes it. Anything + // nested would be a rule, not a declaration, and has no tokens to find. + const close = bare.indexOf("}", open); + if (close === -1) continue; + for (const name of tokenNames(bare.slice(open, close))) names.add(name); + } + return names; } /** @@ -116,15 +136,46 @@ export function darkOverriddenNames(css: string = themeCss): Set { */ export function readTokens(host: Element = document.documentElement): DesignToken[] { const computed = getComputedStyle(host); - const dark = darkOverriddenNames(); + const modal = modeOverriddenNames(); return tokenNames().map((name) => ({ name, group: groupFor(name), value: computed.getPropertyValue(name).trim(), - overriddenInDark: dark.has(name), + modeAware: modal.has(name), })); } +/** + * Read a set of DECLARED values as the browser would resolve them. + * + * The point is to compare like with like. A design system records + * `color-mix(in srgb, var(--fs-accent) 15%, transparent)`; the browser reports + * the same token with `var()` already substituted. Comparing those two strings + * marks every derived token as drift, which is a report nobody can read. + * + * So both sides go through the same engine: set the declarations on an + * offscreen probe, read them back, and the substitution is done by the + * implementation that will do it for real rather than by a parser of ours. + * Undeclared references fall through to the page's own values, which is what + * the cascade would do anyway. + */ +export function resolveDeclared(declared: Map): Map { + const probe = document.createElement("div"); + probe.style.display = "none"; + for (const [name, value] of declared) probe.style.setProperty(name, value); + document.body.appendChild(probe); + try { + const computed = getComputedStyle(probe); + const out = new Map(); + for (const name of declared.keys()) { + out.set(name, computed.getPropertyValue(name).trim()); + } + return out; + } finally { + probe.remove(); + } +} + /** * Read tokens as they would resolve in a given mode, without touching the page. * @@ -132,16 +183,15 @@ export function readTokens(host: Element = document.documentElement): DesignToke * mutated to take a reading. * * KNOWN LIMITATION, and it is a property of the stylesheet rather than of this - * function: light is declared on `:root` while dark is declared on - * `[data-theme="dark"]`. An attribute selector can ADD the dark values to a - * subtree, but there is no `[data-theme="light"]` block to add the light ones - * back. So reading "light" from inside a dark page returns the dark values — - * the probe has nothing to match. + * function: mode scoping is one-way. Whichever mode the sheet treats as its + * BASE lives on `:root` and has no attribute selector of its own, so a probe + * can add an overriding mode to a subtree but can never add the base mode back. * - * Concretely: dark-inside-light previews work, light-inside-dark previews do - * not. Introducing a `[data-theme="light"]` block alongside the dark-first flip - * (milestone #251 step 6) is what makes this symmetric, and until then callers - * should treat a cross-mode read as best-effort. + * The sheet is dark-first today — `:root` carries dark, `[data-theme="light"]` + * overrides it — so light-inside-dark previews work and dark-inside-light ones + * return the light values. That direction flipped when the sheet did, which is + * why this says "the base mode" rather than naming one: callers should treat a + * cross-mode read as best-effort either way. */ export function readTokensForMode(mode: ThemeMode): DesignToken[] { const probe = document.createElement("div"); @@ -155,13 +205,25 @@ export function readTokensForMode(mode: ThemeMode): DesignToken[] { } } -/** Tokens grouped by family, preserving source order within each group. */ -export function groupTokens(tokens: DesignToken[]): Map { - const out = new Map(); +/** + * Tokens grouped by family, preserving source order within each group. + * + * `overrides` maps a token name to the group it should sit under, and exists + * because the prefix table above can only know the families that shipped with + * the product. An install's own design system knows the groups it authored, so + * a caller holding the record passes them here rather than the taxonomy growing + * one operator's prefixes (rule #115). + */ +export function groupTokens( + tokens: DesignToken[], + overrides: Map = new Map(), +): Map { + const out = new Map(); for (const token of tokens) { - const bucket = out.get(token.group); + const group = overrides.get(token.name) ?? token.group; + const bucket = out.get(group); if (bucket) bucket.push(token); - else out.set(token.group, [token]); + else out.set(group, [token]); } return out; } diff --git a/frontend/src/views/DesignView.vue b/frontend/src/views/DesignView.vue index 9a6d4e4..f5b7a49 100644 --- a/frontend/src/views/DesignView.vue +++ b/frontend/src/views/DesignView.vue @@ -6,7 +6,7 @@ * runtime rather than parsed from source, so what you see here is what the app * is using right now. * - * HONESTY RULE, and the reason parts of this page say "not implemented": + * HONESTY RULE, and the reason parts of this page can say "not implemented": * a gallery of hand-written look-alikes drifts from the app within a month and * then lies — which is the same failure this whole surface exists to catch. So * every specimen below is either a REAL component imported from the app, or a @@ -18,87 +18,179 @@ * `assets/components.css` is now the single definition (#2273), so the * specimens below are the app's real classes — they cannot drift from the app * without drifting the app itself. + * + * The panel at the top applies the same rule one level up: the sheet the app + * loads is generated from a design system, and nothing checked that the app + * ever loaded it (#2419). */ -import { computed, onMounted, ref } from "vue"; +import { computed, onMounted, ref, watch } from "vue"; -import { fetchDesignExpectations } from "@/api/design"; +import { fetchUiDesignSystem } from "@/api/design"; +import { fetchResolvedTokens, type ResolvedToken } from "@/api/designSystems"; import DesignTabs from "@/components/DesignTabs.vue"; import PriorityBadge from "@/components/PriorityBadge.vue"; import StatusBadge from "@/components/StatusBadge.vue"; import TagPill from "@/components/TagPill.vue"; +import { useTheme } from "@/composables/useTheme"; import { - compareToTokens, - rankFindings, + compareToApp, + rankAgreements, summarise, - type Expectation, - type Finding, + valueForMode, + type Agreement, + type RecordedToken, } from "@/utils/designDrift"; -import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens"; +import { + groupTokens, + readTokens, + resolveDeclared, + type DesignToken, + type TokenGroup, +} from "@/utils/designTokens"; + +const { theme } = useTheme(); const tokens = ref([]); -const expectations = ref([]); -const designRulebookId = ref(null); -const driftLoaded = ref(false); -const showCleanRows = ref(false); + +/** The designation, and the two ways it can be absent — see api/design.ts. */ +const systemId = ref(null); +const systemTitle = ref(null); +const checkLoaded = ref(false); +const checkFailed = ref(false); +const showAgreeingRows = ref(false); + +/** The record, as fetched. Kept raw because the mode narrowing has to be redone + * whenever the theme changes — a comparison against the wrong mode's values + * would report every mode-aware token as drift. */ +const records = ref([]); +const recorded = ref([]); +const resolved = ref>(new Map()); + +/** Narrow the record to the live mode and re-read the app. Idempotent. */ +function recheck() { + tokens.value = readTokens(); + recorded.value = records.value.map((t) => ({ + name: t.name, + value: valueForMode(t.value_by_mode, theme.value), + groupName: t.group_name, + })); + const declared = new Map(); + for (const token of recorded.value) { + if (token.value) declared.set(token.name, token.value); + } + resolved.value = resolveDeclared(declared); +} /** * Read on mount, not at module scope: the values depend on the live cascade, * which needs the app's stylesheets applied and the theme attribute set. */ onMounted(async () => { - tokens.value = readTokens(); + recheck(); try { - const response = await fetchDesignExpectations(); - designRulebookId.value = response.rulebook_id; - expectations.value = response.expectations; + const designation = await fetchUiDesignSystem(); + systemId.value = designation.design_system_id; + systemTitle.value = designation.title; + if (designation.design_system_id !== null && designation.title !== null) { + records.value = (await fetchResolvedTokens(designation.design_system_id)).tokens; + recheck(); + } } catch { // The gallery is useful without the panel, so a failed fetch degrades to - // "no drift data" rather than taking the page down with it. - designRulebookId.value = null; + // "couldn't check" rather than taking the page down with it. It says so + // rather than showing the same empty state as "nothing designated" — that + // conflation is what let this feature sit dead (#2419). + checkFailed.value = true; } finally { - driftLoaded.value = true; + checkLoaded.value = true; } }); -const findings = computed(() => - rankFindings(compareToTokens(expectations.value, tokens.value)), +// Toggling the theme on this page is the most likely thing anyone does here. +watch(theme, () => recheck()); + +const agreements = computed(() => + rankAgreements(compareToApp(recorded.value, resolved.value, tokens.value)), ); -const driftSummary = computed(() => summarise(findings.value)); -const visibleFindings = computed(() => - showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"), +const summary = computed(() => summarise(agreements.value)); +const visibleAgreements = computed(() => + showAgreeingRows.value + ? agreements.value + : agreements.value.filter((a) => a.status !== "ok"), +); +/** Named but unvalued roles — skipped by the comparison, worth stating once. */ +const unvaluedCount = computed( + () => records.value.filter((t) => !valueForMode(t.value_by_mode, theme.value)).length, ); -const grouped = computed(() => groupTokens(tokens.value)); +const STATUS_LABEL: Record = { + absent: "not in the app", + differs: "app renders another value", + unrecorded: "not in the record", + ok: "agrees", +}; +/* Gallery ------------------------------------------------------------------ */ + +/** + * Groups come from the RECORD where there is one, and from the name prefix + * otherwise. The prefix table in designTokens.ts can only know the families + * that shipped with the product; the record knows the ones this install + * authored, and grouping 110 tokens under "other" because a table never heard + * of their prefix is a gallery nobody reads. + */ +const recordGroups = computed(() => { + const out = new Map(); + for (const token of records.value) { + if (token.group_name) out.set(token.name, token.group_name); + } + return out; +}); + +const grouped = computed(() => groupTokens(tokens.value, recordGroups.value)); + +/** Order for the prefix-derived groups only; record groups sort by name. */ const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"]; -const orderedGroups = computed(() => - GROUP_ORDER.filter((g) => grouped.value.has(g)).map((g) => ({ group: g, tokens: grouped.value.get(g)! })), -); + +const orderedGroups = computed(() => { + const fromRecord = new Set(recordGroups.value.values()); + const rank = (g: string) => { + const i = GROUP_ORDER.indexOf(g as TokenGroup); + return i < 0 ? GROUP_ORDER.length : i; + }; + const keys = [...grouped.value.keys()].sort((a, b) => { + // The record's own groups lead: they are the system, and the prefix-derived + // ones are whatever else the sheet happens to carry. + const byOrigin = Number(!fromRecord.has(a)) - Number(!fromRecord.has(b)); + if (byOrigin !== 0) return byOrigin; + if (fromRecord.has(a)) return a.localeCompare(b); + return rank(a) - rank(b); + }); + return keys.map((group) => ({ group, tokens: grouped.value.get(group)! })); +}); /** A token whose value reads as a colour is worth showing as a swatch. */ function isColourish(value: string): boolean { return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim()); } -/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */ -const RULEBOOK_BUTTONS = [ - { name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" }, - { name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" }, - { name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" }, - { name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" }, +/** The button family as shared classes, described by the roles they reach for. */ +const BUTTON_VARIANTS = [ + { name: "Primary", spec: "action-primary background, text-on-action label, no border" }, + { name: "Secondary", spec: "action-secondary background, text-on-action label, no border" }, + { name: "Ghost", spec: "transparent, primary text, one-pixel border" }, + { name: "Danger", spec: "action-destructive background, text-on-action label" }, ]; -const TYPE_SPECIMENS = [ - { token: "Display", spec: "40 / 500 / Fraunces" }, - { token: "H1", spec: "32 / 500 / Fraunces" }, - { token: "H2", spec: "24 / 500 / Fraunces" }, - { token: "H3", spec: "18 / 500 / Inter" }, - { token: "Body", spec: "15 / 400 / Inter" }, - { token: "Body small", spec: "13 / 400 / Inter" }, - { token: "Label", spec: "12 / 500 / Inter" }, - { token: "Code", spec: "13 / 400 / JetBrains Mono" }, - { token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" }, -]; +/** + * The type scale, read from whatever size tokens the sheet actually declares. + * + * Was a hand-written table of nine sizes marked "no token" — true when written, + * and false since the scale was recorded. Deriving it from the live tokens is + * what stops it going stale a second time: if the scale is removed, this + * section empties out and says so. + */ +const sizeTokens = computed(() => tokens.value.filter((t) => /-size(-|$)/.test(t.name)));