/** * 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; }); }