CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 30s
CI & Build / TypeScript typecheck (push) Successful in 45s
CI & Build / integration (push) Successful in 1m50s
CI & Build / Python tests (push) Successful in 2m13s
CI & Build / Build & push image (push) Successful in 42s
Twelve more files onto the shared buttons. What the pass turned up: DEAD, verified not merely unnamed: - .btn-reconsolidate (TaskEditorView). A comment eleven hundred lines up in the same file says the feature was removed in Phase 8. The CSS outlived it. - .btn-remove-slot (SettingsView), style rules only, no template anywhere. OFF-PALETTE, the #2319 shape: TrashView's restore and purge hovers used `var(--color-primary, #6366f1)` and `var(--color-danger, #ef4444)` — Tailwind indigo and Tailwind red, from no palette in this system. The fallback is what renders if the token is ever absent, and it renders something plausible forever. Now the action and destructive colours, no fallback. A REAL BREAKAGE MY OWN CHECK COULD NOT SEE, worth recording. Deleting a rule whose selector was part of a comma-separated group left the leading selectors behind: .btn-log-edit, <nothing> .log-textarea { … } which silently swallows the next rule. Brace counting passed — there are no braces in a dangling fragment. Found by scanning for selector lines ending in `,` not followed by another selector; three instances across two files, one of them interleaved with comments so the first sweep missed it. The sweep is now part of the verification, not a one-off. Kept bespoke, deliberately: .btn-pin/.btn-unpin (pill-shaped history badges), .btn-add-share and .btn-new-note (gradient CTAs — brand moments, which the house style does sanction), .btn-icon/.btn-bell (icon buttons, a different component), .btn-add-system/.btn-add-milestone (dashed "add" affordances). These are not drift; they are other things wearing a btn- prefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
516 lines
18 KiB
Vue
516 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, watch } from "vue";
|
|
import { useSystemsStore } from "@/stores/systems";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import { getProjectIssues } from "@/api/systems";
|
|
import type { System, TaskLike } from "@/api/systems";
|
|
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
|
|
|
|
const props = defineProps<{ projectId: number }>();
|
|
|
|
const store = useSystemsStore();
|
|
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("");
|
|
const creating = ref(false);
|
|
|
|
// Edit state
|
|
const editingId = ref<number | null>(null);
|
|
const editName = ref("");
|
|
const editDescription = ref("");
|
|
const savingEdit = ref(false);
|
|
|
|
// 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,
|
|
);
|
|
|
|
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 = [];
|
|
}
|
|
}
|
|
|
|
onMounted(load);
|
|
watch(() => props.projectId, load);
|
|
|
|
function openCreate() {
|
|
showCreate.value = true;
|
|
newName.value = "";
|
|
newDescription.value = "";
|
|
}
|
|
|
|
function cancelCreate() {
|
|
showCreate.value = false;
|
|
newName.value = "";
|
|
newDescription.value = "";
|
|
}
|
|
|
|
async function submitCreate() {
|
|
const name = newName.value.trim();
|
|
if (!name || creating.value) return;
|
|
creating.value = true;
|
|
try {
|
|
await store.createSystem(props.projectId, {
|
|
name,
|
|
description: newDescription.value.trim() || undefined,
|
|
});
|
|
cancelCreate();
|
|
toast.show("System created");
|
|
} catch {
|
|
toast.show("Failed to create system", "error");
|
|
} finally {
|
|
creating.value = false;
|
|
}
|
|
}
|
|
|
|
function startEdit(system: System) {
|
|
editingId.value = system.id;
|
|
editName.value = system.name;
|
|
editDescription.value = system.description;
|
|
}
|
|
|
|
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(),
|
|
});
|
|
editingId.value = null;
|
|
toast.show("System updated");
|
|
} catch {
|
|
toast.show("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>
|
|
|
|
<!-- 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="system-input"
|
|
placeholder="System name"
|
|
aria-label="System name"
|
|
autofocus
|
|
@keydown.escape="cancelCreate"
|
|
/>
|
|
<textarea
|
|
v-model="newDescription"
|
|
class="system-textarea"
|
|
rows="2"
|
|
placeholder="What is this subsystem responsible for? (optional)"
|
|
aria-label="System description"
|
|
></textarea>
|
|
<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="system-input"
|
|
placeholder="System name"
|
|
aria-label="System name"
|
|
autofocus
|
|
@keydown.escape="cancelEdit"
|
|
/>
|
|
<textarea
|
|
v-model="editDescription"
|
|
class="system-textarea"
|
|
rows="2"
|
|
placeholder="Description (optional)"
|
|
aria-label="System description"
|
|
></textarea>
|
|
<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(--color-text-muted)' }"
|
|
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>
|
|
</div>
|
|
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
|
</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(--color-text-muted); }
|
|
.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(--radius-sm); text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
|
.issue-link:hover { background: var(--color-bg-secondary); }
|
|
.issue-mark { color: var(--color-text-muted); flex-shrink: 0; }
|
|
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
|
.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(--color-text-secondary); background: var(--color-bg-secondary); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
|
|
|
/* ── Toolbar ──────────────────────────────────────────────────── */
|
|
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
|
.btn-add-system {
|
|
background: none;
|
|
border: 1px dashed var(--color-border);
|
|
color: var(--color-text-secondary);
|
|
padding: 0.28rem 0.65rem;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.78rem;
|
|
font-family: inherit;
|
|
}
|
|
.btn-add-system:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
|
.btn-add-system:focus-visible { outline: none; border-color: var(--color-primary); color: var(--color-primary); }
|
|
|
|
.archived-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.78rem;
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.archived-checkbox { accent-color: var(--color-primary); cursor: pointer; }
|
|
|
|
/* ── Create / edit form ───────────────────────────────────────── */
|
|
.system-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
|
.system-input, .system-textarea {
|
|
padding: 0.4rem 0.6rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
font-size: 0.875rem;
|
|
font-family: inherit;
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
}
|
|
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--color-primary); }
|
|
.system-textarea { resize: vertical; }
|
|
|
|
.system-form-actions { display: flex; gap: 0.4rem; }
|
|
.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(--color-text); word-break: break-word; }
|
|
.issue-badge {
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
|
color: var(--color-primary);
|
|
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(--color-text-muted);
|
|
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
|
border-radius: 999px;
|
|
padding: 0.05rem 0.45rem;
|
|
}
|
|
.system-description {
|
|
margin: 0.25rem 0 0;
|
|
font-size: 0.82rem;
|
|
color: var(--color-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(--color-text-muted);
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: var(--radius-sm);
|
|
transition: background 0.12s, color 0.12s;
|
|
}
|
|
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
|
|
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
|
|
.action-delete:hover { color: var(--color-danger, #e74c3c); }
|
|
|
|
/* ── Empty ────────────────────────────────────────────────────── */
|
|
.systems-empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 2rem 1rem;
|
|
text-align: center;
|
|
border: 1px dashed var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
.empty-title { margin: 0; font-weight: 500; color: var(--color-text); }
|
|
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--color-text-muted); max-width: 32ch; }
|
|
|
|
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
|
|
|
/* ── 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(--radius-md);
|
|
background: linear-gradient(
|
|
90deg,
|
|
var(--color-bg-secondary) 25%,
|
|
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
|
|
var(--color-bg-secondary) 75%
|
|
);
|
|
background-size: 200% 100%;
|
|
animation: skel-shine 1.5s ease infinite;
|
|
}
|
|
.skel-row--short { width: 65%; }
|
|
|
|
/* ── Modal ────────────────────────────────────────────────────── */
|
|
.modal-overlay {
|
|
position: fixed; inset: 0;
|
|
background: var(--color-overlay, rgba(0,0,0,0.45));
|
|
display: flex; align-items: center; justify-content: center;
|
|
z-index: 200;
|
|
}
|
|
.modal-card {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 1.5rem;
|
|
width: 100%;
|
|
max-width: 400px;
|
|
box-shadow: 0 8px 32px var(--color-shadow);
|
|
}
|
|
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
|
.modal-message { font-size: 0.9rem; color: var(--color-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
|
|
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
|
|
.modal-btn {
|
|
padding: 0.4rem 0.9rem;
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-bg-secondary);
|
|
color: var(--color-text);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-family: inherit;
|
|
}
|
|
.modal-btn:hover { background: var(--color-bg); }
|
|
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); }
|
|
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
|
|
</style>
|