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
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.
1576 lines
49 KiB
Vue
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>
|