Design surface: starter roles, theme literals, and the view that could only inspect itself #97
@@ -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
|
||||
@@ -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<ExpectationResponse>("/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<UiSystemResponse>("/api/design/ui-system");
|
||||
|
||||
+168
-102
@@ -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<string, string>,
|
||||
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<string, string[]> {
|
||||
const index = new Map<string, string[]>();
|
||||
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>): string[] {
|
||||
const families = new Set<string>();
|
||||
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<string, string>,
|
||||
live: DesignToken[],
|
||||
): Agreement[] {
|
||||
const liveByName = new Map<string, string>();
|
||||
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<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
|
||||
return [...findings].sort((a, b) => {
|
||||
export function rankAgreements(agreements: Agreement[]): Agreement[] {
|
||||
const order: Record<AgreementStatus, number> = {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
/** The subset re-declared under a mode selector — i.e. tokens that change with mode. */
|
||||
export function modeOverriddenNames(css: string = themeCss): Set<string> {
|
||||
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<string>();
|
||||
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<string> {
|
||||
*/
|
||||
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<string, string>): Map<string, string> {
|
||||
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<string, string>();
|
||||
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<TokenGroup, DesignToken[]> {
|
||||
const out = new Map<TokenGroup, DesignToken[]>();
|
||||
/**
|
||||
* 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<string, string> = new Map(),
|
||||
): Map<string, DesignToken[]> {
|
||||
const out = new Map<string, DesignToken[]>();
|
||||
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;
|
||||
}
|
||||
|
||||
+237
-102
@@ -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<DesignToken[]>([]);
|
||||
const expectations = ref<Expectation[]>([]);
|
||||
const designRulebookId = ref<number | null>(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<number | null>(null);
|
||||
const systemTitle = ref<string | null>(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<ResolvedToken[]>([]);
|
||||
const recorded = ref<RecordedToken[]>([]);
|
||||
const resolved = ref<Map<string, string>>(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<string, string>();
|
||||
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<Finding[]>(() =>
|
||||
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<Agreement[]>(() =>
|
||||
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<Agreement["status"], string> = {
|
||||
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<string, string>();
|
||||
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)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -116,78 +208,108 @@ const TYPE_SPECIMENS = [
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Drift: what the rulebook claims vs what the tokens do. -->
|
||||
<!-- Does the running app match the design system it was generated from? -->
|
||||
<section class="design-section">
|
||||
<h2>Rulebook drift</h2>
|
||||
<h2>Agreement with the record</h2>
|
||||
|
||||
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook…</p>
|
||||
<p v-if="!checkLoaded" class="muted">
|
||||
Checking the running app against its design system…
|
||||
</p>
|
||||
|
||||
<div v-else-if="designRulebookId === null" class="gap-notice">
|
||||
<strong>No design rulebook designated.</strong>
|
||||
<div v-else-if="checkFailed" class="gap-notice">
|
||||
<strong>The design system couldn't be read.</strong>
|
||||
<p>
|
||||
This install hasn't said which rulebook describes its design system, so
|
||||
there is nothing to check the tokens against. Designate one in
|
||||
<router-link to="/settings">Settings</router-link> and this panel will
|
||||
compare every colour and token the rulebook names against what the
|
||||
stylesheet actually resolves to.
|
||||
The gallery below is still live — it comes from the browser, not the
|
||||
server — but nothing is being compared against the record right now.
|
||||
This is a failure, not an empty result.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="systemId === null" class="gap-notice">
|
||||
<strong>No design system designated for this UI.</strong>
|
||||
<p>
|
||||
This install hasn't said which design system its own interface is
|
||||
built from, so there is nothing to check the running app against.
|
||||
Designate one in <router-link to="/settings">Settings</router-link>
|
||||
and this panel will report every token the record declares that the
|
||||
app doesn't have, renders differently, or has never heard of.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="systemTitle === null" class="gap-notice">
|
||||
<strong>Design system #{{ systemId }} could not be read.</strong>
|
||||
<p>
|
||||
It is designated in <router-link to="/settings">Settings</router-link>,
|
||||
but it has since been deleted or is no longer shared with you. Nothing
|
||||
is being checked — this is a misconfiguration, not a clean result.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p class="section-note">
|
||||
<strong>{{ driftSummary.violated }}</strong> violated ·
|
||||
<strong>{{ driftSummary.missing }}</strong> missing ·
|
||||
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
|
||||
claims in rulebook #{{ designRulebookId }}.
|
||||
<strong>{{ summary.absent }}</strong> not in the app ·
|
||||
<strong>{{ summary.differs }}</strong> rendering another value ·
|
||||
{{ summary.unrecorded }} not in the record ·
|
||||
{{ summary.ok }} agreeing, across {{ summary.total }} tokens checked
|
||||
against <strong>{{ systemTitle }}</strong> in
|
||||
<code>{{ theme }}</code> mode.
|
||||
<template v-if="unvaluedCount">
|
||||
{{ unvaluedCount }} recorded {{ unvaluedCount === 1 ? "role has" : "roles have" }}
|
||||
no value yet and {{ unvaluedCount === 1 ? "was" : "were" }} not checked.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div class="gap-notice">
|
||||
<strong>This compares the rulebook against the TOKENS only.</strong>
|
||||
<strong>This compares the record against the TOKENS only.</strong>
|
||||
<p>
|
||||
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".
|
||||
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".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="!findings.length" class="muted">
|
||||
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.
|
||||
<p v-if="!agreements.length" class="muted">
|
||||
The record declares no valued tokens, so there is nothing to compare
|
||||
yet. Give its roles values and this panel starts reporting.
|
||||
</p>
|
||||
|
||||
<ul v-else class="spec-list">
|
||||
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
|
||||
<li v-for="row in visibleAgreements" :key="row.name">
|
||||
<span class="spec-name">
|
||||
<span
|
||||
v-if="finding.expectation.kind !== 'token'"
|
||||
v-if="isColourish(row.live || row.recorded)"
|
||||
class="swatch"
|
||||
:style="{ background: finding.expectation.value }"
|
||||
:style="{ background: row.live || row.recorded }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<code>{{ finding.expectation.value }}</code>
|
||||
<code>{{ row.name }}</code>
|
||||
</span>
|
||||
<span class="spec-detail">
|
||||
rule #{{ finding.expectation.rule_id }} — {{ finding.expectation.rule_title }}
|
||||
<span v-if="finding.matches.length" class="matches">
|
||||
· {{ finding.matches.join(", ") }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="spec-status" :class="finding.status">
|
||||
{{ finding.status === "violated" ? "forbidden, but present"
|
||||
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
|
||||
<template v-if="row.status === 'differs'">
|
||||
record <code>{{ row.recorded }}</code> · app
|
||||
<code>{{ row.live }}</code>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'unrecorded'">
|
||||
app <code>{{ row.live }}</code>
|
||||
</template>
|
||||
<template v-else>
|
||||
record <code>{{ row.recorded }}</code>
|
||||
</template>
|
||||
<span v-if="row.groupName" class="matches"> · {{ row.groupName }}</span>
|
||||
</span>
|
||||
<span class="spec-status" :class="row.status">{{ STATUS_LABEL[row.status] }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
v-if="findings.length && driftSummary.ok"
|
||||
v-if="agreements.length && summary.ok"
|
||||
class="reveal-toggle"
|
||||
@click="showCleanRows = !showCleanRows"
|
||||
@click="showAgreeingRows = !showAgreeingRows"
|
||||
>
|
||||
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
|
||||
{{ showAgreeingRows ? "Hide" : "Show" }} the {{ summary.ok }} agreeing tokens
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
@@ -249,7 +371,7 @@ const TYPE_SPECIMENS = [
|
||||
<button class="btn-primary btn-inline">Inline</button>
|
||||
</div>
|
||||
<ul class="spec-list">
|
||||
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
|
||||
<li v-for="b in BUTTON_VARIANTS" :key="b.name">
|
||||
<span class="spec-name">{{ b.name }}</span>
|
||||
<span class="spec-detail">{{ b.spec }}</span>
|
||||
<span class="spec-status ok">shared</span>
|
||||
@@ -257,23 +379,21 @@ const TYPE_SPECIMENS = [
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Typography: the families load, the scale does not exist as tokens. -->
|
||||
<!-- Type: rendered at the sizes the sheet declares, not described. -->
|
||||
<section class="design-section">
|
||||
<h2>Type scale</h2>
|
||||
<div class="gap-notice">
|
||||
<strong>Families load; the scale has no tokens.</strong>
|
||||
<div v-if="!sizeTokens.length" class="gap-notice">
|
||||
<strong>The scale has no tokens.</strong>
|
||||
<p>
|
||||
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
|
||||
scale is not expressed as custom properties, so sizes and weights are
|
||||
set ad hoc per component. Listed here as specification, not as a live
|
||||
specimen — there is nothing to read.
|
||||
Nothing in the stylesheet declares a size token, so sizes are being set
|
||||
ad hoc per component. There is nothing live to show here.
|
||||
</p>
|
||||
</div>
|
||||
<ul class="spec-list">
|
||||
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
|
||||
<span class="spec-name">{{ t.token }}</span>
|
||||
<span class="spec-detail">{{ t.spec }}</span>
|
||||
<span class="spec-status missing">no token</span>
|
||||
<ul v-else class="spec-list">
|
||||
<li v-for="t in sizeTokens" :key="t.name">
|
||||
<span class="spec-name" :style="{ fontSize: t.value }">Ag</span>
|
||||
<span class="spec-detail"><code>{{ t.name }}</code></span>
|
||||
<span class="spec-status ok">{{ t.value || "—" }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -292,7 +412,7 @@ const TYPE_SPECIMENS = [
|
||||
<span v-else class="swatch swatch-none" aria-hidden="true" />
|
||||
<code class="token-name">{{ token.name }}</code>
|
||||
<code class="token-value">{{ token.value || "—" }}</code>
|
||||
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
|
||||
<span v-if="token.modeAware" class="token-flag" title="Re-declared under a mode selector">
|
||||
mode-aware
|
||||
</span>
|
||||
</li>
|
||||
@@ -430,6 +550,7 @@ const TYPE_SPECIMENS = [
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.spec-status {
|
||||
@@ -438,6 +559,25 @@ const TYPE_SPECIMENS = [
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* `absent` is the one status that can mean the whole sheet failed to load, so
|
||||
it carries the same weight as a high-priority finding; `differs` is a wrong
|
||||
value on screen right now; `unrecorded` is bookkeeping and reads quietest. */
|
||||
.spec-status.absent {
|
||||
background: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high);
|
||||
}
|
||||
|
||||
.spec-status.differs {
|
||||
background: var(--color-priority-medium-bg);
|
||||
color: var(--color-priority-medium);
|
||||
}
|
||||
|
||||
.spec-status.unrecorded {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.spec-status.missing {
|
||||
@@ -445,11 +585,6 @@ 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);
|
||||
|
||||
@@ -4,7 +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 { fetchDesignSystems } from "@/api/designSystems";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
@@ -32,11 +32,12 @@ 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 }[]>([]);
|
||||
// Which design system this install's own UI is built from, for the /design
|
||||
// agreement panel. Empty = none designated, which is the normal state for a
|
||||
// fresh install rather than a misconfiguration — the panel explains itself when
|
||||
// unset. Replaced design_rulebook_id when the rulebook was retired (#2419).
|
||||
const uiDesignSystemId = ref("");
|
||||
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||
const savingKbInject = ref(false);
|
||||
const kbInjectSaved = ref(false);
|
||||
|
||||
@@ -107,8 +108,8 @@ async function saveKbInject() {
|
||||
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,
|
||||
// exactly right for "no design system" — absent rather than zero.
|
||||
ui_design_system_id: uiDesignSystemId.value,
|
||||
});
|
||||
kbInjectSaved.value = true;
|
||||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||||
@@ -499,13 +500,15 @@ onMounted(async () => {
|
||||
if (allSettings.kb_duplicate_threshold !== undefined) {
|
||||
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
|
||||
}
|
||||
designRulebookId.value = allSettings.design_rulebook_id ?? "";
|
||||
uiDesignSystemId.value = allSettings.ui_design_system_id ?? "";
|
||||
// Best-effort: the picker degrades to "none available" rather than blocking
|
||||
// the whole settings page if rulebooks can't be listed.
|
||||
// the whole settings page if design systems can't be listed.
|
||||
try {
|
||||
designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title }));
|
||||
designSystems.value = (await fetchDesignSystems()).design_systems.map(
|
||||
(s) => ({ id: s.id, title: s.title }),
|
||||
);
|
||||
} catch {
|
||||
designRulebooks.value = [];
|
||||
designSystems.value = [];
|
||||
}
|
||||
if (allSettings.notify_task_reminders !== undefined) {
|
||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||
@@ -1278,19 +1281,22 @@ function formatUserDate(iso: string): string {
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="design-rulebook">Design-system rulebook</label>
|
||||
<select id="design-rulebook" v-model="designRulebookId" class="input" style="max-width: 22rem">
|
||||
<option value="">None — don't check for design drift</option>
|
||||
<option v-for="rb in designRulebooks" :key="rb.id" :value="String(rb.id)">
|
||||
{{ rb.title }}
|
||||
<label for="ui-design-system">This app's design system</label>
|
||||
<select id="ui-design-system" v-model="uiDesignSystemId" class="input" style="max-width: 22rem">
|
||||
<option value="">None — don't check the interface against a record</option>
|
||||
<option v-for="ds in designSystems" :key="ds.id" :value="String(ds.id)">
|
||||
{{ ds.title }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">
|
||||
Which rulebook describes how this app should look. Once set, the
|
||||
<router-link to="/design">Design</router-link> 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.
|
||||
Which design system this interface is supposed to be built from. Once
|
||||
set, the <router-link to="/design">Design</router-link> page compares
|
||||
every token the system declares against what the browser has actually
|
||||
resolved, and reports the ones the app is missing, renders differently,
|
||||
or has never heard of. That catches a stylesheet that was regenerated
|
||||
but never shipped — which the record alone cannot tell you, since the
|
||||
sheet is generated from it. Leave it as None if this install's
|
||||
interface isn't described by one of your design systems.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
||||
+31
-15
@@ -1,29 +1,45 @@
|
||||
"""Design-system surface — what the rulebook expects of the stylesheet.
|
||||
"""This install's UI surface — which design system it claims to be built from.
|
||||
|
||||
The client owns the other half of the comparison: it reads live token values from
|
||||
the browser (see `utils/designTokens.ts`), which is the only place they exist
|
||||
resolved. This endpoint supplies the claims to check them against.
|
||||
Kept separate from the design-systems CRUD blueprint on purpose. That one is
|
||||
the RECORD: create a system, move a token, read the cascade. This one answers a
|
||||
question about the RUNNING APP, and it exists because those are not the same
|
||||
question. A design system can be a perfect record of a stylesheet the app never
|
||||
loaded.
|
||||
|
||||
The client owns the other half. `utils/designTokens.ts` reads what the browser
|
||||
actually resolved, which is the one thing no server can report, and compares it
|
||||
to what this endpoint's system declares. So the comparison is
|
||||
"does the app agree with its own sheet?" rather than "is the record
|
||||
self-consistent?", which would be a tautology — the sheet is generated from the
|
||||
record (#2419).
|
||||
"""
|
||||
from quart import Blueprint, jsonify
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.services import design_rulebook_import as design_svc
|
||||
from scribe.services import design_systems as ds_svc
|
||||
|
||||
design_bp = Blueprint("design", __name__, url_prefix="/api/design")
|
||||
|
||||
|
||||
@design_bp.get("/expectations")
|
||||
@design_bp.get("/ui-system")
|
||||
@login_required
|
||||
async def get_expectations():
|
||||
"""Checkable claims from the rulebook this install designated as its design system.
|
||||
async def get_ui_system():
|
||||
"""The design system this install designated as the source of its own UI.
|
||||
|
||||
Returns `{"rulebook_id": int|null, "expectations": [...]}`.
|
||||
Returns `{"design_system_id": int|null, "title": str|null}`.
|
||||
|
||||
`rulebook_id: null` is the NORMAL case, not an error — an install that has
|
||||
not designated a design rulebook has nothing to compare against, and the
|
||||
client shows an explanatory empty state (rule #115). Distinguishing it from
|
||||
"designated but empty" is why the id is returned alongside the list.
|
||||
Both nulls is the NORMAL case, not an error — an install that has not
|
||||
designated one has nothing to check the running app against, and the client
|
||||
shows an explanatory empty state (rule #115).
|
||||
|
||||
An id with a null title is the third case and the reason the id is returned
|
||||
separately: designated, but deleted or not readable by this caller. Folding
|
||||
that into "none designated" is precisely how a feature comes to render a
|
||||
reassuring empty state forever.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
result = await design_svc.design_expectations(uid)
|
||||
return jsonify(result.as_dict())
|
||||
system_id, system = await ds_svc.ui_design_system(uid)
|
||||
return jsonify({
|
||||
"design_system_id": system_id,
|
||||
"title": system.title if system else None,
|
||||
})
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
"""Design-system expectations — turning rulebook prose into checkable claims.
|
||||
|
||||
Milestone #251 step 2. The drift panel compares what the design rulebook SAYS
|
||||
against what the stylesheet and components actually DO. This module owns the
|
||||
first half: reading a rulebook's rules and extracting the claims that can be
|
||||
mechanically checked.
|
||||
|
||||
WHY THIS LIVES SERVER-SIDE. The frontend has no test runner — `vue-tsc --noEmit`
|
||||
is the entire check — and this is the one genuinely fiddly piece of the feature.
|
||||
Extraction happens here where pytest can assert on it; the comparison itself is
|
||||
set arithmetic and stays in the browser, where the live token values are.
|
||||
|
||||
WHY NOT NLP. Rule statements are prose written for humans, and they should stay
|
||||
that way — they are read by people far more often than they are parsed. So this
|
||||
extracts only what is unambiguous in ANY prose: the hex colours and CSS custom
|
||||
property names a rule mentions. Everything subtler (padding scales, type ramps)
|
||||
needs a rule author to opt into a structured form, which is deliberately left for
|
||||
when someone wants it rather than invented up front.
|
||||
|
||||
RULE #115. Nothing here assumes a design rulebook exists, or that it is this
|
||||
operator's. An install designates one; an install that hasn't gets an empty
|
||||
result and a panel that explains itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from scribe.models.rulebook import Rule
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Which rulebook describes this install's design system. A plain setting rather
|
||||
# than a column: no migration, discoverable in the Settings UI (rule #25), and
|
||||
# honest about being a per-install choice rather than a property of the rulebook.
|
||||
DESIGN_RULEBOOK_SETTING = "design_rulebook_id"
|
||||
|
||||
# `#abc` and `#aabbcc`, plus the 4/8-digit alpha forms.
|
||||
_HEX = re.compile(r"#([0-9a-fA-F]{3,8})\b")
|
||||
|
||||
# A custom-property name as written in prose, including the slash shorthand the
|
||||
# rulebook uses: `--fs-radius-sm/md/lg/xl`, `--fs-obsidian/iron/slate/pewter`.
|
||||
_TOKEN = re.compile(r"(--[a-zA-Z][\w-]*(?:/[\w-]+)*)")
|
||||
|
||||
# Sentence-ish split. Rules use semicolons as hard breaks as often as periods.
|
||||
_SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+|\n+")
|
||||
|
||||
# Negation markers. Checked PER SENTENCE, which is the whole trick — see
|
||||
# _extract_from_sentence.
|
||||
_NEGATIONS = ("never", "not ", "no ", "avoid", "don't", "must not", "excluded")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Expectation:
|
||||
"""One mechanically-checkable claim a rule makes."""
|
||||
|
||||
kind: str # "token" | "color" | "prohibited_color"
|
||||
value: str # "--fs-obsidian" | "#14171a"
|
||||
rule_id: int
|
||||
rule_title: str
|
||||
context: str # the sentence it came from, for showing your work
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"value": self.value,
|
||||
"rule_id": self.rule_id,
|
||||
"rule_title": self.rule_title,
|
||||
"context": self.context,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpectationSet:
|
||||
rulebook_id: int | None = None
|
||||
expectations: list[Expectation] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"rulebook_id": self.rulebook_id,
|
||||
"expectations": [e.as_dict() for e in self.expectations],
|
||||
}
|
||||
|
||||
|
||||
def normalize_hex(value: str) -> str | None:
|
||||
"""Fold a hex colour to a comparable form, or None if it isn't one.
|
||||
|
||||
Load-bearing for the whole comparison: the rulebook writes `#FFFFFF` and the
|
||||
code writes `#fff`, and those must compare equal or the single largest drift
|
||||
finding (#2275) reads as zero. Expands 3-digit shorthand and lowercases.
|
||||
|
||||
Alpha forms (4 and 8 digit) keep their alpha — `#fff` and `#ffff` are not the
|
||||
same colour, and silently dropping the alpha would invent equality.
|
||||
"""
|
||||
match = _HEX.fullmatch(value.strip()) or _HEX.match(value.strip())
|
||||
if not match:
|
||||
return None
|
||||
digits = match.group(1).lower()
|
||||
if len(digits) in (3, 4):
|
||||
digits = "".join(c * 2 for c in digits)
|
||||
if len(digits) not in (6, 8):
|
||||
return None
|
||||
return f"#{digits}"
|
||||
|
||||
|
||||
def expand_token_shorthand(raw: str) -> list[str]:
|
||||
"""`--fs-radius-sm/md/lg/xl` -> the four names it stands for.
|
||||
|
||||
The rulebook writes token families in a slash shorthand, and both forms it
|
||||
uses expand correctly under one rule: take everything up to and including the
|
||||
LAST hyphen of the first segment as the prefix, then append each alternative.
|
||||
|
||||
--fs-radius-sm/md/lg/xl prefix `--fs-radius-` -> sm, md, lg, xl
|
||||
--fs-obsidian/iron/slate prefix `--fs-` -> obsidian, iron, slate
|
||||
--fs-dur-fast/base/slow prefix `--fs-dur-` -> fast, base, slow
|
||||
|
||||
A name with no slash is returned as-is.
|
||||
"""
|
||||
if "/" not in raw:
|
||||
return [raw]
|
||||
head, *rest = raw.split("/")
|
||||
cut = head.rfind("-")
|
||||
if cut <= 1: # no hyphen beyond the leading `--`
|
||||
return [head, *rest]
|
||||
prefix = head[: cut + 1]
|
||||
return [head, *[f"{prefix}{part}" for part in rest if part]]
|
||||
|
||||
|
||||
def _is_negated(sentence: str) -> bool:
|
||||
return any(marker in sentence.lower() for marker in _NEGATIONS)
|
||||
|
||||
|
||||
def _extract_from_sentence(sentence: str, rule: Rule) -> list[Expectation]:
|
||||
"""Claims in ONE sentence, with negation scoped to that sentence.
|
||||
|
||||
Sentence scope is what makes the prohibition detection usable. Rule 52 reads:
|
||||
|
||||
"Text tokens: Parchment #E8E4D8 …, Vellum #C2BFB4 …, Ash #9C9A92 ….
|
||||
Pure white #FFFFFF is NEVER used as text color."
|
||||
|
||||
Three colours the palette REQUIRES and one it FORBIDS, in one statement.
|
||||
Detecting negation across the whole statement would mark all four as
|
||||
forbidden; detecting it per sentence gets all four right.
|
||||
"""
|
||||
out: list[Expectation] = []
|
||||
negated = _is_negated(sentence)
|
||||
|
||||
for match in _HEX.finditer(sentence):
|
||||
value = normalize_hex(match.group(0))
|
||||
if not value:
|
||||
continue
|
||||
out.append(Expectation(
|
||||
kind="prohibited_color" if negated else "color",
|
||||
value=value,
|
||||
rule_id=int(rule.id),
|
||||
rule_title=rule.title,
|
||||
context=sentence.strip(),
|
||||
))
|
||||
|
||||
# Token names are not negated in practice — a rule says which tokens should
|
||||
# exist, never which must not — so they are recorded as expectations
|
||||
# regardless. If that ever changes, it needs its own kind rather than
|
||||
# borrowing the colour one.
|
||||
for match in _TOKEN.finditer(sentence):
|
||||
for name in expand_token_shorthand(match.group(1)):
|
||||
out.append(Expectation(
|
||||
kind="token",
|
||||
value=name,
|
||||
rule_id=int(rule.id),
|
||||
rule_title=rule.title,
|
||||
context=sentence.strip(),
|
||||
))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def extract_expectations(rules: list[Rule]) -> list[Expectation]:
|
||||
"""Every checkable claim across a set of rules, deduped on (kind, value).
|
||||
|
||||
First occurrence wins so the reported rule is the one that introduced the
|
||||
claim, which is usually the most specific place to send a reader.
|
||||
"""
|
||||
seen: set[tuple[str, str]] = set()
|
||||
out: list[Expectation] = []
|
||||
for rule in rules:
|
||||
text = " ".join(filter(None, [rule.statement or "", rule.how_to_apply or ""]))
|
||||
for sentence in _SENTENCE_SPLIT.split(text):
|
||||
if not sentence.strip():
|
||||
continue
|
||||
for expectation in _extract_from_sentence(sentence, rule):
|
||||
key = (expectation.kind, expectation.value)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(expectation)
|
||||
return out
|
||||
|
||||
|
||||
async def get_design_rulebook_id(user_id: int) -> int | None:
|
||||
"""The rulebook this install designated as its design system, if any."""
|
||||
raw = (await get_setting(user_id, DESIGN_RULEBOOK_SETTING, "")).strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
async def design_expectations(user_id: int) -> ExpectationSet:
|
||||
"""Checkable claims from the designated design rulebook.
|
||||
|
||||
Returns an empty set when no rulebook is designated — the normal case for
|
||||
any install but the one that set it up (rule #115). The caller shows an
|
||||
explanatory empty state rather than treating this as an error.
|
||||
"""
|
||||
rulebook_id = await get_design_rulebook_id(user_id)
|
||||
if rulebook_id is None:
|
||||
return ExpectationSet()
|
||||
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
|
||||
try:
|
||||
rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id)
|
||||
except Exception:
|
||||
logger.warning("Design rulebook %s could not be read", rulebook_id, exc_info=True)
|
||||
return ExpectationSet(rulebook_id=rulebook_id)
|
||||
|
||||
return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules))
|
||||
@@ -37,6 +37,7 @@ from scribe.services.design_cascade import (
|
||||
resolve_tokens,
|
||||
would_cycle,
|
||||
)
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -134,6 +135,40 @@ async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem
|
||||
return system
|
||||
|
||||
|
||||
# Which design system this install's own UI is built from. A plain setting
|
||||
# rather than a column: no migration, discoverable in the Settings UI (rule
|
||||
# #25), and honest about being a per-install claim rather than a property of the
|
||||
# system — the same system can be the record for an app that never loads it.
|
||||
UI_DESIGN_SYSTEM_SETTING = "ui_design_system_id"
|
||||
|
||||
|
||||
async def ui_design_system(user_id: int) -> tuple[int | None, DesignSystem | None]:
|
||||
"""The design system this install says its UI is built from.
|
||||
|
||||
Returns `(id, system)`. Three outcomes, deliberately distinguishable:
|
||||
|
||||
- `(None, None)` — nothing designated. The NORMAL state for any install but
|
||||
the one that set it up (rule #115), not an error.
|
||||
- `(id, None)` — designated, but gone or not readable by this caller. A
|
||||
misconfiguration worth naming rather than silently degrading to "none",
|
||||
which is exactly the failure that orphaned the panel this feeds (#2419).
|
||||
- `(id, system)` — designated and readable.
|
||||
|
||||
A non-numeric setting value reads as nothing designated: the value is only
|
||||
ever written by a `<select>` of real ids, so garbage here means hand-edited
|
||||
or stale, and refusing to guess is better than raising on a page load.
|
||||
"""
|
||||
raw = (await get_setting(user_id, UI_DESIGN_SYSTEM_SETTING, "")).strip()
|
||||
if not raw:
|
||||
return None, None
|
||||
try:
|
||||
system_id = int(raw)
|
||||
except ValueError:
|
||||
logger.warning("Ignoring non-numeric %s: %r", UI_DESIGN_SYSTEM_SETTING, raw)
|
||||
return None, None
|
||||
return system_id, await get_design_system(user_id, system_id)
|
||||
|
||||
|
||||
async def list_design_systems(user_id: int) -> list[DesignSystem]:
|
||||
"""The caller's own systems, ordered by title. Empty is normal."""
|
||||
async with async_session() as session:
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
"""Rulebook prose → checkable claims (milestone #251 step 2).
|
||||
|
||||
This is the piece of the design explorer that most needed to be testable, which
|
||||
is why it lives in Python at all: the frontend has no test runner, so the fiddly
|
||||
extraction happens server-side and the browser only does set arithmetic over it.
|
||||
|
||||
Rule text below is representative of a real design rulebook rather than copied
|
||||
from this operator's — rule #115: the product must work for an install that has
|
||||
none of their data, and a test that only passes against their exact wording would
|
||||
be testing the instance, not the parser.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from scribe.services.design_rulebook_import import (
|
||||
expand_token_shorthand,
|
||||
extract_expectations,
|
||||
normalize_hex,
|
||||
)
|
||||
|
||||
|
||||
def _rule(rule_id, title, statement, how_to_apply=None):
|
||||
return SimpleNamespace(
|
||||
id=rule_id, title=title, statement=statement, how_to_apply=how_to_apply
|
||||
)
|
||||
|
||||
|
||||
# --- hex normalisation -------------------------------------------------------
|
||||
|
||||
def test_normalize_hex_makes_shorthand_and_case_comparable():
|
||||
"""LOAD-BEARING. The rulebook writes `#FFFFFF` and components write `#fff`.
|
||||
If those don't compare equal, the single largest drift finding — 67 hardcoded
|
||||
white text colours (#2275) — reads as zero findings."""
|
||||
assert normalize_hex("#fff") == normalize_hex("#FFFFFF") == "#ffffff"
|
||||
assert normalize_hex("#E8E4D8") == "#e8e4d8"
|
||||
assert normalize_hex("#14171a") == "#14171a"
|
||||
|
||||
|
||||
def test_normalize_hex_keeps_alpha_rather_than_inventing_equality():
|
||||
"""`#fff` and `#ffff` are different colours. Dropping the alpha to make them
|
||||
match would manufacture agreement that isn't there."""
|
||||
assert normalize_hex("#ffff") == "#ffffffff"
|
||||
assert normalize_hex("#fff") != normalize_hex("#ffff")
|
||||
|
||||
|
||||
def test_normalize_hex_rejects_non_colours():
|
||||
for junk in ("", " ", "not-a-colour", "#", "#gg", "#12345"):
|
||||
assert normalize_hex(junk) is None
|
||||
|
||||
|
||||
# --- the slash shorthand -----------------------------------------------------
|
||||
|
||||
def test_expand_token_shorthand_handles_every_form_a_rulebook_uses():
|
||||
"""One rule expands all three shapes: take everything up to and including the
|
||||
LAST hyphen of the first segment as the prefix."""
|
||||
assert expand_token_shorthand("--fs-radius-sm/md/lg/xl") == [
|
||||
"--fs-radius-sm", "--fs-radius-md", "--fs-radius-lg", "--fs-radius-xl",
|
||||
]
|
||||
# Prefix is just `--fs-` here, and the same rule finds it.
|
||||
assert expand_token_shorthand("--fs-obsidian/iron/slate/pewter") == [
|
||||
"--fs-obsidian", "--fs-iron", "--fs-slate", "--fs-pewter",
|
||||
]
|
||||
assert expand_token_shorthand("--fs-dur-fast/base/slow") == [
|
||||
"--fs-dur-fast", "--fs-dur-base", "--fs-dur-slow",
|
||||
]
|
||||
|
||||
|
||||
def test_expand_token_shorthand_passes_plain_names_through():
|
||||
assert expand_token_shorthand("--fs-ease") == ["--fs-ease"]
|
||||
|
||||
|
||||
# --- extraction --------------------------------------------------------------
|
||||
|
||||
def test_negation_is_scoped_to_the_sentence_not_the_rule():
|
||||
"""THE trick that makes prohibition detection usable.
|
||||
|
||||
A single rule routinely states what the palette REQUIRES and what it FORBIDS
|
||||
in consecutive sentences. Detecting negation across the whole statement would
|
||||
mark the required colours as forbidden too — inverting the finding rather
|
||||
than missing it, which is worse.
|
||||
"""
|
||||
rule = _rule(
|
||||
52, "Text palette",
|
||||
"Text tokens: Parchment #E8E4D8 (primary), Vellum #C2BFB4 (secondary), "
|
||||
"Ash #9C9A92 (tertiary). Pure white #FFFFFF is NEVER used as text color.",
|
||||
)
|
||||
found = extract_expectations([rule])
|
||||
required = {e.value for e in found if e.kind == "color"}
|
||||
forbidden = {e.value for e in found if e.kind == "prohibited_color"}
|
||||
|
||||
assert required == {"#e8e4d8", "#c2bfb4", "#9c9a92"}
|
||||
assert forbidden == {"#ffffff"}
|
||||
assert not (required & forbidden)
|
||||
|
||||
|
||||
def test_token_names_are_extracted_and_expanded():
|
||||
rule = _rule(
|
||||
72, "CSS custom properties",
|
||||
"Expose the system as custom properties on :root — surfaces "
|
||||
"(--fs-obsidian/iron/slate/pewter), radius (--fs-radius-sm/md/lg/xl), "
|
||||
"and motion (--fs-ease).",
|
||||
)
|
||||
names = {e.value for e in extract_expectations([rule]) if e.kind == "token"}
|
||||
assert "--fs-obsidian" in names and "--fs-pewter" in names
|
||||
assert "--fs-radius-xl" in names
|
||||
assert "--fs-ease" in names
|
||||
assert len(names) == 9
|
||||
|
||||
|
||||
def test_how_to_apply_is_read_as_well_as_the_statement():
|
||||
"""Rulebooks routinely put the concrete values in how_to_apply and keep the
|
||||
statement declarative, so ignoring it would miss the checkable half."""
|
||||
rule = _rule(
|
||||
56, "Per-app accent", "Each app owns exactly one accent.",
|
||||
how_to_apply='[data-app="scribe"] #5B4A8A, [data-app="minstrel"] #4A6B5C.',
|
||||
)
|
||||
colours = {e.value for e in extract_expectations([rule]) if e.kind == "color"}
|
||||
assert colours == {"#5b4a8a", "#4a6b5c"}
|
||||
|
||||
|
||||
def test_claims_are_deduped_across_rules_keeping_the_first_source():
|
||||
"""A colour named by several rules is one expectation, attributed to the rule
|
||||
that introduced it — usually the most specific place to send a reader."""
|
||||
rules = [
|
||||
_rule(51, "Surfaces", "Obsidian #14171A is the page background."),
|
||||
_rule(99, "Elsewhere", "Obsidian #14171A again, mentioned in passing."),
|
||||
]
|
||||
found = [e for e in extract_expectations(rules) if e.kind == "color"]
|
||||
assert len(found) == 1
|
||||
assert found[0].rule_id == 51
|
||||
|
||||
|
||||
def test_prose_with_nothing_checkable_yields_nothing():
|
||||
"""Most rules are judgement, not specification. They must contribute no
|
||||
findings rather than a shrug — a panel that reports unparseable rules as
|
||||
problems would be unusable."""
|
||||
rule = _rule(
|
||||
68, "Voice and tone",
|
||||
"Voice is understated: plain language for anything functional, flavour "
|
||||
"only where the user is waiting or failing. Be brief.",
|
||||
)
|
||||
assert extract_expectations([rule]) == []
|
||||
|
||||
|
||||
def test_every_expectation_carries_the_sentence_it_came_from():
|
||||
"""The panel has to show its working — "the rulebook says X" is only
|
||||
actionable if you can see where, and in what context.
|
||||
|
||||
Asserts the context is the SENTENCE, not the whole statement: a rule that
|
||||
states a requirement and a prohibition in consecutive sentences would
|
||||
otherwise attribute both to the same undifferentiated blob of prose.
|
||||
"""
|
||||
rule = _rule(63, "Radius", "Radius: Small 4px. Pure white #FFFFFF is never used.")
|
||||
found = extract_expectations([rule])
|
||||
assert len(found) == 1
|
||||
|
||||
only = found[0]
|
||||
assert only.kind == "prohibited_color"
|
||||
assert only.rule_id == 63
|
||||
assert only.rule_title == "Radius"
|
||||
assert only.context == "Pure white #FFFFFF is never used."
|
||||
assert "Radius: Small 4px" not in only.context
|
||||
@@ -284,8 +284,9 @@ def test_a_shorter_hex_does_not_match_inside_a_longer_one():
|
||||
|
||||
|
||||
def test_the_literal_match_is_case_insensitive():
|
||||
"""Rulebooks write `#FFFFFF` and code writes `#ffffff`. A case-sensitive
|
||||
check would silently find nothing — the same trap normalize_hex exists for."""
|
||||
"""A record writes `#FFFFFF` and code writes `#ffffff`. A case-sensitive
|
||||
check would silently find nothing — the same trap `normalizeColour` in
|
||||
utils/designDrift.ts exists for on the client side."""
|
||||
report = check_code_against_tokens("color: #FFFFFF;", SHEET)
|
||||
assert report["superseded_literals"] == [
|
||||
{"literal": "#ffffff", "use_instead": "--fs-parchment"}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""The /api/design blueprint — this install's UI, not the design-system record.
|
||||
|
||||
Modelled on tests/test_routes_design_systems.py. The URL enumeration is
|
||||
deliberate rather than a pattern match: this blueprint was repointed from
|
||||
`/expectations` to `/ui-system` (#2419), and the failure worth catching is the
|
||||
old rule surviving the change — a route nothing serves any more, answering with
|
||||
whatever the last handler registered on it did.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_design_blueprint_registered():
|
||||
from scribe.routes.design import design_bp
|
||||
assert design_bp.name == "design"
|
||||
assert design_bp.url_prefix == "/api/design"
|
||||
|
||||
|
||||
def test_design_blueprint_registered_in_app():
|
||||
from scribe.app import create_app
|
||||
app = create_app()
|
||||
assert "design" in app.blueprints
|
||||
|
||||
|
||||
def test_the_blueprint_serves_exactly_one_rule():
|
||||
from scribe.app import create_app
|
||||
app = create_app()
|
||||
rules = {
|
||||
str(r.rule) for r in app.url_map.iter_rules()
|
||||
if r.endpoint.startswith("design.")
|
||||
}
|
||||
assert rules == {"/api/design/ui-system"}
|
||||
|
||||
|
||||
def test_the_designation_reader_takes_user_id():
|
||||
"""The setting is per-user, so the read must be too (rule #78). A
|
||||
module-level or admin-scoped read would hand one user another's designation."""
|
||||
from scribe.services import design_systems as svc
|
||||
assert "user_id" in inspect.signature(svc.ui_design_system).parameters
|
||||
|
||||
|
||||
def test_the_setting_key_is_the_new_one():
|
||||
"""Named explicitly because the panel silently reading a retired key is the
|
||||
exact shape of the bug this replaced: a feature that renders, and reports
|
||||
nothing, forever."""
|
||||
from scribe.services.design_systems import UI_DESIGN_SYSTEM_SETTING
|
||||
assert UI_DESIGN_SYSTEM_SETTING == "ui_design_system_id"
|
||||
|
||||
|
||||
def test_the_rulebook_expectation_extractor_is_gone():
|
||||
"""It was retired with the rulebook it parsed (#2288, #2419). Left importable
|
||||
it would keep looking like a live path to the next reader."""
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
import scribe.services.design_rulebook_import # noqa: F401
|
||||
Reference in New Issue
Block a user