From 4ca3ab02c432e460cf7508a8c0487614ee32f866 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 30 Jul 2026 15:32:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(design-explorer):=20the=20drift=20panel=20?= =?UTF-8?q?=E2=80=94=20rulebook=20says=20X,=20tokens=20say=20Y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone #251 step 5 (#2262), plus the Settings control that makes it reachable. The comparison is deliberately thin — set arithmetic over live token values, which is the one thing the browser knows and the server doesn't. The hard half (prose to claims) already lives in Python where pytest can assert on it. Three claim kinds, and the third inverts the test: 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 FAILS when present. normalizeColour is the client-side twin of normalize_hex and has one job the server cannot do: getComputedStyle reports colours as rgb()/rgba() regardless of how they were authored. So one colour has three spellings in play — #FFFFFF in the rulebook, #fff in the stylesheet, rgb(255,255,255) from the browser — and a comparison that misses any of them under-reports silently rather than erroring. THE PANEL STATES ITS OWN BLIND SPOT, which matters more than it sounds. This compares the rulebook against TOKENS. A literal hardcoded in a component, where a token should have been referenced, is invisible to it — the drift isn't in the tokens at all (#2275: 67 hardcoded whites against a rule forbidding pure white). Reading those would mean bundling every SFC's source into the app; the check belongs in CI and is tracked at #2277. A drift report that silently omitted a whole category would invite the reader to conclude the category is clean, so the panel says so in the panel rather than in a comment nobody reads. Findings are ranked violated → missing → ok, and `ok` rows are hidden behind a toggle. Same principle the auto-inject menu is built on: a short list that gets read beats a complete one that doesn't. Settings gains a rulebook picker. "None" is a first-class choice, not an unset error — most installs have no rulebook describing their design system, and saving empty DELETES the setting rather than storing a zero. The panel's empty state points at Settings and Settings points back at the panel, so neither is a dead end. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- frontend/src/api/design.ts | 9 ++ frontend/src/utils/designDrift.ts | 169 ++++++++++++++++++++++++++++ frontend/src/views/DesignView.vue | 144 +++++++++++++++++++++++- frontend/src/views/SettingsView.vue | 33 ++++++ 4 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/design.ts create mode 100644 frontend/src/utils/designDrift.ts diff --git a/frontend/src/api/design.ts b/frontend/src/api/design.ts new file mode 100644 index 0000000..cbdfe09 --- /dev/null +++ b/frontend/src/api/design.ts @@ -0,0 +1,9 @@ +import { apiGet } from "@/api/client"; +import type { ExpectationResponse } from "@/utils/designDrift"; + +/** Checkable claims from the rulebook this install designated as its design system. + * + * `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"); diff --git a/frontend/src/utils/designDrift.ts b/frontend/src/utils/designDrift.ts new file mode 100644 index 0000000..453e84f --- /dev/null +++ b/frontend/src/utils/designDrift.ts @@ -0,0 +1,169 @@ +/** + * Drift comparison — what the rulebook claims vs what the stylesheet does. + * + * 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. + * + * 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. + */ +import type { DesignToken } from "@/utils/designTokens"; + +export type ExpectationKind = "token" | "color" | "prohibited_color"; + +export interface Expectation { + kind: ExpectationKind; + value: string; + rule_id: number; + rule_title: string; + context: string; +} + +export interface ExpectationResponse { + rulebook_id: number | null; + expectations: Expectation[]; +} + +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[]; +} + +/** + * Normalise a colour for comparison — the client-side twin of + * `normalize_hex` in services/design_system.py. + * + * 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. + */ +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 always reports colours as rgb()/rgba(), never as authored. + 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; +} + +/** 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 claims against the live tokens. + * + * 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. + */ +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 interface DriftSummary { + ok: number; + missing: number; + violated: 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; + 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. + */ +export function rankFindings(findings: Finding[]): Finding[] { + const order: Record = { violated: 0, missing: 1, ok: 2 }; + return [...findings].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; + }); +} diff --git a/frontend/src/views/DesignView.vue b/frontend/src/views/DesignView.vue index 39cfd05..0e86b8d 100644 --- a/frontend/src/views/DesignView.vue +++ b/frontend/src/views/DesignView.vue @@ -20,21 +20,52 @@ */ import { computed, onMounted, ref } from "vue"; +import { fetchDesignExpectations } from "@/api/design"; import PriorityBadge from "@/components/PriorityBadge.vue"; import StatusBadge from "@/components/StatusBadge.vue"; import TagPill from "@/components/TagPill.vue"; +import { + compareToTokens, + rankFindings, + summarise, + type Expectation, + type Finding, +} from "@/utils/designDrift"; import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens"; const tokens = ref([]); +const expectations = ref([]); +const designRulebookId = ref(null); +const driftLoaded = ref(false); +const showCleanRows = ref(false); /** * 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(() => { +onMounted(async () => { tokens.value = readTokens(); + try { + const response = await fetchDesignExpectations(); + designRulebookId.value = response.rulebook_id; + expectations.value = response.expectations; + } 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; + } finally { + driftLoaded.value = true; + } }); +const findings = computed(() => + rankFindings(compareToTokens(expectations.value, tokens.value)), +); +const driftSummary = computed(() => summarise(findings.value)); +const visibleFindings = computed(() => + showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"), +); + const grouped = computed(() => groupTokens(tokens.value)); const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"]; @@ -81,6 +112,82 @@ const TYPE_SPECIMENS = [

+ +
+

Rulebook drift

+ +

Checking against the design rulebook…

+ +
+ No design rulebook designated. +

+ This install hasn't said which rulebook describes its design system, so + there is nothing to check the tokens against. Designate one in + Settings and this panel will + compare every colour and token the rulebook names against what the + stylesheet actually resolves to. +

+
+ + +
+

Components

@@ -307,6 +414,41 @@ const TYPE_SPECIMENS = [ color: var(--color-priority-medium); } +.spec-status.violated { + background: var(--color-priority-high-bg); + color: var(--color-priority-high); +} + +.spec-status.ok { + background: var(--color-status-done-bg); + color: var(--color-status-done); +} + +.spec-name .swatch { + vertical-align: middle; + margin-right: 0.4rem; +} + +.matches { + color: var(--color-text-muted); +} + +.reveal-toggle { + margin-top: 0.75rem; + padding: 0.35rem 0.75rem; + background: transparent; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + font-family: inherit; + font-size: 0.8rem; +} + +.reveal-toggle:hover { + border-color: var(--color-text-muted); +} + /* Tokens ----------------------------------------------------------------- */ .token-list { diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 4c7c8d8..fbcf524 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -4,6 +4,7 @@ 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 { listRulebooks } from "@/api/rulebooks"; import type { User } from "@/types/auth"; import PaginationBar from "@/components/PaginationBar.vue"; import TagInput from "@/components/TagInput.vue"; @@ -31,6 +32,11 @@ 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 rulebook describes this install's design system, for the /design drift +// panel. Empty = none designated, which is the normal state for a fresh install +// rather than a misconfiguration — the panel explains itself when unset. +const designRulebookId = ref(""); +const designRulebooks = ref<{ id: number; title: string }[]>([]); const savingKbInject = ref(false); const kbInjectSaved = ref(false); @@ -100,6 +106,9 @@ 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 rulebook" — absent rather than zero. + design_rulebook_id: designRulebookId.value, }); kbInjectSaved.value = true; setTimeout(() => (kbInjectSaved.value = false), 2000); @@ -490,6 +499,14 @@ onMounted(async () => { if (allSettings.kb_duplicate_threshold !== undefined) { kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold; } + designRulebookId.value = allSettings.design_rulebook_id ?? ""; + // Best-effort: the picker degrades to "none available" rather than blocking + // the whole settings page if rulebooks can't be listed. + try { + designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title })); + } catch { + designRulebooks.value = []; + } if (allSettings.notify_task_reminders !== undefined) { notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; } @@ -1260,6 +1277,22 @@ function formatUserDate(iso: string): string { location, not by resemblance.

+
+ + +

+ Which rulebook describes how this app should look. Once set, the + Design page compares every colour + and token your rules name against what the stylesheet actually resolves + to, and reports where they disagree. Leave it as None if your rules + don't describe a design system — nothing else depends on this. +

+