feat(design): the panel now asks whether the app agrees with its own sheet
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
Retiring rulebook #2 left the /design drift panel with no data source, and because its empty state was well-written the feature read as working while it could only ever render "nothing designated" (#2419). The original question is genuinely gone: theme.css is generated from design system 2, so checking the system against a sheet derived from it would be a tautology. The question that survives is the one no server can answer. A generated sheet still has to be LOADED and APPLIED, and nothing checked that it was: absent the record declares a token the app doesn't have — the sheet was never regenerated after the record changed, or never loaded differs the app has it with another value — a stale sheet, or a later rule that overrode it unrecorded the app declares a token in the record's own family that the record has never heard of Both sides go through the same engine so the comparison is honest: declared values are set on an offscreen probe and read back, which performs the same var() substitution the browser already did to the live values. Comparing raw strings would mark every derived token as drift. The designation moved with the feature — design_rulebook_id becomes ui_design_system_id, with a migration deleting the retired key rather than leaving an inert row. The prose extractor it fed goes too (#2288 said its runtime role ended when the import landed). Three orphans of the same shape, found alongside and fixed here: - darkOverriddenNames hardcoded [data-theme="dark"]. The sheet went dark-first months ago, so it matched nothing and the "mode-aware" flag silently left the gallery. Now matches the SHAPE of a mode selector, which also holds for an install whose modes aren't light and dark. - groupFor's prefix table never heard of --fs-, so 110 tokens sat under "other". Groups now come from the record where there is one; the table can only know families that shipped with the product (rule #115). - The type scale was a hand-written table of nine sizes marked "no token", true when written and false since the scale was recorded. Now rendered from whatever size tokens the sheet declares, so it can't go stale twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
+237
-102
@@ -6,7 +6,7 @@
|
||||
* runtime rather than parsed from source, so what you see here is what the app
|
||||
* is using right now.
|
||||
*
|
||||
* HONESTY RULE, and the reason parts of this page say "not implemented":
|
||||
* HONESTY RULE, and the reason parts of this page can say "not implemented":
|
||||
* a gallery of hand-written look-alikes drifts from the app within a month and
|
||||
* then lies — which is the same failure this whole surface exists to catch. So
|
||||
* every specimen below is either a REAL component imported from the app, or a
|
||||
@@ -18,87 +18,179 @@
|
||||
* `assets/components.css` is now the single definition (#2273), so the
|
||||
* specimens below are the app's real classes — they cannot drift from the app
|
||||
* without drifting the app itself.
|
||||
*
|
||||
* The panel at the top applies the same rule one level up: the sheet the app
|
||||
* loads is generated from a design system, and nothing checked that the app
|
||||
* ever loaded it (#2419).
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { fetchDesignExpectations } from "@/api/design";
|
||||
import { fetchUiDesignSystem } from "@/api/design";
|
||||
import { fetchResolvedTokens, type ResolvedToken } from "@/api/designSystems";
|
||||
import DesignTabs from "@/components/DesignTabs.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import {
|
||||
compareToTokens,
|
||||
rankFindings,
|
||||
compareToApp,
|
||||
rankAgreements,
|
||||
summarise,
|
||||
type Expectation,
|
||||
type Finding,
|
||||
valueForMode,
|
||||
type Agreement,
|
||||
type RecordedToken,
|
||||
} from "@/utils/designDrift";
|
||||
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
|
||||
import {
|
||||
groupTokens,
|
||||
readTokens,
|
||||
resolveDeclared,
|
||||
type DesignToken,
|
||||
type TokenGroup,
|
||||
} from "@/utils/designTokens";
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
const tokens = ref<DesignToken[]>([]);
|
||||
const expectations = ref<Expectation[]>([]);
|
||||
const designRulebookId = ref<number | null>(null);
|
||||
const driftLoaded = ref(false);
|
||||
const showCleanRows = ref(false);
|
||||
|
||||
/** The designation, and the two ways it can be absent — see api/design.ts. */
|
||||
const systemId = ref<number | null>(null);
|
||||
const systemTitle = ref<string | null>(null);
|
||||
const checkLoaded = ref(false);
|
||||
const checkFailed = ref(false);
|
||||
const showAgreeingRows = ref(false);
|
||||
|
||||
/** The record, as fetched. Kept raw because the mode narrowing has to be redone
|
||||
* whenever the theme changes — a comparison against the wrong mode's values
|
||||
* would report every mode-aware token as drift. */
|
||||
const records = ref<ResolvedToken[]>([]);
|
||||
const recorded = ref<RecordedToken[]>([]);
|
||||
const resolved = ref<Map<string, string>>(new Map());
|
||||
|
||||
/** Narrow the record to the live mode and re-read the app. Idempotent. */
|
||||
function recheck() {
|
||||
tokens.value = readTokens();
|
||||
recorded.value = records.value.map((t) => ({
|
||||
name: t.name,
|
||||
value: valueForMode(t.value_by_mode, theme.value),
|
||||
groupName: t.group_name,
|
||||
}));
|
||||
const declared = new Map<string, string>();
|
||||
for (const token of recorded.value) {
|
||||
if (token.value) declared.set(token.name, token.value);
|
||||
}
|
||||
resolved.value = resolveDeclared(declared);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read on mount, not at module scope: the values depend on the live cascade,
|
||||
* which needs the app's stylesheets applied and the theme attribute set.
|
||||
*/
|
||||
onMounted(async () => {
|
||||
tokens.value = readTokens();
|
||||
recheck();
|
||||
try {
|
||||
const response = await fetchDesignExpectations();
|
||||
designRulebookId.value = response.rulebook_id;
|
||||
expectations.value = response.expectations;
|
||||
const designation = await fetchUiDesignSystem();
|
||||
systemId.value = designation.design_system_id;
|
||||
systemTitle.value = designation.title;
|
||||
if (designation.design_system_id !== null && designation.title !== null) {
|
||||
records.value = (await fetchResolvedTokens(designation.design_system_id)).tokens;
|
||||
recheck();
|
||||
}
|
||||
} catch {
|
||||
// The gallery is useful without the panel, so a failed fetch degrades to
|
||||
// "no drift data" rather than taking the page down with it.
|
||||
designRulebookId.value = null;
|
||||
// "couldn't check" rather than taking the page down with it. It says so
|
||||
// rather than showing the same empty state as "nothing designated" — that
|
||||
// conflation is what let this feature sit dead (#2419).
|
||||
checkFailed.value = true;
|
||||
} finally {
|
||||
driftLoaded.value = true;
|
||||
checkLoaded.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const findings = computed<Finding[]>(() =>
|
||||
rankFindings(compareToTokens(expectations.value, tokens.value)),
|
||||
// Toggling the theme on this page is the most likely thing anyone does here.
|
||||
watch(theme, () => recheck());
|
||||
|
||||
const agreements = computed<Agreement[]>(() =>
|
||||
rankAgreements(compareToApp(recorded.value, resolved.value, tokens.value)),
|
||||
);
|
||||
const driftSummary = computed(() => summarise(findings.value));
|
||||
const visibleFindings = computed(() =>
|
||||
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
|
||||
const summary = computed(() => summarise(agreements.value));
|
||||
const visibleAgreements = computed(() =>
|
||||
showAgreeingRows.value
|
||||
? agreements.value
|
||||
: agreements.value.filter((a) => a.status !== "ok"),
|
||||
);
|
||||
/** Named but unvalued roles — skipped by the comparison, worth stating once. */
|
||||
const unvaluedCount = computed(
|
||||
() => records.value.filter((t) => !valueForMode(t.value_by_mode, theme.value)).length,
|
||||
);
|
||||
|
||||
const grouped = computed(() => groupTokens(tokens.value));
|
||||
const STATUS_LABEL: Record<Agreement["status"], string> = {
|
||||
absent: "not in the app",
|
||||
differs: "app renders another value",
|
||||
unrecorded: "not in the record",
|
||||
ok: "agrees",
|
||||
};
|
||||
|
||||
/* Gallery ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Groups come from the RECORD where there is one, and from the name prefix
|
||||
* otherwise. The prefix table in designTokens.ts can only know the families
|
||||
* that shipped with the product; the record knows the ones this install
|
||||
* authored, and grouping 110 tokens under "other" because a table never heard
|
||||
* of their prefix is a gallery nobody reads.
|
||||
*/
|
||||
const recordGroups = computed(() => {
|
||||
const out = new Map<string, string>();
|
||||
for (const token of records.value) {
|
||||
if (token.group_name) out.set(token.name, token.group_name);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const grouped = computed(() => groupTokens(tokens.value, recordGroups.value));
|
||||
|
||||
/** Order for the prefix-derived groups only; record groups sort by name. */
|
||||
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
|
||||
const orderedGroups = computed(() =>
|
||||
GROUP_ORDER.filter((g) => grouped.value.has(g)).map((g) => ({ group: g, tokens: grouped.value.get(g)! })),
|
||||
);
|
||||
|
||||
const orderedGroups = computed(() => {
|
||||
const fromRecord = new Set(recordGroups.value.values());
|
||||
const rank = (g: string) => {
|
||||
const i = GROUP_ORDER.indexOf(g as TokenGroup);
|
||||
return i < 0 ? GROUP_ORDER.length : i;
|
||||
};
|
||||
const keys = [...grouped.value.keys()].sort((a, b) => {
|
||||
// The record's own groups lead: they are the system, and the prefix-derived
|
||||
// ones are whatever else the sheet happens to carry.
|
||||
const byOrigin = Number(!fromRecord.has(a)) - Number(!fromRecord.has(b));
|
||||
if (byOrigin !== 0) return byOrigin;
|
||||
if (fromRecord.has(a)) return a.localeCompare(b);
|
||||
return rank(a) - rank(b);
|
||||
});
|
||||
return keys.map((group) => ({ group, tokens: grouped.value.get(group)! }));
|
||||
});
|
||||
|
||||
/** A token whose value reads as a colour is worth showing as a swatch. */
|
||||
function isColourish(value: string): boolean {
|
||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
||||
}
|
||||
|
||||
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
|
||||
const RULEBOOK_BUTTONS = [
|
||||
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
|
||||
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
|
||||
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
|
||||
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
|
||||
/** The button family as shared classes, described by the roles they reach for. */
|
||||
const BUTTON_VARIANTS = [
|
||||
{ name: "Primary", spec: "action-primary background, text-on-action label, no border" },
|
||||
{ name: "Secondary", spec: "action-secondary background, text-on-action label, no border" },
|
||||
{ name: "Ghost", spec: "transparent, primary text, one-pixel border" },
|
||||
{ name: "Danger", spec: "action-destructive background, text-on-action label" },
|
||||
];
|
||||
|
||||
const TYPE_SPECIMENS = [
|
||||
{ token: "Display", spec: "40 / 500 / Fraunces" },
|
||||
{ token: "H1", spec: "32 / 500 / Fraunces" },
|
||||
{ token: "H2", spec: "24 / 500 / Fraunces" },
|
||||
{ token: "H3", spec: "18 / 500 / Inter" },
|
||||
{ token: "Body", spec: "15 / 400 / Inter" },
|
||||
{ token: "Body small", spec: "13 / 400 / Inter" },
|
||||
{ token: "Label", spec: "12 / 500 / Inter" },
|
||||
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
|
||||
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
|
||||
];
|
||||
/**
|
||||
* The type scale, read from whatever size tokens the sheet actually declares.
|
||||
*
|
||||
* Was a hand-written table of nine sizes marked "no token" — true when written,
|
||||
* and false since the scale was recorded. Deriving it from the live tokens is
|
||||
* what stops it going stale a second time: if the scale is removed, this
|
||||
* section empties out and says so.
|
||||
*/
|
||||
const sizeTokens = computed(() => tokens.value.filter((t) => /-size(-|$)/.test(t.name)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -116,78 +208,108 @@ const TYPE_SPECIMENS = [
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Drift: what the rulebook claims vs what the tokens do. -->
|
||||
<!-- Does the running app match the design system it was generated from? -->
|
||||
<section class="design-section">
|
||||
<h2>Rulebook drift</h2>
|
||||
<h2>Agreement with the record</h2>
|
||||
|
||||
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook…</p>
|
||||
<p v-if="!checkLoaded" class="muted">
|
||||
Checking the running app against its design system…
|
||||
</p>
|
||||
|
||||
<div v-else-if="designRulebookId === null" class="gap-notice">
|
||||
<strong>No design rulebook designated.</strong>
|
||||
<div v-else-if="checkFailed" class="gap-notice">
|
||||
<strong>The design system couldn't be read.</strong>
|
||||
<p>
|
||||
This install hasn't said which rulebook describes its design system, so
|
||||
there is nothing to check the tokens against. Designate one in
|
||||
<router-link to="/settings">Settings</router-link> and this panel will
|
||||
compare every colour and token the rulebook names against what the
|
||||
stylesheet actually resolves to.
|
||||
The gallery below is still live — it comes from the browser, not the
|
||||
server — but nothing is being compared against the record right now.
|
||||
This is a failure, not an empty result.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="systemId === null" class="gap-notice">
|
||||
<strong>No design system designated for this UI.</strong>
|
||||
<p>
|
||||
This install hasn't said which design system its own interface is
|
||||
built from, so there is nothing to check the running app against.
|
||||
Designate one in <router-link to="/settings">Settings</router-link>
|
||||
and this panel will report every token the record declares that the
|
||||
app doesn't have, renders differently, or has never heard of.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="systemTitle === null" class="gap-notice">
|
||||
<strong>Design system #{{ systemId }} could not be read.</strong>
|
||||
<p>
|
||||
It is designated in <router-link to="/settings">Settings</router-link>,
|
||||
but it has since been deleted or is no longer shared with you. Nothing
|
||||
is being checked — this is a misconfiguration, not a clean result.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p class="section-note">
|
||||
<strong>{{ driftSummary.violated }}</strong> violated ·
|
||||
<strong>{{ driftSummary.missing }}</strong> missing ·
|
||||
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
|
||||
claims in rulebook #{{ designRulebookId }}.
|
||||
<strong>{{ summary.absent }}</strong> not in the app ·
|
||||
<strong>{{ summary.differs }}</strong> rendering another value ·
|
||||
{{ summary.unrecorded }} not in the record ·
|
||||
{{ summary.ok }} agreeing, across {{ summary.total }} tokens checked
|
||||
against <strong>{{ systemTitle }}</strong> in
|
||||
<code>{{ theme }}</code> mode.
|
||||
<template v-if="unvaluedCount">
|
||||
{{ unvaluedCount }} recorded {{ unvaluedCount === 1 ? "role has" : "roles have" }}
|
||||
no value yet and {{ unvaluedCount === 1 ? "was" : "were" }} not checked.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div class="gap-notice">
|
||||
<strong>This compares the rulebook against the TOKENS only.</strong>
|
||||
<strong>This compares the record against the TOKENS only.</strong>
|
||||
<p>
|
||||
A value hardcoded in a component — where a token should have been
|
||||
referenced — is invisible here, because the drift isn't in the tokens
|
||||
at all. Reading it would mean bundling every component's source into
|
||||
the app. That check belongs in CI and is tracked separately, so treat
|
||||
a clean panel as "the tokens agree", not "the app agrees".
|
||||
referenced — is invisible here, because the drift isn't in the
|
||||
tokens at all. Reading it would mean bundling every component's
|
||||
source into the app. That check belongs in CI and is tracked
|
||||
separately, so treat a clean panel as "the tokens agree", not "the
|
||||
app agrees".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="!findings.length" class="muted">
|
||||
The rulebook names nothing this panel can check. Rules that state values
|
||||
— colours, token names — produce claims; rules that state judgement
|
||||
don't, by design.
|
||||
<p v-if="!agreements.length" class="muted">
|
||||
The record declares no valued tokens, so there is nothing to compare
|
||||
yet. Give its roles values and this panel starts reporting.
|
||||
</p>
|
||||
|
||||
<ul v-else class="spec-list">
|
||||
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
|
||||
<li v-for="row in visibleAgreements" :key="row.name">
|
||||
<span class="spec-name">
|
||||
<span
|
||||
v-if="finding.expectation.kind !== 'token'"
|
||||
v-if="isColourish(row.live || row.recorded)"
|
||||
class="swatch"
|
||||
:style="{ background: finding.expectation.value }"
|
||||
:style="{ background: row.live || row.recorded }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<code>{{ finding.expectation.value }}</code>
|
||||
<code>{{ row.name }}</code>
|
||||
</span>
|
||||
<span class="spec-detail">
|
||||
rule #{{ finding.expectation.rule_id }} — {{ finding.expectation.rule_title }}
|
||||
<span v-if="finding.matches.length" class="matches">
|
||||
· {{ finding.matches.join(", ") }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="spec-status" :class="finding.status">
|
||||
{{ finding.status === "violated" ? "forbidden, but present"
|
||||
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
|
||||
<template v-if="row.status === 'differs'">
|
||||
record <code>{{ row.recorded }}</code> · app
|
||||
<code>{{ row.live }}</code>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'unrecorded'">
|
||||
app <code>{{ row.live }}</code>
|
||||
</template>
|
||||
<template v-else>
|
||||
record <code>{{ row.recorded }}</code>
|
||||
</template>
|
||||
<span v-if="row.groupName" class="matches"> · {{ row.groupName }}</span>
|
||||
</span>
|
||||
<span class="spec-status" :class="row.status">{{ STATUS_LABEL[row.status] }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
v-if="findings.length && driftSummary.ok"
|
||||
v-if="agreements.length && summary.ok"
|
||||
class="reveal-toggle"
|
||||
@click="showCleanRows = !showCleanRows"
|
||||
@click="showAgreeingRows = !showAgreeingRows"
|
||||
>
|
||||
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
|
||||
{{ showAgreeingRows ? "Hide" : "Show" }} the {{ summary.ok }} agreeing tokens
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
@@ -249,7 +371,7 @@ const TYPE_SPECIMENS = [
|
||||
<button class="btn-primary btn-inline">Inline</button>
|
||||
</div>
|
||||
<ul class="spec-list">
|
||||
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
|
||||
<li v-for="b in BUTTON_VARIANTS" :key="b.name">
|
||||
<span class="spec-name">{{ b.name }}</span>
|
||||
<span class="spec-detail">{{ b.spec }}</span>
|
||||
<span class="spec-status ok">shared</span>
|
||||
@@ -257,23 +379,21 @@ const TYPE_SPECIMENS = [
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Typography: the families load, the scale does not exist as tokens. -->
|
||||
<!-- Type: rendered at the sizes the sheet declares, not described. -->
|
||||
<section class="design-section">
|
||||
<h2>Type scale</h2>
|
||||
<div class="gap-notice">
|
||||
<strong>Families load; the scale has no tokens.</strong>
|
||||
<div v-if="!sizeTokens.length" class="gap-notice">
|
||||
<strong>The scale has no tokens.</strong>
|
||||
<p>
|
||||
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
|
||||
scale is not expressed as custom properties, so sizes and weights are
|
||||
set ad hoc per component. Listed here as specification, not as a live
|
||||
specimen — there is nothing to read.
|
||||
Nothing in the stylesheet declares a size token, so sizes are being set
|
||||
ad hoc per component. There is nothing live to show here.
|
||||
</p>
|
||||
</div>
|
||||
<ul class="spec-list">
|
||||
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
|
||||
<span class="spec-name">{{ t.token }}</span>
|
||||
<span class="spec-detail">{{ t.spec }}</span>
|
||||
<span class="spec-status missing">no token</span>
|
||||
<ul v-else class="spec-list">
|
||||
<li v-for="t in sizeTokens" :key="t.name">
|
||||
<span class="spec-name" :style="{ fontSize: t.value }">Ag</span>
|
||||
<span class="spec-detail"><code>{{ t.name }}</code></span>
|
||||
<span class="spec-status ok">{{ t.value || "—" }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -292,7 +412,7 @@ const TYPE_SPECIMENS = [
|
||||
<span v-else class="swatch swatch-none" aria-hidden="true" />
|
||||
<code class="token-name">{{ token.name }}</code>
|
||||
<code class="token-value">{{ token.value || "—" }}</code>
|
||||
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
|
||||
<span v-if="token.modeAware" class="token-flag" title="Re-declared under a mode selector">
|
||||
mode-aware
|
||||
</span>
|
||||
</li>
|
||||
@@ -430,6 +550,7 @@ const TYPE_SPECIMENS = [
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.spec-status {
|
||||
@@ -438,6 +559,25 @@ const TYPE_SPECIMENS = [
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* `absent` is the one status that can mean the whole sheet failed to load, so
|
||||
it carries the same weight as a high-priority finding; `differs` is a wrong
|
||||
value on screen right now; `unrecorded` is bookkeeping and reads quietest. */
|
||||
.spec-status.absent {
|
||||
background: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high);
|
||||
}
|
||||
|
||||
.spec-status.differs {
|
||||
background: var(--color-priority-medium-bg);
|
||||
color: var(--color-priority-medium);
|
||||
}
|
||||
|
||||
.spec-status.unrecorded {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.spec-status.missing {
|
||||
@@ -445,11 +585,6 @@ const TYPE_SPECIMENS = [
|
||||
color: var(--color-priority-medium);
|
||||
}
|
||||
|
||||
.spec-status.violated {
|
||||
background: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high);
|
||||
}
|
||||
|
||||
.spec-status.ok {
|
||||
background: var(--color-status-done-bg);
|
||||
color: var(--color-status-done);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
|
||||
import { listRulebooks } from "@/api/rulebooks";
|
||||
import { fetchDesignSystems } from "@/api/designSystems";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
@@ -32,11 +32,12 @@ const kbWritePathThreshold = ref("0.68");
|
||||
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
|
||||
// suggests a merge the operator reviews (services/dedup.py).
|
||||
const kbDuplicateThreshold = ref("0.82");
|
||||
// Which rulebook describes this install's design system, for the /design drift
|
||||
// panel. Empty = none designated, which is the normal state for a fresh install
|
||||
// rather than a misconfiguration — the panel explains itself when unset.
|
||||
const designRulebookId = ref("");
|
||||
const designRulebooks = ref<{ id: number; title: string }[]>([]);
|
||||
// Which design system this install's own UI is built from, for the /design
|
||||
// agreement panel. Empty = none designated, which is the normal state for a
|
||||
// fresh install rather than a misconfiguration — the panel explains itself when
|
||||
// unset. Replaced design_rulebook_id when the rulebook was retired (#2419).
|
||||
const uiDesignSystemId = ref("");
|
||||
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||
const savingKbInject = ref(false);
|
||||
const kbInjectSaved = ref(false);
|
||||
|
||||
@@ -107,8 +108,8 @@ async function saveKbInject() {
|
||||
kb_writepath_threshold: String(wpT),
|
||||
kb_duplicate_threshold: String(dupT),
|
||||
// Empty string DELETES the setting (see routes/settings.py), which is
|
||||
// exactly right for "no design rulebook" — absent rather than zero.
|
||||
design_rulebook_id: designRulebookId.value,
|
||||
// exactly right for "no design system" — absent rather than zero.
|
||||
ui_design_system_id: uiDesignSystemId.value,
|
||||
});
|
||||
kbInjectSaved.value = true;
|
||||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||||
@@ -499,13 +500,15 @@ onMounted(async () => {
|
||||
if (allSettings.kb_duplicate_threshold !== undefined) {
|
||||
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
|
||||
}
|
||||
designRulebookId.value = allSettings.design_rulebook_id ?? "";
|
||||
uiDesignSystemId.value = allSettings.ui_design_system_id ?? "";
|
||||
// Best-effort: the picker degrades to "none available" rather than blocking
|
||||
// the whole settings page if rulebooks can't be listed.
|
||||
// the whole settings page if design systems can't be listed.
|
||||
try {
|
||||
designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title }));
|
||||
designSystems.value = (await fetchDesignSystems()).design_systems.map(
|
||||
(s) => ({ id: s.id, title: s.title }),
|
||||
);
|
||||
} catch {
|
||||
designRulebooks.value = [];
|
||||
designSystems.value = [];
|
||||
}
|
||||
if (allSettings.notify_task_reminders !== undefined) {
|
||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||
@@ -1278,19 +1281,22 @@ function formatUserDate(iso: string): string {
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="design-rulebook">Design-system rulebook</label>
|
||||
<select id="design-rulebook" v-model="designRulebookId" class="input" style="max-width: 22rem">
|
||||
<option value="">None — don't check for design drift</option>
|
||||
<option v-for="rb in designRulebooks" :key="rb.id" :value="String(rb.id)">
|
||||
{{ rb.title }}
|
||||
<label for="ui-design-system">This app's design system</label>
|
||||
<select id="ui-design-system" v-model="uiDesignSystemId" class="input" style="max-width: 22rem">
|
||||
<option value="">None — don't check the interface against a record</option>
|
||||
<option v-for="ds in designSystems" :key="ds.id" :value="String(ds.id)">
|
||||
{{ ds.title }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">
|
||||
Which rulebook describes how this app should look. Once set, the
|
||||
<router-link to="/design">Design</router-link> page compares every colour
|
||||
and token your rules name against what the stylesheet actually resolves
|
||||
to, and reports where they disagree. Leave it as None if your rules
|
||||
don't describe a design system — nothing else depends on this.
|
||||
Which design system this interface is supposed to be built from. Once
|
||||
set, the <router-link to="/design">Design</router-link> page compares
|
||||
every token the system declares against what the browser has actually
|
||||
resolved, and reports the ones the app is missing, renders differently,
|
||||
or has never heard of. That catches a stylesheet that was regenerated
|
||||
but never shipped — which the record alone cannot tell you, since the
|
||||
sheet is generated from it. Leave it as None if this install's
|
||||
interface isn't described by one of your design systems.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
||||
Reference in New Issue
Block a user