CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 45s
Measured before deciding: across the ~100 bespoke button rules, vertical padding does not spread — it clusters. ~27 at 0.4–0.45rem, ~28 at 0.25–0.3rem, ~23 at 0.1–0.15rem. Those are three different components that happen to share a name prefix: a page action, a row action, and an affordance that lives inside a card. Collapsing them to the single size the shared sheet had would have visibly broken every card layout, which is why the one-off migration stopped here for a decision rather than proceeding on the assumption that a button is a button. default 8px 16px page action — what the house style specifies .btn-compact 4px 12px toolbar, table row, list item controls .btn-inline 2px 4px dismiss ×, confirm tick, add-chip .btn-small and .btn-sm already sat at the compact step, so they are kept as aliases for it — no template churn, and the two spellings stop being a third thing that might drift. .btn-inline is deliberately below the spacing scale's first step on the vertical axis: 4px of padding on an 11px label already exceeds the line box these sit in. Stated in the file so it reads as a measured exception rather than someone ignoring the scale. DesignView renders all three as real specimens. A size scale described in prose is one nobody can check; rendered from the actual classes, it cannot claim something the app does not do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
546 lines
16 KiB
Vue
546 lines
16 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 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.
|
|
*/
|
|
import { computed, onMounted, ref } from "vue";
|
|
|
|
import { fetchDesignExpectations } from "@/api/design";
|
|
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 {
|
|
compareToTokens,
|
|
rankFindings,
|
|
summarise,
|
|
type Expectation,
|
|
type Finding,
|
|
} from "@/utils/designDrift";
|
|
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
|
|
|
|
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,
|
|
* which needs the app's stylesheets applied and the theme attribute set.
|
|
*/
|
|
onMounted(async () => {
|
|
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 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)! })),
|
|
);
|
|
|
|
/** 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" },
|
|
];
|
|
|
|
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" },
|
|
];
|
|
</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>
|
|
|
|
<!-- 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. -->
|
|
<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 RULEBOOK_BUTTONS" :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>
|
|
|
|
<!-- Typography: the families load, the scale does not exist as tokens. -->
|
|
<section class="design-section">
|
|
<h2>Type scale</h2>
|
|
<div class="gap-notice">
|
|
<strong>Families load; 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.
|
|
</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>
|
|
</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.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
|
|
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;
|
|
}
|
|
|
|
.spec-status {
|
|
font-size: 0.7rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
padding: 0.1rem 0.45rem;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
.spec-status.missing {
|
|
background: var(--color-priority-medium-bg);
|
|
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 ----------------------------------------------------------------- */
|
|
|
|
.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>
|