CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 44s
The record view listed values as text and drew a swatch only where the value looked like a colour. Two problems, one cause: a derived value such as color-mix(in srgb, var(--accent) 15%, transparent) was drawn by resolving --accent against THIS app, so previewing another project's system showed Scribe's palette. It looked right, which is why nobody noticed. TokenPreview draws the system from its own record. Every value is resolved on an offscreen probe carrying only that system's declarations, so a system whose app this browser has never loaded renders in its own colours — which is the difference between a tool and a mirror. Specimens are chosen by value SHAPE, never by name: colours become swatches, lengths become rules drawn to scale, gradients and shadows get a surface, font stacks are set in themselves. Nothing matches --fs-space-* or any other convention, because the convention belongs to the install (rule #115) — a system that calls its spacing --gap-N gets the same treatment. Translucent values sit on a checkerboard, or a 15% tint over a solid card reads as opaque and shows the wrong colour. Modes come from the system, not from the app: a system declaring base and light offers both, independent of the theme this page is in. The provenance list keeps its swatches only for self-contained colours — the ones needing no resolution, which it can therefore draw honestly. Everything with a var() inside is left to the preview built for it. Step 2 of milestone #274. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
1578 lines
50 KiB
Vue
1578 lines
50 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Design systems — the stylesheets this install RECORDS, for the projects it
|
|
* tracks (milestone #254 step 5).
|
|
*
|
|
* It had a sibling, `/design`, which showed the system as the BROWSER had it —
|
|
* names out of a bundled stylesheet, values out of `getComputedStyle`. That
|
|
* could only ever describe the install serving the page, so it was a mirror
|
|
* rather than a tool and was retired (#274). Everything here works on a system
|
|
* whose app this browser has never loaded.
|
|
*
|
|
* 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 { ApiError } from "@/api/client";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import StarterRolePicker from "@/components/StarterRolePicker.vue";
|
|
import TokenPreview from "@/components/TokenPreview.vue";
|
|
|
|
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);
|
|
// Starter roles (#2349). The picker fills these on mount; empty means the
|
|
// operator unchecked everything, which is a real answer.
|
|
const starterGroups = ref<string[]>([]);
|
|
const tokenPrefix = ref("");
|
|
|
|
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,
|
|
starter_role_groups: starterGroups.value.length ? starterGroups.value : undefined,
|
|
token_prefix: tokenPrefix.value.trim() || undefined,
|
|
});
|
|
newTitle.value = "";
|
|
newDescription.value = "";
|
|
newParentId.value = null;
|
|
showCreate.value = false;
|
|
// NOT reset: the picker owns these and re-seeds on mount. Clearing them
|
|
// here would race the next mount and silently create the following system
|
|
// with no roles at all.
|
|
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;
|
|
});
|
|
|
|
/**
|
|
* A colour this row can draw HONESTLY — self-contained, no `var()` inside.
|
|
*
|
|
* The provenance list below shows values as the record states them, and a
|
|
* `var()` reference states nothing on its own: rendering
|
|
* `color-mix(in srgb, var(--accent) 15%, transparent)` as a background resolves
|
|
* `--accent` against THIS app, so a system that isn't the one Scribe runs on
|
|
* would be drawn in Scribe's palette. It looked right, which is why it went
|
|
* unnoticed (#274).
|
|
*
|
|
* The preview above resolves values properly, on a probe carrying only that
|
|
* system's declarations. So this list draws only what needs no resolving, and
|
|
* leaves the rest to the surface built for it.
|
|
*/
|
|
function isSelfContainedColour(value: string): boolean {
|
|
const v = value.trim();
|
|
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(v) && !v.includes("var(");
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="ds-view">
|
|
|
|
<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>
|
|
<StarterRolePicker
|
|
v-model:selected="starterGroups"
|
|
v-model:prefix="tokenPrefix"
|
|
/>
|
|
<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>
|
|
<StarterRolePicker
|
|
v-model:selected="starterGroups"
|
|
v-model:prefix="tokenPrefix"
|
|
/>
|
|
<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="isSelfContainedColour(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="isSelfContainedColour(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="isSelfContainedColour(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>
|
|
|
|
<!-- What it looks like. Drawn from the record on an isolated probe,
|
|
so this is THIS system's palette even when the app around it is
|
|
running a different one. -->
|
|
<section v-if="resolved.length" class="ds-section">
|
|
<h2>Preview</h2>
|
|
<p class="section-note">
|
|
{{ selected.title }} as it would render — resolved from the record,
|
|
not from the stylesheet this app happens to be running.
|
|
</p>
|
|
<TokenPreview :tokens="resolved" />
|
|
</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="isSelfContainedColour(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="isSelfContainedColour(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 ---------------------------------------------------------------- */
|
|
|
|
|
|
|
|
.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>
|