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
681 lines
21 KiB
Vue
681 lines
21 KiB
Vue
<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>
|