Files
FabledScribe/frontend/src/components/SystemsSection.vue
T
bvandeusenandClaude Opus 5.5 0a1bb68808
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 36s
feat(rulings): system_usage_events is read back — per-System counts and a telemetry block (#4769)
#4769 "Rulings are counted where someone will read them": milestone 444
step 4 wrote system_usage_events and nothing read it.

- retrieval_telemetry gains a `system_usage` block: surfacings and opens by
  source, distinct counts, and `by_system` naming the areas most shown.
  There is deliberately no pull-through ratio, because rulings travel in full
  in the line and opens are the exception.
- usage_for_systems (one GROUP BY) adds `usage` to the REST Systems list and
  detail, and to MCP get_system. MCP list_systems is unchanged.
- The Systems UI shows a "rulings shown N×" chip.
- rulings_pre_tool, rulings_write_path and mcp_get_system are now declared
  registry points; the registry guard covers their recorders.
- The Systems store merges a PATCH reply instead of replacing the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:49:20 -04:00

852 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useSystemsStore } from "@/stores/systems";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import { useToastStore } from "@/stores/toast";
import { getProjectIssues } from "@/api/systems";
import type { System, TaskLike } from "@/api/systems";
import type { CanonicalMatch } from "@/api/canonicalSystems";
import { apiErrorMessage } from "@/api/client";
import { fmtDate } from "@/utils/dateFormat";
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
const props = defineProps<{ projectId: number }>();
const store = useSystemsStore();
const canon = useCanonicalSystemsStore();
const toast = useToastStore();
const error = ref<string | null>(null);
const showArchived = ref(false);
const issues = ref<TaskLike[]>([]);
// Create state
const showCreate = ref(false);
const newName = ref("");
const newDescription = ref("");
// The global area, chosen explicitly. A PICKER rather than a live matcher on
// purpose: reproducing the server's slug rule in TypeScript would give this
// feature two matchers to keep in step, which is the exact drift the catalog
// exists to end. The server still applies an exact hit on submit.
const newCanonicalId = ref<number | null>(null);
const newPaths = ref("");
const creating = ref(false);
// An `overlap` the server offered after a create — an offer, never applied.
const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null);
// Edit state
const editingId = ref<number | null>(null);
const editName = ref("");
const editDescription = ref("");
const editCanonicalId = ref<number | null>(null);
const editPaths = ref("");
const savingEdit = ref(false);
// Mapping review
const showReview = ref(false);
const reviewBusy = ref<number | null>(null);
// Delete confirmation
const deletingSystem = ref<System | null>(null);
const systems = computed<System[]>(() => store.systemsByProject[props.projectId] ?? []);
const activeSystems = computed(() => systems.value.filter((s) => s.status === "active"));
const archivedSystems = computed(() => systems.value.filter((s) => s.status === "archived"));
const visibleSystems = computed(() =>
showArchived.value ? systems.value : activeSystems.value,
);
const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []);
// The files field is one pattern per line. The server is the one that
// validates and tidies them, so this only splits — a second validator here
// would be a second answer to "is this pattern acceptable".
function parsePaths(text: string): string[] {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function areaName(system: System): string | null {
return canon.byId(system.canonical_id)?.name ?? null;
}
/** How many times this area's rulings reached a session (#4769). Not the
* UsageBadge: its "N/M used" reads opens over showings, and rulings are
* delivered in full in the line, so an unopened System is the arm working,
* not dead weight. */
function rulingsShown(system: System): number {
return system.usage?.surfaced_count ?? 0;
}
function rulingsTitle(system: System): string {
const u = system.usage;
if (!u) return "";
const last = u.last_surfaced_at ? ` Last shown ${fmtDate(u.last_surfaced_at)}.` : "";
return (
`This area's rulings were shown in full to a session ${u.surfaced_count}×, ` +
`when a command or edit touched its files.${last} ` +
`Opened with get_system ${u.pull_count}×.`
);
}
async function load() {
error.value = null;
try {
await store.fetchSystems(props.projectId);
} catch {
error.value = "Failed to load systems.";
}
try {
issues.value = await getProjectIssues(props.projectId);
} catch {
issues.value = [];
}
// Both fail soft: the catalog is a naming aid, and a review prompt that
// cannot load must not take the Systems list down with it.
await canon.fetchCatalog();
try {
await canon.fetchProposals(props.projectId);
} catch {
/* no proposals shown */
}
}
onMounted(load);
watch(() => props.projectId, load);
function openCreate() {
showCreate.value = true;
newName.value = "";
newDescription.value = "";
newCanonicalId.value = null;
newPaths.value = "";
}
function cancelCreate() {
showCreate.value = false;
newName.value = "";
newDescription.value = "";
newCanonicalId.value = null;
newPaths.value = "";
}
async function submitCreate() {
const name = newName.value.trim();
if (!name || creating.value) return;
creating.value = true;
try {
const created = await store.createSystem(props.projectId, {
name,
description: newDescription.value.trim() || undefined,
canonical_id: newCanonicalId.value ?? undefined,
path_patterns: parsePaths(newPaths.value),
});
cancelCreate();
if (created.canonical_suggestion) {
// An overlap: shown as an offer beside the new System, never applied.
suggestion.value = { systemId: created.id, match: created.canonical_suggestion };
}
toast.show(
created.canonical_id
? `System created and filed under ${canon.byId(created.canonical_id)?.name}`
: "System created",
);
} catch (e) {
// 409 = this project already has that System. Say WHICH one, so the
// answer is actionable rather than "it didn't work".
toast.show(apiErrorMessage(e, "Failed to create system"), "error");
} finally {
creating.value = false;
}
}
async function acceptSuggestion() {
const pending = suggestion.value;
if (!pending) return;
suggestion.value = null;
try {
await canon.mapSystem(props.projectId, pending.systemId, pending.match.id);
await store.fetchSystems(props.projectId);
toast.show(`Filed under ${pending.match.name}`);
} catch {
/* the store already reported it */
}
}
async function applyProposal(systemId: number, canonicalId: number) {
reviewBusy.value = systemId;
try {
await canon.mapSystem(props.projectId, systemId, canonicalId);
await store.fetchSystems(props.projectId);
} catch {
/* the store already reported it */
} finally {
reviewBusy.value = null;
}
}
function startEdit(system: System) {
editingId.value = system.id;
editName.value = system.name;
editDescription.value = system.description;
editCanonicalId.value = system.canonical_id;
editPaths.value = system.path_patterns.join("\n");
}
function cancelEdit() {
editingId.value = null;
}
async function submitEdit(system: System) {
const name = editName.value.trim();
if (!name || savingEdit.value) return;
savingEdit.value = true;
try {
await store.updateSystem(props.projectId, system.id, {
name,
description: editDescription.value.trim(),
path_patterns: parsePaths(editPaths.value),
});
// The mapping is a separate write with its own validation — one column,
// one writer (services/canonical_systems.set_system_canonical).
if (editCanonicalId.value !== system.canonical_id) {
await canon.mapSystem(props.projectId, system.id, editCanonicalId.value);
await store.fetchSystems(props.projectId);
}
editingId.value = null;
toast.show("System updated");
} catch (e) {
// A refused pattern comes back as a 400 naming it — say which.
toast.show(apiErrorMessage(e, "Failed to update system"), "error");
} finally {
savingEdit.value = false;
}
}
async function archive(system: System) {
try {
await store.archiveSystem(props.projectId, system.id);
toast.show("System archived");
} catch {
toast.show("Failed to archive system", "error");
}
}
async function unarchive(system: System) {
try {
await store.unarchiveSystem(props.projectId, system.id);
toast.show("System restored");
} catch {
toast.show("Failed to restore system", "error");
}
}
async function confirmDelete() {
const system = deletingSystem.value;
if (!system) return;
deletingSystem.value = null;
try {
await store.deleteSystem(props.projectId, system.id);
toast.show("System deleted");
} catch {
toast.show("Failed to delete system", "error");
}
}
</script>
<template>
<div class="systems-section">
<!-- Open issues -->
<div v-if="issues.length" class="open-issues">
<div class="open-issues-label">⚠ Open issues ({{ issues.length }})</div>
<ul class="issue-list">
<li v-for="issue in issues" :key="issue.id" class="issue-item">
<router-link :to="`/tasks/${issue.id}`" class="issue-link">
<span class="issue-mark" :class="`imk-${issue.status}`">{{ issue.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="issue-name">{{ issue.title }}</span>
<span v-if="issue.systems && issue.systems.length" class="issue-systems">
<span v-for="s in issue.systems" :key="s.id" class="issue-sys-chip">{{ s.name }}</span>
</span>
</router-link>
</li>
</ul>
</div>
<!-- Mapping review. Only appears when there is something to decide, and
it says HOW MANY rather than nagging with a permanent banner. -->
<div v-if="proposals.length" class="area-review">
<button class="area-review-head" @click="showReview = !showReview">
<span class="area-review-count">{{ proposals.length }}</span>
{{ proposals.length === 1 ? "system" : "systems" }} may belong to a shared area
<span class="area-review-chev">{{ showReview ? "▾" : "▸" }}</span>
</button>
<ul v-if="showReview" class="area-proposals">
<li v-for="p in proposals" :key="p.system_id" class="area-proposal">
<div class="area-proposal-text">
<span class="area-proposal-name">{{ p.system_name }}</span>
<span class="area-proposal-arrow" aria-hidden="true">→</span>
<span class="area-proposal-target">{{ p.canonical_name }}</span>
<!-- The basis is the decision the reviewer is making: `exact`
differs only in spelling, `overlap` is a judgment call.
Showing them identically is how a wrong mapping is waved
through, so they never share a style. -->
<span
class="area-basis"
:class="p.basis === 'exact' ? 'area-basis--exact' : 'area-basis--overlap'"
:title="
p.basis === 'exact'
? 'Same name up to spelling — safe to accept.'
: 'Shares a word. Accept only if it is really the same area.'
"
>{{ p.basis === "exact" ? "same name" : "similar" }}</span>
</div>
<div class="area-proposal-actions">
<button
class="btn-primary btn-compact"
:disabled="reviewBusy === p.system_id"
@click="applyProposal(p.system_id, p.canonical_id)"
>
{{ reviewBusy === p.system_id ? "Filing…" : "File here" }}
</button>
<button
class="btn-ghost btn-compact"
@click="canon.dismissProposal(props.projectId, p.system_id)"
>
Not this
</button>
</div>
</li>
</ul>
</div>
<!-- An overlap offered by the server after a create. Never applied. -->
<div v-if="suggestion" class="area-offer">
<span>
Is this the same area as
<strong>{{ suggestion.match.name }}</strong>?
</span>
<div class="area-proposal-actions">
<button class="btn-primary btn-compact" @click="acceptSuggestion">File it there</button>
<button class="btn-ghost btn-compact" @click="suggestion = null">No, it's ours</button>
</div>
</div>
<!-- Toolbar -->
<div class="systems-toolbar">
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
+ System
</button>
<label v-if="archivedSystems.length" class="archived-toggle">
<input v-model="showArchived" type="checkbox" class="archived-checkbox" />
Show archived ({{ archivedSystems.length }})
</label>
</div>
<!-- Create form -->
<form v-if="showCreate" class="system-form" @submit.prevent="submitCreate">
<input
v-model="newName"
class="fs-input system-input"
placeholder="System name"
aria-label="System name"
autofocus
@keydown.escape="cancelCreate"
/>
<textarea
v-model="newDescription"
class="fs-input system-textarea"
rows="2"
placeholder="What is this subsystem responsible for? (optional)"
aria-label="System description"
></textarea>
<label v-if="canon.catalog.length" class="area-field">
<span class="area-label">Shared area</span>
<select v-model="newCanonicalId" class="fs-input area-select" aria-label="Shared area">
<option :value="null">None — specific to this project</option>
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
{{ entry.name }}
</option>
</select>
<!-- .field-hint is the shared hint class beside .fs-input
(components.css) — not restated scoped. -->
<span class="field-hint">
Files this system under an area shared by every project. Your name stays as you typed it.
</span>
</label>
<label class="area-field">
<span class="area-label">Files</span>
<textarea
v-model="newPaths"
class="fs-input system-textarea system-paths-input"
rows="2"
placeholder="e.g. src/billing — one pattern per line"
aria-label="System files"
spellcheck="false"
></textarea>
<span class="field-hint">
One pattern per line, from the repo root. <code>*</code> stays in one folder, <code>**</code> reaches any depth, and a folder covers everything in it.
</span>
</label>
<div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
{{ creating ? "Creating…" : "Create" }}
</button>
<button type="button" class="btn-ghost btn-compact" @click="cancelCreate">Cancel</button>
</div>
</form>
<!-- Loading -->
<div v-if="store.loading && !systems.length" class="systems-skeleton" aria-label="Loading systems">
<div class="skel-row"></div>
<div class="skel-row skel-row--short"></div>
<div class="skel-row"></div>
</div>
<!-- Error -->
<p v-else-if="error" class="error-msg">{{ error }}</p>
<!-- Empty -->
<div v-else-if="!visibleSystems.length" class="systems-empty">
<p class="empty-title">No systems yet</p>
<p class="empty-sub">Define a reusable subsystem or area to organize issues against.</p>
<button v-if="!showCreate" class="btn-primary btn-compact" @click="openCreate">+ Create a system</button>
</div>
<!-- List -->
<ul v-else class="systems-list">
<li
v-for="system in visibleSystems"
:key="system.id"
class="system-card"
:class="{ 'system-card--archived': system.status === 'archived' }"
>
<!-- Inline edit -->
<template v-if="editingId === system.id">
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
<input
v-model="editName"
class="fs-input system-input"
placeholder="System name"
aria-label="System name"
autofocus
@keydown.escape="cancelEdit"
/>
<textarea
v-model="editDescription"
class="fs-input system-textarea"
rows="2"
placeholder="Description (optional)"
aria-label="System description"
></textarea>
<label v-if="canon.catalog.length" class="area-field">
<span class="area-label">Shared area</span>
<select v-model="editCanonicalId" class="fs-input area-select" aria-label="Shared area">
<option :value="null">None — specific to this project</option>
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
{{ entry.name }}
</option>
</select>
</label>
<label class="area-field">
<span class="area-label">Files</span>
<textarea
v-model="editPaths"
class="fs-input system-textarea system-paths-input"
rows="2"
placeholder="One pattern per line, from the repo root"
aria-label="System files"
spellcheck="false"
></textarea>
</label>
<div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
{{ savingEdit ? "Saving…" : "Save" }}
</button>
<button type="button" class="btn-ghost btn-compact" @click="cancelEdit">Cancel</button>
</div>
</form>
</template>
<!-- Display -->
<template v-else>
<span
class="system-swatch"
:style="{ background: system.color || 'var(--fs-text-tertiary)' }"
aria-hidden="true"
></span>
<div class="system-body">
<div class="system-name-row">
<span class="system-name">{{ system.name }}</span>
<span
class="issue-badge"
:title="`${system.open_issue_count} open issue(s)`"
>{{ system.open_issue_count }} open</span>
<span v-if="system.status === 'archived'" class="archived-badge">Archived</span>
<!-- Not a TagPill: that recipe prefixes "#" and means a tag.
This is the shared AREA this system is an instance of. -->
<span
v-if="areaName(system)"
class="area-chip"
:title="`Filed under the shared area “${areaName(system)}” — records and rules about this area line up across projects.`"
>{{ areaName(system) }}</span>
<span
v-if="rulingsShown(system)"
class="usage-tag"
:title="rulingsTitle(system)"
>rulings shown {{ rulingsShown(system) }}×</span>
</div>
<p v-if="system.description" class="system-description">{{ system.description }}</p>
<ul v-if="system.path_patterns.length" class="system-paths" aria-label="Files">
<li v-for="pattern in system.path_patterns" :key="pattern" class="system-path">{{ pattern }}</li>
</ul>
</div>
<div class="system-actions">
<button class="action-btn" title="Edit" aria-label="Edit system" @click="startEdit(system)">
<Pencil :size="16" />
</button>
<button
v-if="system.status === 'active'"
class="action-btn"
title="Archive"
aria-label="Archive system"
@click="archive(system)"
>
<Archive :size="16" />
</button>
<button
v-else
class="action-btn"
title="Restore"
aria-label="Restore system"
@click="unarchive(system)"
>
<ArchiveRestore :size="16" />
</button>
<button
class="action-btn action-delete"
title="Delete"
aria-label="Delete system"
@click="deletingSystem = system"
>
<Trash2 :size="16" />
</button>
</div>
</template>
</li>
</ul>
<!-- Delete confirmation -->
<teleport to="body">
<div v-if="deletingSystem" class="modal-overlay" @click.self="deletingSystem = null">
<div class="modal-card">
<h3 class="modal-title">Delete System</h3>
<p class="modal-message">
Delete <strong>{{ deletingSystem.name }}</strong>? This cannot be undone.
</p>
<div class="modal-actions">
<button class="modal-btn" @click="deletingSystem = null">Cancel</button>
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
</div>
</template>
<style scoped>
.systems-section { display: flex; flex-direction: column; gap: 0.75rem; }
/* ── Open issues ──────────────────────────────────────────────── */
.open-issues { display: flex; flex-direction: column; gap: 0.35rem; }
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fs-text-tertiary); }
.issue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--fs-radius-sm); text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
.issue-link:hover { background: var(--fs-surface-raised); }
.issue-mark { color: var(--fs-text-tertiary); flex-shrink: 0; }
.issue-mark.imk-in_progress { color: var(--fs-accent); }
.issue-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
.issue-sys-chip { font-size: 0.66rem; color: var(--fs-text-secondary); background: var(--fs-surface-raised); border-radius: 999px; padding: 0.05rem 0.4rem; }
/* ── Shared-area mapping (milestone 307) ──────────────────────────
The review is a disclosure, not a banner: it exists only while there is
something to decide, and collapses to one line until opened. */
.area-review {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--fs-surface-raised);
}
.area-review-head {
display: flex;
align-items: center;
gap: var(--fs-space-2);
width: 100%;
padding: var(--fs-space-3);
background: none;
border: none;
color: var(--fs-text-secondary);
font: inherit;
font-size: 0.82rem;
text-align: left;
cursor: pointer;
border-radius: var(--fs-radius-lg);
}
.area-review-head:hover { color: var(--fs-text-primary); }
.area-review-head:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
.area-review-count {
background: var(--fs-accent-soft);
color: var(--fs-accent);
border-radius: var(--fs-radius-pill);
padding: 0.05rem 0.45rem;
font-variant-numeric: tabular-nums;
}
.area-review-chev { margin-left: auto; color: var(--fs-text-tertiary); }
.area-proposals { list-style: none; margin: 0; padding: 0 var(--fs-space-3) var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-2); }
.area-proposal {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fs-space-3);
flex-wrap: wrap;
padding: var(--fs-space-2) var(--fs-space-3);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.area-proposal-text { display: flex; align-items: center; gap: var(--fs-space-2); flex-wrap: wrap; font-size: 0.85rem; min-width: 0; }
.area-proposal-name { color: var(--fs-text-primary); }
.area-proposal-arrow { color: var(--fs-text-tertiary); }
.area-proposal-target { color: var(--fs-accent); }
.area-proposal-actions { display: flex; gap: var(--fs-space-2); flex-shrink: 0; }
/* The two bases must never look alike — one is mechanical, the other is the
reviewer's judgment, and that difference is the whole decision. */
.area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; }
.area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium-fg); }
.area-offer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fs-space-3);
flex-wrap: wrap;
padding: var(--fs-space-3);
font-size: 0.85rem;
color: var(--fs-text-secondary);
background: var(--fs-accent-faint);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.area-field { display: flex; flex-direction: column; gap: 0.3rem; }
.area-label { font-size: 0.78rem; color: var(--fs-text-tertiary); }
.area-select { box-sizing: border-box; width: 100%; }
.area-chip {
font-size: 0.66rem;
color: var(--fs-accent);
background: var(--fs-accent-soft);
border-radius: var(--fs-radius-pill);
padding: 0.05rem 0.45rem;
white-space: nowrap;
}
/* ── Toolbar ──────────────────────────────────────────────────── */
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.btn-add-system {
background: none;
border: 1px dashed var(--fs-border-color);
color: var(--fs-text-secondary);
padding: 0.28rem 0.65rem;
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
.btn-add-system:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.btn-add-system:focus-visible { outline: none; border-color: var(--fs-accent); color: var(--fs-accent); }
.archived-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--fs-text-tertiary);
cursor: pointer;
user-select: none;
}
.archived-checkbox { accent-color: var(--fs-accent); cursor: pointer; }
/* ── Create / edit form ───────────────────────────────────────── */
.system-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
/* The input itself is the .fs-input canon (components.css); only the
layout remainder lives here. */
.system-input, .system-textarea { box-sizing: border-box; width: 100%; }
.system-textarea { resize: vertical; }
.system-form-actions { display: flex; gap: 0.4rem; }
.system-paths-input { font-family: var(--fs-font-mono); font-size: 0.8rem; }
/* The area's files (milestone 444). Mono because they are paths to be read
character for character; small because they qualify the card, not lead it. */
.system-paths {
list-style: none;
margin: var(--fs-space-2) 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--fs-space-1);
}
.system-path {
font-family: var(--fs-font-mono);
font-size: 0.7rem;
color: var(--fs-text-secondary);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.05rem 0.4rem;
word-break: break-all;
}
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
`--archived` modifier and the `:hover .system-actions` reveal survived.
The card WAS a flex row and every child still says so — `.system-swatch`
and `.system-actions` are `flex-shrink: 0`, `.system-body` is `flex: 1`,
and `.system-form--inline` is `flex: 1`. `align-items: flex-start` is why
the swatch carries `margin-top: 0.3rem`: it is nudged onto the first line
of text rather than centred against the whole card.
The list had no rule at all, so it rendered with browser bullets and
indent — invisible to the dangling-style check, which can only see a class
that is PARTLY styled. A class with no rules anywhere looks exactly like a
semantic-only hook.
Surface values match `.system-form` above, which is the same card shape in
this file and the reason they can be recovered rather than guessed. */
.systems-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.system-card {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.6rem 0.75rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.system-card--archived { opacity: 0.6; }
.system-swatch {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 0.3rem;
}
.system-body { flex: 1; min-width: 0; }
.system-name-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.system-name { font-weight: 500; color: var(--fs-text-primary); word-break: break-word; }
.issue-badge {
font-size: 0.7rem;
font-weight: 500;
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
color: var(--fs-accent-fg);
border-radius: 999px;
padding: 0.05rem 0.45rem;
flex-shrink: 0;
}
.archived-badge {
font-size: 0.65rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fs-text-tertiary-fg);
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
border-radius: 999px;
padding: 0.05rem 0.45rem;
}
.system-description {
margin: 0.25rem 0 0;
font-size: 0.82rem;
color: var(--fs-text-secondary);
line-height: 1.4;
word-break: break-word;
}
.system-actions { display: flex; gap: 0.15rem; flex-shrink: 0; opacity: 0; transition: opacity 0.15s; }
.system-card:hover .system-actions,
.system-card:focus-within .system-actions { opacity: 1; }
.action-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
color: var(--fs-text-tertiary);
width: 26px;
height: 26px;
border-radius: var(--fs-radius-sm);
transition: background 0.12s, color 0.12s;
}
.action-btn:hover { background: var(--fs-surface-raised); color: var(--fs-text-primary); }
.action-btn:focus-visible { outline: 2px solid var(--fs-accent); outline-offset: 1px; opacity: 1; }
.action-delete:hover { color: var(--fs-error); }
/* ── Empty ────────────────────────────────────────────────────── */
.systems-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 2rem 1rem;
text-align: center;
border: 1px dashed var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
/* remainders over the shared recipes (components.css, m302) */
.empty-title { margin: 0; color: var(--fs-text-primary); }
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; max-width: 32ch; }
/* ── Skeleton ─────────────────────────────────────────────────── */
@keyframes skel-shine { to { background-position: 200% center; } }
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
.skel-row {
height: 3rem;
border-radius: var(--fs-radius-lg);
background: linear-gradient(
90deg,
var(--fs-surface-raised) 25%,
color-mix(in srgb, var(--fs-text-tertiary) 16%, var(--fs-surface-raised)) 50%,
var(--fs-surface-raised) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
}
.skel-row--short { width: 65%; }
</style>