Files
FabledScribe/frontend/src/views/DesignSystemsView.vue
T
bvandeusen 473280e690
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 41s
chore(design-systems): stop teaching one install's kit in product copy
The operator's check: this must be a system for managing design systems, not one
with the FabledSword family built into it.

No LOGIC was coupled — the audit found zero behavioural dependencies. But every
docstring example, every UI placeholder and several comments named this install's
palette, so a stranger creating their first design system was shown
"FabledSword" as the expected shape and `--fs-obsidian` as the expected token.
Examples teach, and these taught the wrong thing.

Placeholders now describe the SHAPE ("Your house style", "--surface-page")
rather than naming one instance's contents, and the token-name placeholder now
says the thing worth saying: name it for its purpose, because `--obsidian` and
`--button-bg` both stop being true the moment the value or the element changes.

Not fixed here, and it is the one real coupling left: DesignView.vue hardcodes
rule 65's button variants and rule 60's type scale as literal arrays, so a
stranger's Design page would display this family's specs. Those arrays exist
because there was no design system to read from — which there now is. They go
when the panel is repointed (#2295), not before.

Scribe's own stylesheet comments ("Moss action-primary per Hybrid") are left
alone: that is the app CONSUMING the family style, which is what dogfooding
looks like, not the tool assuming it.
2026-07-31 10:18:53 -04:00

1576 lines
49 KiB
Vue

<script setup lang="ts">
/**
* Design systems — editing the stylesheet Scribe holds (milestone #254 step 5).
*
* Sibling of /design, which shows the system as the BROWSER has it. This page
* shows it as the RECORD has it, which is the half you can change.
*
* The layout follows the model rather than decorating it. A system with a
* parent holds only what it changes, so this page has two lists and they are
* deliberately different:
*
* Overrides — this system's own rows. Short by design. Empty is correct for
* an app that hasn't departed from its family yet.
* Effective — what it resolves to once inheritance is applied, each row
* labelled with where its value came from.
*
* Provenance is shown PER MODE, not per token, because overriding is per mode:
* a system can own `base` and inherit `dark` at the same time. A single badge
* per row would have to lie about one of them, so a row whose modes disagree
* breaks out into one line each.
*/
import { computed, onMounted, ref, watch } from "vue";
import {
createDesignSystem,
createDesignToken,
deleteDesignSystem,
deleteDesignToken,
fetchDesignSystems,
fetchDesignTokens,
fetchResolvedTokens,
checkSnippets,
fetchStylesheet,
updateDesignSystem,
updateDesignToken,
type DesignSystem,
type DesignToken,
type ResolvedToken,
type SnippetCheck,
type StylesheetResult,
} from "@/api/designSystems";
import DesignTabs from "@/components/DesignTabs.vue";
import { ApiError } from "@/api/client";
import { useToastStore } from "@/stores/toast";
const toast = useToastStore();
const systems = ref<DesignSystem[]>([]);
const selectedId = ref<number | null>(null);
const ownTokens = ref<DesignToken[]>([]);
const resolved = ref<ResolvedToken[]>([]);
const loading = ref(true);
const loadError = ref(false);
const detailLoading = ref(false);
const selected = computed(() => systems.value.find((s) => s.id === selectedId.value) ?? null);
const byId = computed(() => new Map(systems.value.map((s) => [s.id, s])));
function systemTitle(id: number): string {
return byId.value.get(id)?.title ?? `system #${id}`;
}
/**
* The chain from the selected system up to its root, nearest first.
*
* Defensive visited-set, matching the server's: writes refuse to create a
* cycle, but a loop reaching this page must render a truncated chain rather
* than freeze the tab.
*/
const chain = computed<DesignSystem[]>(() => {
const out: DesignSystem[] = [];
const seen = new Set<number>();
let current = selected.value;
while (current && !seen.has(current.id)) {
seen.add(current.id);
out.push(current);
current = current.parent_id ? byId.value.get(current.parent_id) ?? null : null;
}
return out;
});
/** Candidates for "inherit from", minus anything that would close a loop.
*
* The server refuses these anyway (with a message that says why). Filtering
* them out of the picker means the operator never has to read that message —
* a refusal you can't trigger beats a refusal explained well. */
const parentOptions = computed(() => {
if (!selected.value) return [];
const descendants = new Set<number>([selected.value.id]);
let grew = true;
while (grew) {
grew = false;
for (const s of systems.value) {
if (s.parent_id !== null && descendants.has(s.parent_id) && !descendants.has(s.id)) {
descendants.add(s.id);
grew = true;
}
}
}
return systems.value.filter((s) => !descendants.has(s.id));
});
async function loadSystems() {
loading.value = true;
loadError.value = false;
try {
const data = await fetchDesignSystems();
systems.value = data.design_systems;
if (selectedId.value && !systems.value.some((s) => s.id === selectedId.value)) {
selectedId.value = null;
}
if (!selectedId.value && systems.value.length) {
selectedId.value = systems.value[0].id;
}
} catch {
loadError.value = true;
} finally {
loading.value = false;
}
}
async function loadDetail(id: number) {
detailLoading.value = true;
try {
const [own, eff] = await Promise.all([fetchDesignTokens(id), fetchResolvedTokens(id)]);
ownTokens.value = own.tokens;
resolved.value = eff.tokens;
} catch {
ownTokens.value = [];
resolved.value = [];
toast.show("Failed to load tokens", "error");
} finally {
detailLoading.value = false;
}
}
onMounted(loadSystems);
watch(selectedId, (id) => {
ownTokens.value = [];
resolved.value = [];
if (id !== null) loadDetail(id);
});
// --- creating a system ------------------------------------------------------
const showCreate = ref(false);
const newTitle = ref("");
const newDescription = ref("");
const newParentId = ref<number | null>(null);
const creating = ref(false);
async function submitCreate() {
const title = newTitle.value.trim();
if (!title || creating.value) return;
creating.value = true;
try {
const created = await createDesignSystem({
title,
description: newDescription.value.trim() || undefined,
parent_id: newParentId.value,
});
newTitle.value = "";
newDescription.value = "";
newParentId.value = null;
showCreate.value = false;
await loadSystems();
selectedId.value = created.id;
toast.show(`Created ${created.title}`);
} catch {
toast.show("Failed to create design system", "error");
} finally {
creating.value = false;
}
}
// --- editing the selected system --------------------------------------------
const editTitle = ref("");
const editDescription = ref("");
const editGuidance = ref("");
const editParentId = ref<number | null>(null);
const savingSystem = ref(false);
watch(
selected,
(system) => {
editTitle.value = system?.title ?? "";
editDescription.value = system?.description ?? "";
editGuidance.value = system?.guidance ?? "";
editParentId.value = system?.parent_id ?? null;
},
{ immediate: true },
);
const systemDirty = computed(() => {
const system = selected.value;
if (!system) return false;
return (
editTitle.value !== system.title ||
editDescription.value !== (system.description ?? "") ||
editGuidance.value !== (system.guidance ?? "") ||
editParentId.value !== system.parent_id
);
});
async function saveSystem() {
const system = selected.value;
if (!system || savingSystem.value) return;
savingSystem.value = true;
try {
// parent_id is always sent: null is a real value here ("become a family
// system"), so omitting it when empty would make that edit impossible.
await updateDesignSystem(system.id, {
title: editTitle.value.trim(),
description: editDescription.value.trim(),
guidance: editGuidance.value.trim(),
parent_id: editParentId.value,
});
await loadSystems();
if (selectedId.value) await loadDetail(selectedId.value);
toast.show("Design system saved");
} catch (err) {
// A cycle comes back as a 400 with a message that names the loop. Show it
// rather than a generic failure — it tells the operator what to change.
const message =
err instanceof ApiError && err.status === 400 ? err.message : "Failed to save";
toast.show(message, "error");
} finally {
savingSystem.value = false;
}
}
const confirmingDelete = ref(false);
async function removeSystem() {
const system = selected.value;
if (!system) return;
try {
await deleteDesignSystem(system.id);
confirmingDelete.value = false;
selectedId.value = null;
await loadSystems();
toast.show(`Deleted ${system.title}`);
} catch {
toast.show("Failed to delete", "error");
}
}
// --- tokens -----------------------------------------------------------------
const showAddToken = ref(false);
const tokenName = ref("");
const tokenGroup = ref("");
const tokenPurpose = ref("");
/** Why the token is this value — a different question from what it is for. */
const tokenRationale = ref("");
/** Mode/value pairs, edited as a list so a token can carry any number of modes. */
const tokenModes = ref<{ mode: string; value: string }[]>([{ mode: "base", value: "" }]);
/** Literals this token should be written instead of, comma-separated in the
* form. Kept as one text field rather than a repeater: it is a short list of
* literals, and a row-per-value UI would cost more than it explains. */
const tokenSupersedes = ref("");
const savingToken = ref(false);
const editingTokenId = ref<number | null>(null);
function resetTokenForm() {
tokenName.value = "";
tokenGroup.value = "";
tokenPurpose.value = "";
tokenRationale.value = "";
tokenModes.value = [{ mode: "base", value: "" }];
tokenSupersedes.value = "";
editingTokenId.value = null;
showAddToken.value = false;
}
function startEditToken(token: DesignToken) {
editingTokenId.value = token.id;
tokenName.value = token.name;
tokenGroup.value = token.group_name ?? "";
tokenPurpose.value = token.purpose ?? "";
tokenRationale.value = token.rationale ?? "";
tokenSupersedes.value = (token.supersedes ?? []).join(", ");
const entries = Object.entries(token.value_by_mode);
tokenModes.value = entries.length
? entries.map(([mode, value]) => ({ mode, value }))
: [{ mode: "base", value: "" }];
showAddToken.value = true;
}
function collectModes(): Record<string, string> {
const out: Record<string, string> = {};
for (const row of tokenModes.value) {
const mode = row.mode.trim();
if (mode) out[mode] = row.value;
}
return out;
}
async function submitToken() {
const name = tokenName.value.trim();
if (!name || selectedId.value === null || savingToken.value) return;
savingToken.value = true;
try {
const body = {
name,
value_by_mode: collectModes(),
group_name: tokenGroup.value.trim() || null,
purpose: tokenPurpose.value.trim() || null,
rationale: tokenRationale.value.trim() || null,
supersedes: tokenSupersedes.value
.split(",")
.map((v) => v.trim())
.filter(Boolean),
};
if (editingTokenId.value !== null) {
await updateDesignToken(editingTokenId.value, body);
} else {
await createDesignToken(selectedId.value, body);
}
resetTokenForm();
await loadDetail(selectedId.value);
toast.show("Token saved");
} catch {
toast.show("Failed to save token", "error");
} finally {
savingToken.value = false;
}
}
async function removeToken(token: DesignToken) {
if (selectedId.value === null) return;
try {
await deleteDesignToken(token.id);
if (editingTokenId.value === token.id) resetTokenForm();
await loadDetail(selectedId.value);
// Naming the consequence rather than the action: in a system with a parent
// this is not a removal, it's a return to the inherited value.
toast.show(
selected.value?.parent_id
? `${token.name} now inherits again`
: `Removed ${token.name}`,
);
} catch {
toast.show("Failed to remove token", "error");
}
}
// --- the effective set ------------------------------------------------------
interface ModeOrigin {
mode: string;
value: string;
systemId: number;
shadowed: number;
}
/** Per-mode provenance for one resolved token, ordered with `base` first. */
function modeOrigins(token: ResolvedToken): ModeOrigin[] {
return Object.keys(token.contributions)
.sort((a, b) => (a === "base" ? -1 : b === "base" ? 1 : a.localeCompare(b)))
.map((mode) => {
const entries = token.contributions[mode];
return {
mode,
value: entries[0]?.value ?? "",
systemId: entries[0]?.system_id ?? 0,
shadowed: Math.max(entries.length - 1, 0),
};
});
}
/** True when every mode of a token came from the same system — the common case,
* and the one where a single badge tells the whole truth. */
function hasUniformOrigin(token: ResolvedToken): boolean {
const origins = new Set(Object.values(token.origin_by_mode));
return origins.size <= 1;
}
function originLabel(systemId: number): string {
return systemId === selectedId.value ? "here" : systemTitle(systemId);
}
const effectiveGroups = computed(() => {
const groups = new Map<string, ResolvedToken[]>();
for (const token of resolved.value) {
const key = token.group_name ?? "ungrouped";
const bucket = groups.get(key);
if (bucket) bucket.push(token);
else groups.set(key, [token]);
}
return [...groups.entries()];
});
const overrideCount = computed(
() => resolved.value.filter((t) => Object.values(t.origin_by_mode).some((id) => id === selectedId.value)).length,
);
// --- the master sheet -------------------------------------------------------
const sheet = ref<StylesheetResult | null>(null);
const sheetLoading = ref(false);
const showSheet = ref(false);
async function loadSheet() {
if (selectedId.value === null) return;
sheetLoading.value = true;
try {
sheet.value = await fetchStylesheet(selectedId.value);
} catch {
sheet.value = null;
toast.show("Failed to render the stylesheet", "error");
} finally {
sheetLoading.value = false;
}
}
/** Toggle, and render on first open rather than on mount — the sheet is a
* derived view most visits don't need, and rendering it unasked would cost a
* round trip per system selection. */
function toggleSheet() {
showSheet.value = !showSheet.value;
if (showSheet.value && !sheet.value) loadSheet();
}
async function copySheet() {
if (!sheet.value) return;
try {
await navigator.clipboard.writeText(sheet.value.css);
toast.show("Stylesheet copied");
} catch {
toast.show("Couldn't copy — select the text instead", "error");
}
}
/** Re-render whenever the tokens behind it change, so the sheet on screen is
* never a stale answer to a question the operator has already moved past. */
watch([selectedId, ownTokens], () => {
sheet.value = null;
if (showSheet.value) loadSheet();
});
const duplicateEntries = computed(() => Object.entries(sheet.value?.duplicates ?? {}));
/** Formulas whose source token doesn't exist. The browser drops the whole
* declaration — invalid at computed-value time — so nothing errors and the
* token simply has no value. Led with, because it is the only entry here that
* is unambiguously broken rather than a judgement call. */
const brokenFormulas = computed(() =>
Object.entries(sheet.value?.derivation.unknown_refs ?? {}),
);
const derivedEntries = computed(() => Object.entries(sheet.value?.derivation.derived ?? {}));
const derivationCycles = computed(() => sheet.value?.derivation.cycles ?? []);
// --- do the snippets use the sheet? -----------------------------------------
const snippetCheck = ref<SnippetCheck | null>(null);
const checkingSnippets = ref(false);
/** The component layer checked against the sheet.
*
* Run on demand rather than on open: it reads every snippet's full body, which
* is a real cost, and the answer only changes when the sheet or the snippets
* do. */
async function runSnippetCheck() {
if (selectedId.value === null || checkingSnippets.value) return;
checkingSnippets.value = true;
try {
snippetCheck.value = await checkSnippets(selectedId.value);
} catch {
snippetCheck.value = null;
toast.show("Couldn't check the snippets", "error");
} finally {
checkingSnippets.value = false;
}
}
watch(selectedId, () => {
snippetCheck.value = null;
});
function isColourish(value: string): boolean {
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
}
</script>
<template>
<div class="ds-view">
<DesignTabs />
<header class="ds-header">
<h1>Design systems</h1>
<p class="lede">
The design system as the record holds it. A system with a parent stores
only what it changes, so its own list is the answer to "what does this
one alter?" nothing to compute.
</p>
</header>
<p v-if="loading" class="muted">Loading design systems</p>
<div v-else-if="loadError" class="notice notice-warn">
<strong>Couldn't load design systems.</strong>
<p>The request failed. Reload the page to try again.</p>
</div>
<!-- Empty is the ordinary state for most installs, not a broken one. -->
<div v-else-if="!systems.length && !showCreate" class="notice">
<strong>No design systems yet.</strong>
<p>
A design system holds your tokens — colours, spacing, radii — as records
rather than as prose. Make one for the house style, then one per app that
inherits from it and stores only its differences.
</p>
<button class="btn-primary" @click="showCreate = true">Create the first one</button>
</div>
<!-- The FIRST system gets its own form, outside the list layout below.
`.ds-body` and the empty state are mutually exclusive branches, so a
form nested in the former is unreachable from the latter — which is
exactly the dead button this replaces. Once a system exists, the form
inside `.ds-body` takes over and this branch never renders. -->
<section v-else-if="!systems.length" class="ds-section">
<h2>New design system</h2>
<p class="section-note">
Nothing to inherit from yet, so this one is a family system — the house
style everything else departs from.
</p>
<div class="field">
<label class="field-label" for="first-title">Title</label>
<input
id="first-title" v-model="newTitle" class="input" type="text"
placeholder="Your house style" @keyup.enter="submitCreate"
/>
</div>
<div class="field">
<label class="field-label" for="first-desc">Description</label>
<input
id="first-desc" v-model="newDescription" class="input" type="text"
placeholder="What it covers"
/>
</div>
<div class="row-actions">
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
{{ creating ? "Creating…" : "Create" }}
</button>
<button class="btn-ghost" @click="showCreate = false">Cancel</button>
</div>
</section>
<div v-else class="ds-body">
<!-- Systems list -->
<aside class="ds-sidebar">
<div class="sidebar-head">
<h2 class="panel-heading">Systems</h2>
<button class="btn-ghost btn-small" @click="showCreate = !showCreate">
{{ showCreate ? "Cancel" : "New" }}
</button>
</div>
<ul class="system-list">
<li v-for="system in systems" :key="system.id">
<button
:class="['system-btn', { active: system.id === selectedId }]"
@click="selectedId = system.id"
>
<span class="system-title">{{ system.title }}</span>
<span class="system-kind">
{{ system.parent_id ? `inherits ${systemTitle(system.parent_id)}` : "family" }}
</span>
</button>
</li>
</ul>
</aside>
<div class="ds-main">
<!-- Create form -->
<section v-if="showCreate" class="ds-section">
<h2>New design system</h2>
<div class="field">
<label class="field-label" for="new-title">Title</label>
<input
id="new-title" v-model="newTitle" class="input" type="text"
placeholder="A house style, or one app in it" @keyup.enter="submitCreate"
/>
</div>
<div class="field">
<label class="field-label" for="new-desc">Description</label>
<input
id="new-desc" v-model="newDescription" class="input" type="text"
placeholder="What it covers"
/>
</div>
<div class="field">
<label class="field-label" for="new-parent">Inherits from</label>
<select id="new-parent" v-model="newParentId" class="input">
<option :value="null">Nothing — this is a family system</option>
<option v-for="s in systems" :key="s.id" :value="s.id">{{ s.title }}</option>
</select>
<p class="field-hint">
A system with a parent stores only its differences from it.
</p>
</div>
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
{{ creating ? "Creating…" : "Create" }}
</button>
</section>
<template v-if="selected">
<!-- Details -->
<section class="ds-section">
<h2>{{ selected.title }}</h2>
<p class="chain">
<template v-for="(link, i) in chain" :key="link.id">
<span v-if="i" class="chain-arrow" aria-hidden="true">←</span>
<span :class="['chain-link', { self: i === 0 }]">{{ link.title }}</span>
</template>
<span v-if="chain.length === 1" class="chain-note">
— a family system, inheriting nothing
</span>
</p>
<div class="field">
<label class="field-label" for="edit-title">Title</label>
<input id="edit-title" v-model="editTitle" class="input" type="text" />
</div>
<div class="field">
<label class="field-label" for="edit-desc">Description</label>
<input id="edit-desc" v-model="editDescription" class="input" type="text" />
</div>
<div class="field">
<label class="field-label" for="edit-guidance">Guidance</label>
<textarea
id="edit-guidance" v-model="editGuidance" class="input" rows="5"
placeholder="Aesthetic, voice and tone, what's deliberately out of scope"
></textarea>
<p class="field-hint">
The prose a token table can't hold. Kept here rather than
scattered, so the system carries its own reasoning.
</p>
</div>
<div class="field">
<label class="field-label" for="edit-parent">Inherits from</label>
<select id="edit-parent" v-model="editParentId" class="input">
<option :value="null">Nothing — this is a family system</option>
<option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.title }}</option>
</select>
<p class="field-hint">
Systems that inherit from this one are left out — a loop would
have nothing to resolve to.
</p>
</div>
<div class="row-actions">
<button class="btn-primary" :disabled="!systemDirty || savingSystem" @click="saveSystem">
{{ savingSystem ? "Saving" : "Save changes" }}
</button>
<button v-if="!confirmingDelete" class="btn-ghost" @click="confirmingDelete = true">
Delete
</button>
<template v-else>
<span class="confirm-copy">
Delete {{ selected.title }}? Systems inheriting from it keep their
own tokens and become family systems.
</span>
<button class="btn-danger" @click="removeSystem">Delete</button>
<button class="btn-ghost" @click="confirmingDelete = false">Keep</button>
</template>
</div>
</section>
<!-- The master sheet -->
<section class="ds-section">
<div class="section-head">
<h2>Master stylesheet</h2>
<button
class="btn-ghost btn-small"
@click="toggleSheet"
>
{{ showSheet ? "Hide" : "Show" }}
</button>
</div>
<template v-if="showSheet">
<p class="section-note">
What this system generates. <strong>Purpose tokens only</strong> —
it declares what values mean and styles no elements. Buttons,
tables and input schemes live as snippets that reference these
names, so each value is stated once and reused rather than
restated per element.
</p>
<p v-if="sheetLoading" class="muted">Rendering…</p>
<template v-else-if="sheet">
<p class="section-note">
<strong>{{ sheet.token_count }}</strong> tokens ·
<strong>{{ sheet.valueless.length }}</strong> still without a value ·
<strong>{{ duplicateEntries.length }}</strong> values declared
under more than one name
</p>
<div v-if="brokenFormulas.length" class="notice notice-warn">
<strong>Some formulas point at tokens that don't exist.</strong>
<p>
The browser drops these declarations entirely — no error, no
warning, the token just has no value. Either the source token
was renamed or the reference is a typo.
</p>
<ul class="dupe-list">
<li v-for="[name, refs] in brokenFormulas" :key="name">
<code>{{ name }}</code> → <code>{{ refs.join(", ") }}</code>
</li>
</ul>
</div>
<div v-if="derivationCycles.length" class="notice notice-warn">
<strong>Some tokens derive from each other in a loop.</strong>
<p>
CSS resolves a loop to nothing rather than looping forever, so
every token in the cycle ends up with no value.
</p>
<ul class="dupe-list">
<li v-for="(cycle, i) in derivationCycles" :key="i">
<code>{{ cycle.join(" ") }} → {{ cycle[0] }}</code>
</li>
</ul>
</div>
<div v-if="derivedEntries.length" class="notice">
<strong>{{ derivedEntries.length }} tokens are computed from others.</strong>
<p>
These follow their source automatically, in every mode, from a
single declaration — change the source and they shift with it.
</p>
<ul class="dupe-list">
<li v-for="[name, refs] in derivedEntries" :key="name">
<code>{{ name }}</code> from <code>{{ refs.join(", ") }}</code>
</li>
</ul>
</div>
<div v-if="duplicateEntries.length" class="notice notice-warn">
<strong>Some values are declared twice.</strong>
<p>
Either a deliberate alias or one idea recorded under two
names — only you can tell which, so nothing is changed here.
</p>
<ul class="dupe-list">
<li v-for="[value, names] in duplicateEntries" :key="value">
<span
v-if="isColourish(value)" class="swatch"
:style="{ background: value }" aria-hidden="true"
/>
<code>{{ value }}</code> — {{ names.join(", ") }}
</li>
</ul>
</div>
<div class="row-actions">
<button class="btn-ghost btn-small" @click="copySheet">Copy</button>
<a
class="btn-ghost btn-small"
:href="`/api/design-systems/${selectedId}/stylesheet?format=css`"
download
>Download</a>
<button class="btn-ghost btn-small" @click="loadSheet">Re-render</button>
</div>
<pre class="sheet"><code>{{ sheet.css }}</code></pre>
</template>
</template>
</section>
<!-- Do the components use the sheet? -->
<section class="ds-section">
<div class="section-head">
<h2>Components using this sheet</h2>
<button
class="btn-ghost btn-small" :disabled="checkingSnippets"
@click="runSnippetCheck"
>
{{ checkingSnippets ? "Checking" : "Check snippets" }}
</button>
</div>
<p class="section-note">
Snippets are the component layer — buttons, tables, input schemes —
and they are meant to use the tags this sheet declares. This finds
the three ways that goes wrong silently.
</p>
<template v-if="snippetCheck">
<p class="section-note">
<strong>{{ snippetCheck.checked }}</strong> snippets checked ·
<strong>{{ snippetCheck.findings.length }}</strong> with something
to act on
</p>
<p v-if="!snippetCheck.findings.length" class="muted">
Nothing to report. Every snippet checked uses tags this system
declares, writes no superseded literals, and mints no tokens of
its own.
</p>
<ul v-else class="finding-list">
<li v-for="f in snippetCheck.findings" :key="f.snippet_id">
<router-link :to="`/snippets/${f.snippet_id}`" class="finding-title">
{{ f.title || `Snippet #${f.snippet_id}` }}
</router-link>
<p v-if="f.unknown.length" class="finding-line">
<span class="spec-status violated">renders as nothing</span>
references
<code v-for="name in f.unknown" :key="name">{{ name }}</code>
— no such token in this system.
</p>
<p v-if="f.superseded_literals.length" class="finding-line">
<span class="spec-status missing">write the token instead</span>
<span v-for="s in f.superseded_literals" :key="s.literal">
<code>{{ s.literal }}</code> → <code>{{ s.use_instead }}</code>
</span>
</p>
<p v-if="f.local_definitions.length" class="finding-line">
<span class="spec-status missing">defines its own</span>
<code v-for="name in f.local_definitions" :key="name">{{ name }}</code>
— a value restated here rather than reused from the sheet.
</p>
</li>
</ul>
</template>
</section>
<!-- Overrides -->
<section class="ds-section">
<div class="section-head">
<h2>{{ selected.parent_id ? "Overrides" : "Tokens" }}</h2>
<button class="btn-ghost btn-small" @click="showAddToken ? resetTokenForm() : (showAddToken = true)">
{{ showAddToken ? "Cancel" : "Add token" }}
</button>
</div>
<p class="section-note">
{{ selected.parent_id
? "What this system changes from " + systemTitle(selected.parent_id) + ". Short is correct."
: "Everything this system defines." }}
</p>
<form v-if="showAddToken" class="token-form" @submit.prevent="submitToken">
<div class="field">
<label class="field-label" for="token-name">Name</label>
<input
id="token-name" v-model="tokenName" class="input mono" type="text"
placeholder="--surface-page"
/>
</div>
<div class="field-row">
<div class="field">
<label class="field-label" for="token-group">Group</label>
<input id="token-group" v-model="tokenGroup" class="input" type="text" placeholder="surface" />
</div>
<div class="field">
<label class="field-label" for="token-purpose">Purpose</label>
<input
id="token-purpose" v-model="tokenPurpose" class="input" type="text"
placeholder="page background, deepest surface"
/>
</div>
</div>
<div class="field">
<label class="field-label" for="token-rationale">Why this value</label>
<input
id="token-rationale" v-model="tokenRationale" class="input" type="text"
placeholder="Matches the primary action colour, deliberately"
/>
<p class="field-hint">
Distinct from purpose: purpose is what the token is FOR, this is
why it is this value.
</p>
</div>
<div class="field">
<label class="field-label" for="token-supersedes">Use instead of</label>
<input
id="token-supersedes" v-model="tokenSupersedes" class="input mono" type="text"
placeholder="#fff, #ffffff"
/>
<p class="field-hint">
Literal values that should be written as this token instead —
comma separated. This is where a rule like "pure white is never
text" belongs: not as a prohibition, but as the thing to write
in its place. Checked by the source lint, not by this page.
</p>
</div>
<fieldset class="modes">
<legend class="field-label">Values by mode</legend>
<p class="field-hint">
<code>base</code> applies when no mode is more specific. A token
that doesn't change between themes needs only that one.
<template v-if="selected.parent_id">
A mode left out here keeps inheriting.
</template>
</p>
<div v-for="(row, i) in tokenModes" :key="i" class="mode-row">
<input v-model="row.mode" class="input mono mode-key" type="text" placeholder="base" />
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
<span
v-if="isColourish(row.value)" class="swatch"
:style="{ background: row.value }" aria-hidden="true"
/>
<button
type="button" class="btn-ghost btn-small"
:disabled="tokenModes.length === 1"
@click="tokenModes.splice(i, 1)"
>
Remove
</button>
</div>
<button
type="button" class="btn-ghost btn-small"
@click="tokenModes.push({ mode: '', value: '' })"
>
Add a mode
</button>
</fieldset>
<button class="btn-primary" type="submit" :disabled="!tokenName.trim() || savingToken">
{{ savingToken ? "Saving" : editingTokenId !== null ? "Save token" : "Add token" }}
</button>
</form>
<p v-if="detailLoading" class="muted">Loading tokens…</p>
<p v-else-if="!ownTokens.length" class="muted">
{{ selected.parent_id
? "Nothing overridden this system resolves to " + systemTitle(selected.parent_id) + " entirely."
: "No tokens yet." }}
</p>
<ul v-else class="token-list">
<li v-for="token in ownTokens" :key="token.id">
<code class="token-name">{{ token.name }}</code>
<span class="token-values">
<span v-for="(value, mode) in token.value_by_mode" :key="mode" class="mode-chip">
<span
v-if="isColourish(value)" class="swatch"
:style="{ background: value }" aria-hidden="true"
/>
<span class="mode-name">{{ mode }}</span>
<code>{{ value }}</code>
</span>
</span>
<span class="token-meta">
{{ token.purpose || token.group_name || "" }}
<span v-if="token.supersedes.length" class="supersedes">
instead of {{ token.supersedes.join(", ") }}
</span>
</span>
<span class="token-actions">
<button class="btn-ghost btn-small" @click="startEditToken(token)">Edit</button>
<button class="btn-ghost btn-small" @click="removeToken(token)">
{{ selected.parent_id ? "Inherit" : "Remove" }}
</button>
</span>
</li>
</ul>
</section>
<!-- Effective set -->
<section class="ds-section">
<h2>Effective tokens</h2>
<p class="section-note">
What {{ selected.title }} resolves to, inheritance applied.
{{ resolved.length }} token{{ resolved.length === 1 ? "" : "s" }},
{{ overrideCount }} with a value from this system.
</p>
<p v-if="detailLoading" class="muted">Resolving…</p>
<p v-else-if="!resolved.length" class="muted">
Nothing in this chain defines a token yet.
</p>
<template v-else>
<div v-for="[group, tokens] in effectiveGroups" :key="group" class="group-block">
<h3 class="group-heading">{{ group }}</h3>
<ul class="resolved-list">
<li v-for="token in tokens" :key="token.name">
<div class="resolved-head">
<code class="token-name">{{ token.name }}</code>
<span v-if="token.purpose" class="token-meta">{{ token.purpose }}</span>
<span v-if="token.rationale" class="supersedes">{{ token.rationale }}</span>
<span v-if="token.supersedes.length" class="supersedes">
instead of {{ token.supersedes.join(", ") }}
</span>
</div>
<!-- One badge only when every mode agrees on its source. -->
<div v-if="hasUniformOrigin(token)" class="resolved-modes">
<span v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-chip">
<span
v-if="isColourish(origin.value)" class="swatch"
:style="{ background: origin.value }" aria-hidden="true"
/>
<span class="mode-name">{{ origin.mode }}</span>
<code>{{ origin.value }}</code>
</span>
<span
class="origin-badge"
:class="{ own: modeOrigins(token)[0]?.systemId === selectedId }"
>
{{ modeOrigins(token)[0]?.systemId === selectedId
? (modeOrigins(token)[0]?.shadowed ? "overridden here" : "defined here")
: `from ${originLabel(modeOrigins(token)[0]?.systemId ?? 0)}` }}
</span>
</div>
<!-- Modes disagree: break out per mode rather than pick one. -->
<div v-else class="resolved-modes split">
<div v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-line">
<span class="mode-chip">
<span
v-if="isColourish(origin.value)" class="swatch"
:style="{ background: origin.value }" aria-hidden="true"
/>
<span class="mode-name">{{ origin.mode }}</span>
<code>{{ origin.value }}</code>
</span>
<span class="origin-badge" :class="{ own: origin.systemId === selectedId }">
{{ origin.systemId === selectedId
? (origin.shadowed ? "overridden here" : "defined here")
: `from ${originLabel(origin.systemId)}` }}
</span>
</div>
</div>
</li>
</ul>
</div>
</template>
</section>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.ds-view {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
}
.ds-header h1 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
font-weight: 500;
}
.lede {
color: var(--color-text-secondary);
max-width: 70ch;
line-height: 1.6;
margin: 0 0 1.5rem;
}
.ds-body {
display: grid;
grid-template-columns: minmax(0, 15rem) minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
@media (max-width: 800px) {
.ds-body {
grid-template-columns: minmax(0, 1fr);
}
}
/* Sidebar ---------------------------------------------------------------- */
.ds-sidebar {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem;
}
.sidebar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.panel-heading {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.system-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.system-btn {
width: 100%;
text-align: left;
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 0.5rem 0.6rem;
cursor: pointer;
color: var(--color-text);
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.system-btn:hover {
background: var(--color-bg-secondary);
}
.system-btn.active {
border-color: var(--color-primary);
background: var(--color-bg-secondary);
}
.system-title {
font-weight: 500;
}
.system-kind {
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* Sections --------------------------------------------------------------- */
.ds-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
.ds-section {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
}
.ds-section h2 {
margin: 0 0 0.35rem;
font-size: 1.1rem;
font-weight: 500;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.section-note,
.muted {
color: var(--color-text-muted);
font-size: 0.85rem;
margin: 0 0 1rem;
}
.chain {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin: 0 0 1rem;
font-size: 0.85rem;
}
.chain-link {
color: var(--color-text-secondary);
}
.chain-link.self {
color: var(--color-text);
font-weight: 500;
}
.chain-arrow,
.chain-note {
color: var(--color-text-muted);
}
/* Fields ----------------------------------------------------------------- */
.field {
margin-bottom: 0.85rem;
min-width: 0;
}
.field-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.field-row .field {
flex: 1 1 12rem;
}
.field-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
margin-bottom: 0.3rem;
}
.field-hint {
margin: 0.3rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.input {
width: 100%;
padding: 0.45rem 0.6rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font: inherit;
}
textarea.input {
resize: vertical;
min-height: 5rem;
line-height: 1.5;
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
}
/* Buttons ---------------------------------------------------------------- */
.btn-primary,
.btn-ghost,
.btn-danger {
border-radius: var(--radius-sm);
padding: 0.45rem 0.9rem;
font: inherit;
font-size: 0.85rem;
cursor: pointer;
border: 1px solid transparent;
}
/* Background matches the house convention (--color-action-primary, as in
ProjectListView and four others). The text colour deliberately does NOT:
every existing copy uses `color: #fff`, which is 67 live violations of the
rule that pure white is never text (#2275). Parchment is what the rulebook
actually specifies. */
.btn-primary {
background: var(--color-action-primary);
color: #E8E4D8;
}
.btn-primary:not(:disabled):hover {
background: var(--color-action-primary-hover);
}
.btn-ghost {
background: none;
border-color: var(--color-border);
color: var(--color-text-secondary);
}
.btn-danger {
background: var(--color-action-destructive);
color: #E8E4D8;
}
.btn-small {
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
}
.btn-primary:disabled,
.btn-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.row-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.confirm-copy {
font-size: 0.85rem;
color: var(--color-text-secondary);
max-width: 40ch;
}
/* Tokens ----------------------------------------------------------------- */
.token-form {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 1rem;
margin-bottom: 1rem;
}
.modes {
border: none;
padding: 0;
margin: 0 0 0.85rem;
}
.mode-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.mode-key {
max-width: 8rem;
}
.token-list,
.resolved-list {
list-style: none;
margin: 0;
padding: 0;
}
.token-list li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
}
.token-name {
font-size: 0.85rem;
min-width: 11rem;
}
.token-values {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.token-meta {
color: var(--color-text-muted);
font-size: 0.8rem;
flex: 1;
min-width: 0;
}
.token-actions {
display: flex;
gap: 0.35rem;
}
.finding-list {
list-style: none;
margin: 0;
padding: 0;
}
.finding-list li {
padding: 0.6rem 0;
border-bottom: 1px solid var(--color-border);
}
.finding-title {
font-weight: 500;
color: var(--color-text);
}
.finding-line {
margin: 0.3rem 0 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
.spec-status {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
flex: none;
}
.spec-status.violated {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
.spec-status.missing {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.sheet {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
margin-top: 0.75rem;
overflow-x: auto;
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
font-size: 0.8rem;
line-height: 1.6;
max-height: 30rem;
overflow-y: auto;
}
.dupe-list {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
font-size: 0.85rem;
}
.dupe-list li {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0;
}
.supersedes {
font-size: 0.75rem;
color: var(--color-text-muted);
font-style: italic;
}
.mode-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
}
.mode-name {
color: var(--color-text-muted);
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.06em;
}
.swatch {
display: inline-block;
width: 0.9rem;
height: 0.9rem;
border-radius: 3px;
border: 1px solid var(--color-border);
flex: none;
}
/* Effective set ---------------------------------------------------------- */
.group-block {
margin-bottom: 1.25rem;
}
.group-heading {
margin: 0 0 0.4rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.resolved-list li {
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
}
.resolved-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.6rem;
}
.resolved-modes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-top: 0.25rem;
}
.resolved-modes.split {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.mode-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.origin-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
color: var(--color-text-muted);
}
.origin-badge.own {
background: var(--color-primary-tint);
color: var(--color-primary);
}
/* Notices ---------------------------------------------------------------- */
.notice {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
max-width: 60ch;
}
.notice-warn {
border-left: 3px solid var(--color-warning);
}
.notice p {
margin: 0.5rem 0 1rem;
color: var(--color-text-secondary);
line-height: 1.6;
}
</style>