Design surface: starter roles, theme literals, and the view that could only inspect itself #97
@@ -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
|
Revision ID: 0075
|
||||||
Revises: 0074
|
Revises: 0074
|
||||||
Create Date: 2026-08-03
|
Create Date: 2026-08-03
|
||||||
|
|
||||||
The /design panel used to compare a design RULEBOOK's prose claims against the
|
Two keys, retired for the same reason a week apart, so they go in one change
|
||||||
live tokens. That rulebook was imported into the design system and retired, and
|
rather than one migration each:
|
||||||
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
|
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
|
Both named a design source for THE RUNNING INSTALL. The design surface is for
|
||||||
an inert one behind (rule #22 — remove the old path, including the setting it
|
the projects an install tracks, and a project already carries its own pointer
|
||||||
read from). The values are not translatable: a rulebook id and a design system
|
(`projects.design_system_id`) — so an install-wide designation had nothing left
|
||||||
id are ids in different tables, and guessing a mapping would silently point the
|
to mean. `ui_design_system_id` was introduced by this same migration's first
|
||||||
new panel at the wrong system.
|
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
|
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
|
table is free-form key/value, so an install that never designated one simply has
|
||||||
no row to delete.
|
no row to delete.
|
||||||
|
|
||||||
Downgrade cannot restore what it never recorded, so it is a no-op rather than a
|
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
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -33,10 +34,12 @@ down_revision = "0074"
|
|||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.execute(
|
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"),
|
component: () => import("@/views/RulesView.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Meta-surface, same family as /rules: it describes the app rather than
|
// The design systems this install RECORDS — for the projects it tracks,
|
||||||
// holding the operator's records.
|
// not for the install itself. There was a sibling `/design` that read the
|
||||||
path: "/design",
|
// running app's own stylesheet out of the browser; it could only ever
|
||||||
name: "design",
|
// inspect the instance it was served from, which made it a mirror rather
|
||||||
component: () => import("@/views/DesignView.vue"),
|
// than a tool (#274).
|
||||||
},
|
|
||||||
{
|
|
||||||
// The editable half of the same surface: /design is what the browser
|
|
||||||
// renders, /design-systems is the record that ought to decide it.
|
|
||||||
path: "/design-systems",
|
path: "/design-systems",
|
||||||
name: "design-systems",
|
name: "design-systems",
|
||||||
component: () => import("@/views/DesignSystemsView.vue"),
|
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">
|
<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
|
* It had a sibling, `/design`, which showed the system as the BROWSER had it —
|
||||||
* shows it as the RECORD has it, which is the half you can change.
|
* 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
|
* 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
|
* parent holds only what it changes, so this page has two lists and they are
|
||||||
@@ -39,7 +43,6 @@ import {
|
|||||||
type SnippetCheck,
|
type SnippetCheck,
|
||||||
type StylesheetResult,
|
type StylesheetResult,
|
||||||
} from "@/api/designSystems";
|
} from "@/api/designSystems";
|
||||||
import DesignTabs from "@/components/DesignTabs.vue";
|
|
||||||
import { ApiError } from "@/api/client";
|
import { ApiError } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import StarterRolePicker from "@/components/StarterRolePicker.vue";
|
import StarterRolePicker from "@/components/StarterRolePicker.vue";
|
||||||
@@ -496,7 +499,6 @@ function isColourish(value: string): boolean {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ds-view">
|
<div class="ds-view">
|
||||||
<DesignTabs />
|
|
||||||
|
|
||||||
<header class="ds-header">
|
<header class="ds-header">
|
||||||
<h1>Design systems</h1>
|
<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 { 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 { 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,12 +31,6 @@ 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 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 savingKbInject = ref(false);
|
||||||
const kbInjectSaved = ref(false);
|
const kbInjectSaved = ref(false);
|
||||||
|
|
||||||
@@ -107,9 +100,6 @@ async function saveKbInject() {
|
|||||||
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
||||||
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
|
|
||||||
// exactly right for "no design system" — absent rather than zero.
|
|
||||||
ui_design_system_id: uiDesignSystemId.value,
|
|
||||||
});
|
});
|
||||||
kbInjectSaved.value = true;
|
kbInjectSaved.value = true;
|
||||||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||||||
@@ -500,16 +490,6 @@ 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;
|
||||||
}
|
}
|
||||||
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) {
|
if (allSettings.notify_task_reminders !== undefined) {
|
||||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||||
}
|
}
|
||||||
@@ -1280,25 +1260,12 @@ function formatUserDate(iso: string): string {
|
|||||||
location, not by resemblance.
|
location, not by resemblance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<!-- A design system belongs to a PROJECT, and the picker for it lives on
|
||||||
<label for="ui-design-system">This app's design system</label>
|
the project. There was a setting here that designated the system
|
||||||
<select id="ui-design-system" v-model="uiDesignSystemId" class="input" style="max-width: 22rem">
|
this install's own interface was built from; it only ever described
|
||||||
<option value="">None — don't check the interface against a record</option>
|
the app you were already looking at, which is not what the feature
|
||||||
<option v-for="ds in designSystems" :key="ds.id" :value="String(ds.id)">
|
is for (#274). -->
|
||||||
{{ 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>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="kb-duplicate-threshold">Near-duplicate report threshold</label>
|
<label for="kb-duplicate-threshold">Near-duplicate report threshold</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from scribe.routes.profile import profile_bp
|
|||||||
from scribe.routes.knowledge import knowledge_bp
|
from scribe.routes.knowledge import knowledge_bp
|
||||||
from scribe.routes.rulebooks import rulebooks_bp
|
from scribe.routes.rulebooks import rulebooks_bp
|
||||||
from scribe.routes.plugin import plugin_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.design_systems import design_systems_bp
|
||||||
from scribe.routes.trash import trash_bp
|
from scribe.routes.trash import trash_bp
|
||||||
from scribe.routes.dashboard import dashboard_bp
|
from scribe.routes.dashboard import dashboard_bp
|
||||||
@@ -91,7 +90,6 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(knowledge_bp)
|
app.register_blueprint(knowledge_bp)
|
||||||
app.register_blueprint(rulebooks_bp)
|
app.register_blueprint(rulebooks_bp)
|
||||||
app.register_blueprint(plugin_bp)
|
app.register_blueprint(plugin_bp)
|
||||||
app.register_blueprint(design_bp)
|
|
||||||
app.register_blueprint(design_systems_bp)
|
app.register_blueprint(design_systems_bp)
|
||||||
app.register_blueprint(trash_bp)
|
app.register_blueprint(trash_bp)
|
||||||
app.register_blueprint(dashboard_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,
|
resolve_tokens,
|
||||||
would_cycle,
|
would_cycle,
|
||||||
)
|
)
|
||||||
from scribe.services.settings import get_setting
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -135,40 +134,6 @@ 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,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