feat(design): the panel now asks whether the app agrees with its own sheet
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
Retiring rulebook #2 left the /design drift panel with no data source, and because its empty state was well-written the feature read as working while it could only ever render "nothing designated" (#2419). The original question is genuinely gone: theme.css is generated from design system 2, so checking the system against a sheet derived from it would be a tautology. The question that survives is the one no server can answer. A generated sheet still has to be LOADED and APPLIED, and nothing checked that it was: absent the record declares a token the app doesn't have — the sheet was never regenerated after the record changed, or never loaded differs the app has it with another value — a stale 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 Both sides go through the same engine so the comparison is honest: declared values are set on an offscreen probe and read back, which performs the same var() substitution the browser already did to the live values. Comparing raw strings would mark every derived token as drift. The designation moved with the feature — design_rulebook_id becomes ui_design_system_id, with a migration deleting the retired key rather than leaving an inert row. The prose extractor it fed goes too (#2288 said its runtime role ended when the import landed). Three orphans of the same shape, found alongside and fixed here: - darkOverriddenNames hardcoded [data-theme="dark"]. The sheet went dark-first months ago, so it matched nothing and the "mode-aware" flag silently left the gallery. Now matches the SHAPE of a mode selector, which also holds for an install whose modes aren't light and dark. - groupFor's prefix table never heard of --fs-, so 110 tokens sat under "other". Groups now come from the record where there is one; the table can only know families that shipped with the product (rule #115). - The type scale was a hand-written table of nine sizes marked "no token", true when written and false since the scale was recorded. Now rendered from whatever size tokens the sheet declares, so it can't go stale twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -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 { 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
|
* Both nulls means none designated — the normal state for a fresh install, not
|
||||||
* fresh install, not an error. The caller shows an explanatory empty state. */
|
* an error; the caller shows an explanatory empty state. An id with a null
|
||||||
export const fetchDesignExpectations = () =>
|
* title means designated but deleted or unreadable, which is a
|
||||||
apiGet<ExpectationResponse>("/api/design/expectations");
|
* misconfiguration and must not be rendered as "none". */
|
||||||
|
export const fetchUiDesignSystem = () =>
|
||||||
|
apiGet<UiSystemResponse>("/api/design/ui-system");
|
||||||
|
|||||||
@@ -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
|
* This panel used to compare a design RULEBOOK's prose claims against the live
|
||||||
* prose into claims) is server-side in `services/design_system.py`, where pytest
|
* tokens. That rulebook was retired into the design system on 2026-08-01, and
|
||||||
* can assert on it. What's left here is set arithmetic over live token values,
|
* the feature spent two days rendering a reassuring empty state instead of
|
||||||
* which is the one thing the browser knows and the server doesn't.
|
* 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
|
* 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
|
* record against the TOKENS. A literal hardcoded in a component where a token
|
||||||
* literal hardcoded in a component where a token should be referenced (#2275,
|
* should be referenced is invisible here, because the drift isn't in the tokens
|
||||||
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
|
* at all — that check has the component sources and belongs in CI (#2277).
|
||||||
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
|
* Saying so in the panel matters: a report that silently omits a category
|
||||||
*
|
* invites the reader to conclude the category is clean.
|
||||||
* 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";
|
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 {
|
/** One token as the RECORD has it, already narrowed to the mode being checked. */
|
||||||
kind: ExpectationKind;
|
export interface RecordedToken {
|
||||||
|
name: string;
|
||||||
|
/** Declared value for this mode, or "" when the role is named but unvalued. */
|
||||||
value: string;
|
value: string;
|
||||||
rule_id: number;
|
groupName: string | null;
|
||||||
rule_title: string;
|
|
||||||
context: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExpectationResponse {
|
export type AgreementStatus = "ok" | "absent" | "differs" | "unrecorded";
|
||||||
rulebook_id: number | null;
|
|
||||||
expectations: Expectation[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FindingStatus = "ok" | "missing" | "violated";
|
export interface Agreement {
|
||||||
|
name: string;
|
||||||
export interface Finding {
|
groupName: string | null;
|
||||||
expectation: Expectation;
|
/** What the record declares, resolved. Empty for an `unrecorded` row. */
|
||||||
status: FindingStatus;
|
recorded: string;
|
||||||
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
|
/** What the browser resolved. Empty for an `absent` row. */
|
||||||
matches: string[];
|
live: string;
|
||||||
|
status: AgreementStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalise a colour for comparison — the client-side twin of
|
* Which declared value applies when the page is in `mode`.
|
||||||
* `normalize_hex` in services/design_system.py.
|
|
||||||
*
|
*
|
||||||
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
|
* Falls back to base, which is the storage model rather than a convenience: a
|
||||||
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
|
* mode block is an OVERRIDE layer, so a token with no entry for the current
|
||||||
* spellings of one colour, and a comparison that misses any of them under-reports
|
* mode is not missing — it is inheriting, exactly as the sheet has it.
|
||||||
* 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 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 {
|
export function normalizeColour(value: string): string | null {
|
||||||
const raw = value.trim().toLowerCase();
|
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;
|
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);
|
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
|
||||||
if (rgb) {
|
if (rgb) {
|
||||||
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
|
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
|
||||||
@@ -84,86 +108,128 @@ export function normalizeColour(value: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
|
/**
|
||||||
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
|
* Compare two CSS values for sameness, not for identical text.
|
||||||
const index = new Map<string, string[]>();
|
*
|
||||||
for (const token of tokens) {
|
* Whitespace inside a compound value is not meaningful — `0 2px 10px` and
|
||||||
const colour = normalizeColour(token.value);
|
* `0 2px 10px` are one shadow — and neither is case, since a custom property
|
||||||
if (!colour) continue;
|
* carries no font names or content strings that would be changed by folding it.
|
||||||
const names = index.get(colour);
|
* Colours go through the normaliser first so spelling differences don't read as
|
||||||
if (names) names.push(token.name);
|
* drift.
|
||||||
else index.set(colour, [token.name]);
|
*/
|
||||||
}
|
export function sameValue(a: string, b: string): boolean {
|
||||||
return index;
|
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.
|
* Used to decide which live tokens count as `unrecorded`. An app's stylesheet
|
||||||
* A `color` claim asks whether any token resolves to that value.
|
* legitimately carries names the record never owned — Scribe's own sheet keeps
|
||||||
* A `prohibited_color` claim INVERTS the test — present is the failure.
|
* 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(
|
export function recordedFamilies(names: Iterable<string>): string[] {
|
||||||
expectations: Expectation[],
|
const families = new Set<string>();
|
||||||
tokens: DesignToken[],
|
for (const name of names) {
|
||||||
): Finding[] {
|
const match = /^(--[A-Za-z0-9]+-)/.exec(name);
|
||||||
const names = new Set(tokens.map((t) => t.name));
|
if (match) families.add(match[1]);
|
||||||
const colours = colourIndex(tokens);
|
}
|
||||||
|
return [...families];
|
||||||
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") {
|
* Compare the record against the running app.
|
||||||
return {
|
*
|
||||||
expectation,
|
* `resolved` is the record's declared values after the browser has substituted
|
||||||
status: matches.length ? "violated" : "ok",
|
* `var()` in them (see `resolveDeclared`) — the same treatment the live values
|
||||||
matches,
|
* already received, which is what makes the two comparable.
|
||||||
};
|
*
|
||||||
}
|
* Tokens the record names but has no value for are SKIPPED, not reported. A
|
||||||
return {
|
* valueless token is a role awaiting a decision, and the stylesheet already
|
||||||
expectation,
|
* reports those under `valueless`; counting them as drift would mean a system
|
||||||
status: matches.length ? "ok" : "missing",
|
* created with starter roles opens this panel red on day one.
|
||||||
matches,
|
*/
|
||||||
};
|
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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DriftSummary {
|
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;
|
ok: number;
|
||||||
missing: number;
|
absent: number;
|
||||||
violated: number;
|
differs: number;
|
||||||
|
unrecorded: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarise(findings: Finding[]): DriftSummary {
|
export function summarise(agreements: Agreement[]): AgreementSummary {
|
||||||
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
|
const summary: AgreementSummary = {
|
||||||
for (const finding of findings) summary[finding.status] += 1;
|
ok: 0, absent: 0, differs: 0, unrecorded: 0, total: agreements.length,
|
||||||
|
};
|
||||||
|
for (const a of agreements) summary[a.status] += 1;
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Findings worth leading with.
|
* Findings worth leading with.
|
||||||
*
|
*
|
||||||
* A panel that opens with every row gets closed and never reopened — the same
|
* A panel that opens with every row gets closed and never reopened. `absent`
|
||||||
* principle the auto-inject menu is built on: a short list that gets read beats
|
* leads because it is the one status that can mean the whole sheet is missing;
|
||||||
* a complete one that doesn't. Violations first (something is actively wrong),
|
* `differs` next, because a wrong value is being rendered right now;
|
||||||
* then missing (something was never built), and `ok` rows are not "findings" at
|
* `unrecorded` last, since it is a bookkeeping gap rather than a visible fault.
|
||||||
* all — they belong behind an expansion.
|
* `ok` rows are not findings at all and belong behind an expansion.
|
||||||
*/
|
*/
|
||||||
export function rankFindings(findings: Finding[]): Finding[] {
|
export function rankAgreements(agreements: Agreement[]): Agreement[] {
|
||||||
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
|
const order: Record<AgreementStatus, number> = {
|
||||||
return [...findings].sort((a, b) => {
|
absent: 0, differs: 1, unrecorded: 2, ok: 3,
|
||||||
|
};
|
||||||
|
return [...agreements].sort((a, b) => {
|
||||||
const byStatus = order[a.status] - order[b.status];
|
const byStatus = order[a.status] - order[b.status];
|
||||||
if (byStatus !== 0) return byStatus;
|
return byStatus !== 0 ? byStatus : a.name.localeCompare(b.name);
|
||||||
return a.expectation.rule_id - b.expectation.rule_id;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* Design-token inventory — what tokens exist, and what they actually resolve to.
|
* Design-token inventory — what tokens exist, and what they actually resolve to.
|
||||||
*
|
*
|
||||||
* Foundation for the design explorer (milestone #251): the gallery renders
|
* 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.
|
* DESIGN NOTE — why this parses NAMES but never VALUES.
|
||||||
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
|
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
|
||||||
@@ -42,8 +43,8 @@ export interface DesignToken {
|
|||||||
group: TokenGroup;
|
group: TokenGroup;
|
||||||
/** Resolved value in the requested context, straight from the browser. */
|
/** Resolved value in the requested context, straight from the browser. */
|
||||||
value: string;
|
value: string;
|
||||||
/** True when the declaration appears inside the dark block in source. */
|
/** True when the token is re-declared under a mode selector in source. */
|
||||||
overriddenInDark: boolean;
|
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. */
|
/** Comments are stripped first so a commented-out declaration isn't counted. */
|
||||||
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
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]> = [
|
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
|
||||||
["--color-", "color"],
|
["--color-", "color"],
|
||||||
@@ -96,15 +110,21 @@ export function tokenNames(css: string = themeCss): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
|
/** The subset re-declared under a mode selector — i.e. tokens that change with mode. */
|
||||||
export function darkOverriddenNames(css: string = themeCss): Set<string> {
|
export function modeOverriddenNames(css: string = themeCss): Set<string> {
|
||||||
const bare = css.replace(COMMENT, "");
|
const bare = css.replace(COMMENT, "");
|
||||||
const start = bare.indexOf(DARK_SELECTOR);
|
const names = new Set<string>();
|
||||||
if (start === -1) return new Set();
|
for (const match of bare.matchAll(MODE_SELECTOR)) {
|
||||||
const open = bare.indexOf("{", start);
|
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);
|
const close = bare.indexOf("}", open);
|
||||||
if (open === -1 || close === -1) return new Set();
|
if (close === -1) continue;
|
||||||
return new Set(tokenNames(bare.slice(open, close)));
|
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[] {
|
export function readTokens(host: Element = document.documentElement): DesignToken[] {
|
||||||
const computed = getComputedStyle(host);
|
const computed = getComputedStyle(host);
|
||||||
const dark = darkOverriddenNames();
|
const modal = modeOverriddenNames();
|
||||||
return tokenNames().map((name) => ({
|
return tokenNames().map((name) => ({
|
||||||
name,
|
name,
|
||||||
group: groupFor(name),
|
group: groupFor(name),
|
||||||
value: computed.getPropertyValue(name).trim(),
|
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.
|
* 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.
|
* mutated to take a reading.
|
||||||
*
|
*
|
||||||
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
|
* 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
|
* function: mode scoping is one-way. Whichever mode the sheet treats as its
|
||||||
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
|
* BASE lives on `:root` and has no attribute selector of its own, so a probe
|
||||||
* subtree, but there is no `[data-theme="light"]` block to add the light ones
|
* can add an overriding mode to a subtree but can never add the base mode back.
|
||||||
* back. So reading "light" from inside a dark page returns the dark values —
|
|
||||||
* the probe has nothing to match.
|
|
||||||
*
|
*
|
||||||
* Concretely: dark-inside-light previews work, light-inside-dark previews do
|
* The sheet is dark-first today — `:root` carries dark, `[data-theme="light"]`
|
||||||
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
|
* overrides it — so light-inside-dark previews work and dark-inside-light ones
|
||||||
* (milestone #251 step 6) is what makes this symmetric, and until then callers
|
* return the light values. That direction flipped when the sheet did, which is
|
||||||
* should treat a cross-mode read as best-effort.
|
* 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[] {
|
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
|
||||||
const probe = document.createElement("div");
|
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[]> {
|
* Tokens grouped by family, preserving source order within each group.
|
||||||
const out = new Map<TokenGroup, DesignToken[]>();
|
*
|
||||||
|
* `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) {
|
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);
|
if (bucket) bucket.push(token);
|
||||||
else out.set(token.group, [token]);
|
else out.set(group, [token]);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
+237
-102
@@ -6,7 +6,7 @@
|
|||||||
* runtime rather than parsed from source, so what you see here is what the app
|
* runtime rather than parsed from source, so what you see here is what the app
|
||||||
* is using right now.
|
* 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
|
* 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
|
* 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
|
* 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
|
* `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
|
* specimens below are the app's real classes — they cannot drift from the app
|
||||||
* without drifting the app itself.
|
* 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 DesignTabs from "@/components/DesignTabs.vue";
|
||||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||||
import StatusBadge from "@/components/StatusBadge.vue";
|
import StatusBadge from "@/components/StatusBadge.vue";
|
||||||
import TagPill from "@/components/TagPill.vue";
|
import TagPill from "@/components/TagPill.vue";
|
||||||
|
import { useTheme } from "@/composables/useTheme";
|
||||||
import {
|
import {
|
||||||
compareToTokens,
|
compareToApp,
|
||||||
rankFindings,
|
rankAgreements,
|
||||||
summarise,
|
summarise,
|
||||||
type Expectation,
|
valueForMode,
|
||||||
type Finding,
|
type Agreement,
|
||||||
|
type RecordedToken,
|
||||||
} from "@/utils/designDrift";
|
} 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 tokens = ref<DesignToken[]>([]);
|
||||||
const expectations = ref<Expectation[]>([]);
|
|
||||||
const designRulebookId = ref<number | null>(null);
|
/** The designation, and the two ways it can be absent — see api/design.ts. */
|
||||||
const driftLoaded = ref(false);
|
const systemId = ref<number | null>(null);
|
||||||
const showCleanRows = ref(false);
|
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,
|
* 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.
|
* which needs the app's stylesheets applied and the theme attribute set.
|
||||||
*/
|
*/
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
tokens.value = readTokens();
|
recheck();
|
||||||
try {
|
try {
|
||||||
const response = await fetchDesignExpectations();
|
const designation = await fetchUiDesignSystem();
|
||||||
designRulebookId.value = response.rulebook_id;
|
systemId.value = designation.design_system_id;
|
||||||
expectations.value = response.expectations;
|
systemTitle.value = designation.title;
|
||||||
|
if (designation.design_system_id !== null && designation.title !== null) {
|
||||||
|
records.value = (await fetchResolvedTokens(designation.design_system_id)).tokens;
|
||||||
|
recheck();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// The gallery is useful without the panel, so a failed fetch degrades to
|
// The gallery is useful without the panel, so a failed fetch degrades to
|
||||||
// "no drift data" rather than taking the page down with it.
|
// "couldn't check" rather than taking the page down with it. It says so
|
||||||
designRulebookId.value = null;
|
// rather than showing the same empty state as "nothing designated" — that
|
||||||
|
// conflation is what let this feature sit dead (#2419).
|
||||||
|
checkFailed.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
driftLoaded.value = true;
|
checkLoaded.value = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const findings = computed<Finding[]>(() =>
|
// Toggling the theme on this page is the most likely thing anyone does here.
|
||||||
rankFindings(compareToTokens(expectations.value, tokens.value)),
|
watch(theme, () => recheck());
|
||||||
|
|
||||||
|
const agreements = computed<Agreement[]>(() =>
|
||||||
|
rankAgreements(compareToApp(recorded.value, resolved.value, tokens.value)),
|
||||||
);
|
);
|
||||||
const driftSummary = computed(() => summarise(findings.value));
|
const summary = computed(() => summarise(agreements.value));
|
||||||
const visibleFindings = computed(() =>
|
const visibleAgreements = computed(() =>
|
||||||
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
|
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 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. */
|
/** A token whose value reads as a colour is worth showing as a swatch. */
|
||||||
function isColourish(value: string): boolean {
|
function isColourish(value: string): boolean {
|
||||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
|
/** The button family as shared classes, described by the roles they reach for. */
|
||||||
const RULEBOOK_BUTTONS = [
|
const BUTTON_VARIANTS = [
|
||||||
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
|
{ name: "Primary", spec: "action-primary background, text-on-action label, no border" },
|
||||||
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
|
{ name: "Secondary", spec: "action-secondary background, text-on-action label, no border" },
|
||||||
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
|
{ name: "Ghost", spec: "transparent, primary text, one-pixel border" },
|
||||||
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
|
{ name: "Danger", spec: "action-destructive background, text-on-action label" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TYPE_SPECIMENS = [
|
/**
|
||||||
{ token: "Display", spec: "40 / 500 / Fraunces" },
|
* The type scale, read from whatever size tokens the sheet actually declares.
|
||||||
{ token: "H1", spec: "32 / 500 / Fraunces" },
|
*
|
||||||
{ token: "H2", spec: "24 / 500 / Fraunces" },
|
* Was a hand-written table of nine sizes marked "no token" — true when written,
|
||||||
{ token: "H3", spec: "18 / 500 / Inter" },
|
* and false since the scale was recorded. Deriving it from the live tokens is
|
||||||
{ token: "Body", spec: "15 / 400 / Inter" },
|
* what stops it going stale a second time: if the scale is removed, this
|
||||||
{ token: "Body small", spec: "13 / 400 / Inter" },
|
* section empties out and says so.
|
||||||
{ token: "Label", spec: "12 / 500 / Inter" },
|
*/
|
||||||
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
|
const sizeTokens = computed(() => tokens.value.filter((t) => /-size(-|$)/.test(t.name)));
|
||||||
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -116,78 +208,108 @@ const TYPE_SPECIMENS = [
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</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">
|
<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">
|
<div v-else-if="checkFailed" class="gap-notice">
|
||||||
<strong>No design rulebook designated.</strong>
|
<strong>The design system couldn't be read.</strong>
|
||||||
<p>
|
<p>
|
||||||
This install hasn't said which rulebook describes its design system, so
|
The gallery below is still live — it comes from the browser, not the
|
||||||
there is nothing to check the tokens against. Designate one in
|
server — but nothing is being compared against the record right now.
|
||||||
<router-link to="/settings">Settings</router-link> and this panel will
|
This is a failure, not an empty result.
|
||||||
compare every colour and token the rulebook names against what the
|
</p>
|
||||||
stylesheet actually resolves to.
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="section-note">
|
<p class="section-note">
|
||||||
<strong>{{ driftSummary.violated }}</strong> violated ·
|
<strong>{{ summary.absent }}</strong> not in the app ·
|
||||||
<strong>{{ driftSummary.missing }}</strong> missing ·
|
<strong>{{ summary.differs }}</strong> rendering another value ·
|
||||||
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
|
{{ summary.unrecorded }} not in the record ·
|
||||||
claims in rulebook #{{ designRulebookId }}.
|
{{ 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>
|
</p>
|
||||||
|
|
||||||
<div class="gap-notice">
|
<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>
|
<p>
|
||||||
A value hardcoded in a component — where a token should have been
|
A value hardcoded in a component — where a token should have been
|
||||||
referenced — is invisible here, because the drift isn't in the tokens
|
referenced — is invisible here, because the drift isn't in the
|
||||||
at all. Reading it would mean bundling every component's source into
|
tokens at all. Reading it would mean bundling every component's
|
||||||
the app. That check belongs in CI and is tracked separately, so treat
|
source into the app. That check belongs in CI and is tracked
|
||||||
a clean panel as "the tokens agree", not "the app agrees".
|
separately, so treat a clean panel as "the tokens agree", not "the
|
||||||
|
app agrees".
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!findings.length" class="muted">
|
<p v-if="!agreements.length" class="muted">
|
||||||
The rulebook names nothing this panel can check. Rules that state values
|
The record declares no valued tokens, so there is nothing to compare
|
||||||
— colours, token names — produce claims; rules that state judgement
|
yet. Give its roles values and this panel starts reporting.
|
||||||
don't, by design.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul v-else class="spec-list">
|
<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 class="spec-name">
|
||||||
<span
|
<span
|
||||||
v-if="finding.expectation.kind !== 'token'"
|
v-if="isColourish(row.live || row.recorded)"
|
||||||
class="swatch"
|
class="swatch"
|
||||||
:style="{ background: finding.expectation.value }"
|
:style="{ background: row.live || row.recorded }"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<code>{{ finding.expectation.value }}</code>
|
<code>{{ row.name }}</code>
|
||||||
</span>
|
</span>
|
||||||
<span class="spec-detail">
|
<span class="spec-detail">
|
||||||
rule #{{ finding.expectation.rule_id }} — {{ finding.expectation.rule_title }}
|
<template v-if="row.status === 'differs'">
|
||||||
<span v-if="finding.matches.length" class="matches">
|
record <code>{{ row.recorded }}</code> · app
|
||||||
· {{ finding.matches.join(", ") }}
|
<code>{{ row.live }}</code>
|
||||||
</span>
|
</template>
|
||||||
</span>
|
<template v-else-if="row.status === 'unrecorded'">
|
||||||
<span class="spec-status" :class="finding.status">
|
app <code>{{ row.live }}</code>
|
||||||
{{ finding.status === "violated" ? "forbidden, but present"
|
</template>
|
||||||
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
|
<template v-else>
|
||||||
|
record <code>{{ row.recorded }}</code>
|
||||||
|
</template>
|
||||||
|
<span v-if="row.groupName" class="matches"> · {{ row.groupName }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="spec-status" :class="row.status">{{ STATUS_LABEL[row.status] }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="findings.length && driftSummary.ok"
|
v-if="agreements.length && summary.ok"
|
||||||
class="reveal-toggle"
|
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>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
@@ -249,7 +371,7 @@ const TYPE_SPECIMENS = [
|
|||||||
<button class="btn-primary btn-inline">Inline</button>
|
<button class="btn-primary btn-inline">Inline</button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="spec-list">
|
<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-name">{{ b.name }}</span>
|
||||||
<span class="spec-detail">{{ b.spec }}</span>
|
<span class="spec-detail">{{ b.spec }}</span>
|
||||||
<span class="spec-status ok">shared</span>
|
<span class="spec-status ok">shared</span>
|
||||||
@@ -257,23 +379,21 @@ const TYPE_SPECIMENS = [
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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">
|
<section class="design-section">
|
||||||
<h2>Type scale</h2>
|
<h2>Type scale</h2>
|
||||||
<div class="gap-notice">
|
<div v-if="!sizeTokens.length" class="gap-notice">
|
||||||
<strong>Families load; the scale has no tokens.</strong>
|
<strong>The scale has no tokens.</strong>
|
||||||
<p>
|
<p>
|
||||||
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
|
Nothing in the stylesheet declares a size token, so sizes are being set
|
||||||
scale is not expressed as custom properties, so sizes and weights are
|
ad hoc per component. There is nothing live to show here.
|
||||||
set ad hoc per component. Listed here as specification, not as a live
|
|
||||||
specimen — there is nothing to read.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="spec-list">
|
<ul v-else class="spec-list">
|
||||||
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
|
<li v-for="t in sizeTokens" :key="t.name">
|
||||||
<span class="spec-name">{{ t.token }}</span>
|
<span class="spec-name" :style="{ fontSize: t.value }">Ag</span>
|
||||||
<span class="spec-detail">{{ t.spec }}</span>
|
<span class="spec-detail"><code>{{ t.name }}</code></span>
|
||||||
<span class="spec-status missing">no token</span>
|
<span class="spec-status ok">{{ t.value || "—" }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@@ -292,7 +412,7 @@ const TYPE_SPECIMENS = [
|
|||||||
<span v-else class="swatch swatch-none" aria-hidden="true" />
|
<span v-else class="swatch swatch-none" aria-hidden="true" />
|
||||||
<code class="token-name">{{ token.name }}</code>
|
<code class="token-name">{{ token.name }}</code>
|
||||||
<code class="token-value">{{ token.value || "—" }}</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
|
mode-aware
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -430,6 +550,7 @@ const TYPE_SPECIMENS = [
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spec-status {
|
.spec-status {
|
||||||
@@ -438,6 +559,25 @@ const TYPE_SPECIMENS = [
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
padding: 0.1rem 0.45rem;
|
padding: 0.1rem 0.45rem;
|
||||||
border-radius: var(--radius-sm);
|
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 {
|
.spec-status.missing {
|
||||||
@@ -445,11 +585,6 @@ const TYPE_SPECIMENS = [
|
|||||||
color: var(--color-priority-medium);
|
color: var(--color-priority-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spec-status.violated {
|
|
||||||
background: var(--color-priority-high-bg);
|
|
||||||
color: var(--color-priority-high);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spec-status.ok {
|
.spec-status.ok {
|
||||||
background: var(--color-status-done-bg);
|
background: var(--color-status-done-bg);
|
||||||
color: var(--color-status-done);
|
color: var(--color-status-done);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useToastStore } from "@/stores/toast";
|
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 { 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 type { User } from "@/types/auth";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
import TagInput from "@/components/TagInput.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
|
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
|
||||||
// suggests a merge the operator reviews (services/dedup.py).
|
// suggests a merge the operator reviews (services/dedup.py).
|
||||||
const kbDuplicateThreshold = ref("0.82");
|
const kbDuplicateThreshold = ref("0.82");
|
||||||
// Which rulebook describes this install's design system, for the /design drift
|
// Which design system this install's own UI is built from, for the /design
|
||||||
// panel. Empty = none designated, which is the normal state for a fresh install
|
// agreement panel. Empty = none designated, which is the normal state for a
|
||||||
// rather than a misconfiguration — the panel explains itself when unset.
|
// fresh install rather than a misconfiguration — the panel explains itself when
|
||||||
const designRulebookId = ref("");
|
// unset. Replaced design_rulebook_id when the rulebook was retired (#2419).
|
||||||
const designRulebooks = ref<{ id: number; title: string }[]>([]);
|
const uiDesignSystemId = ref("");
|
||||||
|
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||||
const savingKbInject = ref(false);
|
const savingKbInject = ref(false);
|
||||||
const kbInjectSaved = ref(false);
|
const kbInjectSaved = ref(false);
|
||||||
|
|
||||||
@@ -107,8 +108,8 @@ async function saveKbInject() {
|
|||||||
kb_writepath_threshold: String(wpT),
|
kb_writepath_threshold: String(wpT),
|
||||||
kb_duplicate_threshold: String(dupT),
|
kb_duplicate_threshold: String(dupT),
|
||||||
// Empty string DELETES the setting (see routes/settings.py), which is
|
// Empty string DELETES the setting (see routes/settings.py), which is
|
||||||
// exactly right for "no design rulebook" — absent rather than zero.
|
// exactly right for "no design system" — absent rather than zero.
|
||||||
design_rulebook_id: designRulebookId.value,
|
ui_design_system_id: uiDesignSystemId.value,
|
||||||
});
|
});
|
||||||
kbInjectSaved.value = true;
|
kbInjectSaved.value = true;
|
||||||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||||||
@@ -499,13 +500,15 @@ onMounted(async () => {
|
|||||||
if (allSettings.kb_duplicate_threshold !== undefined) {
|
if (allSettings.kb_duplicate_threshold !== undefined) {
|
||||||
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
|
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
|
// 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 {
|
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 {
|
} catch {
|
||||||
designRulebooks.value = [];
|
designSystems.value = [];
|
||||||
}
|
}
|
||||||
if (allSettings.notify_task_reminders !== undefined) {
|
if (allSettings.notify_task_reminders !== undefined) {
|
||||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||||
@@ -1278,19 +1281,22 @@ function formatUserDate(iso: string): string {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="design-rulebook">Design-system rulebook</label>
|
<label for="ui-design-system">This app's design system</label>
|
||||||
<select id="design-rulebook" v-model="designRulebookId" class="input" style="max-width: 22rem">
|
<select id="ui-design-system" v-model="uiDesignSystemId" class="input" style="max-width: 22rem">
|
||||||
<option value="">None — don't check for design drift</option>
|
<option value="">None — don't check the interface against a record</option>
|
||||||
<option v-for="rb in designRulebooks" :key="rb.id" :value="String(rb.id)">
|
<option v-for="ds in designSystems" :key="ds.id" :value="String(ds.id)">
|
||||||
{{ rb.title }}
|
{{ ds.title }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
Which rulebook describes how this app should look. Once set, the
|
Which design system this interface is supposed to be built from. Once
|
||||||
<router-link to="/design">Design</router-link> page compares every colour
|
set, the <router-link to="/design">Design</router-link> page compares
|
||||||
and token your rules name against what the stylesheet actually resolves
|
every token the system declares against what the browser has actually
|
||||||
to, and reports where they disagree. Leave it as None if your rules
|
resolved, and reports the ones the app is missing, renders differently,
|
||||||
don't describe a design system — nothing else depends on this.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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
|
Kept separate from the design-systems CRUD blueprint on purpose. That one is
|
||||||
the browser (see `utils/designTokens.ts`), which is the only place they exist
|
the RECORD: create a system, move a token, read the cascade. This one answers a
|
||||||
resolved. This endpoint supplies the claims to check them against.
|
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 quart import Blueprint, jsonify
|
||||||
|
|
||||||
from scribe.auth import get_current_user_id, login_required
|
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 = Blueprint("design", __name__, url_prefix="/api/design")
|
||||||
|
|
||||||
|
|
||||||
@design_bp.get("/expectations")
|
@design_bp.get("/ui-system")
|
||||||
@login_required
|
@login_required
|
||||||
async def get_expectations():
|
async def get_ui_system():
|
||||||
"""Checkable claims from the rulebook this install designated as its design 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
|
Both nulls is the NORMAL case, not an error — an install that has not
|
||||||
not designated a design rulebook has nothing to compare against, and the
|
designated one has nothing to check the running app against, and the client
|
||||||
client shows an explanatory empty state (rule #115). Distinguishing it from
|
shows an explanatory empty state (rule #115).
|
||||||
"designated but empty" is why the id is returned alongside the list.
|
|
||||||
|
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()
|
uid = get_current_user_id()
|
||||||
result = await design_svc.design_expectations(uid)
|
system_id, system = await ds_svc.ui_design_system(uid)
|
||||||
return jsonify(result.as_dict())
|
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,
|
resolve_tokens,
|
||||||
would_cycle,
|
would_cycle,
|
||||||
)
|
)
|
||||||
|
from scribe.services.settings import get_setting
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -134,6 +135,40 @@ async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem
|
|||||||
return system
|
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]:
|
async def list_design_systems(user_id: int) -> list[DesignSystem]:
|
||||||
"""The caller's own systems, ordered by title. Empty is normal."""
|
"""The caller's own systems, ordered by title. Empty is normal."""
|
||||||
async with async_session() as session:
|
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():
|
def test_the_literal_match_is_case_insensitive():
|
||||||
"""Rulebooks write `#FFFFFF` and code writes `#ffffff`. A case-sensitive
|
"""A record writes `#FFFFFF` and code writes `#ffffff`. A case-sensitive
|
||||||
check would silently find nothing — the same trap normalize_hex exists for."""
|
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)
|
report = check_code_against_tokens("color: #FFFFFF;", SHEET)
|
||||||
assert report["superseded_literals"] == [
|
assert report["superseded_literals"] == [
|
||||||
{"literal": "#ffffff", "use_instead": "--fs-parchment"}
|
{"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