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)));