CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.
73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.
One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.
Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.
Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).
Refs #2533
551 lines
19 KiB
Vue
551 lines
19 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(--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>
|
|
</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(--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; }
|
|
|
|
/* ── 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; }
|
|
.system-input, .system-textarea {
|
|
padding: 0.4rem 0.6rem;
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-sm);
|
|
background: var(--fs-surface-page);
|
|
color: var(--fs-text-primary);
|
|
font-size: 0.875rem;
|
|
font-family: inherit;
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
}
|
|
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--fs-accent); }
|
|
.system-textarea { resize: vertical; }
|
|
|
|
.system-form-actions { display: flex; gap: 0.4rem; }
|
|
|
|
/* 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);
|
|
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);
|
|
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);
|
|
}
|
|
.empty-title { margin: 0; font-weight: 500; color: var(--fs-text-primary); }
|
|
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--fs-text-tertiary); max-width: 32ch; }
|
|
|
|
.error-msg { color: var(--fs-error); 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(--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%; }
|
|
|
|
/* ── Modal ────────────────────────────────────────────────────── */
|
|
.modal-overlay {
|
|
position: fixed; inset: 0;
|
|
background: var(--fs-overlay);
|
|
display: flex; align-items: center; justify-content: center;
|
|
z-index: 200;
|
|
}
|
|
.modal-card {
|
|
background: var(--fs-surface-raised);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-lg);
|
|
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(--fs-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(--fs-border-color);
|
|
background: var(--fs-surface-raised);
|
|
color: var(--fs-text-primary);
|
|
border-radius: var(--fs-radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-family: inherit;
|
|
}
|
|
.modal-btn:hover { background: var(--fs-surface-page); }
|
|
.modal-btn-danger { background: var(--fs-action-destructive); border-color: var(--fs-action-destructive); color: var(--fs-text-on-action); }
|
|
.modal-btn-danger:hover { background: var(--fs-action-destructive-hover); border-color: var(--fs-action-destructive-hover); }
|
|
</style>
|