Design systems as records — the stylesheet Scribe holds, plus two live bug fixes #88
@@ -0,0 +1,9 @@
|
|||||||
|
import { apiGet } from "@/api/client";
|
||||||
|
import type { ExpectationResponse } from "@/utils/designDrift";
|
||||||
|
|
||||||
|
/** Checkable claims from the rulebook this install designated as its design system.
|
||||||
|
*
|
||||||
|
* `rulebook_id: null` means none has been designated — the normal state for a
|
||||||
|
* fresh install, not an error. The caller shows an explanatory empty state. */
|
||||||
|
export const fetchDesignExpectations = () =>
|
||||||
|
apiGet<ExpectationResponse>("/api/design/expectations");
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Drift comparison — what the rulebook claims vs what the stylesheet does.
|
||||||
|
*
|
||||||
|
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
|
||||||
|
* prose into claims) is server-side in `services/design_system.py`, where pytest
|
||||||
|
* can assert on it. What's left here is set arithmetic over live token values,
|
||||||
|
* which is the one thing the browser knows and the server doesn't.
|
||||||
|
*
|
||||||
|
* SCOPE, and it is a real limit rather than an omission. This compares the
|
||||||
|
* rulebook against the TOKENS. It cannot see the third category of drift — a
|
||||||
|
* literal hardcoded in a component where a token should be referenced (#2275,
|
||||||
|
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
|
||||||
|
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
|
||||||
|
*
|
||||||
|
* Catching it needs the component sources, which would mean bundling every SFC
|
||||||
|
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
|
||||||
|
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
|
||||||
|
* matters: a drift report that silently omits a category invites the reader to
|
||||||
|
* conclude the category is clean.
|
||||||
|
*/
|
||||||
|
import type { DesignToken } from "@/utils/designTokens";
|
||||||
|
|
||||||
|
export type ExpectationKind = "token" | "color" | "prohibited_color";
|
||||||
|
|
||||||
|
export interface Expectation {
|
||||||
|
kind: ExpectationKind;
|
||||||
|
value: string;
|
||||||
|
rule_id: number;
|
||||||
|
rule_title: string;
|
||||||
|
context: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpectationResponse {
|
||||||
|
rulebook_id: number | null;
|
||||||
|
expectations: Expectation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FindingStatus = "ok" | "missing" | "violated";
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
expectation: Expectation;
|
||||||
|
status: FindingStatus;
|
||||||
|
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
|
||||||
|
matches: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a colour for comparison — the client-side twin of
|
||||||
|
* `normalize_hex` in services/design_system.py.
|
||||||
|
*
|
||||||
|
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
|
||||||
|
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
|
||||||
|
* spellings of one colour, and a comparison that misses any of them under-reports
|
||||||
|
* rather than erroring. The rgb() case is browser-specific and therefore has no
|
||||||
|
* server-side counterpart, which is exactly why it is handled here.
|
||||||
|
*/
|
||||||
|
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 always reports colours as rgb()/rgba(), never as authored.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
|
||||||
|
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
|
||||||
|
const index = new Map<string, string[]>();
|
||||||
|
for (const token of tokens) {
|
||||||
|
const colour = normalizeColour(token.value);
|
||||||
|
if (!colour) continue;
|
||||||
|
const names = index.get(colour);
|
||||||
|
if (names) names.push(token.name);
|
||||||
|
else index.set(colour, [token.name]);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare claims against the live tokens.
|
||||||
|
*
|
||||||
|
* A `token` claim asks whether a custom property of that name exists.
|
||||||
|
* A `color` claim asks whether any token resolves to that value.
|
||||||
|
* A `prohibited_color` claim INVERTS the test — present is the failure.
|
||||||
|
*/
|
||||||
|
export function compareToTokens(
|
||||||
|
expectations: Expectation[],
|
||||||
|
tokens: DesignToken[],
|
||||||
|
): Finding[] {
|
||||||
|
const names = new Set(tokens.map((t) => t.name));
|
||||||
|
const colours = colourIndex(tokens);
|
||||||
|
|
||||||
|
return expectations.map((expectation) => {
|
||||||
|
if (expectation.kind === "token") {
|
||||||
|
const present = names.has(expectation.value);
|
||||||
|
return {
|
||||||
|
expectation,
|
||||||
|
status: present ? "ok" : "missing",
|
||||||
|
matches: present ? [expectation.value] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = colours.get(expectation.value) ?? [];
|
||||||
|
if (expectation.kind === "prohibited_color") {
|
||||||
|
return {
|
||||||
|
expectation,
|
||||||
|
status: matches.length ? "violated" : "ok",
|
||||||
|
matches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
expectation,
|
||||||
|
status: matches.length ? "ok" : "missing",
|
||||||
|
matches,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriftSummary {
|
||||||
|
ok: number;
|
||||||
|
missing: number;
|
||||||
|
violated: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarise(findings: Finding[]): DriftSummary {
|
||||||
|
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
|
||||||
|
for (const finding of findings) summary[finding.status] += 1;
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findings worth leading with.
|
||||||
|
*
|
||||||
|
* A panel that opens with every row gets closed and never reopened — the same
|
||||||
|
* principle the auto-inject menu is built on: a short list that gets read beats
|
||||||
|
* a complete one that doesn't. Violations first (something is actively wrong),
|
||||||
|
* then missing (something was never built), and `ok` rows are not "findings" at
|
||||||
|
* all — they belong behind an expansion.
|
||||||
|
*/
|
||||||
|
export function rankFindings(findings: Finding[]): Finding[] {
|
||||||
|
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
|
||||||
|
return [...findings].sort((a, b) => {
|
||||||
|
const byStatus = order[a.status] - order[b.status];
|
||||||
|
if (byStatus !== 0) return byStatus;
|
||||||
|
return a.expectation.rule_id - b.expectation.rule_id;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,21 +20,52 @@
|
|||||||
*/
|
*/
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
import { fetchDesignExpectations } from "@/api/design";
|
||||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||||
import StatusBadge from "@/components/StatusBadge.vue";
|
import StatusBadge from "@/components/StatusBadge.vue";
|
||||||
import TagPill from "@/components/TagPill.vue";
|
import TagPill from "@/components/TagPill.vue";
|
||||||
|
import {
|
||||||
|
compareToTokens,
|
||||||
|
rankFindings,
|
||||||
|
summarise,
|
||||||
|
type Expectation,
|
||||||
|
type Finding,
|
||||||
|
} from "@/utils/designDrift";
|
||||||
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
|
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
|
||||||
|
|
||||||
const tokens = ref<DesignToken[]>([]);
|
const tokens = ref<DesignToken[]>([]);
|
||||||
|
const expectations = ref<Expectation[]>([]);
|
||||||
|
const designRulebookId = ref<number | null>(null);
|
||||||
|
const driftLoaded = ref(false);
|
||||||
|
const showCleanRows = ref(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read on mount, not at module scope: the values depend on the live cascade,
|
* Read on mount, not at module scope: the values depend on the live cascade,
|
||||||
* which needs the app's stylesheets applied and the theme attribute set.
|
* which needs the app's stylesheets applied and the theme attribute set.
|
||||||
*/
|
*/
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
tokens.value = readTokens();
|
tokens.value = readTokens();
|
||||||
|
try {
|
||||||
|
const response = await fetchDesignExpectations();
|
||||||
|
designRulebookId.value = response.rulebook_id;
|
||||||
|
expectations.value = response.expectations;
|
||||||
|
} 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;
|
||||||
|
} finally {
|
||||||
|
driftLoaded.value = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const findings = computed<Finding[]>(() =>
|
||||||
|
rankFindings(compareToTokens(expectations.value, tokens.value)),
|
||||||
|
);
|
||||||
|
const driftSummary = computed(() => summarise(findings.value));
|
||||||
|
const visibleFindings = computed(() =>
|
||||||
|
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
|
||||||
|
);
|
||||||
|
|
||||||
const grouped = computed(() => groupTokens(tokens.value));
|
const grouped = computed(() => groupTokens(tokens.value));
|
||||||
|
|
||||||
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
|
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
|
||||||
@@ -81,6 +112,82 @@ const TYPE_SPECIMENS = [
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Drift: what the rulebook claims vs what the tokens do. -->
|
||||||
|
<section class="design-section">
|
||||||
|
<h2>Rulebook drift</h2>
|
||||||
|
|
||||||
|
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook…</p>
|
||||||
|
|
||||||
|
<div v-else-if="designRulebookId === null" class="gap-notice">
|
||||||
|
<strong>No design rulebook designated.</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.
|
||||||
|
</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 }}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="gap-notice">
|
||||||
|
<strong>This compares the rulebook 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="!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>
|
||||||
|
|
||||||
|
<ul v-else class="spec-list">
|
||||||
|
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
|
||||||
|
<span class="spec-name">
|
||||||
|
<span
|
||||||
|
v-if="finding.expectation.kind !== 'token'"
|
||||||
|
class="swatch"
|
||||||
|
:style="{ background: finding.expectation.value }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<code>{{ finding.expectation.value }}</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" }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="findings.length && driftSummary.ok"
|
||||||
|
class="reveal-toggle"
|
||||||
|
@click="showCleanRows = !showCleanRows"
|
||||||
|
>
|
||||||
|
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Real components: these are imported, not recreated. -->
|
<!-- Real components: these are imported, not recreated. -->
|
||||||
<section class="design-section">
|
<section class="design-section">
|
||||||
<h2>Components</h2>
|
<h2>Components</h2>
|
||||||
@@ -307,6 +414,41 @@ const TYPE_SPECIMENS = [
|
|||||||
color: var(--color-priority-medium);
|
color: var(--color-priority-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spec-status.violated {
|
||||||
|
background: var(--color-priority-high-bg);
|
||||||
|
color: var(--color-priority-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-status.ok {
|
||||||
|
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 ----------------------------------------------------------------- */
|
/* Tokens ----------------------------------------------------------------- */
|
||||||
|
|
||||||
.token-list {
|
.token-list {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
|
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
|
||||||
|
import { listRulebooks } from "@/api/rulebooks";
|
||||||
import 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";
|
||||||
@@ -31,6 +32,11 @@ const kbWritePathThreshold = ref("0.68");
|
|||||||
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
|
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
|
||||||
// suggests a merge the operator reviews (services/dedup.py).
|
// suggests a merge the operator reviews (services/dedup.py).
|
||||||
const kbDuplicateThreshold = ref("0.82");
|
const kbDuplicateThreshold = ref("0.82");
|
||||||
|
// Which rulebook describes this install's design system, for the /design drift
|
||||||
|
// 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 }[]>([]);
|
||||||
const savingKbInject = ref(false);
|
const savingKbInject = ref(false);
|
||||||
const kbInjectSaved = ref(false);
|
const kbInjectSaved = ref(false);
|
||||||
|
|
||||||
@@ -100,6 +106,9 @@ 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 rulebook" — absent rather than zero.
|
||||||
|
design_rulebook_id: designRulebookId.value,
|
||||||
});
|
});
|
||||||
kbInjectSaved.value = true;
|
kbInjectSaved.value = true;
|
||||||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||||||
@@ -490,6 +499,14 @@ onMounted(async () => {
|
|||||||
if (allSettings.kb_duplicate_threshold !== undefined) {
|
if (allSettings.kb_duplicate_threshold !== undefined) {
|
||||||
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
|
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
|
||||||
}
|
}
|
||||||
|
designRulebookId.value = allSettings.design_rulebook_id ?? "";
|
||||||
|
// Best-effort: the picker degrades to "none available" rather than blocking
|
||||||
|
// the whole settings page if rulebooks can't be listed.
|
||||||
|
try {
|
||||||
|
designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title }));
|
||||||
|
} catch {
|
||||||
|
designRulebooks.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";
|
||||||
}
|
}
|
||||||
@@ -1260,6 +1277,22 @@ function formatUserDate(iso: string): string {
|
|||||||
location, not by resemblance.
|
location, not by resemblance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 }}
|
||||||
|
</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.
|
||||||
|
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user