refactor(design): retire /design — a surface that could only inspect itself
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 40s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 40s
The design surface is for the projects an install tracks. /design read the running app's own stylesheet — names out of a bundled theme.css, values out of getComputedStyle(document.documentElement) — so it could only ever describe the instance serving the page. Scribe is one project among the projects Scribe tracks; it gets no view hardcoded into every install. The mechanism that makes this a tool rather than a mirror already existed and already covers Scribe: scripts/check_design_tokens.py runs in CI against a sheet path it knows nothing about, using check_code_against_tokens — the same engine behind check_snippets_against_system. /design was redundant even here. Removed: DesignView, DesignTabs (nothing left to tab between), api/design.ts, routes/design.py and its blueprint, the /design route, ui_design_system() and its setting, and the Settings picker that designated "this app's UI". utils/designTokens.ts and utils/designDrift.ts go with it — between them they were the browser-reading half. What survives is utils/designValues.ts, which works on a record rather than a document: valueForMode, modesPresent, and resolveDeclared. resolveDeclared gained real isolation in the move. Custom properties inherit and `all: initial` does not reset them, so a probe sitting in this page would resolve any reference a record leaves undeclared against the SURROUNDING app's tokens — previewing another project's system would quietly borrow this one's palette wherever that system was incomplete, and a token already reported under unknown_refs would render as though it were fine. Undeclared references are now blanked on the probe first, so they resolve to nothing, which is what the record says they are. Migration 0075 absorbs ui_design_system_id alongside design_rulebook_id rather than an 0076 undoing it: 0075 has not run anywhere, since dev is unmerged and deploys come from main. Both keys named a design source for the running install, and a project already carries its own pointer. This retires the agreement panel shipped yesterday. It asked whether the sheet was actually loaded and applied — the one question a record cannot answer about itself — but only ever about the app you are already inside. Nothing replaces it; recorded in #2430 rather than quietly dropped. Step 1 of milestone #274. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -1,28 +1,29 @@
|
||||
"""retire the design_rulebook_id setting
|
||||
"""retire the two settings that designated a design source for the app itself
|
||||
|
||||
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:
|
||||
Two keys, retired for the same reason a week apart, so they go in one change
|
||||
rather than one migration each:
|
||||
|
||||
design_rulebook_id -> ui_design_system_id
|
||||
design_rulebook_id which rulebook described how this app should look
|
||||
ui_design_system_id which design system this app's own UI was built from
|
||||
|
||||
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.
|
||||
Both named a design source for THE RUNNING INSTALL. The design surface is for
|
||||
the projects an install tracks, and a project already carries its own pointer
|
||||
(`projects.design_system_id`) — so an install-wide designation had nothing left
|
||||
to mean. `ui_design_system_id` was introduced by this same migration's first
|
||||
draft and never reached a deployed database; it is listed here rather than
|
||||
undone by an 0076 that would reverse a change nobody ran.
|
||||
|
||||
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.
|
||||
lie: the pointer lives on the project now, and always did for anyone who set it
|
||||
there.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
@@ -33,10 +34,12 @@ down_revision = "0074"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text("DELETE FROM settings WHERE key = 'design_rulebook_id'")
|
||||
sa.text(
|
||||
"DELETE FROM settings "
|
||||
"WHERE key IN ('design_rulebook_id', 'ui_design_system_id')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { apiGet } from "@/api/client";
|
||||
|
||||
export interface UiSystemResponse {
|
||||
design_system_id: number | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
/** The design system this install says its own UI is built from.
|
||||
*
|
||||
* Both nulls means none designated — the normal state for a fresh install, not
|
||||
* an error; the caller shows an explanatory empty state. An id with a null
|
||||
* title means designated but deleted or unreadable, which is a
|
||||
* misconfiguration and must not be rendered as "none". */
|
||||
export const fetchUiDesignSystem = () =>
|
||||
apiGet<UiSystemResponse>("/api/design/ui-system");
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sub-navigation for the Design surface.
|
||||
*
|
||||
* There are two pages here and they are halves of ONE thing: the record that
|
||||
* decides the styling, and what the browser is actually rendering from it. They
|
||||
* were briefly two top-level nav entries, which put the read-only diagnostic
|
||||
* first and buried the editable record under it — backwards, since the record
|
||||
* is the thing you work with and the live view is the check on it.
|
||||
*
|
||||
* A component rather than the same markup pasted into both views: two copies of
|
||||
* a tab bar diverge the moment a third tab appears, and that is the exact shape
|
||||
* of duplication this whole surface exists to make visible.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="design-tabs" aria-label="Design views">
|
||||
<router-link to="/design-systems" class="design-tab">Design system</router-link>
|
||||
<router-link to="/design" class="design-tab">Live tokens</router-link>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.design-tab {
|
||||
padding: 0.5rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.design-tab:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* `router-link-active` rather than `-exact-active`: both routes are leaves, and
|
||||
exact matching would drop the highlight on any future child route. */
|
||||
.design-tab.router-link-active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -110,15 +110,11 @@ const router = createRouter({
|
||||
component: () => import("@/views/RulesView.vue"),
|
||||
},
|
||||
{
|
||||
// Meta-surface, same family as /rules: it describes the app rather than
|
||||
// holding the operator's records.
|
||||
path: "/design",
|
||||
name: "design",
|
||||
component: () => import("@/views/DesignView.vue"),
|
||||
},
|
||||
{
|
||||
// The editable half of the same surface: /design is what the browser
|
||||
// renders, /design-systems is the record that ought to decide it.
|
||||
// The design systems this install RECORDS — for the projects it tracks,
|
||||
// not for the install itself. There was a sibling `/design` that read the
|
||||
// running app's own stylesheet out of the browser; it could only ever
|
||||
// inspect the instance it was served from, which made it a mirror rather
|
||||
// than a tool (#274).
|
||||
path: "/design-systems",
|
||||
name: "design-systems",
|
||||
component: () => import("@/views/DesignSystemsView.vue"),
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* Agreement — does the running app match the design system it was built from?
|
||||
*
|
||||
* This panel used to compare a design RULEBOOK's prose claims against the live
|
||||
* tokens. That rulebook was retired into the design system on 2026-08-01, and
|
||||
* the feature spent two days rendering a reassuring empty state instead of
|
||||
* failing (#2419). Its replacement is not the same question re-aimed: comparing
|
||||
* a design system against a stylesheet generated from that same design system
|
||||
* would be a tautology.
|
||||
*
|
||||
* The question that survives is the one no server can answer. A sheet still has
|
||||
* to be LOADED and APPLIED, and until now nothing checked that it was. Three
|
||||
* failures live in that gap:
|
||||
*
|
||||
* absent the app has no such token at all — the sheet was never
|
||||
* regenerated after the record changed, or never loaded
|
||||
* differs the app has the token with another value — a stale copy of the
|
||||
* sheet, or a later rule that overrode it
|
||||
* unrecorded the app declares a token in the record's own family that the
|
||||
* record has never heard of — hand-editing that outlived its reason
|
||||
*
|
||||
* SCOPE, and it is a real limit rather than an omission. This compares the
|
||||
* record against the TOKENS. A literal hardcoded in a component where a token
|
||||
* should be referenced is invisible here, because the drift isn't in the tokens
|
||||
* at all — that check has the component sources and belongs in CI (#2277).
|
||||
* Saying so in the panel matters: a report that silently omits a category
|
||||
* invites the reader to conclude the category is clean.
|
||||
*/
|
||||
import type { DesignToken } from "@/utils/designTokens";
|
||||
|
||||
/** The base mode's key in a token's `value_by_mode`, mirroring services/design_stylesheet. */
|
||||
export const BASE_MODE = "base";
|
||||
|
||||
/** One token as the RECORD has it, already narrowed to the mode being checked. */
|
||||
export interface RecordedToken {
|
||||
name: string;
|
||||
/** Declared value for this mode, or "" when the role is named but unvalued. */
|
||||
value: string;
|
||||
groupName: string | null;
|
||||
}
|
||||
|
||||
export type AgreementStatus = "ok" | "absent" | "differs" | "unrecorded";
|
||||
|
||||
export interface Agreement {
|
||||
name: string;
|
||||
groupName: string | null;
|
||||
/** What the record declares, resolved. Empty for an `unrecorded` row. */
|
||||
recorded: string;
|
||||
/** What the browser resolved. Empty for an `absent` row. */
|
||||
live: string;
|
||||
status: AgreementStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which declared value applies when the page is in `mode`.
|
||||
*
|
||||
* Falls back to base, which is the storage model rather than a convenience: a
|
||||
* mode block is an OVERRIDE layer, so a token with no entry for the current
|
||||
* mode is not missing — it is inheriting, exactly as the sheet has it.
|
||||
*/
|
||||
export function valueForMode(
|
||||
valueByMode: Record<string, string>,
|
||||
mode: string,
|
||||
): string {
|
||||
const own = valueByMode[mode];
|
||||
if (own !== undefined && own !== "") return own;
|
||||
return valueByMode[BASE_MODE] ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a colour for comparison.
|
||||
*
|
||||
* A record writes `#FFFFFF`, a sheet writes `#fff`, and
|
||||
* getComputedStyle can hand back `rgb(255, 255, 255)` — three spellings of one
|
||||
* colour, and a comparison that misses any of them over-reports drift, which is
|
||||
* the failure that gets a panel ignored. The rgb() case is browser-specific and
|
||||
* therefore has no server-side counterpart, which is exactly why it is here.
|
||||
*/
|
||||
export function normalizeColour(value: string): string | null {
|
||||
const raw = value.trim().toLowerCase();
|
||||
|
||||
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
|
||||
if (hex) {
|
||||
let digits = hex[1];
|
||||
if (digits.length === 3 || digits.length === 4) {
|
||||
digits = digits.split("").map((c) => c + c).join("");
|
||||
}
|
||||
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
|
||||
}
|
||||
|
||||
// getComputedStyle reports real colour properties as rgb()/rgba(), never as
|
||||
// authored. Custom properties are token streams and usually come back as
|
||||
// written, so this arm is insurance rather than the common path.
|
||||
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
|
||||
if (rgb) {
|
||||
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
const channels = parts.slice(0, 3).map((p) => Number(p));
|
||||
if (channels.some((n) => !Number.isFinite(n))) return null;
|
||||
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
|
||||
const base = `#${channels.map(hexOf).join("")}`;
|
||||
if (parts.length === 3) return base;
|
||||
const alpha = Number(parts[3]);
|
||||
if (!Number.isFinite(alpha) || alpha >= 1) return base;
|
||||
return `${base}${hexOf(alpha * 255)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two CSS values for sameness, not for identical text.
|
||||
*
|
||||
* Whitespace inside a compound value is not meaningful — `0 2px 10px` and
|
||||
* `0 2px 10px` are one shadow — and neither is case, since a custom property
|
||||
* carries no font names or content strings that would be changed by folding it.
|
||||
* Colours go through the normaliser first so spelling differences don't read as
|
||||
* drift.
|
||||
*/
|
||||
export function sameValue(a: string, b: string): boolean {
|
||||
const canon = (v: string) => {
|
||||
const trimmed = v.trim();
|
||||
return normalizeColour(trimmed) ?? trimmed.replace(/\s+/g, " ").toLowerCase();
|
||||
};
|
||||
return canon(a) === canon(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* The families the record claims, as name prefixes.
|
||||
*
|
||||
* Used to decide which live tokens count as `unrecorded`. An app's stylesheet
|
||||
* legitimately carries names the record never owned — Scribe's own sheet keeps
|
||||
* a `--color-*` alias layer over the design system's `--fs-*` block — and
|
||||
* reporting those as drift would bury the real findings under a compatibility
|
||||
* shim. So the record is treated as owning a FAMILY, identified by the prefix
|
||||
* up to the first separator, and nothing outside it is judged.
|
||||
*
|
||||
* Derived from the data rather than configured, because the prefix is the
|
||||
* install's choice (see design_starter_roles) and hardcoding one would put a
|
||||
* single operator's naming into every install (rule #115).
|
||||
*/
|
||||
export function recordedFamilies(names: Iterable<string>): string[] {
|
||||
const families = new Set<string>();
|
||||
for (const name of names) {
|
||||
const match = /^(--[A-Za-z0-9]+-)/.exec(name);
|
||||
if (match) families.add(match[1]);
|
||||
}
|
||||
return [...families];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the record against the running app.
|
||||
*
|
||||
* `resolved` is the record's declared values after the browser has substituted
|
||||
* `var()` in them (see `resolveDeclared`) — the same treatment the live values
|
||||
* already received, which is what makes the two comparable.
|
||||
*
|
||||
* Tokens the record names but has no value for are SKIPPED, not reported. A
|
||||
* valueless token is a role awaiting a decision, and the stylesheet already
|
||||
* reports those under `valueless`; counting them as drift would mean a system
|
||||
* created with starter roles opens this panel red on day one.
|
||||
*/
|
||||
export function compareToApp(
|
||||
recorded: RecordedToken[],
|
||||
resolved: Map<string, string>,
|
||||
live: DesignToken[],
|
||||
): Agreement[] {
|
||||
const liveByName = new Map<string, string>();
|
||||
for (const token of live) liveByName.set(token.name, token.value);
|
||||
const out: Agreement[] = [];
|
||||
|
||||
for (const token of recorded) {
|
||||
if (!token.value) continue;
|
||||
const declared = resolved.get(token.name) ?? token.value;
|
||||
const actual = liveByName.get(token.name) ?? "";
|
||||
out.push({
|
||||
name: token.name,
|
||||
groupName: token.groupName,
|
||||
recorded: declared,
|
||||
live: actual,
|
||||
status: !actual ? "absent" : sameValue(declared, actual) ? "ok" : "differs",
|
||||
});
|
||||
}
|
||||
|
||||
const known = new Set(recorded.map((t) => t.name));
|
||||
const families = recordedFamilies(known);
|
||||
for (const token of live) {
|
||||
if (known.has(token.name)) continue;
|
||||
if (!families.some((prefix) => token.name.startsWith(prefix))) continue;
|
||||
out.push({
|
||||
name: token.name,
|
||||
groupName: null,
|
||||
recorded: "",
|
||||
live: token.value,
|
||||
status: "unrecorded",
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface AgreementSummary {
|
||||
ok: number;
|
||||
absent: number;
|
||||
differs: number;
|
||||
unrecorded: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function summarise(agreements: Agreement[]): AgreementSummary {
|
||||
const summary: AgreementSummary = {
|
||||
ok: 0, absent: 0, differs: 0, unrecorded: 0, total: agreements.length,
|
||||
};
|
||||
for (const a of agreements) summary[a.status] += 1;
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings worth leading with.
|
||||
*
|
||||
* A panel that opens with every row gets closed and never reopened. `absent`
|
||||
* leads because it is the one status that can mean the whole sheet is missing;
|
||||
* `differs` next, because a wrong value is being rendered right now;
|
||||
* `unrecorded` last, since it is a bookkeeping gap rather than a visible fault.
|
||||
* `ok` rows are not findings at all and belong behind an expansion.
|
||||
*/
|
||||
export function rankAgreements(agreements: Agreement[]): Agreement[] {
|
||||
const order: Record<AgreementStatus, number> = {
|
||||
absent: 0, differs: 1, unrecorded: 2, ok: 3,
|
||||
};
|
||||
return [...agreements].sort((a, b) => {
|
||||
const byStatus = order[a.status] - order[b.status];
|
||||
return byStatus !== 0 ? byStatus : a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
/**
|
||||
* Design-token inventory — what tokens exist, and what they actually resolve to.
|
||||
*
|
||||
* Foundation for the design explorer (milestone #251): the gallery renders
|
||||
* against these, and the agreement panel compares them to the design system the
|
||||
* install says its UI is built from (#2419).
|
||||
*
|
||||
* DESIGN NOTE — why this parses NAMES but never VALUES.
|
||||
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
|
||||
* its VALUE is not: values contain nested parens, commas inside rgba(),
|
||||
* `var()` references to other tokens, multi-part shadows, and gradients — and
|
||||
* `theme.css` has all of those today. So we take the names from the source and
|
||||
* ask the BROWSER for every value.
|
||||
*
|
||||
* That is not just easier, it is more correct. getComputedStyle reports what
|
||||
* actually won the cascade, resolves `var()` chains, and — critically for this
|
||||
* milestone — reflects live overrides set on a container, which is exactly what
|
||||
* the preview surface needs (see #2261). Parsing the source would report what
|
||||
* the file says rather than what the user is looking at.
|
||||
*
|
||||
* It also means this module needs no unit tests to be trustworthy: the only
|
||||
* logic here is a name regex and a group lookup. The frontend has no test
|
||||
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
|
||||
* error-prone half in the browser rather than in our code is deliberate.
|
||||
*/
|
||||
import themeCss from "@/assets/theme.css?raw";
|
||||
|
||||
export type TokenGroup =
|
||||
| "color"
|
||||
| "radius"
|
||||
| "gradient"
|
||||
| "glow"
|
||||
| "focus"
|
||||
| "layout"
|
||||
| "other";
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
|
||||
export interface DesignToken {
|
||||
/** Full custom-property name, including the leading `--`. */
|
||||
name: string;
|
||||
/** Coarse family, derived from the name prefix. */
|
||||
group: TokenGroup;
|
||||
/** Resolved value in the requested context, straight from the browser. */
|
||||
value: string;
|
||||
/** True when the token is re-declared under a mode selector in source. */
|
||||
modeAware: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
|
||||
*
|
||||
* The discriminator is the COLON, not the preceding character. A declaration is
|
||||
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
|
||||
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
|
||||
* actively wrong: an earlier version required the match to follow `{` or `;`,
|
||||
* which silently dropped every declaration that came after a comment —
|
||||
* including `--color-bg`, the first and most-used token in the file.
|
||||
*/
|
||||
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
|
||||
|
||||
/** Comments are stripped first so a commented-out declaration isn't counted. */
|
||||
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
||||
|
||||
/**
|
||||
* Any mode-override block, whichever mode it names.
|
||||
*
|
||||
* This used to hardcode `[data-theme="dark"]`, and that stopped being true the
|
||||
* day the sheet went dark-first: `:root` now carries dark and
|
||||
* `[data-theme="light"]` overrides it. The hardcoded selector matched nothing,
|
||||
* `overriddenInDark` was false for all 186 tokens, and the "mode-aware" flag
|
||||
* silently vanished from the gallery — a UI that kept rendering, wrongly.
|
||||
*
|
||||
* Matching the SHAPE rather than one mode name is what makes that unrepeatable,
|
||||
* and it is also the only version that holds for an install whose modes aren't
|
||||
* light and dark (rule #115). `selector_for_mode` in services/design_stylesheet
|
||||
* emits exactly this shape, so the two ends agree by construction.
|
||||
*/
|
||||
const MODE_SELECTOR = /\[data-theme=["']?[\w-]+["']?\]/g;
|
||||
|
||||
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
|
||||
["--color-", "color"],
|
||||
["--radius-", "radius"],
|
||||
["--gradient-", "gradient"],
|
||||
["--glow-", "glow"],
|
||||
["--focus-", "focus"],
|
||||
["--page-", "layout"],
|
||||
["--sidebar-", "layout"],
|
||||
["--chat-", "layout"],
|
||||
];
|
||||
|
||||
export function groupFor(name: string): TokenGroup {
|
||||
for (const [prefix, group] of GROUP_PREFIXES) {
|
||||
if (name.startsWith(prefix)) return group;
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
|
||||
export function tokenNames(css: string = themeCss): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
|
||||
const name = match[1];
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name);
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The subset re-declared under a mode selector — i.e. tokens that change with mode. */
|
||||
export function modeOverriddenNames(css: string = themeCss): Set<string> {
|
||||
const bare = css.replace(COMMENT, "");
|
||||
const names = new Set<string>();
|
||||
for (const match of bare.matchAll(MODE_SELECTOR)) {
|
||||
if (match.index === undefined) continue;
|
||||
const open = bare.indexOf("{", match.index + match[0].length);
|
||||
if (open === -1) continue;
|
||||
// A custom-property block is flat, so the first `}` closes it. Anything
|
||||
// nested would be a rule, not a declaration, and has no tokens to find.
|
||||
const close = bare.indexOf("}", open);
|
||||
if (close === -1) continue;
|
||||
for (const name of tokenNames(bare.slice(open, close))) names.add(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved value of every token in `host`'s context.
|
||||
*
|
||||
* Pass a container to read the tokens as they apply INSIDE it — which is how
|
||||
* the preview surface reads a scoped override without disturbing the page.
|
||||
* Defaults to the document root, i.e. the app-wide values.
|
||||
*/
|
||||
export function readTokens(host: Element = document.documentElement): DesignToken[] {
|
||||
const computed = getComputedStyle(host);
|
||||
const modal = modeOverriddenNames();
|
||||
return tokenNames().map((name) => ({
|
||||
name,
|
||||
group: groupFor(name),
|
||||
value: computed.getPropertyValue(name).trim(),
|
||||
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.
|
||||
*
|
||||
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
|
||||
* mutated to take a reading.
|
||||
*
|
||||
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
|
||||
* function: mode scoping is one-way. Whichever mode the sheet treats as its
|
||||
* BASE lives on `:root` and has no attribute selector of its own, so a probe
|
||||
* can add an overriding mode to a subtree but can never add the base mode back.
|
||||
*
|
||||
* The sheet is dark-first today — `:root` carries dark, `[data-theme="light"]`
|
||||
* overrides it — so light-inside-dark previews work and dark-inside-light ones
|
||||
* return the light values. That direction flipped when the sheet did, which is
|
||||
* why this says "the base mode" rather than naming one: callers should treat a
|
||||
* cross-mode read as best-effort either way.
|
||||
*/
|
||||
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
|
||||
const probe = document.createElement("div");
|
||||
probe.setAttribute("data-theme", mode);
|
||||
probe.style.display = "none";
|
||||
document.body.appendChild(probe);
|
||||
try {
|
||||
return readTokens(probe);
|
||||
} finally {
|
||||
probe.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens grouped by family, preserving source order within each group.
|
||||
*
|
||||
* `overrides` maps a token name to the group it should sit under, and exists
|
||||
* because the prefix table above can only know the families that shipped with
|
||||
* the product. An install's own design system knows the groups it authored, so
|
||||
* a caller holding the record passes them here rather than the taxonomy growing
|
||||
* one operator's prefixes (rule #115).
|
||||
*/
|
||||
export function groupTokens(
|
||||
tokens: DesignToken[],
|
||||
overrides: Map<string, string> = new Map(),
|
||||
): Map<string, DesignToken[]> {
|
||||
const out = new Map<string, DesignToken[]>();
|
||||
for (const token of tokens) {
|
||||
const group = overrides.get(token.name) ?? token.group;
|
||||
const bucket = out.get(group);
|
||||
if (bucket) bucket.push(token);
|
||||
else out.set(group, [token]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens declared in the stylesheet that nothing references with `var()`.
|
||||
*
|
||||
* Dead tokens are drift too: `--chat-reading-width` and
|
||||
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
|
||||
* in the MCP-first pivot, and nothing has referenced them since. Takes the
|
||||
* corpus of source files to search as an argument so the caller decides what
|
||||
* "used" means — this module has no opinion about the project layout.
|
||||
*/
|
||||
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
|
||||
const haystack = sources.join("\n");
|
||||
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Turning a design system's RECORDED values into ones you can look at.
|
||||
*
|
||||
* Replaces `designTokens.ts` and `designDrift.ts`, which between them read the
|
||||
* running app's own stylesheet — names out of a bundled `theme.css`, values out
|
||||
* of `getComputedStyle(document.documentElement)`. That could only ever describe
|
||||
* the install serving the page, and the design surface is for the projects an
|
||||
* install TRACKS (#274). What is left here works on any system's record,
|
||||
* including one for an app this browser has never loaded.
|
||||
*
|
||||
* Nothing in this module reads the document's own tokens or mutates the page.
|
||||
*/
|
||||
|
||||
/** The base mode's key in `value_by_mode`, mirroring services/design_stylesheet. */
|
||||
export const BASE_MODE = "base";
|
||||
|
||||
/**
|
||||
* Which declared value applies in `mode`.
|
||||
*
|
||||
* Falls back to base, which is the storage model rather than a convenience: a
|
||||
* mode block is an OVERRIDE layer, so a token with no entry for the current
|
||||
* mode is not missing — it is inheriting, exactly as the generated sheet has it.
|
||||
*/
|
||||
export function valueForMode(
|
||||
valueByMode: Record<string, string>,
|
||||
mode: string,
|
||||
): string {
|
||||
const own = valueByMode[mode];
|
||||
if (own !== undefined && own !== "") return own;
|
||||
return valueByMode[BASE_MODE] ?? "";
|
||||
}
|
||||
|
||||
/** Every mode any token in the set declares, base first then the rest by name. */
|
||||
export function modesPresent(
|
||||
tokens: { value_by_mode: Record<string, string> }[],
|
||||
): string[] {
|
||||
const modes = new Set<string>();
|
||||
for (const token of tokens) {
|
||||
for (const [mode, value] of Object.entries(token.value_by_mode)) {
|
||||
if (value) modes.add(mode);
|
||||
}
|
||||
}
|
||||
const rest = [...modes].filter((m) => m !== BASE_MODE).sort();
|
||||
return modes.has(BASE_MODE) ? [BASE_MODE, ...rest] : rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve declared values the way a browser would, without applying them.
|
||||
*
|
||||
* A record holds `color-mix(in srgb, var(--fs-accent) 15%, transparent)`. Shown
|
||||
* as text that is a string; shown as a swatch it needs `var()` substituted and
|
||||
* the mix evaluated. Rather than write a CSS parser, set the declarations on an
|
||||
* offscreen probe and read them back — the substitution is done by the
|
||||
* implementation that would do it for real.
|
||||
*
|
||||
* Custom properties INHERIT, and `all: initial` does not reset them — so a probe
|
||||
* sitting in this page would resolve any reference the record leaves undeclared
|
||||
* against the surrounding app's own tokens. Previewing another project's system
|
||||
* would then quietly borrow this one's palette wherever that system was
|
||||
* incomplete, and a token the record already knows is broken (it shows up under
|
||||
* `unknown_refs`) would render as though it were fine.
|
||||
*
|
||||
* So every name referenced but not declared is blanked on the probe first. It
|
||||
* resolves to nothing, which is what the record says it is.
|
||||
*/
|
||||
const VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
|
||||
|
||||
export function resolveDeclared(declared: Map<string, string>): Map<string, string> {
|
||||
const probe = document.createElement("div");
|
||||
probe.style.display = "none";
|
||||
for (const value of declared.values()) {
|
||||
for (const match of value.matchAll(VAR_REFERENCE)) {
|
||||
if (!declared.has(match[1])) probe.style.setProperty(match[1], " ");
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Design systems — editing the stylesheet Scribe holds (milestone #254 step 5).
|
||||
* Design systems — the stylesheets this install RECORDS, for the projects it
|
||||
* tracks (milestone #254 step 5).
|
||||
*
|
||||
* Sibling of /design, which shows the system as the BROWSER has it. This page
|
||||
* shows it as the RECORD has it, which is the half you can change.
|
||||
* It had a sibling, `/design`, which showed the system as the BROWSER had it —
|
||||
* names out of a bundled stylesheet, values out of `getComputedStyle`. That
|
||||
* could only ever describe the install serving the page, so it was a mirror
|
||||
* rather than a tool and was retired (#274). Everything here works on a system
|
||||
* whose app this browser has never loaded.
|
||||
*
|
||||
* The layout follows the model rather than decorating it. A system with a
|
||||
* parent holds only what it changes, so this page has two lists and they are
|
||||
@@ -39,7 +43,6 @@ import {
|
||||
type SnippetCheck,
|
||||
type StylesheetResult,
|
||||
} from "@/api/designSystems";
|
||||
import DesignTabs from "@/components/DesignTabs.vue";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import StarterRolePicker from "@/components/StarterRolePicker.vue";
|
||||
@@ -496,7 +499,6 @@ function isColourish(value: string): boolean {
|
||||
|
||||
<template>
|
||||
<div class="ds-view">
|
||||
<DesignTabs />
|
||||
|
||||
<header class="ds-header">
|
||||
<h1>Design systems</h1>
|
||||
|
||||
@@ -1,680 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Design explorer — the gallery (milestone #251 step 3).
|
||||
*
|
||||
* Renders the design system against the tokens that are actually live, read at
|
||||
* runtime rather than parsed from source, so what you see here is what the app
|
||||
* is using right now.
|
||||
*
|
||||
* HONESTY RULE, and the reason parts of this page can say "not implemented":
|
||||
* a gallery of hand-written look-alikes drifts from the app within a month and
|
||||
* then lies — which is the same failure this whole surface exists to catch. So
|
||||
* every specimen below is either a REAL component imported from the app, or a
|
||||
* real token read from the browser, or it is explicitly marked as missing.
|
||||
*
|
||||
* Buttons WERE the case where that bit: `.btn-primary` was defined five times
|
||||
* in five `<style scoped>` blocks, all five drifted, and this page reported it
|
||||
* as a gap because drawing a look-alike would have made it a sixth copy.
|
||||
* `assets/components.css` is now the single definition (#2273), so the
|
||||
* specimens below are the app's real classes — they cannot drift from the app
|
||||
* without drifting the app itself.
|
||||
*
|
||||
* The panel at the top applies the same rule one level up: the sheet the app
|
||||
* loads is generated from a design system, and nothing checked that the app
|
||||
* ever loaded it (#2419).
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { fetchUiDesignSystem } from "@/api/design";
|
||||
import { fetchResolvedTokens, type ResolvedToken } from "@/api/designSystems";
|
||||
import DesignTabs from "@/components/DesignTabs.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import {
|
||||
compareToApp,
|
||||
rankAgreements,
|
||||
summarise,
|
||||
valueForMode,
|
||||
type Agreement,
|
||||
type RecordedToken,
|
||||
} from "@/utils/designDrift";
|
||||
import {
|
||||
groupTokens,
|
||||
readTokens,
|
||||
resolveDeclared,
|
||||
type DesignToken,
|
||||
type TokenGroup,
|
||||
} from "@/utils/designTokens";
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
const tokens = ref<DesignToken[]>([]);
|
||||
|
||||
/** The designation, and the two ways it can be absent — see api/design.ts. */
|
||||
const systemId = ref<number | null>(null);
|
||||
const systemTitle = ref<string | null>(null);
|
||||
const checkLoaded = ref(false);
|
||||
const checkFailed = ref(false);
|
||||
const showAgreeingRows = ref(false);
|
||||
|
||||
/** The record, as fetched. Kept raw because the mode narrowing has to be redone
|
||||
* whenever the theme changes — a comparison against the wrong mode's values
|
||||
* would report every mode-aware token as drift. */
|
||||
const records = ref<ResolvedToken[]>([]);
|
||||
const recorded = ref<RecordedToken[]>([]);
|
||||
const resolved = ref<Map<string, string>>(new Map());
|
||||
|
||||
/** Narrow the record to the live mode and re-read the app. Idempotent. */
|
||||
function recheck() {
|
||||
tokens.value = readTokens();
|
||||
recorded.value = records.value.map((t) => ({
|
||||
name: t.name,
|
||||
value: valueForMode(t.value_by_mode, theme.value),
|
||||
groupName: t.group_name,
|
||||
}));
|
||||
const declared = new Map<string, string>();
|
||||
for (const token of recorded.value) {
|
||||
if (token.value) declared.set(token.name, token.value);
|
||||
}
|
||||
resolved.value = resolveDeclared(declared);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read on mount, not at module scope: the values depend on the live cascade,
|
||||
* which needs the app's stylesheets applied and the theme attribute set.
|
||||
*/
|
||||
onMounted(async () => {
|
||||
recheck();
|
||||
try {
|
||||
const designation = await fetchUiDesignSystem();
|
||||
systemId.value = designation.design_system_id;
|
||||
systemTitle.value = designation.title;
|
||||
if (designation.design_system_id !== null && designation.title !== null) {
|
||||
records.value = (await fetchResolvedTokens(designation.design_system_id)).tokens;
|
||||
recheck();
|
||||
}
|
||||
} catch {
|
||||
// The gallery is useful without the panel, so a failed fetch degrades to
|
||||
// "couldn't check" rather than taking the page down with it. It says so
|
||||
// rather than showing the same empty state as "nothing designated" — that
|
||||
// conflation is what let this feature sit dead (#2419).
|
||||
checkFailed.value = true;
|
||||
} finally {
|
||||
checkLoaded.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Toggling the theme on this page is the most likely thing anyone does here.
|
||||
watch(theme, () => recheck());
|
||||
|
||||
const agreements = computed<Agreement[]>(() =>
|
||||
rankAgreements(compareToApp(recorded.value, resolved.value, tokens.value)),
|
||||
);
|
||||
const summary = computed(() => summarise(agreements.value));
|
||||
const visibleAgreements = computed(() =>
|
||||
showAgreeingRows.value
|
||||
? agreements.value
|
||||
: agreements.value.filter((a) => a.status !== "ok"),
|
||||
);
|
||||
/** Named but unvalued roles — skipped by the comparison, worth stating once. */
|
||||
const unvaluedCount = computed(
|
||||
() => records.value.filter((t) => !valueForMode(t.value_by_mode, theme.value)).length,
|
||||
);
|
||||
|
||||
const STATUS_LABEL: Record<Agreement["status"], string> = {
|
||||
absent: "not in the app",
|
||||
differs: "app renders another value",
|
||||
unrecorded: "not in the record",
|
||||
ok: "agrees",
|
||||
};
|
||||
|
||||
/* Gallery ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Groups come from the RECORD where there is one, and from the name prefix
|
||||
* otherwise. The prefix table in designTokens.ts can only know the families
|
||||
* that shipped with the product; the record knows the ones this install
|
||||
* authored, and grouping 110 tokens under "other" because a table never heard
|
||||
* of their prefix is a gallery nobody reads.
|
||||
*/
|
||||
const recordGroups = computed(() => {
|
||||
const out = new Map<string, string>();
|
||||
for (const token of records.value) {
|
||||
if (token.group_name) out.set(token.name, token.group_name);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const grouped = computed(() => groupTokens(tokens.value, recordGroups.value));
|
||||
|
||||
/** Order for the prefix-derived groups only; record groups sort by name. */
|
||||
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
|
||||
|
||||
const orderedGroups = computed(() => {
|
||||
const fromRecord = new Set(recordGroups.value.values());
|
||||
const rank = (g: string) => {
|
||||
const i = GROUP_ORDER.indexOf(g as TokenGroup);
|
||||
return i < 0 ? GROUP_ORDER.length : i;
|
||||
};
|
||||
const keys = [...grouped.value.keys()].sort((a, b) => {
|
||||
// The record's own groups lead: they are the system, and the prefix-derived
|
||||
// ones are whatever else the sheet happens to carry.
|
||||
const byOrigin = Number(!fromRecord.has(a)) - Number(!fromRecord.has(b));
|
||||
if (byOrigin !== 0) return byOrigin;
|
||||
if (fromRecord.has(a)) return a.localeCompare(b);
|
||||
return rank(a) - rank(b);
|
||||
});
|
||||
return keys.map((group) => ({ group, tokens: grouped.value.get(group)! }));
|
||||
});
|
||||
|
||||
/** A token whose value reads as a colour is worth showing as a swatch. */
|
||||
function isColourish(value: string): boolean {
|
||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
||||
}
|
||||
|
||||
/** The button family as shared classes, described by the roles they reach for. */
|
||||
const BUTTON_VARIANTS = [
|
||||
{ name: "Primary", spec: "action-primary background, text-on-action label, no border" },
|
||||
{ name: "Secondary", spec: "action-secondary background, text-on-action label, no border" },
|
||||
{ name: "Ghost", spec: "transparent, primary text, one-pixel border" },
|
||||
{ name: "Danger", spec: "action-destructive background, text-on-action label" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The type scale, read from whatever size tokens the sheet actually declares.
|
||||
*
|
||||
* Was a hand-written table of nine sizes marked "no token" — true when written,
|
||||
* and false since the scale was recorded. Deriving it from the live tokens is
|
||||
* what stops it going stale a second time: if the scale is removed, this
|
||||
* section empties out and says so.
|
||||
*/
|
||||
const sizeTokens = computed(() => tokens.value.filter((t) => /-size(-|$)/.test(t.name)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="design-view">
|
||||
<DesignTabs />
|
||||
|
||||
<header class="design-header">
|
||||
<h1>Live tokens</h1>
|
||||
<p class="lede">
|
||||
The system as it actually is. Token values are read from the browser at
|
||||
runtime, so this page reflects the live cascade rather than what the
|
||||
stylesheet says. Components shown are the real ones — where a piece of
|
||||
the system has no shared implementation, it is marked missing rather
|
||||
than mocked up.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Does the running app match the design system it was generated from? -->
|
||||
<section class="design-section">
|
||||
<h2>Agreement with the record</h2>
|
||||
|
||||
<p v-if="!checkLoaded" class="muted">
|
||||
Checking the running app against its design system…
|
||||
</p>
|
||||
|
||||
<div v-else-if="checkFailed" class="gap-notice">
|
||||
<strong>The design system couldn't be read.</strong>
|
||||
<p>
|
||||
The gallery below is still live — it comes from the browser, not the
|
||||
server — but nothing is being compared against the record right now.
|
||||
This is a failure, not an empty result.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="systemId === null" class="gap-notice">
|
||||
<strong>No design system designated for this UI.</strong>
|
||||
<p>
|
||||
This install hasn't said which design system its own interface is
|
||||
built from, so there is nothing to check the running app against.
|
||||
Designate one in <router-link to="/settings">Settings</router-link>
|
||||
and this panel will report every token the record declares that the
|
||||
app doesn't have, renders differently, or has never heard of.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="systemTitle === null" class="gap-notice">
|
||||
<strong>Design system #{{ systemId }} could not be read.</strong>
|
||||
<p>
|
||||
It is designated in <router-link to="/settings">Settings</router-link>,
|
||||
but it has since been deleted or is no longer shared with you. Nothing
|
||||
is being checked — this is a misconfiguration, not a clean result.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p class="section-note">
|
||||
<strong>{{ summary.absent }}</strong> not in the app ·
|
||||
<strong>{{ summary.differs }}</strong> rendering another value ·
|
||||
{{ summary.unrecorded }} not in the record ·
|
||||
{{ summary.ok }} agreeing, across {{ summary.total }} tokens checked
|
||||
against <strong>{{ systemTitle }}</strong> in
|
||||
<code>{{ theme }}</code> mode.
|
||||
<template v-if="unvaluedCount">
|
||||
{{ unvaluedCount }} recorded {{ unvaluedCount === 1 ? "role has" : "roles have" }}
|
||||
no value yet and {{ unvaluedCount === 1 ? "was" : "were" }} not checked.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div class="gap-notice">
|
||||
<strong>This compares the record against the TOKENS only.</strong>
|
||||
<p>
|
||||
A value hardcoded in a component — where a token should have been
|
||||
referenced — is invisible here, because the drift isn't in the
|
||||
tokens at all. Reading it would mean bundling every component's
|
||||
source into the app. That check belongs in CI and is tracked
|
||||
separately, so treat a clean panel as "the tokens agree", not "the
|
||||
app agrees".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="!agreements.length" class="muted">
|
||||
The record declares no valued tokens, so there is nothing to compare
|
||||
yet. Give its roles values and this panel starts reporting.
|
||||
</p>
|
||||
|
||||
<ul v-else class="spec-list">
|
||||
<li v-for="row in visibleAgreements" :key="row.name">
|
||||
<span class="spec-name">
|
||||
<span
|
||||
v-if="isColourish(row.live || row.recorded)"
|
||||
class="swatch"
|
||||
:style="{ background: row.live || row.recorded }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<code>{{ row.name }}</code>
|
||||
</span>
|
||||
<span class="spec-detail">
|
||||
<template v-if="row.status === 'differs'">
|
||||
record <code>{{ row.recorded }}</code> · app
|
||||
<code>{{ row.live }}</code>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'unrecorded'">
|
||||
app <code>{{ row.live }}</code>
|
||||
</template>
|
||||
<template v-else>
|
||||
record <code>{{ row.recorded }}</code>
|
||||
</template>
|
||||
<span v-if="row.groupName" class="matches"> · {{ row.groupName }}</span>
|
||||
</span>
|
||||
<span class="spec-status" :class="row.status">{{ STATUS_LABEL[row.status] }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
v-if="agreements.length && summary.ok"
|
||||
class="reveal-toggle"
|
||||
@click="showAgreeingRows = !showAgreeingRows"
|
||||
>
|
||||
{{ showAgreeingRows ? "Hide" : "Show" }} the {{ summary.ok }} agreeing tokens
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Real components: these are imported, not recreated. -->
|
||||
<section class="design-section">
|
||||
<h2>Components</h2>
|
||||
<p class="section-note">Imported from the app. What you see is what ships.</p>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Status badge</span>
|
||||
<div class="specimen-row">
|
||||
<StatusBadge status="todo" />
|
||||
<StatusBadge status="in_progress" />
|
||||
<StatusBadge status="done" />
|
||||
<StatusBadge status="cancelled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Priority badge</span>
|
||||
<div class="specimen-row">
|
||||
<PriorityBadge priority="low" />
|
||||
<PriorityBadge priority="medium" />
|
||||
<PriorityBadge priority="high" />
|
||||
<span class="muted">(<code>none</code> renders nothing, by design)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Tag pill</span>
|
||||
<div class="specimen-row">
|
||||
<TagPill tag="design-system" />
|
||||
<TagPill tag="dismissible" dismissible />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- No longer a gap: these are the app's real classes, from the shared
|
||||
sheet. Nothing here is a look-alike — change components.css and these
|
||||
specimens change with it, which is the only way this page stays true. -->
|
||||
<section class="design-section">
|
||||
<h2>Buttons</h2>
|
||||
<div class="button-specimens">
|
||||
<button class="btn-primary">Save</button>
|
||||
<button class="btn-secondary">Detect</button>
|
||||
<button class="btn-ghost">Cancel</button>
|
||||
<button class="btn-danger">Delete</button>
|
||||
<button class="btn-primary" disabled>Disabled</button>
|
||||
</div>
|
||||
<p class="spec-caption">
|
||||
Three sizes, because the app has three kinds of button: a page action, a
|
||||
row action, and an affordance that sits inside a card without disturbing
|
||||
its rhythm.
|
||||
</p>
|
||||
<div class="button-specimens">
|
||||
<button class="btn-primary">Default — page action</button>
|
||||
<button class="btn-primary btn-compact">Compact — row action</button>
|
||||
<button class="btn-primary btn-inline">Inline</button>
|
||||
</div>
|
||||
<ul class="spec-list">
|
||||
<li v-for="b in BUTTON_VARIANTS" :key="b.name">
|
||||
<span class="spec-name">{{ b.name }}</span>
|
||||
<span class="spec-detail">{{ b.spec }}</span>
|
||||
<span class="spec-status ok">shared</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Type: rendered at the sizes the sheet declares, not described. -->
|
||||
<section class="design-section">
|
||||
<h2>Type scale</h2>
|
||||
<div v-if="!sizeTokens.length" class="gap-notice">
|
||||
<strong>The scale has no tokens.</strong>
|
||||
<p>
|
||||
Nothing in the stylesheet declares a size token, so sizes are being set
|
||||
ad hoc per component. There is nothing live to show here.
|
||||
</p>
|
||||
</div>
|
||||
<ul v-else class="spec-list">
|
||||
<li v-for="t in sizeTokens" :key="t.name">
|
||||
<span class="spec-name" :style="{ fontSize: t.value }">Ag</span>
|
||||
<span class="spec-detail"><code>{{ t.name }}</code></span>
|
||||
<span class="spec-status ok">{{ t.value || "—" }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Tokens: entirely real, read live. -->
|
||||
<section v-for="{ group, tokens: groupTokenList } in orderedGroups" :key="group" class="design-section">
|
||||
<h2 class="token-group-heading">{{ group }} <span class="count">{{ groupTokenList.length }}</span></h2>
|
||||
<ul class="token-list">
|
||||
<li v-for="token in groupTokenList" :key="token.name" class="token-row">
|
||||
<span
|
||||
v-if="isColourish(token.value)"
|
||||
class="swatch"
|
||||
:style="{ background: token.value }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-else class="swatch swatch-none" aria-hidden="true" />
|
||||
<code class="token-name">{{ token.name }}</code>
|
||||
<code class="token-value">{{ token.value || "—" }}</code>
|
||||
<span v-if="token.modeAware" class="token-flag" title="Re-declared under a mode selector">
|
||||
mode-aware
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="!tokens.length" class="muted">Reading tokens…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-view {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem var(--page-padding-x) 4rem;
|
||||
}
|
||||
|
||||
.design-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 60ch;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.design-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.design-section h2 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.token-group-heading {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.section-note,
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Specimens -------------------------------------------------------------- */
|
||||
|
||||
.specimen {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.specimen-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.specimen-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Gaps ------------------------------------------------------------------- */
|
||||
|
||||
.spec-caption {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* Layout only. The buttons inside style themselves from the shared sheet —
|
||||
adding any appearance rule here would recreate the copy this section
|
||||
just stopped being. */
|
||||
.button-specimens {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-3);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gap-notice {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gap-notice p {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.spec-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spec-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spec-name {
|
||||
min-width: 8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spec-detail {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.spec-status {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* `absent` is the one status that can mean the whole sheet failed to load, so
|
||||
it carries the same weight as a high-priority finding; `differs` is a wrong
|
||||
value on screen right now; `unrecorded` is bookkeeping and reads quietest. */
|
||||
.spec-status.absent {
|
||||
background: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high);
|
||||
}
|
||||
|
||||
.spec-status.differs {
|
||||
background: var(--color-priority-medium-bg);
|
||||
color: var(--color-priority-medium);
|
||||
}
|
||||
|
||||
.spec-status.unrecorded {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.spec-status.missing {
|
||||
background: var(--color-priority-medium-bg);
|
||||
color: var(--color-priority-medium);
|
||||
}
|
||||
|
||||
.spec-status.ok {
|
||||
background: var(--color-status-done-bg);
|
||||
color: var(--color-status-done);
|
||||
}
|
||||
|
||||
.spec-name .swatch {
|
||||
vertical-align: middle;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.matches {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reveal-toggle {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.reveal-toggle:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Tokens ----------------------------------------------------------------- */
|
||||
|
||||
.token-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.swatch-none {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 3px,
|
||||
var(--color-border) 3px,
|
||||
var(--color-border) 4px
|
||||
);
|
||||
}
|
||||
|
||||
.token-name {
|
||||
min-width: 16rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.token-flag {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.token-name {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,6 @@ import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
|
||||
import { fetchDesignSystems } from "@/api/designSystems";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
@@ -32,12 +31,6 @@ const kbWritePathThreshold = ref("0.68");
|
||||
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
|
||||
// suggests a merge the operator reviews (services/dedup.py).
|
||||
const kbDuplicateThreshold = ref("0.82");
|
||||
// Which design system this install's own UI is built from, for the /design
|
||||
// agreement panel. Empty = none designated, which is the normal state for a
|
||||
// fresh install rather than a misconfiguration — the panel explains itself when
|
||||
// unset. Replaced design_rulebook_id when the rulebook was retired (#2419).
|
||||
const uiDesignSystemId = ref("");
|
||||
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||
const savingKbInject = ref(false);
|
||||
const kbInjectSaved = ref(false);
|
||||
|
||||
@@ -107,9 +100,6 @@ async function saveKbInject() {
|
||||
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
||||
kb_writepath_threshold: String(wpT),
|
||||
kb_duplicate_threshold: String(dupT),
|
||||
// Empty string DELETES the setting (see routes/settings.py), which is
|
||||
// exactly right for "no design system" — absent rather than zero.
|
||||
ui_design_system_id: uiDesignSystemId.value,
|
||||
});
|
||||
kbInjectSaved.value = true;
|
||||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||||
@@ -500,16 +490,6 @@ onMounted(async () => {
|
||||
if (allSettings.kb_duplicate_threshold !== undefined) {
|
||||
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
|
||||
}
|
||||
uiDesignSystemId.value = allSettings.ui_design_system_id ?? "";
|
||||
// Best-effort: the picker degrades to "none available" rather than blocking
|
||||
// the whole settings page if design systems can't be listed.
|
||||
try {
|
||||
designSystems.value = (await fetchDesignSystems()).design_systems.map(
|
||||
(s) => ({ id: s.id, title: s.title }),
|
||||
);
|
||||
} catch {
|
||||
designSystems.value = [];
|
||||
}
|
||||
if (allSettings.notify_task_reminders !== undefined) {
|
||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||
}
|
||||
@@ -1280,25 +1260,12 @@ function formatUserDate(iso: string): string {
|
||||
location, not by resemblance.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ui-design-system">This app's design system</label>
|
||||
<select id="ui-design-system" v-model="uiDesignSystemId" class="input" style="max-width: 22rem">
|
||||
<option value="">None — don't check the interface against a record</option>
|
||||
<option v-for="ds in designSystems" :key="ds.id" :value="String(ds.id)">
|
||||
{{ ds.title }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">
|
||||
Which design system this interface is supposed to be built from. Once
|
||||
set, the <router-link to="/design">Design</router-link> page compares
|
||||
every token the system declares against what the browser has actually
|
||||
resolved, and reports the ones the app is missing, renders differently,
|
||||
or has never heard of. That catches a stylesheet that was regenerated
|
||||
but never shipped — which the record alone cannot tell you, since the
|
||||
sheet is generated from it. Leave it as None if this install's
|
||||
interface isn't described by one of your design systems.
|
||||
</p>
|
||||
</div>
|
||||
<!-- A design system belongs to a PROJECT, and the picker for it lives on
|
||||
the project. There was a setting here that designated the system
|
||||
this install's own interface was built from; it only ever described
|
||||
the app you were already looking at, which is not what the feature
|
||||
is for (#274). -->
|
||||
|
||||
<div class="field">
|
||||
<label for="kb-duplicate-threshold">Near-duplicate report threshold</label>
|
||||
<input
|
||||
|
||||
@@ -26,7 +26,6 @@ from scribe.routes.profile import profile_bp
|
||||
from scribe.routes.knowledge import knowledge_bp
|
||||
from scribe.routes.rulebooks import rulebooks_bp
|
||||
from scribe.routes.plugin import plugin_bp
|
||||
from scribe.routes.design import design_bp
|
||||
from scribe.routes.design_systems import design_systems_bp
|
||||
from scribe.routes.trash import trash_bp
|
||||
from scribe.routes.dashboard import dashboard_bp
|
||||
@@ -91,7 +90,6 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(knowledge_bp)
|
||||
app.register_blueprint(rulebooks_bp)
|
||||
app.register_blueprint(plugin_bp)
|
||||
app.register_blueprint(design_bp)
|
||||
app.register_blueprint(design_systems_bp)
|
||||
app.register_blueprint(trash_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""This install's UI surface — which design system it claims to be built from.
|
||||
|
||||
Kept separate from the design-systems CRUD blueprint on purpose. That one is
|
||||
the RECORD: create a system, move a token, read the cascade. This one answers a
|
||||
question about the RUNNING APP, and it exists because those are not the same
|
||||
question. A design system can be a perfect record of a stylesheet the app never
|
||||
loaded.
|
||||
|
||||
The client owns the other half. `utils/designTokens.ts` reads what the browser
|
||||
actually resolved, which is the one thing no server can report, and compares it
|
||||
to what this endpoint's system declares. So the comparison is
|
||||
"does the app agree with its own sheet?" rather than "is the record
|
||||
self-consistent?", which would be a tautology — the sheet is generated from the
|
||||
record (#2419).
|
||||
"""
|
||||
from quart import Blueprint, jsonify
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.services import design_systems as ds_svc
|
||||
|
||||
design_bp = Blueprint("design", __name__, url_prefix="/api/design")
|
||||
|
||||
|
||||
@design_bp.get("/ui-system")
|
||||
@login_required
|
||||
async def get_ui_system():
|
||||
"""The design system this install designated as the source of its own UI.
|
||||
|
||||
Returns `{"design_system_id": int|null, "title": str|null}`.
|
||||
|
||||
Both nulls is the NORMAL case, not an error — an install that has not
|
||||
designated one has nothing to check the running app against, and the client
|
||||
shows an explanatory empty state (rule #115).
|
||||
|
||||
An id with a null title is the third case and the reason the id is returned
|
||||
separately: designated, but deleted or not readable by this caller. Folding
|
||||
that into "none designated" is precisely how a feature comes to render a
|
||||
reassuring empty state forever.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
system_id, system = await ds_svc.ui_design_system(uid)
|
||||
return jsonify({
|
||||
"design_system_id": system_id,
|
||||
"title": system.title if system else None,
|
||||
})
|
||||
@@ -37,7 +37,6 @@ from scribe.services.design_cascade import (
|
||||
resolve_tokens,
|
||||
would_cycle,
|
||||
)
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -135,40 +134,6 @@ async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem
|
||||
return system
|
||||
|
||||
|
||||
# Which design system this install's own UI is built from. A plain setting
|
||||
# rather than a column: no migration, discoverable in the Settings UI (rule
|
||||
# #25), and honest about being a per-install claim rather than a property of the
|
||||
# system — the same system can be the record for an app that never loads it.
|
||||
UI_DESIGN_SYSTEM_SETTING = "ui_design_system_id"
|
||||
|
||||
|
||||
async def ui_design_system(user_id: int) -> tuple[int | None, DesignSystem | None]:
|
||||
"""The design system this install says its UI is built from.
|
||||
|
||||
Returns `(id, system)`. Three outcomes, deliberately distinguishable:
|
||||
|
||||
- `(None, None)` — nothing designated. The NORMAL state for any install but
|
||||
the one that set it up (rule #115), not an error.
|
||||
- `(id, None)` — designated, but gone or not readable by this caller. A
|
||||
misconfiguration worth naming rather than silently degrading to "none",
|
||||
which is exactly the failure that orphaned the panel this feeds (#2419).
|
||||
- `(id, system)` — designated and readable.
|
||||
|
||||
A non-numeric setting value reads as nothing designated: the value is only
|
||||
ever written by a `<select>` of real ids, so garbage here means hand-edited
|
||||
or stale, and refusing to guess is better than raising on a page load.
|
||||
"""
|
||||
raw = (await get_setting(user_id, UI_DESIGN_SYSTEM_SETTING, "")).strip()
|
||||
if not raw:
|
||||
return None, None
|
||||
try:
|
||||
system_id = int(raw)
|
||||
except ValueError:
|
||||
logger.warning("Ignoring non-numeric %s: %r", UI_DESIGN_SYSTEM_SETTING, raw)
|
||||
return None, None
|
||||
return system_id, await get_design_system(user_id, system_id)
|
||||
|
||||
|
||||
async def list_design_systems(user_id: int) -> list[DesignSystem]:
|
||||
"""The caller's own systems, ordered by title. Empty is normal."""
|
||||
async with async_session() as session:
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""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