CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / Build & push image (push) Successful in 1m2s
The badge fix (#3132) exposed the same defect everywhere: 48 rules painting a token as TEXT on an inline color-mix tint of that same token. Worst raw measurements, across every tint strength in use, both modes, over page/raised/hover: accent 1.53:1 · success 1.67:1 · text-tertiary 2.15:1 warning 2.32:1 · error 2.36:1 against AA's 4.5 THE DEFECT IS IN THE HOUSE, NOT IN SCRIBE. The semantic hues are shared family-wide, and the accent case was measured against every app's real accent, not assumed from Scribe's: Minstrel 1.81, Forge 1.87, Steward 1.65, Roundtable 3.01 — all failing. So the six -fg tokens are recorded on FabledSword (design system 1), where their parents live, rather than copied into each app. 45% toward --fs-text-primary clears AA for ALL FIVE accents (4.56-5.00), so this is one house token rather than five overrides, and it keeps deriving from --fs-accent — an app that overrides its accent still gets a legible tinted-text colour in its own colour, the same mechanism as --fs-accent-soft. The tokens are additive: a sibling app is unaffected until it regenerates its own stylesheet. One token is honestly redundant. --fs-text-secondary already passes at 4.82:1, and --fs-text-secondary-fg barely moves it. It exists so the rule has NO exceptions, because the alternative is a permanent allow-list entry for the one case that happens to pass — and a guard with an invisible exception is a guard that erodes. 46 substitutions across 18 files, each rewriting only the `color:` inside a block that tints its own background. THE CHECK NOW GATES BOTH SPELLINGS. It previously reported the inline form, because a gate nobody can satisfy on the day it lands gets switched off. Both are clean, so both fail the build now. And the check had a false-positive bug worth naming: its `color\s*:` regex matched the tail of `border-color`, `border-left-color` and `outline-color`, so it flagged seven rules that were already correct. A border is a non-text graphic with a 3:1 floor, not text at 4.5. A check that cries wolf on correct code is one that gets muted, so that mattered more than the noise. Verified by construction, not by passing: reintroduced each defect form (exit 1 each), and confirmed a legitimate border-only rule still exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
759 lines
28 KiB
Vue
759 lines
28 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 { 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 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 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] ?? []);
|
|
|
|
function areaName(system: System): string | null {
|
|
return canon.byId(system.canonical_id)?.name ?? null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function cancelCreate() {
|
|
showCreate.value = false;
|
|
newName.value = "";
|
|
newDescription.value = "";
|
|
newCanonicalId.value = null;
|
|
}
|
|
|
|
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,
|
|
});
|
|
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;
|
|
}
|
|
|
|
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(),
|
|
});
|
|
// 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 {
|
|
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>
|
|
|
|
<!-- 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>
|
|
<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>
|
|
<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>
|
|
</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; }
|
|
|
|
/* ── 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; }
|
|
|
|
/* 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>
|