CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 47s
Two reports, one root cause each. **The dead button.** "Create the first one" set `showCreate = true`, but the create form lived inside `<div v-else class="ds-body">` — the sibling branch of the empty state. The two are mutually exclusive, so on a fresh install the flag flipped and nothing rendered. The first action a new install can take was the one that didn't work, which is a poor way to honour "an install with zero design systems is the ordinary state". The first system now gets its own form outside the list layout, and it drops the parent picker entirely: there is nothing to inherit from yet, so it says so instead of offering an empty select. **Two surfaces, the wrong one first.** /design and /design-systems are halves of one thing — the record that decides the styling, and what the browser renders from it — and I had added them as two separate nav entries with the read-only diagnostic listed first. Backwards: the record is what you work with; the live view is the check on it. Now one nav entry pointing at the record, with a shared tab bar joining the two. The explorer is renamed "Live tokens", which is what it actually shows. The tab bar is a component rather than the same markup in both views. Two copies diverge the moment a third tab appears — and a design surface that ships duplicated markup would be arguing against itself.
1683 lines
53 KiB
Vue
1683 lines
53 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,
|
|
importFromRulebook,
|
|
updateDesignSystem,
|
|
updateDesignToken,
|
|
type DesignSystem,
|
|
type DesignToken,
|
|
type ImportReport,
|
|
type ResolvedToken,
|
|
type SnippetCheck,
|
|
type StylesheetResult,
|
|
} from "@/api/designSystems";
|
|
import { listRulebooks, type Rulebook } from "@/api/rulebooks";
|
|
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 ?? {}));
|
|
|
|
// --- 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;
|
|
});
|
|
|
|
// --- import from a rulebook -------------------------------------------------
|
|
|
|
const rulebooks = ref<Rulebook[]>([]);
|
|
const importRulebookId = ref<number | null>(null);
|
|
const importReport = ref<ImportReport | null>(null);
|
|
const importing = ref(false);
|
|
const showImport = ref(false);
|
|
|
|
async function loadRulebooks() {
|
|
try {
|
|
rulebooks.value = await listRulebooks();
|
|
} catch {
|
|
rulebooks.value = [];
|
|
}
|
|
}
|
|
onMounted(loadRulebooks);
|
|
|
|
/** Preview, then apply — never one step.
|
|
*
|
|
* A rulebook is prose written aspirationally, so an import is a proposal and
|
|
* the operator has to be able to read it before it becomes records. Applying
|
|
* from an unread preview is still one click; applying without one is not
|
|
* possible, which is the point. */
|
|
async function runImport(apply: boolean) {
|
|
if (selectedId.value === null || importRulebookId.value === null || importing.value) return;
|
|
importing.value = true;
|
|
try {
|
|
const report = await importFromRulebook(selectedId.value, importRulebookId.value, apply);
|
|
importReport.value = report;
|
|
if (apply) {
|
|
await loadDetail(selectedId.value);
|
|
toast.show(`Imported ${report.created.length} tokens`);
|
|
}
|
|
} catch {
|
|
toast.show("Import failed", "error");
|
|
} finally {
|
|
importing.value = false;
|
|
}
|
|
}
|
|
|
|
/** Proposals the rulebook names but states no readable value for.
|
|
*
|
|
* Surfaced as a count rather than buried: the rulebook writes radius steps and
|
|
* type sizes as prose ("Small 4px"), which nothing parses, so these arrive as
|
|
* names awaiting a value. That is an honest gap, and hiding it would make the
|
|
* import look more complete than it is. */
|
|
const proposalsWithoutValues = computed(
|
|
() => importReport.value?.proposed.filter((p) => !Object.keys(p.value_by_mode).length).length ?? 0,
|
|
);
|
|
|
|
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="FabledSword" @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="FabledSword, or Scribe" @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="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>
|
|
|
|
<!-- Import from a rulebook -->
|
|
<section class="ds-section">
|
|
<div class="section-head">
|
|
<h2>Import from a rulebook</h2>
|
|
<button class="btn-ghost btn-small" @click="showImport = !showImport">
|
|
{{ showImport ? "Hide" : "Show" }}
|
|
</button>
|
|
</div>
|
|
|
|
<template v-if="showImport">
|
|
<p class="section-note">
|
|
Reads a rulebook's colour and token declarations and proposes the
|
|
tokens they add up to — joining "Obsidian #14171A" in one rule to
|
|
<code>--fs-obsidian</code> in another, since neither alone is a
|
|
token. Preview first: a rulebook is written aspirationally, so
|
|
this is a proposal rather than a fact.
|
|
</p>
|
|
|
|
<p v-if="!rulebooks.length" class="muted">
|
|
No rulebooks to import from.
|
|
</p>
|
|
|
|
<template v-else>
|
|
<div class="field">
|
|
<label class="field-label" for="import-rulebook">Rulebook</label>
|
|
<select id="import-rulebook" v-model="importRulebookId" class="input">
|
|
<option :value="null">Choose one…</option>
|
|
<option v-for="rb in rulebooks" :key="rb.id" :value="rb.id">{{ rb.title }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="row-actions">
|
|
<button
|
|
class="btn-ghost" :disabled="importRulebookId === null || importing"
|
|
@click="runImport(false)"
|
|
>
|
|
{{ importing ? "Reading…" : "Preview" }}
|
|
</button>
|
|
<button
|
|
v-if="importReport && importReport.proposed.length"
|
|
class="btn-primary" :disabled="importing" @click="runImport(true)"
|
|
>
|
|
Import {{ importReport.proposed.length - importReport.skipped.length }} tokens
|
|
</button>
|
|
</div>
|
|
|
|
<template v-if="importReport">
|
|
<p class="section-note import-summary">
|
|
<strong>{{ importReport.proposed.length }}</strong> proposed ·
|
|
<strong>{{ proposalsWithoutValues }}</strong> with no value the
|
|
rulebook states in a readable form ·
|
|
<strong>{{ importReport.skipped.length }}</strong> already defined here
|
|
<template v-if="importReport.created.length">
|
|
· <strong>{{ importReport.created.length }}</strong> created
|
|
</template>
|
|
</p>
|
|
|
|
<p v-if="!importReport.proposed.length" class="muted">
|
|
That rulebook names no custom properties, so there is nothing
|
|
to propose. Most rulebooks aren't design rulebooks.
|
|
</p>
|
|
|
|
<ul v-else class="token-list">
|
|
<li v-for="p in importReport.proposed" :key="p.name">
|
|
<code class="token-name">{{ p.name }}</code>
|
|
<span class="token-values">
|
|
<span
|
|
v-for="(value, mode) in p.value_by_mode" :key="mode" class="mode-chip"
|
|
>
|
|
<span
|
|
v-if="isColourish(value)" class="swatch"
|
|
:style="{ background: value }" aria-hidden="true"
|
|
/>
|
|
<code>{{ value }}</code>
|
|
</span>
|
|
<span v-if="!Object.keys(p.value_by_mode).length" class="supersedes">
|
|
needs a value
|
|
</span>
|
|
</span>
|
|
<span class="token-meta">
|
|
{{ p.purpose || "" }}
|
|
<span v-if="p.supersedes.length" class="supersedes">
|
|
instead of {{ p.supersedes.join(", ") }}
|
|
</span>
|
|
<span v-if="p.source_rule_id" class="supersedes">
|
|
rule #{{ p.source_rule_id }}
|
|
</span>
|
|
</span>
|
|
<span
|
|
v-if="importReport.skipped.includes(p.name)" class="origin-badge"
|
|
>
|
|
already defined
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
</template>
|
|
</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="--fs-obsidian"
|
|
/>
|
|
</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 bg, 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="Success equals Moss, aligned by design"
|
|
/>
|
|
<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;
|
|
}
|
|
|
|
.import-summary {
|
|
margin-top: 0.75rem;
|
|
}
|
|
|
|
.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>
|