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. +

+