Merge pull request 'Derive pay-down + CSS families by name (milestone 299 steps 5–6, note #2917)' (#126) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 14s

This commit was merged in pull request #126.
This commit is contained in:
2026-08-23 00:32:07 -04:00
32 changed files with 419 additions and 2136 deletions
+33
View File
@@ -297,3 +297,36 @@
background: var(--fs-action-destructive-hover); background: var(--fs-action-destructive-hover);
border-color: var(--fs-action-destructive-hover); border-color: var(--fs-action-destructive-hover);
} }
/* ── Page container ─────────────────────────────────────────────────────────
The one wrapper a top-level view sits in: page width from the layout
tokens, centred, clipped horizontally so a wide child (a kanban, a table)
scrolls inside itself instead of the page. ProjectListView, ProjectView and
SnippetListView each carried this rule under their own name until #2903
(milestone 299). */
.page-container {
max-width: var(--fs-layout-page-max);
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
/* ── Form input (fs-surfaces, snippet #2336) ────────────────────────────────
Inputs sit DARKER than the page they're on — an inset well rather than a
raised panel; that inversion is what makes a field read as writable. The
design system's recipe, verbatim; width/box-sizing stay the caller's
(an inline select and a full-width textarea differ there). Three scoped
copies of an older input recipe were folded into this in #2903. */
.fs-input {
background: var(--fs-surface-page);
border: var(--fs-border);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-2) var(--fs-space-3); /* 8px 12px */
color: var(--fs-text-primary);
font-family: var(--fs-font-body);
font-size: var(--fs-size-body);
transition: box-shadow var(--fs-dur-fast) var(--fs-ease);
}
.fs-input::placeholder { color: var(--fs-text-tertiary); }
.fs-input:focus { outline: none; box-shadow: var(--fs-focus-ring); }
.fs-input:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
+50
View File
@@ -0,0 +1,50 @@
/* The near-duplicate report, shared by KnowledgeView (notes/tasks) and
SnippetListView (snippets) so the two reports read as one feature. Load
with <style src="@/assets/dup-report.css" /> beside the view's scoped
block; the view keeps only its own extras (.dup-claimed, .dup-action).
Promoted from two identical scoped copies in #2903 (milestone 299). */
.dup-panel {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: 8px;
background: var(--fs-surface-hover);
}
.dup-empty,
.dup-head {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
}
.dup-empty { margin-bottom: 0; }
.dup-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--fs-border-color);
}
.dup-members {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
flex: 1 1 20rem;
min-width: 0;
}
.dup-member {
font-size: 0.8rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
color: var(--fs-text-primary);
text-decoration: none;
overflow-wrap: anywhere;
}
.dup-member:hover { background: var(--fs-surface-hover); }
.dup-score {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
+34 -1
View File
@@ -78,7 +78,7 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.3rem;
} }
.tag-pill { .tag-pill {
display: inline-flex; display: inline-flex;
@@ -508,3 +508,36 @@
opacity: var(--fs-disabled-opacity); opacity: var(--fs-disabled-opacity);
cursor: not-allowed; cursor: not-allowed;
} }
/* Shared by NoteEditorView and TaskEditorView — both carried identical scoped
copies of these until #2903 (milestone 299); one source here. */
.body-tabs-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
}
.body-editor-wrap {
min-height: 200px;
}
.stream-preview {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.75rem;
background: var(--fs-surface-raised);
min-height: 200px;
}
.main-diff {
flex: 1;
min-height: 0;
}
.assist-section-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
+14
View File
@@ -0,0 +1,14 @@
/* Shared by the three rules panes (RulebookListPane, RuleListPane,
RulebookDetailPane): the pane surface and its heading. Load with
<style src="@/assets/rules-shared.css" /> beside the component's own
scoped block; never restate these there (#2903, milestone 299). */
.pane {
background: var(--fs-surface-hover);
padding: 1rem;
overflow-y: auto;
}
.pane header h2 {
font-family: Fraunces, serif;
font-style: italic;
margin: 0 0 0.5rem 0;
}
+3 -16
View File
@@ -51,7 +51,7 @@ function onChange(e: Event) {
<template> <template>
<select <select
class="milestone-select" class="fs-input milestone-select"
:value="modelValue ?? ''" :value="modelValue ?? ''"
:disabled="!projectId || loading" :disabled="!projectId || loading"
@change="onChange" @change="onChange"
@@ -64,23 +64,10 @@ function onChange(e: Event) {
</template> </template>
<style scoped> <style scoped>
/* The input itself is the .fs-input canon (components.css); only the
layout remainder lives here. */
.milestone-select { .milestone-select {
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; box-sizing: border-box;
width: 100%; width: 100%;
} }
.milestone-select:focus {
outline: none;
border-color: var(--fs-accent);
}
.milestone-select:disabled {
opacity: 0.5;
cursor: default;
}
</style> </style>
+7 -16
View File
@@ -176,7 +176,7 @@ async function confirmDelete() {
<form v-if="showCreate" class="system-form" @submit.prevent="submitCreate"> <form v-if="showCreate" class="system-form" @submit.prevent="submitCreate">
<input <input
v-model="newName" v-model="newName"
class="system-input" class="fs-input system-input"
placeholder="System name" placeholder="System name"
aria-label="System name" aria-label="System name"
autofocus autofocus
@@ -184,7 +184,7 @@ async function confirmDelete() {
/> />
<textarea <textarea
v-model="newDescription" v-model="newDescription"
class="system-textarea" class="fs-input system-textarea"
rows="2" rows="2"
placeholder="What is this subsystem responsible for? (optional)" placeholder="What is this subsystem responsible for? (optional)"
aria-label="System description" aria-label="System description"
@@ -227,7 +227,7 @@ async function confirmDelete() {
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)"> <form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
<input <input
v-model="editName" v-model="editName"
class="system-input" class="fs-input system-input"
placeholder="System name" placeholder="System name"
aria-label="System name" aria-label="System name"
autofocus autofocus
@@ -235,7 +235,7 @@ async function confirmDelete() {
/> />
<textarea <textarea
v-model="editDescription" v-model="editDescription"
class="system-textarea" class="fs-input system-textarea"
rows="2" rows="2"
placeholder="Description (optional)" placeholder="Description (optional)"
aria-label="System description" aria-label="System description"
@@ -372,18 +372,9 @@ async function confirmDelete() {
border-radius: var(--fs-radius-lg); border-radius: var(--fs-radius-lg);
} }
.system-form--inline { padding: 0; background: none; border: none; flex: 1; } .system-form--inline { padding: 0; background: none; border: none; flex: 1; }
.system-input, .system-textarea { /* The input itself is the .fs-input canon (components.css); only the
padding: 0.4rem 0.6rem; layout remainder lives here. */
border: 1px solid var(--fs-border-color); .system-input, .system-textarea { box-sizing: border-box; width: 100%; }
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-textarea { resize: vertical; }
.system-form-actions { display: flex; gap: 0.4rem; } .system-form-actions { display: flex; gap: 0.4rem; }
@@ -21,9 +21,8 @@ const emit = defineEmits<{
</section> </section>
</template> </template>
<style src="@/assets/rules-shared.css" />
<style scoped> <style scoped>
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; } ul { list-style: none; padding: 0; margin: 1rem 0; }
li { li {
padding: 0.75rem; padding: 0.75rem;
@@ -121,10 +121,9 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
</section> </section>
</template> </template>
<style src="@/assets/rules-shared.css" />
<style scoped> <style scoped>
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
.always-on-toggle { .always-on-toggle {
display: flex; align-items: center; gap: 0.4rem; display: flex; align-items: center; gap: 0.4rem;
font-size: 0.85rem; opacity: 0.85; cursor: pointer; font-size: 0.85rem; opacity: 0.85; cursor: pointer;
@@ -47,9 +47,8 @@ async function submitNew() {
</aside> </aside>
</template> </template>
<style src="@/assets/rules-shared.css" />
<style scoped> <style scoped>
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; } ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; } li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
li.active { background: var(--fs-accent-soft); } li.active { background: var(--fs-accent-soft); }
+1 -50
View File
@@ -574,6 +574,7 @@ onUnmounted(() => {
</div> </div>
</template> </template>
<style src="@/assets/dup-report.css" />
<style scoped> <style scoped>
/* ── Root layout ─────────────────────────────────────────── */ /* ── Root layout ─────────────────────────────────────────── */
.knowledge-root { .knowledge-root {
@@ -1042,56 +1043,6 @@ onUnmounted(() => {
} }
/* ── Near-duplicate report ──────────────────────────────────────────────────
Mirrors SnippetListView's panel so the two reports read as one feature.
Scoped styles can't be shared across SFCs; if a third view ever grows this
panel, promote the family to components.css and record it (#2464's rule:
two-or-more is when a recipe earns the shared sheet). */
.dup-panel {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: 8px;
background: var(--fs-surface-hover);
}
.dup-empty,
.dup-head {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
}
.dup-empty { margin-bottom: 0; }
.dup-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--fs-border-color);
}
.dup-members {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
flex: 1 1 20rem;
min-width: 0;
}
.dup-member {
font-size: 0.8rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
color: var(--fs-text-primary);
text-decoration: none;
overflow-wrap: anywhere;
}
.dup-member:hover { background: var(--fs-surface-hover); }
.dup-score {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */ /* A set someone already ruled on — quiet, not celebratory: it means "skip". */
.dup-claimed { .dup-claimed {
font-size: 0.72rem; font-size: 0.72rem;
-485
View File
@@ -1,485 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { apiGet } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import PaginationBar from "@/components/PaginationBar.vue";
import { fmtLogStamp } from "@/utils/dateFormat";
const toastStore = useToastStore();
interface LogEntry {
id: number;
category: string;
user_id: number | null;
username: string | null;
action: string | null;
endpoint: string | null;
method: string | null;
status_code: number | null;
duration_ms: number | null;
ip_address: string | null;
details: string | null;
created_at: string;
}
interface LogStats {
audit: number;
usage: number;
error: number;
total: number;
}
const logs = ref<LogEntry[]>([]);
const stats = ref<LogStats>({ audit: 0, usage: 0, error: 0, total: 0 });
const total = ref(0);
const loading = ref(true);
const expandedId = ref<number | null>(null);
// Filters
const category = ref("");
const search = ref("");
const dateFrom = ref("");
const dateTo = ref("");
const limit = 50;
const offset = ref(0);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
onMounted(async () => {
await Promise.all([fetchLogs(), fetchStats()]);
loading.value = false;
});
watch([category, dateFrom, dateTo], () => {
offset.value = 0;
fetchLogs();
});
watch(search, () => {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
offset.value = 0;
fetchLogs();
}, 300);
});
watch(offset, () => {
fetchLogs();
});
async function fetchLogs() {
try {
const params = new URLSearchParams();
if (category.value) params.set("category", category.value);
if (search.value) params.set("search", search.value);
if (dateFrom.value) params.set("date_from", dateFrom.value);
if (dateTo.value) params.set("date_to", dateTo.value);
params.set("limit", String(limit));
params.set("offset", String(offset.value));
const data = await apiGet<{ logs: LogEntry[]; total: number }>(
`/api/admin/logs?${params}`
);
logs.value = data.logs;
total.value = data.total;
} catch {
toastStore.show("Failed to load logs", "error");
}
}
async function fetchStats() {
try {
stats.value = await apiGet<LogStats>("/api/admin/logs/stats");
} catch {
// Ignore
}
}
function toggleExpand(id: number) {
expandedId.value = expandedId.value === id ? null : id;
}
function formatDetails(details: string | null): string {
if (!details) return "";
try {
return JSON.stringify(JSON.parse(details), null, 2);
} catch {
return details;
}
}
function displayLabel(entry: LogEntry): string {
if (entry.category === "audit" && entry.action) return entry.action;
if (entry.endpoint) return entry.endpoint;
return "—";
}
function clearFilters() {
category.value = "";
search.value = "";
dateFrom.value = "";
dateTo.value = "";
offset.value = 0;
}
</script>
<template>
<main class="logs-page">
<h1>Application Logs</h1>
<section class="settings-section stats-section">
<div class="stats-grid">
<div class="stat-card">
<span class="stat-count">{{ stats.total.toLocaleString() }}</span>
<span class="stat-label">Total</span>
</div>
<div class="stat-card">
<span class="stat-count stat-audit">{{ stats.audit.toLocaleString() }}</span>
<span class="stat-label">Audit</span>
</div>
<div class="stat-card">
<span class="stat-count stat-usage">{{ stats.usage.toLocaleString() }}</span>
<span class="stat-label">Usage</span>
</div>
<div class="stat-card">
<span class="stat-count stat-error">{{ stats.error.toLocaleString() }}</span>
<span class="stat-label">Error</span>
</div>
</div>
</section>
<section class="settings-section">
<h2>Filters</h2>
<div class="filter-bar">
<select v-model="category" class="filter-select">
<option value="">All categories</option>
<option value="audit">Audit</option>
<option value="usage">Usage</option>
<option value="error">Error</option>
</select>
<input
v-model="search"
type="text"
placeholder="Search logs..."
class="filter-input"
/>
<input v-model="dateFrom" type="date" class="filter-date" title="From date" />
<input v-model="dateTo" type="date" class="filter-date" title="To date" />
<button
v-if="category || search || dateFrom || dateTo"
class="btn-ghost btn-compact"
@click="clearFilters"
>
Clear
</button>
</div>
</section>
<section class="settings-section">
<div v-if="loading" class="loading-msg">Loading logs...</div>
<div v-else-if="logs.length === 0" class="empty-msg">No log entries found.</div>
<template v-else>
<table class="users-table logs-table">
<thead>
<tr>
<th>Time</th>
<th>Category</th>
<th class="hide-mobile">User</th>
<th>Action / Endpoint</th>
<th class="hide-mobile">IP</th>
<th class="hide-mobile">Status</th>
<th class="hide-mobile">Duration</th>
</tr>
</thead>
<tbody>
<template v-for="entry in logs" :key="entry.id">
<tr
class="log-row"
:class="{ 'row-expanded': expandedId === entry.id }"
@click="toggleExpand(entry.id)"
>
<td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
<td>
<span class="category-badge" :class="'cat-' + entry.category">
{{ entry.category }}
</span>
</td>
<td class="hide-mobile cell-user">{{ entry.username || "—" }}</td>
<td class="cell-action">
<span v-if="entry.method" class="method-tag">{{ entry.method }}</span>
{{ displayLabel(entry) }}
</td>
<td class="hide-mobile cell-ip">{{ entry.ip_address || "—" }}</td>
<td class="hide-mobile cell-status">
<span v-if="entry.status_code" :class="entry.status_code >= 400 ? 'text-error' : ''">
{{ entry.status_code }}
</span>
<span v-else></span>
</td>
<td class="hide-mobile cell-duration">
{{ entry.duration_ms != null ? entry.duration_ms + "ms" : "—" }}
</td>
</tr>
<tr v-if="expandedId === entry.id && (entry.details || entry.ip_address)" class="detail-row">
<td colspan="7">
<div v-if="entry.ip_address" class="detail-ip">IP: {{ entry.ip_address }}</div>
<pre v-if="entry.details" class="detail-json">{{ formatDetails(entry.details) }}</pre>
</td>
</tr>
</template>
</tbody>
</table>
<PaginationBar
:total="total"
:limit="limit"
:offset="offset"
@update:offset="offset = $event"
/>
</template>
</section>
</main>
</template>
<style scoped>
.logs-page {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.logs-page h1 {
margin: 0 0 1.5rem;
}
.settings-section {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.settings-section h2 {
margin: 0 0 0.75rem;
font-size: 1.1rem;
}
/* Stats */
.stats-section {
padding: 1rem 1.25rem;
}
.stats-grid {
display: flex;
gap: 1rem;
}
.stat-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.stat-count {
font-size: 1.5rem;
font-weight: 700;
color: var(--fs-text-primary);
}
.stat-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
}
.stat-audit {
color: var(--fs-accent);
}
.stat-usage {
color: var(--fs-success);
}
.stat-error {
color: var(--fs-error);
}
/* Filters */
.filter-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-select,
.filter-input,
.filter-date {
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.85rem;
}
.filter-select {
min-width: 140px;
}
.filter-input {
flex: 1;
min-width: 150px;
}
.filter-date {
width: 140px;
}
/* Table */
.loading-msg,
.empty-msg {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.9rem;
padding: 1rem 0;
}
.logs-table {
width: 100%;
border-collapse: collapse;
}
.logs-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
}
.logs-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
font-size: 0.85rem;
}
.logs-table tbody tr:last-child td {
border-bottom: none;
}
.log-row {
cursor: pointer;
transition: background 0.1s;
}
.log-row:hover {
background: var(--fs-surface-raised);
}
.row-expanded {
background: var(--fs-surface-raised);
}
.cell-time {
white-space: nowrap;
color: var(--fs-text-tertiary);
font-size: 0.8rem;
}
.cell-user {
color: var(--fs-text-secondary);
}
.cell-action {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-status {
font-family: monospace;
font-size: 0.85rem;
}
.cell-ip {
font-family: monospace;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
white-space: nowrap;
}
.cell-duration {
color: var(--fs-text-tertiary);
font-size: 0.8rem;
white-space: nowrap;
}
.detail-ip {
font-family: monospace;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
margin-bottom: 0.4rem;
}
.text-error {
color: var(--fs-error);
}
/* Category badges */
.category-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.35rem;
border-radius: var(--fs-radius-sm);
}
.cat-audit {
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
}
.cat-usage {
color: var(--fs-success);
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
}
.cat-error {
color: var(--fs-error);
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
}
/* Method tag */
.method-tag {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
font-family: monospace;
padding: 0.05rem 0.25rem;
border-radius: 3px;
background: var(--fs-surface-raised);
color: var(--fs-text-tertiary);
margin-right: 0.25rem;
}
/* Detail row */
/* `.detail-row` is deliberately bare: a `<tr>` has nothing to style that its
cells don't carry, and the row exists to scope the rule below (#2444). */
.detail-row td {
padding: 0 0.75rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
}
.detail-json {
margin: 0;
padding: 0.75rem;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.8rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
}
@media (max-width: 768px) {
.stats-grid {
flex-wrap: wrap;
}
.stat-card {
min-width: calc(50% - 0.5rem);
}
.filter-bar {
flex-direction: column;
}
.filter-select,
.filter-input,
.filter-date {
width: 100%;
}
.cell-action {
max-width: 160px;
}
}
</style>
-43
View File
@@ -626,16 +626,6 @@ onUnmounted(() => assist.clearSelection());
gap: 0.75rem; gap: 0.75rem;
} }
.body-tabs-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
}
.editor-tabs { .editor-tabs {
display: inline-flex; display: inline-flex;
background: var(--fs-surface-page); background: var(--fs-surface-page);
@@ -673,28 +663,11 @@ onUnmounted(() => assist.clearSelection());
opacity: 0; opacity: 0;
} }
.body-editor-wrap {
min-height: 200px;
}
.stream-label { .stream-label {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--fs-text-tertiary); color: var(--fs-text-tertiary);
} }
.stream-preview {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.75rem;
background: var(--fs-surface-raised);
min-height: 200px;
}
.main-diff {
flex: 1;
min-height: 0;
}
/* Right sidebar */ /* Right sidebar */
.note-sidebar { .note-sidebar {
width: 280px; width: 280px;
@@ -721,14 +694,6 @@ onUnmounted(() => assist.clearSelection());
border-color: var(--fs-accent); border-color: var(--fs-accent);
} }
/* Tag suggest row inside sidebar */
.tag-suggest-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
}
/* Link Suggestions */ /* Link Suggestions */
.link-suggest-field { gap: 0.4rem; } .link-suggest-field { gap: 0.4rem; }
@@ -798,14 +763,6 @@ onUnmounted(() => assist.clearSelection());
gap: 0.5rem; gap: 0.5rem;
} }
.assist-section-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Process editor ─────────────────────────────────────── */ /* ── Process editor ─────────────────────────────────────── */
.ef-label { .ef-label {
font-family: 'Fraunces', Georgia, serif; font-family: 'Fraunces', Georgia, serif;
+1 -8
View File
@@ -171,7 +171,7 @@ function overallPct(project: Project): { total: number; pct: number } {
</script> </script>
<template> <template>
<main class="projects-list"> <main class="page-container">
<div class="page-header"> <div class="page-header">
<h1>Projects</h1> <h1>Projects</h1>
<button class="btn-primary" @click="openNewProjectModal">+ New Project</button> <button class="btn-primary" @click="openNewProjectModal">+ New Project</button>
@@ -336,13 +336,6 @@ function overallPct(project: Project): { total: number; pct: number } {
</template> </template>
<style scoped> <style scoped>
.projects-list {
max-width: var(--fs-layout-page-max);
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
+11 -28
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPatch, apiDelete, apiPost, apiPut } from "@/api/client"; import { apiGet, apiPatch, apiDelete, apiPost, apiPut, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import { useTasksStore } from "@/stores/tasks"; import { useTasksStore } from "@/stores/tasks";
@@ -543,8 +543,7 @@ async function saveForgePin() {
if (project.value) project.value.forge_connection_id = forgePin.value; if (project.value) project.value.forge_connection_id = forgePin.value;
await loadCoverage(); await loadCoverage();
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toast.show(apiErrorMessage(e, "Failed to change the project's forge"), "error");
toast.show(body?.error || "Failed to change the project's forge", "error");
forgePin.value = project.value?.forge_connection_id ?? null; forgePin.value = project.value?.forge_connection_id ?? null;
} finally { } finally {
savingForgePin.value = false; savingForgePin.value = false;
@@ -641,7 +640,7 @@ async function confirmDelete() {
</script> </script>
<template> <template>
<main class="project-view"> <main class="page-container">
<!-- Nav bar --> <!-- Nav bar -->
<div class="page-header"> <div class="page-header">
@@ -874,15 +873,15 @@ async function confirmDelete() {
paragraph in practice this one showed as "Maintain Scribe as paragraph in practice this one showed as "Maintain Scribe as
the reliabl" and gave no way to read the rest without arrowing the reliabl" and gave no way to read the rest without arrowing
through it. --> through it. -->
<textarea v-model="editGoal" class="edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea> <textarea v-model="editGoal" class="fs-input edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea>
</div> </div>
<div class="edit-field"> <div class="edit-field">
<label class="edit-label">Description</label> <label class="edit-label">Description</label>
<textarea v-model="editDescription" class="edit-textarea" rows="6" placeholder="Optional description..."></textarea> <textarea v-model="editDescription" class="fs-input edit-textarea" rows="6" placeholder="Optional description..."></textarea>
</div> </div>
<div class="edit-field"> <div class="edit-field">
<label class="edit-label">Status</label> <label class="edit-label">Status</label>
<select v-model="editStatus" class="edit-select"> <select v-model="editStatus" class="fs-input edit-select">
<option value="active">Active</option> <option value="active">Active</option>
<option value="paused">Paused</option> <option value="paused">Paused</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
@@ -891,7 +890,7 @@ async function confirmDelete() {
</div> </div>
<div v-if="designSystems.length" class="edit-field"> <div v-if="designSystems.length" class="edit-field">
<label class="edit-label" for="project-design-system">Design system</label> <label class="edit-label" for="project-design-system">Design system</label>
<select id="project-design-system" v-model="editDesignSystemId" class="edit-select"> <select id="project-design-system" v-model="editDesignSystemId" class="fs-input edit-select">
<option :value="null">None</option> <option :value="null">None</option>
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option> <option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select> </select>
@@ -1202,13 +1201,6 @@ async function confirmDelete() {
<style scoped> <style scoped>
/* ── Layout ─────────────────────────────────────────────────── */ /* ── Layout ─────────────────────────────────────────────────── */
.project-view {
max-width: var(--fs-layout-page-max);
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
/* ── Nav bar ─────────────────────────────────────────────────── */ /* ── Nav bar ─────────────────────────────────────────────────── */
.page-header { .page-header {
display: flex; display: flex;
@@ -1409,7 +1401,7 @@ async function confirmDelete() {
/* `minmax(0, 1fr)`, not `1fr`. A bare `1fr` track has an AUTO minimum, so it /* `minmax(0, 1fr)`, not `1fr`. A bare `1fr` track has an AUTO minimum, so it
cannot shrink below its content — one wide descendant anywhere in the cannot shrink below its content — one wide descendant anywhere in the
content column widens the whole column past the grid, and everything inside content column widens the whole column past the grid, and everything inside
it then overflows the page and gets cut by `.project-view`'s it then overflows the page and gets cut by `.page-container`'s
`overflow-x: clip`. `overflow-x: clip`.
This is the same property the header nav relies on and wants (neither side This is the same property the header nav relies on and wants (neither side
squeezed under its content); here it is exactly wrong, because the column squeezed under its content); here it is exactly wrong, because the column
@@ -1453,18 +1445,9 @@ async function confirmDelete() {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
.edit-input, .edit-textarea, .edit-select { /* The input itself is the .fs-input canon (components.css); only the
padding: 0.4rem 0.6rem; layout remainder lives here. */
border: 1px solid var(--fs-border-color); .edit-input, .edit-textarea, .edit-select { box-sizing: border-box; width: 100%; }
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%;
}
.edit-input:focus, .edit-textarea:focus, .edit-select:focus { outline: none; border-color: var(--fs-accent); }
.edit-textarea { resize: vertical; } .edit-textarea { resize: vertical; }
/* Save panel: Moss action-primary per Hybrid rule */ /* Save panel: Moss action-primary per Hybrid rule */
+10 -19
View File
@@ -780,8 +780,7 @@ async function saveConnection() {
connFormOpen.value = false; connFormOpen.value = false;
await loadForgeConnections(); await loadForgeConnections();
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Failed to save forge connection"), "error");
toastStore.show(body?.error || "Failed to save forge connection", "error");
} finally { } finally {
savingConn.value = false; savingConn.value = false;
} }
@@ -793,8 +792,7 @@ async function removeConnection(id: number) {
connTestResult.value = null; connTestResult.value = null;
await loadForgeConnections(); await loadForgeConnections();
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Failed to delete forge connection"), "error");
toastStore.show(body?.error || "Failed to delete forge connection", "error");
} }
} }
@@ -810,10 +808,9 @@ async function testConnection(id: number) {
message: `Connected — ${res.version}, authenticated as ${res.username}`, message: `Connected — ${res.version}, authenticated as ${res.username}`,
}; };
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body;
connTestResult.value = { connTestResult.value = {
id, ok: false, id, ok: false,
message: body?.error || "Connection test failed", message: apiErrorMessage(e, "Connection test failed"),
}; };
} finally { } finally {
testingConnId.value = 0; testingConnId.value = 0;
@@ -830,8 +827,7 @@ async function saveForgeWebhook() {
forgeWebhookSaved.value = true; forgeWebhookSaved.value = true;
setTimeout(() => (forgeWebhookSaved.value = false), 2000); setTimeout(() => (forgeWebhookSaved.value = false), 2000);
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Failed to save webhook secret"), "error");
toastStore.show(body?.error || "Failed to save webhook secret", "error");
} finally { } finally {
savingForgeWebhook.value = false; savingForgeWebhook.value = false;
} }
@@ -864,8 +860,7 @@ async function saveMarketplaceUrl() {
marketplaceUrlSaved.value = true; marketplaceUrlSaved.value = true;
setTimeout(() => (marketplaceUrlSaved.value = false), 2000); setTimeout(() => (marketplaceUrlSaved.value = false), 2000);
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Failed to save marketplace URL"), "error");
toastStore.show(body?.error || "Failed to save marketplace URL", "error");
} finally { } finally {
savingMarketplaceUrl.value = false; savingMarketplaceUrl.value = false;
} }
@@ -882,8 +877,7 @@ async function saveDbMaintenance() {
dbMaintSaved.value = true; dbMaintSaved.value = true;
setTimeout(() => (dbMaintSaved.value = false), 2000); setTimeout(() => (dbMaintSaved.value = false), 2000);
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Failed to save maintenance settings"), "error");
toastStore.show(body?.error || "Failed to save maintenance settings", "error");
} finally { } finally {
savingDbMaint.value = false; savingDbMaint.value = false;
} }
@@ -912,8 +906,7 @@ async function runDbMaintenanceNow() {
); );
await loadDbHealth(); // reflect the dead-tuple drop await loadDbHealth(); // reflect the dead-tuple drop
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Maintenance run failed"), "error");
toastStore.show(body?.error || "Maintenance run failed", "error");
} finally { } finally {
runningDbMaint.value = false; runningDbMaint.value = false;
} }
@@ -1144,8 +1137,7 @@ async function sendInvite() {
inviteEmail.value = ""; inviteEmail.value = "";
await fetchInvitations(); await fetchInvitations();
} catch (e: unknown) { } catch (e: unknown) {
const body = (e as { body?: { error?: string } })?.body; toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
toastStore.show(body?.error || "Failed to send invitation", "error");
} finally { } finally {
sendingInvite.value = false; sendingInvite.value = false;
} }
@@ -1196,8 +1188,7 @@ async function deleteUser(userId: number) {
users.value = users.value.filter((u) => u.id !== userId); users.value = users.value.filter((u) => u.id !== userId);
toastStore.show("User deleted"); toastStore.show("User deleted");
} catch (e: unknown) { } catch (e: unknown) {
const body = (e as { body?: { error?: string } })?.body; toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
toastStore.show(body?.error || "Failed to delete user", "error");
} finally { } finally {
deleting.value = null; deleting.value = null;
} }
@@ -3172,7 +3163,7 @@ async function deleteUser(userId: number) {
.cell-status { font-family: monospace; font-size: 0.85rem; } .cell-status { font-family: monospace; font-size: 0.85rem; }
.cell-duration { color: var(--fs-text-tertiary); font-size: 0.8rem; white-space: nowrap; } .cell-duration { color: var(--fs-text-tertiary); font-size: 0.8rem; white-space: nowrap; }
.text-error { color: var(--fs-error); } .text-error { color: var(--fs-error); }
/* Bare by design, like LogsView's twin of this: a `<tr>` has nothing to style /* Bare by design: a `<tr>` has nothing to style
that its cells don't carry (#2444). */ that its cells don't carry (#2444). */
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--fs-border-color); } .detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--fs-border-color); }
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--fs-text-tertiary); margin-bottom: 0.4rem; } .detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--fs-text-tertiary); margin-bottom: 0.4rem; }
+2 -61
View File
@@ -273,7 +273,7 @@ function usageTitle(s: SnippetListItem): string {
</script> </script>
<template> <template>
<main class="snippets-list"> <main class="page-container">
<div class="page-header"> <div class="page-header">
<h1>Snippets</h1> <h1>Snippets</h1>
<div class="header-actions"> <div class="header-actions">
@@ -517,14 +517,8 @@ function usageTitle(s: SnippetListItem): string {
</main> </main>
</template> </template>
<style src="@/assets/dup-report.css" />
<style scoped> <style scoped>
.snippets-list {
max-width: var(--fs-layout-page-max);
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -764,59 +758,6 @@ function usageTitle(s: SnippetListItem): string {
color: var(--fs-text-tertiary); color: var(--fs-text-tertiary);
} }
/* Near-duplicate report */
.dup-panel {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: 8px;
background: var(--fs-surface-hover);
}
.dup-empty,
.dup-head {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
}
.dup-empty {
margin-bottom: 0;
}
.dup-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--fs-border-color);
}
.dup-members {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
flex: 1 1 20rem;
min-width: 0;
}
.dup-member {
font-size: 0.8rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
/* Long snippet names must not push the row into a horizontal scroll. */
overflow-wrap: anywhere;
}
.dup-score {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.dup-action { .dup-action {
white-space: nowrap; white-space: nowrap;
} }
-41
View File
@@ -823,16 +823,6 @@ useEditorGuards(dirty, save);
gap: 0.75rem; gap: 0.75rem;
} }
.body-tabs-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
}
/* .task-main is a flex column; without flex-shrink: 0, long body content /* .task-main is a flex column; without flex-shrink: 0, long body content
gets squeezed back to min-height and overflows visibly on top of siblings. */ gets squeezed back to min-height and overflows visibly on top of siblings. */
.body-editor-wrap, .body-editor-wrap,
@@ -840,10 +830,6 @@ useEditorGuards(dirty, save);
flex-shrink: 0; flex-shrink: 0;
} }
.body-editor-wrap {
min-height: 200px;
}
:deep(.preview-pane) { :deep(.preview-pane) {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -949,18 +935,6 @@ useEditorGuards(dirty, save);
font-family: inherit; font-family: inherit;
} }
.subtask-input:focus { outline: none; border-color: var(--fs-accent); } .subtask-input:focus { outline: none; border-color: var(--fs-accent); }
.stream-preview {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.75rem;
background: var(--fs-surface-raised);
min-height: 200px;
}
.main-diff {
flex: 1;
min-height: 0;
}
/* Systems multi-select (in sidebar) */ /* Systems multi-select (in sidebar) */
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; } .sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--fs-text-primary); cursor: pointer; } .sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--fs-text-primary); cursor: pointer; }
@@ -973,26 +947,11 @@ useEditorGuards(dirty, save);
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.assist-section-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.assist-actions { .assist-actions {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
} }
/* Tag suggest row inside sidebar */
.tag-suggest-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
}
/* Lifecycle timestamps */ /* Lifecycle timestamps */
.sb-timestamps { .sb-timestamps {
display: flex; display: flex;
-767
View File
@@ -1,767 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { renderMarkdown } from "@/utils/markdown";
import { relativeTime } from "@/composables/useRelativeTime";
import { apiPost, apiGet } from "@/api/client";
import type { Note } from "@/types/note";
import type { TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
import ShareDialog from "@/components/ShareDialog.vue";
import { Clock, Pencil, Link as LinkIcon } from "lucide-vue-next";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const notesStore = useNotesStore();
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
const converting = ref(false);
const showShare = ref(false);
// Context enrichment
const projectTitle = ref<string | null>(null);
const milestoneName = ref<string | null>(null);
const subTasks = ref<Note[]>([]);
const taskId = computed(() => Number(route.params.id));
const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
cancelled: "todo",
};
const statusDotClass: Record<TaskStatus, string> = {
todo: "dot-todo",
in_progress: "dot-in-progress",
done: "dot-done",
cancelled: "dot-cancelled",
};
function cycleSubTaskStatus(subTask: Note) {
if (!subTask.status) return;
const next = statusCycle[subTask.status as TaskStatus];
store.patchStatus(subTask.id, next).then(() => {
const idx = subTasks.value.findIndex((t) => t.id === subTask.id);
if (idx !== -1) subTasks.value[idx] = { ...subTasks.value[idx], status: next };
});
}
async function loadContext(task: Note) {
projectTitle.value = null;
milestoneName.value = null;
subTasks.value = [];
const promises: Promise<void>[] = [];
if (task.project_id) {
promises.push(
apiGet<any>(`/api/projects/${task.project_id}`).then((data) => {
projectTitle.value = data.title ?? null;
if (task.milestone_id && data.summary?.milestone_summary) {
const ms = (data.summary.milestone_summary as Array<{ id: number; title: string }>)
.find((m) => m.id === task.milestone_id);
if (ms) milestoneName.value = ms.title;
}
}).catch(() => {})
);
}
// Load sub-tasks via the notes endpoint with parent_id filter
promises.push(
apiGet<{ notes: Note[]; total: number }>(
`/api/notes?parent_id=${task.id}&type=task&sort=created_at&order=asc&limit=50`
).then((data) => {
subTasks.value = data.notes;
}).catch(() => {})
);
await Promise.all(promises);
}
async function loadTask(id: number) {
backlinks.value = [];
await store.fetchTask(id);
if (!store.currentTask) return;
const [bl] = await Promise.allSettled([
notesStore.fetchBacklinks(id),
loadContext(store.currentTask),
]);
if (bl.status === "fulfilled") backlinks.value = bl.value;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
e.stopPropagation(); // prevent App.vue's global handler from also firing
const active = document.activeElement as HTMLElement | null;
if (active && active !== document.body) {
(active as HTMLElement).blur();
return;
}
if (store.currentTask?.project_id) {
router.push(`/projects/${store.currentTask.project_id}`);
} else {
router.push("/tasks");
}
}
onMounted(() => {
loadTask(taskId.value);
// Capture phase so this fires before App.vue's document-level handler
window.addEventListener("keydown", handleKeydown, true);
});
onUnmounted(() => window.removeEventListener("keydown", handleKeydown, true));
watch(() => route.params.id, (newId) => {
if (newId) loadTask(Number(newId));
});
const renderedBody = computed(() => {
if (!store.currentTask) return "";
return renderMarkdown(store.currentTask.body);
});
function cycleStatus() {
if (!store.currentTask) return;
store.patchStatus(
store.currentTask.id,
statusCycle[store.currentTask.status as TaskStatus]
);
}
const forwardStatus: Record<TaskStatus, TaskStatus | null> = {
todo: "in_progress",
in_progress: "done",
done: null,
cancelled: null,
};
function recurrenceSummary(rule: Record<string, unknown> | null): string | null {
if (!rule) return null;
if (rule.type === "interval") {
return `Every ${rule.every} ${rule.unit}(s)`;
}
if (rule.type === "calendar") {
if (rule.unit === "month") return `Monthly on day ${rule.day_of_month}`;
if (rule.unit === "year") {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const m = months[((rule.month as number) ?? 1) - 1];
return `Yearly on ${m} ${rule.day_of_month}`;
}
}
return null;
}
const advanceLabel = computed(() => {
const s = store.currentTask?.status as TaskStatus | undefined;
if (!s) return null;
const next = forwardStatus[s];
if (!next) return null;
return next === "in_progress" ? "→ In Progress" : "→ Done";
});
function advanceStatus() {
if (!store.currentTask) return;
const next = forwardStatus[store.currentTask.status as TaskStatus];
if (next) store.patchStatus(store.currentTask.id, next);
}
function isOverdue(): boolean {
if (!store.currentTask?.due_date || store.currentTask.status === "done")
return false;
const today = new Date().toISOString().slice(0, 10);
return store.currentTask.due_date < today;
}
async function convertToNote() {
if (converting.value) return;
converting.value = true;
try {
await notesStore.convertToNote(taskId.value);
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Converted to note");
router.push(`/notes/${taskId.value}`);
} catch {
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Failed to convert task", "error");
} finally {
converting.value = false;
}
}
async function onBodyClick(e: MouseEvent) {
const target = e.target as HTMLElement;
const tagLink = target.closest(".inline-tag") as HTMLAnchorElement | null;
if (tagLink) {
e.preventDefault();
const tag = tagLink.dataset.tag;
if (tag) {
router.push({ path: "/notes", query: { tag } });
}
return;
}
const wikilink = target.closest(".wikilink") as HTMLAnchorElement | null;
if (wikilink) {
e.preventDefault();
const title = wikilink.dataset.title;
if (title) {
try {
const note = await apiPost<Note>(
"/api/notes/resolve-title",
{ title }
);
router.push(`/notes/${note.id}`);
} catch {
const { useToastStore } = await import("@/stores/toast");
useToastStore().show(`Failed to resolve note "${title}"`, "error");
}
}
}
}
function onTagClick(tag: string) {
router.push({ path: "/tasks", query: { tag } });
}
// Sub-task progress
const subTaskProgress = computed(() => {
if (!subTasks.value.length) return null;
const done = subTasks.value.filter((t) => t.status === "done").length;
const total = subTasks.value.length;
return { done, total, pct: Math.round((done / total) * 100) };
});
</script>
<template>
<div class="viewer-layout">
<main class="viewer">
<div v-if="store.loading" class="viewer-skeleton" aria-label="Loading task">
<div class="skel-toolbar">
<div class="skel-btn"></div>
<div class="skel-btn skel-btn--wide"></div>
<div class="skel-btn"></div>
</div>
<div class="skel-title"></div>
<div class="skel-meta"></div>
<div class="skel-badges"></div>
<div class="skel-line"></div>
<div class="skel-line skel-line--short"></div>
<div class="skel-line"></div>
<div class="skel-line skel-line--medium"></div>
<div class="skel-line skel-line--short"></div>
</div>
<template v-else-if="store.currentTask">
<div class="toolbar">
<router-link
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
class="btn-ghost"
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
<router-link
:to="`/tasks/${store.currentTask.id}/edit`"
class="btn-primary"
>
Edit
</router-link>
<button
v-if="advanceLabel"
class="btn-primary"
@click="advanceStatus"
>
{{ advanceLabel }}
</button>
<button
class="btn-secondary btn-compact"
@click="convertToNote"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Note" }}
</button>
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
</div>
<!-- Breadcrumb: parent task project milestone -->
<div
v-if="store.currentTask.parent_id || store.currentTask.project_id"
class="context-bar"
>
<router-link
v-if="store.currentTask.parent_id"
:to="`/tasks/${store.currentTask.parent_id}`"
class="ctx-crumb ctx-crumb-parent"
>
{{ store.currentTask.parent_title || "Parent task" }}
</router-link>
<router-link
v-if="store.currentTask.project_id && projectTitle"
:to="`/projects/${store.currentTask.project_id}`"
class="ctx-crumb ctx-crumb-project"
>
{{ projectTitle }}
</router-link>
<span v-if="milestoneName" class="ctx-crumb ctx-crumb-milestone">
{{ milestoneName }}
</span>
</div>
<h1 class="task-title">{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta">
<span class="meta-item">
<Clock :size="16" />
Updated {{ relativeTime(store.currentTask.updated_at) }}
</span>
<span class="meta-sep" aria-hidden="true">·</span>
<span class="meta-item">
<Pencil :size="16" />
Created {{ relativeTime(store.currentTask.created_at) }}
</span>
</p>
<div class="badges">
<StatusBadge
:status="store.currentTask.status!"
clickable
@click="cycleStatus"
/>
<PriorityBadge :priority="store.currentTask.priority!" />
<span
v-if="store.currentTask.due_date"
:class="['due-date', { overdue: isOverdue() }]"
>
Due: {{ store.currentTask.due_date }}
</span>
</div>
<div class="task-meta-row" v-if="store.currentTask.started_at || store.currentTask.completed_at || store.currentTask.recurrence_rule">
<span v-if="store.currentTask.started_at" class="task-meta-item">
Started: {{ new Date(store.currentTask.started_at).toLocaleString() }}
</span>
<span v-if="store.currentTask.completed_at" class="task-meta-item">
Completed: {{ new Date(store.currentTask.completed_at).toLocaleString() }}
</span>
<span v-if="recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null)" class="task-meta-item task-meta-recurrence">
{{ recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null) }}
</span>
</div>
<div class="tags" v-if="store.currentTask.tags.length">
<TagPill
v-for="tag in store.currentTask.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
v-if="store.currentTask.description"
class="task-goal-display"
>
<h3 class="goal-label">Goal</h3>
<p class="goal-text">{{ store.currentTask.description }}</p>
</div>
<div
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<!-- Sub-tasks -->
<div v-if="subTasks.length" class="subtasks">
<div class="subtasks-header">
<h2 class="subtasks-title">Sub-tasks</h2>
<span v-if="subTaskProgress" class="subtasks-progress">
{{ subTaskProgress.done }}/{{ subTaskProgress.total }}
<span class="subtasks-pct">({{ subTaskProgress.pct }}%)</span>
</span>
</div>
<div v-if="subTaskProgress" class="subtasks-track">
<div class="subtasks-fill" :style="{ width: subTaskProgress.pct + '%' }"></div>
</div>
<ul class="subtasks-list">
<li
v-for="sub in subTasks"
:key="sub.id"
class="subtask-row"
>
<button
:class="['sub-dot', statusDotClass[sub.status as TaskStatus] ?? 'dot-todo']"
:title="`${sub.status} — click to advance`"
@click="cycleSubTaskStatus(sub)"
></button>
<router-link :to="`/tasks/${sub.id}/edit`" class="sub-title" :class="{ 'sub-done': sub.status === 'done' }">
{{ sub.title || "Untitled" }}
</router-link>
<span v-if="sub.due_date" class="sub-due">{{ sub.due_date }}</span>
</li>
</ul>
</div>
<div v-if="backlinks.length" class="backlinks">
<h3 class="backlinks-heading">
<LinkIcon :size="16" />
Backlinks
<span class="backlinks-count">{{ backlinks.length }}</span>
</h3>
<div class="backlinks-grid">
<router-link
v-for="link in backlinks"
:key="`${link.type}-${link.id}`"
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-card"
>
<span :class="['backlink-type-badge', `badge-${link.type}`]">{{ link.type }}</span>
<span class="backlink-title">{{ link.title || "Untitled" }}</span>
</router-link>
</div>
</div>
</template>
<p v-else>Task not found.</p>
</main>
<TableOfContents
v-if="store.currentTask?.body"
:body="store.currentTask.body"
class="toc-sidebar"
/>
</div>
<ShareDialog
v-if="showShare && store.currentTask"
resource-type="note"
:resource-id="store.currentTask.id"
:resource-title="store.currentTask.title || '(untitled)'"
@close="showShare = false"
/>
</template>
<style src="@/assets/viewer-shared.css" />
<style scoped>
.viewer-layout {
display: flex;
max-width: 1400px;
margin: 0 auto;
gap: 2rem;
}
.viewer {
flex: 1;
min-width: 0;
max-width: 1100px;
margin: 2rem 0;
padding: 0 1rem;
}
.toc-sidebar {
margin-top: 2rem;
}
@media (max-width: 1200px) {
.toc-sidebar {
display: none;
}
}
.toolbar {
display: flex;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.83rem;
color: var(--fs-text-tertiary);
margin: 0 0 0.75rem;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.meta-sep {
opacity: 0.5;
}
.badges {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.due-date {
font-size: 0.85rem;
color: var(--fs-text-secondary);
}
.due-date.overdue {
color: var(--fs-overdue);
font-weight: 500;
}
.task-meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.task-meta-item {
font-size: 0.78rem;
color: var(--fs-text-tertiary);
}
.task-meta-recurrence {
color: var(--fs-accent);
font-weight: 500;
}
.tags {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
/* Sub-tasks */
.subtasks {
margin-top: 2rem;
border-top: 1px solid var(--fs-border-color);
padding-top: 1rem;
}
.subtasks-header {
display: flex;
align-items: baseline;
gap: 0.6rem;
margin-bottom: 0.4rem;
}
.subtasks-title {
font-size: 1rem;
margin: 0;
font-weight: 500;
}
.subtasks-progress {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.subtasks-pct {
color: var(--fs-text-tertiary);
}
.subtasks-track {
height: 4px;
background: var(--fs-surface-raised);
border-radius: 2px;
margin-bottom: 0.75rem;
overflow: hidden;
}
.subtasks-fill {
height: 100%;
background: var(--fs-status-done);
border-radius: 2px;
transition: width 0.3s ease;
}
.subtasks-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.subtask-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
border-radius: var(--fs-radius-sm);
}
.subtask-row:hover {
background: var(--fs-surface-raised);
}
.sub-dot {
flex-shrink: 0;
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
cursor: pointer;
padding: 0;
transition: transform 0.1s, opacity 0.1s;
}
.sub-dot:hover {
transform: scale(1.25);
opacity: 0.8;
}
.dot-todo {
background: transparent;
border: 2px solid var(--fs-text-tertiary);
}
.dot-in-progress {
background: var(--fs-status-in-progress);
}
.dot-done {
background: var(--fs-status-done);
}
.dot-cancelled {
background: var(--fs-text-tertiary);
}
.sub-title {
flex: 1;
font-size: 0.9rem;
color: var(--fs-text-primary);
text-decoration: none;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-title:hover {
color: var(--fs-accent);
}
.sub-title.sub-done {
color: var(--fs-text-tertiary);
text-decoration: line-through;
}
.sub-due {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
flex-shrink: 0;
}
.backlinks {
margin-top: 2.5rem;
border-top: 1px solid var(--fs-border-color);
padding-top: 1.25rem;
}
.backlinks-heading {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fs-text-tertiary);
margin: 0 0 0.75rem;
}
.backlinks-count {
margin-left: 0.2rem;
font-size: 0.72rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: 999px;
padding: 0 0.4rem;
line-height: 1.4;
}
.backlinks-grid {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.backlink-card {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: var(--fs-radius-lg);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
text-decoration: none;
color: var(--fs-text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.9rem;
}
.backlink-card:hover {
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
color: var(--fs-accent);
}
.backlink-type-badge {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
padding: 0.1rem 0.45rem;
border-radius: 999px;
flex-shrink: 0;
}
.badge-note {
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
color: var(--fs-accent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
}
.badge-task {
background: color-mix(in srgb, #f59e0b 12%, transparent);
color: #d97706;
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
}
.backlink-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Skeleton loader ── */
@keyframes skel-shine {
to { background-position: 200% center; }
}
.viewer-skeleton {
display: flex;
flex-direction: column;
gap: 0.65rem;
padding-top: 0.5rem;
}
.skel-btn,
.skel-title,
.skel-meta,
.skel-badges,
.skel-line {
border-radius: var(--fs-radius-sm);
background: linear-gradient(
90deg,
var(--fs-surface-raised) 25%,
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
var(--fs-surface-raised) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
}
.skel-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.skel-btn { width: 70px; height: 32px; }
.skel-btn--wide { width: 90px; }
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--fs-radius-lg); }
.skel-meta { height: 0.85rem; width: 45%; }
.skel-badges { height: 1.6rem; width: 30%; border-radius: 999px; }
.skel-line { height: 0.9rem; }
.skel-line--short { width: 50%; }
.skel-line--medium { width: 78%; }
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
.task-goal-display {
border-left: 2px solid var(--fs-border-color);
padding: 0.4rem 0 0.4rem 0.9rem;
margin: 0.75rem 0 1.25rem;
background: rgba(255, 255, 255, 0.02);
}
.goal-label {
font-family: var(--fs-font-display);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fs-text-tertiary);
margin: 0 0 0.25rem;
}
.goal-text {
margin: 0;
font-size: 0.95rem;
line-height: 1.45;
color: var(--fs-text-primary);
white-space: pre-wrap;
}
</style>
-441
View File
@@ -1,441 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPut, apiDelete, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import type { User } from "@/types/auth";
import { fmtDate } from "@/utils/dateFormat";
interface Invitation {
id: number;
email: string;
created_at: string;
expires_at: string;
}
const authStore = useAuthStore();
const toastStore = useToastStore();
const users = ref<User[]>([]);
const registrationOpen = ref(false);
const loading = ref(true);
const toggling = ref(false);
const confirmDeleteId = ref<number | null>(null);
const deleting = ref<number | null>(null);
const inviteEmail = ref("");
const sendingInvite = ref(false);
const invitations = ref<Invitation[]>([]);
const revokingId = ref<number | null>(null);
onMounted(async () => {
await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]);
loading.value = false;
});
async function fetchUsers() {
try {
const data = await apiGet<{ users: User[] }>("/api/admin/users");
users.value = data.users;
} catch {
toastStore.show("Failed to load users", "error");
}
}
async function fetchRegistration() {
try {
const data = await apiGet<{ open: boolean }>("/api/admin/registration");
registrationOpen.value = data.open;
} catch {
// Ignore — will default to false
}
}
async function fetchInvitations() {
try {
const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations");
invitations.value = data.invitations;
} catch {
// Ignore
}
}
async function sendInvite() {
const email = inviteEmail.value.trim().toLowerCase();
if (!email) return;
sendingInvite.value = true;
try {
await apiPost("/api/admin/invitations", { email });
toastStore.show(`Invitation sent to ${email}`);
inviteEmail.value = "";
await fetchInvitations();
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
} finally {
sendingInvite.value = false;
}
}
async function revokeInvitation(id: number) {
revokingId.value = id;
try {
await apiDelete(`/api/admin/invitations/${id}`);
invitations.value = invitations.value.filter((inv) => inv.id !== id);
toastStore.show("Invitation revoked");
} catch {
toastStore.show("Failed to revoke invitation", "error");
} finally {
revokingId.value = null;
}
}
async function toggleRegistration() {
toggling.value = true;
try {
const data = await apiPut<{ open: boolean }>("/api/admin/registration", {
open: !registrationOpen.value,
});
registrationOpen.value = data.open;
toastStore.show(data.open ? "Registration opened" : "Registration closed");
} catch {
toastStore.show("Failed to update registration setting", "error");
} finally {
toggling.value = false;
}
}
function confirmDelete(userId: number) {
if (confirmDeleteId.value === userId) {
deleteUser(userId);
} else {
confirmDeleteId.value = userId;
}
}
function cancelDelete() {
confirmDeleteId.value = null;
}
async function deleteUser(userId: number) {
confirmDeleteId.value = null;
deleting.value = userId;
try {
await apiDelete(`/api/admin/users/${userId}`);
users.value = users.value.filter((u) => u.id !== userId);
toastStore.show("User deleted");
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
} finally {
deleting.value = null;
}
}
</script>
<template>
<main class="users-page">
<h1>User Management</h1>
<section class="settings-section">
<h2>Registration</h2>
<div class="registration-row">
<div class="registration-info">
<p class="registration-status">
Registration is currently
<strong :class="registrationOpen ? 'text-success' : 'text-muted'">
{{ registrationOpen ? "open" : "closed" }}
</strong>
</p>
<p class="field-hint">
When closed, new users can only be added by an administrator.
</p>
</div>
<button
class="btn-primary btn-toggle"
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
@click="toggleRegistration"
:disabled="toggling"
>
{{ toggling ? "Updating..." : registrationOpen ? "Close Registration" : "Open Registration" }}
</button>
</div>
</section>
<section class="settings-section">
<h2>Invite User</h2>
<form class="invite-form" @submit.prevent="sendInvite">
<input
v-model="inviteEmail"
type="email"
placeholder="Email address"
class="input invite-input"
required
:disabled="sendingInvite"
/>
<button
type="submit"
class="btn-primary"
:disabled="sendingInvite || !inviteEmail.trim()"
>
{{ sendingInvite ? "Sending..." : "Send Invite" }}
</button>
</form>
<p class="field-hint">Send an invitation link to allow someone to register, even when public registration is closed.</p>
<div v-if="invitations.length > 0" class="invite-list">
<h3>Pending Invitations</h3>
<table class="users-table">
<thead>
<tr>
<th>Email</th>
<th class="hide-mobile">Sent</th>
<th class="hide-mobile">Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
<td class="cell-actions">
<button
class="btn-ghost btn-compact"
@click="revokeInvitation(inv.id)"
:disabled="revokingId !== null"
>
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="settings-section">
<h2>Users</h2>
<div v-if="loading" class="loading-msg">Loading users...</div>
<div v-else-if="users.length === 0" class="empty-msg">No users found.</div>
<table v-else class="users-table">
<thead>
<tr>
<th>Username</th>
<th class="hide-mobile">Email</th>
<th>Role</th>
<th class="hide-mobile">Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td class="cell-username">{{ u.username }}</td>
<td class="hide-mobile cell-email">{{ u.email || "—" }}</td>
<td>
<span class="role-badge" :class="u.role === 'admin' ? 'role-admin' : 'role-user'">
{{ u.role }}
</span>
</td>
<td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
<td class="cell-actions">
<template v-if="u.id === authStore.user?.id">
<span class="you-label">You</span>
</template>
<template v-else-if="confirmDeleteId === u.id">
<button
class="btn-danger btn-compact"
@click="confirmDelete(u.id)"
:disabled="deleting !== null"
>
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
</button>
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
</template>
<template v-else>
<button
class="btn-ghost btn-compact"
@click="confirmDelete(u.id)"
:disabled="deleting !== null"
>
Delete
</button>
</template>
</td>
</tr>
</tbody>
</table>
</section>
</main>
</template>
<style scoped>
.users-page {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.users-page h1 {
margin: 0 0 1.5rem;
}
.settings-section {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.settings-section h2 {
margin: 0 0 0.75rem;
font-size: 1.1rem;
}
/* Invite form */
.invite-form {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.invite-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.invite-input:focus {
outline: none;
border-color: var(--fs-accent);
}
.invite-list {
margin-top: 1rem;
}
.invite-list h3 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
color: var(--fs-text-secondary);
}
/* Registration toggle */
.registration-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.registration-info {
flex: 1;
}
.registration-status {
margin: 0;
font-size: 0.95rem;
}
.text-success {
color: var(--fs-success);
}
.text-muted {
color: var(--fs-text-tertiary);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
/* The one genuine override: 'close registration' must NOT read as the
primary action it sits on. Scoped, so it beats the shared variant. */
.btn-toggle-close {
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color);
}
.btn-toggle-close:hover:not(:disabled) {
border-color: var(--fs-warning);
color: var(--fs-warning);
}
/* Users table */
.loading-msg,
.empty-msg {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.9rem;
padding: 1rem 0;
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
}
.users-table td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
font-size: 0.9rem;
}
.users-table tbody tr:last-child td {
border-bottom: none;
}
.cell-username {
font-weight: 600;
}
.cell-email {
color: var(--fs-text-secondary);
}
.cell-date {
color: var(--fs-text-tertiary);
font-size: 0.85rem;
}
.cell-actions {
white-space: nowrap;
}
/* Role badges */
.role-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.4rem;
border-radius: var(--fs-radius-sm);
}
.role-admin {
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
}
.role-user {
color: var(--fs-text-tertiary);
background: var(--fs-surface-raised);
}
/* Action buttons */
.you-label {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
@media (max-width: 768px) {
.registration-row {
flex-direction: column;
align-items: flex-start;
}
.btn-toggle {
width: 100%;
}
.invite-form {
flex-direction: column;
}
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.39", "version": "0.1.41",
"author": { "name": "Bryan Van Deusen" }, "author": { "name": "Bryan Van Deusen" },
"mcpServers": { "mcpServers": {
"scribe": { "scribe": {
+7
View File
@@ -58,6 +58,13 @@ scribe_defs() {
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) { if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
t = line; sub(/^[a-z]+[[:space:]]+/, "", t) t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
sub(/[^A-Za-z0-9_$].*$/, "", t) sub(/[^A-Za-z0-9_$].*$/, "", t)
# `type` defines only when something follows the name (= or {); an
# import specifier `type Foo,` is the same two words and defines
# nothing (mirror of coverage.py, #2904).
if (line ~ /^type[[:space:]]/) {
rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest)
if (rest !~ /[={]/) next
}
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
} }
# Arrow/expression assignment: const name = (…) / let name = async ( # Arrow/expression assignment: const name = (…) / let name = async (
+3 -2
View File
@@ -45,8 +45,9 @@ through recall/auto-inject; this skill is the active reflex around that.
duplicate — reuse it and drop yours — or it isn't, and the record needs the new duplicate — reuse it and drop yours — or it isn't, and the record needs the new
location adding. Both are cheaper now than after the duplicate settles in. location adding. Both are cheaper now than after the duplicate settles in.
- **A `Shape ledger at …` line is the ledger speaking, not the record.** It - **A `Shape ledger at …` line is the ledger speaking, not the record.** It
names a duplicate family ("identical body in N other files, no canon") or a names a duplicate family ("identical body in N other files, no canon"), a
canon elsewhere for a name you just wrote — for edits made through Bash repeated name ("defined in N other files") or a canon elsewhere for a name
you just wrote — for edits made through Bash
(sed, heredocs, scripts) as much as through Write/Edit. Derive the family or (sed, heredocs, scripts) as much as through Write/Edit. Derive the family or
reuse the canon *now*; a family that is convention rather than copies is reuse the canon *now*; a family that is convention rather than copies is
dismissed with `classify_shapes(..., status="exempt", dismissed with `classify_shapes(..., status="exempt",
+10 -1
View File
@@ -94,7 +94,8 @@ the last sweep left it. Three surfaces say so without anyone running an audit
- **At the write** — the prior-art hint (the Write/Edit hook, and since - **At the write** — the prior-art hint (the Write/Edit hook, and since
0.1.39 the after-write hook on Bash, so sed/heredoc/script edits count too) 0.1.39 the after-write hook on Bash, so sed/heredoc/script edits count too)
carries a `Shape ledger at <path>` line when a name just written is a known carries a `Shape ledger at <path>` line when a name just written is a known
**duplicate family** ("identical body in N other files, no canon") or a **duplicate family** ("identical body in N other files, no canon"), a
**repeated name** ("defined in N other files, no canon") or a
**canon elsewhere** ("snippet #N at <path> — reuse, don't redefine"). Act **canon elsewhere** ("snippet #N at <path> — reuse, don't redefine"). Act
on it *then*: pull the canon and build from it, or derive the family now — on it *then*: pull the canon and build from it, or derive the family now —
`create_snippet` the dominant form, repoint the copies, `classify_shapes` `create_snippet` the dominant form, repoint the copies, `classify_shapes`
@@ -109,6 +110,14 @@ the last sweep left it. Three surfaces say so without anyone running an audit
reason_code="convention-plumbing", reason=…)` (or `classify_shapes_by_rule` reason_code="convention-plumbing", reason=…)` (or `classify_shapes_by_rule`
for a whole family) removes it from the queue. Dismissal is a judgment and for a whole family) removes it from the queue. Dismissal is a judgment and
it is recorded; silence is not. it is recorded; silence is not.
- **CSS is watched by name, never by body** (note 2917). Classes serving
different purposes share declarations because the style system makes them
alike — `.text-muted` and `.pin-badge-auto` carrying the same `color:` are
two meanings, not two copies — so a CSS family is the *same class defined
in ≥2 files* (a recipe living in several places), and identical bodies
under different names are never a family. Derive a CSS family by moving
the recipe to the shared sheet and recording it; a class name reused for
genuinely different things is dismissed with `reason_code="scoped-css"`.
After the one-time pay-down the derive queue reads empty; anything in it After the one-time pay-down the derive queue reads empty; anything in it
afterwards is drift of the moment, and the hint already said so at the write. afterwards is drift of the moment, and the hint already said so at the write.
+27 -5
View File
@@ -123,6 +123,12 @@ def _definition_on(raw: str) -> tuple[str, str] | None:
name = m.group(1) name = m.group(1)
if name.startswith("__") and name.endswith("__"): if name.startswith("__") and name.endswith("__"):
return None return None
# `type` announces a definition only when something is declared after
# the name (`type Foo = …`, `type Foo struct {`); an import specifier
# (`import { type Foo, bar }`) is the same two words and defines
# nothing — it showed up as a two-file "identical body" family (#2904).
if line.startswith("type") and not re.search(r"[={]", line[m.end():]):
return None
return ("sym", name) return ("sym", name)
if m := _ARROW_RE.match(line): if m := _ARROW_RE.match(line):
return ("sym", m.group(1)) return ("sym", m.group(1))
@@ -156,6 +162,12 @@ def _block_sha(lines: list[str]) -> str:
return hashlib.sha1("\n".join(kept).encode("utf-8")).hexdigest()[:16] return hashlib.sha1("\n".join(kept).encode("utf-8")).hexdigest()[:16]
def _declaration_count(lines: list[str]) -> int:
"""How many `prop: value` declarations a CSS block body carries."""
body = " ".join(lines)
return sum(1 for part in body.replace("}", "").split(";") if ":" in part)
def extract_definitions(text: str) -> list[Definition]: def extract_definitions(text: str) -> list[Definition]:
"""Every definition this text makes, with signature + fingerprint. """Every definition this text makes, with signature + fingerprint.
@@ -186,11 +198,13 @@ def extract_definitions(text: str) -> list[Definition]:
break break
block = lines[i:end] block = lines[i:end]
# A CSS rule's fingerprint is its DECLARATIONS, not its selector # A CSS rule's fingerprint is its DECLARATIONS, not its selector
# (#2872): the row's identity already carries the selector, and the # (#2872): the row's identity already carries the selector. Since
# question the fingerprint answers for derive grouping is "is this the # note 2917 the derive grouping no longer reads CSS bodies at all (a
# same rule under another name?" — .closed-msg / .error-block / # class is grouped by name only), so for CSS the fingerprint is the
# .success-msg with identical bodies are one dup group, not three # recheck identity — "did this rule's body change since it was
# lonely rows. Sym blocks keep their signature line in the hash. # judged?" — and nothing more. The shape of the hash is kept as-is on
# purpose: changing it would flip every judged CSS row to recheck on
# the next sync. Sym blocks keep their signature line in the hash.
if kind == "css": if kind == "css":
# One-line rules (`.x { color: red; }`) carry their declarations on # One-line rules (`.x { color: red; }`) carry their declarations on
# the selector line itself; a block that is only the selector plus # the selector line itself; a block that is only the selector plus
@@ -202,6 +216,14 @@ def extract_definitions(text: str) -> list[Definition]:
hashed = head + block[1:] hashed = head + block[1:]
if not any(x.strip() for x in hashed): if not any(x.strip() for x in hashed):
hashed = block hashed = block
# A SINGLE declaration is not a shape (#2903): `color: var(--fs-
# text-tertiary)` under .text-muted, .task-mark and .pin-badge-auto
# is three meanings sharing one line, not three copies of one
# rule. Keep the selector in the hash for one-liners; two
# declarations and up stay selector-agnostic. (Moot for grouping
# since note 2917, kept for fingerprint stability — see above.)
elif _declaration_count(hashed) < 2:
hashed = block
else: else:
hashed = block hashed = block
out.append(Definition( out.append(Definition(
+14 -4
View File
@@ -1061,17 +1061,27 @@ def _derive_line(path: str, derive: list[dict]) -> str:
) )
continue continue
f = d["family"] f = d["family"]
how = "identical body" if f.get("identical") else "same name defined"
files = ", ".join(f"`{x}`" for x in f.get("files") or []) files = ", ".join(f"`{x}`" for x in f.get("files") or [])
more = f.get("file_count", 0) - len(f.get("files") or []) more = f.get("file_count", 0) - len(f.get("files") or [])
if more > 0: if more > 0:
files += f" +{more} more" files += f" +{more} more"
n = f.get("file_count", 0)
if f.get("identical"):
what = f"is a duplicate family with no canon — identical body in {n} other file(s)"
else:
# A name family: the same definition name living in several
# files. CSS is only ever grouped this way (note 2917) — a class
# is a recipe, and the recipe is what gets derived or dismissed.
what = f"is a repeated name with no canon — defined in {n} other file(s)"
# The dismissal reason the family most likely earns: a class name
# reused for different purposes is scoped styling; a code name reused
# across modules is convention plumbing.
dismiss = "scoped-css" if d.get("kind") == "css" else "convention-plumbing"
parts.append( parts.append(
f"`{f['label']}` is a duplicate family with no canon — {how} in " f"`{f['label']}` {what}: {files}; derive it now: "
f"{f.get('file_count', 0)} other file(s): {files}; derive it now: "
"record the canon (create_snippet) and make the copies instances " "record the canon (create_snippet) and make the copies instances "
"(classify_shapes) — or, if these are convention not copies, " "(classify_shapes) — or, if these are convention not copies, "
"`classify_shapes(..., status=\"exempt\", reason_code=\"convention-plumbing\")` " f"`classify_shapes(..., status=\"exempt\", reason_code=\"{dismiss}\")` "
"dismisses the family — rather than adding another copy" "dismisses the family — rather than adding another copy"
) )
return f"> Shape ledger at `{path}`: " + "; ".join(parts) + "." return f"> Shape ledger at `{path}`: " + "; ".join(parts) + "."
+19 -9
View File
@@ -923,6 +923,13 @@ async def stamp_write_path_instances(
# recur by convention, not by duplication). # recur by convention, not by duplication).
_DERIVE_MIN_DUP = 2 _DERIVE_MIN_DUP = 2
_DERIVE_MIN_NAME = 3 _DERIVE_MIN_NAME = 3
# CSS is never grouped by body (note 2917): classes for different purposes
# share declarations because the style system makes them alike — `.text-muted`
# and `.pin-badge-auto` carrying the same `color: var(--fs-text-tertiary)` are
# two meanings, not two copies. A CSS family is a NAME defined in more than
# one file: that is a recipe living in several places, and two is already
# the signal (a class name is deliberate in a way `setup`/`load` are not).
_DERIVE_MIN_NAME_CSS = 2
# Semantic checks per repo per refresh — an embedding each (local fastembed), # Semantic checks per repo per refresh — an embedding each (local fastembed),
# bounded so a 4,000-row ledger is worked through over refreshes, not in one. # bounded so a 4,000-row ledger is worked through over refreshes, not in one.
_SEMANTIC_CAP = 150 _SEMANTIC_CAP = 150
@@ -1298,15 +1305,16 @@ def derive_groups(
rows: Iterable[tuple[str, str, str, str]] rows: Iterable[tuple[str, str, str, str]]
) -> dict[tuple[str, str, str], str]: ) -> dict[tuple[str, str, str], str]:
"""The derive-first grouping over (path, kind, symbol, body_sha) rows """The derive-first grouping over (path, kind, symbol, body_sha) rows
that matched no canon: {(path, kind, symbol): group_key}. Identical that matched no canon: {(path, kind, symbol): group_key}. For code
bodies in ≥2 places group as `dup:<sha>`; the same name defined in ≥3 (kind `sym`) identical bodies in ≥2 places group as `dup:<sha>` and the
files groups as `name:<kind>:<symbol>`; a row joins at most one group, same name defined in ≥3 files groups as `name:sym:<symbol>`, the copy
the copy before the name.""" before the name. CSS groups by name only — the same class defined in
≥2 files is `name:css:<symbol>`; its body never groups it (note 2917)."""
by_sha: dict[str, list[tuple[str, str, str]]] = {} by_sha: dict[str, list[tuple[str, str, str]]] = {}
by_name: dict[tuple[str, str], list[tuple[str, str, str]]] = {} by_name: dict[tuple[str, str], list[tuple[str, str, str]]] = {}
for path, kind, symbol, sha in rows: for path, kind, symbol, sha in rows:
key = (path, kind, symbol) key = (path, kind, symbol)
if sha: if sha and kind != "css":
by_sha.setdefault(sha, []).append(key) by_sha.setdefault(sha, []).append(key)
by_name.setdefault((kind, _norm_symbol(symbol)), []).append(key) by_name.setdefault((kind, _norm_symbol(symbol)), []).append(key)
out: dict[tuple[str, str, str], str] = {} out: dict[tuple[str, str, str], str] = {}
@@ -1315,7 +1323,8 @@ def derive_groups(
for key in keys: for key in keys:
out.setdefault(key, f"dup:{sha}") out.setdefault(key, f"dup:{sha}")
for (kind, symbol), keys in by_name.items(): for (kind, symbol), keys in by_name.items():
if len({k[0] for k in keys}) >= _DERIVE_MIN_NAME: floor = _DERIVE_MIN_NAME_CSS if kind == "css" else _DERIVE_MIN_NAME
if len({k[0] for k in keys}) >= floor:
for key in keys: for key in keys:
out.setdefault(key, f"name:{kind}:{symbol}") out.setdefault(key, f"name:{kind}:{symbol}")
return out return out
@@ -1606,9 +1615,10 @@ async def write_time_derive(
named at ``path``, what the ledger already knows about that name named at ``path``, what the ledger already knows about that name
elsewhere in the project — elsewhere in the project —
family the name sits in a derive-first group (identical body in N family the name sits in a derive-first group (code: identical body
files, or the same name in ≥3): "this is a known duplicate in N files or the same name in ≥3; CSS: the same class in
family with no canon — derive it now, don't add a copy"; ≥2 files, note 2917): "this is a known family with no canon
— derive it now, don't add a copy";
canon a `canonical` row of that name at another path: "this is canon a `canonical` row of that name at another path: "this is
canon #N at <path> — reuse, don't redefine". canon #N at <path> — reuse, don't redefine".
+37
View File
@@ -6,6 +6,7 @@ them; a module imports what it needs with ``from tests.helpers import ...``.
""" """
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -182,3 +183,39 @@ def design_token_stub(name, value_by_mode, group_name=None, purpose=None,
name=name, value_by_mode=value_by_mode, group_name=group_name, name=name, value_by_mode=value_by_mode, group_name=group_name,
purpose=purpose, order_index=order_index, supersedes=supersedes or [], purpose=purpose, order_index=order_index, supersedes=supersedes or [],
) )
@contextmanager
def http_sink(reply: bytes = b'{"context":"","note_ids":[]}'):
"""A throwaway local HTTP listener for hook end-to-end tests: yields
``(port, seen)`` where ``seen`` collects every GET's parsed query string
(one dict per request, in order). Lets the shell be tested end to end —
the extraction, the encoding, the URL — without a Scribe instance.
Three test modules each carried their own ``_Sink`` handler before #2904
consolidated them here; pass ``reply`` for the body the hook should see.
"""
import http.server
import threading
import urllib.parse
seen: list[dict] = []
class _Sink(http.server.BaseHTTPRequestHandler):
def do_GET(self):
seen.append(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(reply)
def log_message(self, *a):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
yield server.server_port, seen
finally:
server.shutdown()
server.server_close()
+35 -59
View File
@@ -6,17 +6,16 @@ the pre-write hook's end-to-end tests. Skips where the hook's tools are
missing; asserts on content where they are present.""" missing; asserts on content where they are present."""
from __future__ import annotations from __future__ import annotations
import http.server
import json import json
import os import os
import shutil import shutil
import subprocess import subprocess
import threading
import urllib.parse
from pathlib import Path from pathlib import Path
import pytest import pytest
from tests.helpers import http_sink
PLUGIN = Path(__file__).resolve().parents[1] / "plugin" PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
HOOK = PLUGIN / "hooks" / "scribe_after_write.sh" HOOK = PLUGIN / "hooks" / "scribe_after_write.sh"
@@ -53,66 +52,43 @@ def _run(repo, env, session="s-after-1", tool="Bash"):
return out.stdout return out.stdout
class _Sink(http.server.BaseHTTPRequestHandler): SINK_REPLY = b'{"context":"> family named","note_ids":[],"sync_note_ids":[],"derive_keys":["dup:483a"]}'
seen: list[dict] = []
reply = b'{"context":"> family named","note_ids":[],"sync_note_ids":[],"derive_keys":["dup:483a"]}'
def do_GET(self):
type(self).seen.append(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(type(self).reply)
def log_message(self, *a):
pass
@pytest.fixture def test_after_write_names_what_bash_just_wrote_then_stays_quiet_until_the_next_change(tmp_path):
def sink(): with http_sink(SINK_REPLY) as (port, seen):
_Sink.seen = [] env = _env(tmp_path, url=f"http://127.0.0.1:{port}")
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink) repo = _repo(tmp_path, env)
threading.Thread(target=server.serve_forever, daemon=True).start() # "A Bash call" wrote an untracked stylesheet and appended to a tracked file.
try: (repo / "a.css").write_text(".log-empty {\n color: red;\n}\n")
yield server (repo / "b.py").write_text("def one():\n return 1\n\ndef slug(t):\n return t\n")
finally: out = _run(repo, env)
server.shutdown() by_path = {q["path"][0]: q for q in seen}
assert set(by_path) == {"a.css", "b.py"} # repo-relative, like the pre hook
assert by_path["a.css"]["shapes"] == ["css:log-empty"]
assert by_path["b.py"]["shapes"] == ["sym:slug"]
# Added lines only for the tracked file — the existing def is not "just written".
assert "def slug" in by_path["b.py"]["code"][0] and "def one" not in by_path["b.py"]["code"][0]
ctx = json.loads(out)["hookSpecificOutput"]
assert ctx["hookEventName"] == "PostToolUse"
assert "> family named" in ctx["additionalContext"]
# The local by-name arm rides along: `slug` already lives in c.py.
assert "`slug` is already defined in 1 other file(s): c.py" in ctx["additionalContext"]
# Derive keys landed on the SHARED channel the pre-write hook reads.
state = tmp_path / "scribe-priorart" / "s-after-1.derive.ids"
assert "dup:483a" in state.read_text().split()
# Nothing changed → one git status, no request, no output.
seen.clear()
assert _run(repo, env) == ""
assert seen == []
def test_after_write_names_what_bash_just_wrote_then_stays_quiet_until_the_next_change(tmp_path, sink): # Another change → only that file, and the dedup channel goes back up.
env = _env(tmp_path, url=f"http://127.0.0.1:{sink.server_port}") (repo / "a.css").write_text(".log-empty {\n color: red;\n}\n.other {\n margin: 0;\n}\n")
repo = _repo(tmp_path, env) _run(repo, env)
# "A Bash call" wrote an untracked stylesheet and appended to a tracked file. assert [q["path"][0] for q in seen] == ["a.css"]
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n") assert seen[0]["exclude_derive"] == ["dup:483a"]
(repo / "b.py").write_text("def one():\n return 1\n\ndef slug(t):\n return t\n") assert set(seen[0]["shapes"][0].split(",")) == {"css:log-empty", "css:other"}
out = _run(repo, env)
by_path = {q["path"][0]: q for q in _Sink.seen}
assert set(by_path) == {"a.css", "b.py"} # repo-relative, like the pre hook
assert by_path["a.css"]["shapes"] == ["css:log-empty"]
assert by_path["b.py"]["shapes"] == ["sym:slug"]
# Added lines only for the tracked file — the existing def is not "just written".
assert "def slug" in by_path["b.py"]["code"][0] and "def one" not in by_path["b.py"]["code"][0]
ctx = json.loads(out)["hookSpecificOutput"]
assert ctx["hookEventName"] == "PostToolUse"
assert "> family named" in ctx["additionalContext"]
# The local by-name arm rides along: `slug` already lives in c.py.
assert "`slug` is already defined in 1 other file(s): c.py" in ctx["additionalContext"]
# Derive keys landed on the SHARED channel the pre-write hook reads.
state = tmp_path / "scribe-priorart" / "s-after-1.derive.ids"
assert "dup:483a" in state.read_text().split()
# Nothing changed → one git status, no request, no output.
_Sink.seen = []
assert _run(repo, env) == ""
assert _Sink.seen == []
# Another change → only that file, and the dedup channel goes back up.
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n.other {\n margin: 0;\n}\n")
_run(repo, env)
assert [q["path"][0] for q in _Sink.seen] == ["a.css"]
assert _Sink.seen[0]["exclude_derive"] == ["dup:483a"]
assert set(_Sink.seen[0]["shapes"][0].split(",")) == {"css:log-empty", "css:other"}
def test_after_write_is_silent_where_it_has_nothing_to_say(tmp_path): def test_after_write_is_silent_where_it_has_nothing_to_say(tmp_path):
env = _env(tmp_path) env = _env(tmp_path)
+18 -8
View File
@@ -588,21 +588,30 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded):
assert summary["derive_groups"][1]["label"] == ".card" assert summary["derive_groups"][1]["label"] == ".card"
assert summary["derive_groups"][0]["size"] == 2 and summary["derive_groups"][1]["size"] == 3 assert summary["derive_groups"][0]["size"] == 2 and summary["derive_groups"][1]["size"] == 3
# One of the css copies gets judged → the group shrinks on the next pass. # One of the css copies gets judged → the group shrinks on the next pass
# but stays a family: a class in two files is already a recipe living in
# two places (css name floor 2, note 2917). Judge the second and it's gone.
await classify_shapes(owner, pid, [ await classify_shapes(owner, pid, [
{"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"}, {"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
]) ])
await apply_derive_groups(pid) await apply_derive_groups(pid)
rows, _ = await list_project_shapes(owner, pid, proposal="derive") rows, _ = await list_project_shapes(owner, pid, proposal="derive")
assert {r.symbol for r in rows} == {"slug"} # 2 files < the name floor assert {r.symbol for r in rows} == {"slug", "card"}
assert {r.path for r in rows if r.symbol == "card"} == {"b/x.css", "b/y.css"}
await classify_shapes(owner, pid, [
{"path": "b/y.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
])
await apply_derive_groups(pid)
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
assert {r.symbol for r in rows} == {"slug"} # 1 file < the css name floor
@pytest.mark.integration @pytest.mark.integration
async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded): async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded):
"""#2900: against real rows — a name in a dup family → the family (other """#2900: against real rows — a name in a family → the family (other
files, count); a name whose canonical row lives elsewhere → that canon; files, count; for CSS a NAME family, never a body one — note 2917); a
a judged row at the path, the canon's own file, or an unknown name → name whose canonical row lives elsewhere → that canon; a judged row at
silence.""" the path, the canon's own file, or an unknown name → silence."""
from scribe.services.shape_ledger import apply_derive_groups, write_time_derive from scribe.services.shape_ledger import apply_derive_groups, write_time_derive
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
@@ -622,9 +631,10 @@ async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded
out = await write_time_derive(pid, "v/D.vue", [("css", "log-empty"), ("css", "unknown")]) out = await write_time_derive(pid, "v/D.vue", [("css", "log-empty"), ("css", "unknown")])
assert len(out) == 1 and out[0]["symbol"] == "log-empty" and out[0]["kind"] == "css" assert len(out) == 1 and out[0]["symbol"] == "log-empty" and out[0]["kind"] == "css"
fam = out[0]["family"] fam = out[0]["family"]
assert fam["identical"] is True and fam["label"] == ".log-empty" # Three identical bodies, and still a NAME family: CSS never groups by body.
assert fam["identical"] is False and fam["label"] == ".log-empty"
assert fam["files"] == ["v/A.vue", "v/B.vue", "v/C.vue"] and fam["file_count"] == 3 assert fam["files"] == ["v/A.vue", "v/B.vue", "v/C.vue"] and fam["file_count"] == 3
assert out[0]["key"] == fam["group"] and fam["group"].startswith("dup:") assert out[0]["key"] == fam["group"] == "name:css:log-empty"
# Editing one existing member still names the OTHER members. # Editing one existing member still names the OTHER members.
out = await write_time_derive(pid, "v/A.vue", [("css", "log-empty")]) out = await write_time_derive(pid, "v/A.vue", [("css", "log-empty")])
assert out[0]["family"]["files"] == ["v/B.vue", "v/C.vue"] and out[0]["family"]["size"] == 3 assert out[0]["family"]["files"] == ["v/B.vue", "v/C.vue"] and out[0]["family"]["size"] == 3
+24 -3
View File
@@ -149,6 +149,21 @@ def test_largest_gaps_ranks_by_unclassified_and_drops_clean_dirs():
assert gaps == [{"dir": "src", "unclassified": 2, "total": 3}] assert gaps == [{"dir": "src", "unclassified": 2, "total": 3}]
def test_type_import_specifiers_are_not_definitions():
"""#2904: `import { type Foo, bar }` is the same two words as `type Foo =`
and defines nothing; only a `type` line with a declaration after the
name counts (TS alias, Go/Rust type)."""
from scribe.services.coverage import extract_shapes
src = (
'import { type DesignSystem, fetchDesignSystems } from "@/api/designSystems";\n'
'import { type Project } from "./x";\n'
"type Baz = { a: number };\n"
"type Wide<T> = T | null;\n"
"type Point struct {\n\tX int\n}\n"
)
assert extract_shapes(src) == [("sym", "Baz"), ("sym", "Wide"), ("sym", "Point")]
def test_coverage_line_is_evidence_carrying_and_labeled_estimate(): def test_coverage_line_is_evidence_carrying_and_labeled_estimate():
line = coverage_line({ line = coverage_line({
"total": 4573, "accounted": 3100, "unclassified": 1473, "total": 4573, "accounted": 3100, "unclassified": 1473,
@@ -484,12 +499,18 @@ def test_extract_definitions_fingerprints_each_block():
d = {x.name: x for x in extract_definitions(css)} d = {x.name: x for x in extract_definitions(css)}
assert d["closed-msg"].body_sha == d["error-block"].body_sha != d["other"].body_sha assert d["closed-msg"].body_sha == d["error-block"].body_sha != d["other"].body_sha
# One-line rules hash their own declarations — never the empty string # One-line rules hash their own declarations — never the empty string
# (first deploy grouped 68 unrelated one-liners as one copy). # (first deploy grouped 68 unrelated one-liners as one copy) — and a
one = ".a { color: red; }\n\n.b { color: red; }\n\n.c { color: blue; }\n\n.d {\n color: red;\n}\n" # SINGLE declaration is not a shape (#2903): it keeps its selector in the
# hash, so `.a { color: red }` groups only with another `.a`, never with
# `.b { color: red }`. Two declarations and up stay selector-agnostic.
one = ".a { color: red; }\n\n.b { color: red; }\n\n.c { color: blue; }\n\n.a {\n color: red;\n}\n"
e = {x.name: x for x in extract_definitions(one)} e = {x.name: x for x in extract_definitions(one)}
import hashlib import hashlib
assert e["a"].body_sha == e["b"].body_sha != e["c"].body_sha assert e["a"].body_sha != e["b"].body_sha != e["c"].body_sha
assert e["a"].body_sha != hashlib.sha1(b"").hexdigest()[:16] assert e["a"].body_sha != hashlib.sha1(b"").hexdigest()[:16]
two = ".a {\n color: red;\n margin: 0;\n}\n.b {\n color: red;\n margin: 0;\n}\n"
f = {x.name: x for x in extract_definitions(two)}
assert f["a"].body_sha == f["b"].body_sha
def test_coverage_line_names_the_proposers_standing(): def test_coverage_line_names_the_proposers_standing():
+10
View File
@@ -308,6 +308,12 @@ def test_derive_groups_copy_before_name_with_floors():
("d.css", "css", "btn", "s1"), ("e.css", "css", "btn", "s2"), ("f.css", "css", "btn", "s3"), ("d.css", "css", "btn", "s1"), ("e.css", "css", "btn", "s2"), ("f.css", "css", "btn", "s3"),
("g.py", "sym", "main", "s4"), ("h.py", "sym", "main", "s5"), # only 2 files → no name group ("g.py", "sym", "main", "s4"), ("h.py", "sym", "main", "s5"), # only 2 files → no name group
("i.py", "sym", "one", "s6"), ("i.py", "sym", "one", "s6"),
# CSS (note 2917): identical bodies under different names are NOT a
# copy — two meanings sharing the style system's look; the same
# class in two files IS a family (the name floor is 2 for css).
("j.css", "css", "muted", "same"), ("k.css", "css", "pin-auto", "same"),
("l.css", "css", "card", "c1"), ("m.css", "css", "card", "c2"),
("n.css", "css", "alone", "c3"),
] ]
g = derive_groups(rows) g = derive_groups(rows)
assert g[("a.py", "sym", "helper")] == "dup:sha1" == g[("b.py", "sym", "helper")] assert g[("a.py", "sym", "helper")] == "dup:sha1" == g[("b.py", "sym", "helper")]
@@ -315,6 +321,10 @@ def test_derive_groups_copy_before_name_with_floors():
assert g[("d.css", "css", "btn")] == "name:css:btn" assert g[("d.css", "css", "btn")] == "name:css:btn"
assert ("g.py", "sym", "main") not in g assert ("g.py", "sym", "main") not in g
assert ("i.py", "sym", "one") not in g assert ("i.py", "sym", "one") not in g
assert ("j.css", "css", "muted") not in g and ("k.css", "css", "pin-auto") not in g
assert g[("l.css", "css", "card")] == "name:css:card" == g[("m.css", "css", "card")]
assert ("n.css", "css", "alone") not in g
assert not any(v.startswith("dup:") for k, v in g.items() if k[1] == "css")
def test_derive_new_summary_counts_copies_first_seen_since_the_previous_refresh(): def test_derive_new_summary_counts_copies_first_seen_since_the_previous_refresh():
+45 -62
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import fake_note from tests.helpers import fake_note, http_sink
PLUGIN = Path(__file__).resolve().parents[1] / "plugin" PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
HOOK = PLUGIN / "hooks" / "scribe_prior_art.sh" HOOK = PLUGIN / "hooks" / "scribe_prior_art.sh"
@@ -1149,6 +1149,23 @@ def test_route_stamps_only_for_a_caller_allowed_to_write():
assert "&shapes=" in hook assert "&shapes=" in hook
def test_hook_does_not_name_a_type_import_specifier_as_a_shape(tmp_path):
"""#2904, the awk mirror of the server rule: `type Foo,` inside an import
list is not a definition; `type Baz = …` on its own line is."""
env = _hook_runtime_env()
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
seen = _run_hook_against_sink(tmp_path, {
"session_id": "s-type", "cwd": str(repo), "tool_name": "Write",
"tool_input": {"file_path": str(repo / "x.ts"),
"content": 'import { type Foo, bar } from "./y";\n'
"type Baz = { a: number };\n"
"export function use(): Baz {\n return { a: 1 };\n}\n"},
})
assert seen["shapes"] == ["sym:Baz,sym:use"]
def test_hook_names_the_shapes_being_written(): def test_hook_names_the_shapes_being_written():
"""The feed's two inputs: every definition in the payload, or — for an Edit """The feed's two inputs: every definition in the payload, or — for an Edit
that changes a body, not a signature — the definition enclosing the edit, that changes a body, not a signature — the definition enclosing the edit,
@@ -1165,39 +1182,15 @@ def test_hook_names_the_shapes_being_written():
def _run_hook_against_sink(tmp_path, payload): def _run_hook_against_sink(tmp_path, payload):
"""Run the hook with SCRIBE_URL pointed at a throwaway local listener and """Run the hook with SCRIBE_URL pointed at a throwaway local listener and
return the query the hook sent. Lets the shell be tested end to end — return the query the hook sent (tests.helpers.http_sink)."""
the extraction, the encoding, the URL — without a Scribe instance.""" with http_sink() as (port, seen):
import http.server env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
import threading
import urllib.parse
seen: dict = {}
class _Sink(http.server.BaseHTTPRequestHandler):
def do_GET(self):
seen.update(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"context":"","note_ids":[]}')
def log_message(self, *a):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{server.server_port}")
out = subprocess.run( out = subprocess.run(
["bash", str(HOOK)], input=json.dumps(payload), ["bash", str(HOOK)], input=json.dumps(payload),
capture_output=True, text=True, env=env, capture_output=True, text=True, env=env,
) )
assert out.returncode == 0, out.stderr assert out.returncode == 0, out.stderr
finally: return seen[0] if seen else {}
server.shutdown()
server.server_close()
return seen
def test_hook_sends_every_definition_in_a_write(tmp_path): def test_hook_sends_every_definition_in_a_write(tmp_path):
@@ -1257,13 +1250,16 @@ async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band():
channel silences a family already named.""" channel silences a family already named."""
from scribe.services import plugin_context as pc from scribe.services import plugin_context as pc
found = [ found = [
{"symbol": "log-empty", "kind": "css", "key": "dup:483a", {"symbol": "log-empty", "kind": "css", "key": "name:css:log-empty",
"family": {"group": "dup:483a", "label": ".log-empty", "identical": True, "family": {"group": "name:css:log-empty", "label": ".log-empty", "identical": False,
"files": ["a/TaskLogSection.vue", "a/WorkspaceTaskPanel.vue"], "files": ["a/TaskLogSection.vue", "a/WorkspaceTaskPanel.vue"],
"file_count": 5, "size": 6}}, "file_count": 5, "size": 6}},
{"symbol": "btn-primary", "kind": "css", "key": "canon:2855", {"symbol": "btn-primary", "kind": "css", "key": "canon:2855",
"canon": {"snippet_id": 2855, "path": "frontend/src/assets/components.css", "canon": {"snippet_id": 2855, "path": "frontend/src/assets/components.css",
"label": ".btn-primary"}}, "label": ".btn-primary"}},
{"symbol": "slugify", "kind": "sym", "key": "dup:483a",
"family": {"group": "dup:483a", "label": "slugify", "identical": True,
"files": ["a/x.py", "a/y.py"], "file_count": 2, "size": 3}},
{"symbol": "load", "kind": "sym", "key": "name:sym:load", {"symbol": "load", "kind": "sym", "key": "name:sym:load",
"family": {"group": "name:sym:load", "label": "load", "identical": False, "family": {"group": "name:sym:load", "label": "load", "identical": False,
"files": ["a/X.vue", "a/Y.vue", "a/Z.vue"], "file_count": 3, "size": 4}}, "files": ["a/X.vue", "a/Y.vue", "a/Z.vue"], "file_count": 3, "size": 4}},
@@ -1281,20 +1277,27 @@ async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band():
patch.object(pc.shape_ledger_svc, "write_time_derive", check): patch.object(pc.shape_ledger_svc, "write_time_derive", check):
out = await pc.build_write_path_hint( out = await pc.build_write_path_hint(
1, "frontend/src/components/New.vue", code=REAL_CODE, project_id=24, 1, "frontend/src/components/New.vue", code=REAL_CODE, project_id=24,
stamp_shapes=[("css", "log-empty"), ("css", "btn-primary"), ("sym", "load")], stamp_shapes=[("css", "log-empty"), ("css", "btn-primary"), ("sym", "slugify"),
("sym", "load")],
exclude_derive=["name:sym:load"], exclude_derive=["name:sym:load"],
) )
check.assert_awaited_once_with(24, "frontend/src/components/New.vue", check.assert_awaited_once_with(24, "frontend/src/components/New.vue",
[("css", "log-empty"), ("css", "btn-primary"), ("sym", "load")]) [("css", "log-empty"), ("css", "btn-primary"),
# The excluded family is gone; the other two render and are keyed. ("sym", "slugify"), ("sym", "load")])
assert [d["key"] for d in out["derive"]] == ["dup:483a", "canon:2855"] # The excluded family is gone; the other three render and are keyed.
assert out["derive_keys"] == ["dup:483a", "canon:2855"] assert [d["key"] for d in out["derive"]] == ["name:css:log-empty", "canon:2855", "dup:483a"]
assert out["derive_keys"] == ["name:css:log-empty", "canon:2855", "dup:483a"]
ctx = out["context"] ctx = out["context"]
assert "Shape ledger at `frontend/src/components/New.vue`" in ctx assert "Shape ledger at `frontend/src/components/New.vue`" in ctx
assert "`.log-empty` is a duplicate family with no canon — identical body in 5 other file(s): " \ # A CSS family is a repeated NAME (note 2917) and its dismissal is scoped-css;
# a code dup family is an identical body and dismisses as convention-plumbing.
assert "`.log-empty` is a repeated name with no canon — defined in 5 other file(s): " \
"`a/TaskLogSection.vue`, `a/WorkspaceTaskPanel.vue` +3 more; derive it now" in ctx "`a/TaskLogSection.vue`, `a/WorkspaceTaskPanel.vue` +3 more; derive it now" in ctx
assert "`slugify` is a duplicate family with no canon — identical body in 2 other file(s): " \
"`a/x.py`, `a/y.py`; derive it now" in ctx
assert "`.btn-primary` is canon — snippet #2855 at `frontend/src/assets/components.css`" in ctx assert "`.btn-primary` is canon — snippet #2855 at `frontend/src/assets/components.css`" in ctx
assert "convention-plumbing" in ctx assert 'reason_code=\"scoped-css\")` dismisses' in ctx
assert 'reason_code=\"convention-plumbing\")` dismisses' in ctx
assert "`load`" not in ctx assert "`load`" not in ctx
# No project → no check; a failing check never sinks the hint. # No project → no check; a failing check never sinks the hint.
check.reset_mock() check.reset_mock()
@@ -1321,28 +1324,10 @@ def test_the_hook_keeps_a_derive_channel_and_sends_it_back(tmp_path):
"""#2900: derive keys the server returns land in the session's own """#2900: derive keys the server returns land in the session's own
`.derive.ids` file and go back as `exclude_derive` on the next write — `.derive.ids` file and go back as `exclude_derive` on the next write —
a family is named once per session, not at every edit.""" a family is named once per session, not at every edit."""
import http.server reply = (b'{"context":"> family","note_ids":[],"sync_note_ids":[],'
import threading b'"derive_keys":["dup:483a","canon:2855"]}')
import urllib.parse with http_sink(reply) as (port, seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}",
seen: list[dict] = []
class _Sink(http.server.BaseHTTPRequestHandler):
def do_GET(self):
seen.append(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"context":"> family","note_ids":[],"sync_note_ids":[],'
b'"derive_keys":["dup:483a","canon:2855"]}')
def log_message(self, *a):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{server.server_port}",
TMPDIR=str(tmp_path)) TMPDIR=str(tmp_path))
payload = {"session_id": "s-derive-1", "cwd": str(tmp_path), "tool_name": "Write", payload = {"session_id": "s-derive-1", "cwd": str(tmp_path), "tool_name": "Write",
"tool_input": {"file_path": str(tmp_path / "a.css"), "tool_input": {"file_path": str(tmp_path / "a.css"),
@@ -1351,8 +1336,6 @@ def test_the_hook_keeps_a_derive_channel_and_sends_it_back(tmp_path):
out = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload), out = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload),
capture_output=True, text=True, env=env) capture_output=True, text=True, env=env)
assert out.returncode == 0, out.stderr assert out.returncode == 0, out.stderr
finally:
server.shutdown()
assert "exclude_derive" not in seen[0] assert "exclude_derive" not in seen[0]
assert seen[1]["exclude_derive"] == ["dup:483a,canon:2855"] assert seen[1]["exclude_derive"] == ["dup:483a,canon:2855"]
state = tmp_path / "scribe-priorart" / "s-derive-1.derive.ids" state = tmp_path / "scribe-priorart" / "s-derive-1.derive.ids"