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
#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>
852 lines
31 KiB
Vue
852 lines
31 KiB
Vue
<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>
|