feat(design-explorer): the gallery, including what isn't there
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 40s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 40s
Milestone #251 step 3 (#2260). New /design view, reachable from a Palette icon beside Trash and Settings — a meta-surface like /rules, so an icon rather than a sixth primary nav link, but not hidden either, since somewhere the design system is visible is the entire point. Renders three things, and refuses to render a fourth: - REAL components, imported not recreated: StatusBadge, PriorityBadge, TagPill. - REAL tokens, read at runtime via readTokens() so the page shows the live cascade rather than what the stylesheet claims. Grouped, swatched where the value is a colour, flagged where the token is mode-aware. - Rule 65's four button variants and rule 60's type scale, listed as SPEC and marked missing. That last part is the point of the step rather than a shortfall of it. Step 3's premise was "render the real components, not copies — a gallery of look-alikes drifts from the app within a month and then lies." Buttons have no shared implementation to import: .btn-primary is defined four separate times in four <style scoped> blocks, and 30 of 54 SFCs carry their own button CSS (#2273). Drawing a button here would have made this page the fifth copy — committing the exact drift the surface exists to catch. Same for the type scale: the three families load (rule 59) but rule 60's sizes and weights are not tokens, so there is nothing to read and a rendered specimen would be invented. So the gallery reports them as gaps. A design system nobody can point at is a design system that isn't there, and saying so is more useful than a page that looks complete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import AppLogo from "@/components/AppLogo.vue";
|
import AppLogo from "@/components/AppLogo.vue";
|
||||||
import NotificationBell from "@/components/NotificationBell.vue";
|
import NotificationBell from "@/components/NotificationBell.vue";
|
||||||
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
|
import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
|
||||||
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { toggleShortcuts } = useShortcuts();
|
const { toggleShortcuts } = useShortcuts();
|
||||||
@@ -64,6 +64,13 @@ router.afterEach(() => {
|
|||||||
<Moon v-else :size="16" />
|
<Moon v-else :size="16" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Design explorer. An icon rather than a sixth primary nav link: it's a
|
||||||
|
meta-surface like Trash and Settings, but hiding it entirely would
|
||||||
|
defeat the point of having somewhere the design system is visible. -->
|
||||||
|
<router-link to="/design" class="btn-icon" aria-label="Design" title="Design">
|
||||||
|
<Palette :size="16" />
|
||||||
|
</router-link>
|
||||||
|
|
||||||
<!-- Trash link -->
|
<!-- Trash link -->
|
||||||
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
||||||
<Trash2 :size="16" />
|
<Trash2 :size="16" />
|
||||||
@@ -98,6 +105,7 @@ router.afterEach(() => {
|
|||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
|
<router-link to="/design" class="nav-link">Design</router-link>
|
||||||
<router-link to="/trash" class="nav-link">Trash</router-link>
|
<router-link to="/trash" class="nav-link">Trash</router-link>
|
||||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ const router = createRouter({
|
|||||||
name: "rules",
|
name: "rules",
|
||||||
component: () => import("@/views/RulesView.vue"),
|
component: () => import("@/views/RulesView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Meta-surface, same family as /rules: it describes the app rather than
|
||||||
|
// holding the operator's records.
|
||||||
|
path: "/design",
|
||||||
|
name: "design",
|
||||||
|
component: () => import("@/views/DesignView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/tasks",
|
path: "/tasks",
|
||||||
redirect: "/",
|
redirect: "/",
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
<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 are the case where that bites. Rule 65 specifies four variants, but
|
||||||
|
* `.btn-primary` is defined four separate times in four `<style scoped>` blocks
|
||||||
|
* and 30 of 54 SFCs carry their own button CSS (#2273). There is no shared
|
||||||
|
* button to import, so rendering one here would just make this a fifth copy.
|
||||||
|
* It is reported as a gap instead.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||||
|
import StatusBadge from "@/components/StatusBadge.vue";
|
||||||
|
import TagPill from "@/components/TagPill.vue";
|
||||||
|
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
|
||||||
|
|
||||||
|
const tokens = ref<DesignToken[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(() => {
|
||||||
|
tokens.value = readTokens();
|
||||||
|
});
|
||||||
|
|
||||||
|
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">
|
||||||
|
<header class="design-header">
|
||||||
|
<h1>Design</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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- The honest gap. -->
|
||||||
|
<section class="design-section">
|
||||||
|
<h2>Buttons</h2>
|
||||||
|
<div class="gap-notice">
|
||||||
|
<strong>Not implemented as a shared component.</strong>
|
||||||
|
<p>
|
||||||
|
Rule 65 specifies four variants. In practice <code>.btn-primary</code> is
|
||||||
|
defined four separate times in four <code><style scoped></code>
|
||||||
|
blocks, and 30 of 54 single-file components carry their own button CSS.
|
||||||
|
There is no shared button to render here, and drawing one would make
|
||||||
|
this page a fifth copy of it — the exact drift this surface exists to
|
||||||
|
catch. Tracked as issue #2273.
|
||||||
|
</p>
|
||||||
|
</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 missing">missing</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 ------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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>
|
||||||
Reference in New Issue
Block a user