Files
FabledGrimoire/frontend/src/views/DesignView.vue
T
bvandeusenandClaude Opus 5 5b824c1626
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
feat(design): the panel now asks whether the app agrees with its own sheet
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
2026-08-03 20:50:03 -04:00

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>