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.
+
+ This compares the rulebook against the TOKENS only.
+
+ A value hardcoded in a component — where a token should have been
+ referenced — is invisible here, because the drift isn't in the tokens
+ at all. Reading it would mean bundling every component's source into
+ the app. That check belongs in CI and is tracked separately, so treat
+ a clean panel as "the tokens agree", not "the app agrees".
+
+
+
+
+ The rulebook names nothing this panel can check. Rules that state values
+ — colours, token names — produce claims; rules that state judgement
+ don't, by design.
+
@@ -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.
+