refactor(frontend): the last pay-down, part 1 — three dead views deleted, editor rules shared, .page-container + .fs-input canon, rules-shared.css; a one-declaration CSS body is not a shape (#2903, milestone 299 step 5)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Failing after 54s
CI & Build / Build & push image (push) Skipped

The derive queue said the biggest duplicate families were whole views:
TaskViewerView, UserManagementView and LogsView were imported nowhere — left
behind when tasks moved to the editor and users/logs became SettingsView
tabs. Deleted (rule 22). Note/TaskEditorView carried six identical scoped
rules -> editor-shared.css (the .tag-suggest-row gap the scoped copies
actually rendered wins). Three views wrapped the page under three names ->
.page-container in components.css. Three scoped input recipes -> the design
system fs-input recipe (snippet #2336), verbatim, in components.css; width/
box-sizing stay with the caller. The three rules panes share .pane and the
pane heading via rules-shared.css (the auth-shared pattern, #2852).

Ledger: a single-declaration CSS rule keeps its selector in its fingerprint,
so `color: var(--fs-text-tertiary)` under five different names is no longer
a five-file "identical body" family — the first pay-down found that most of
the 148 dup families were exactly this, and nobody would consolidate them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 14:55:28 -04:00
co-authored by Claude Fable 5
parent 4fa8158329
commit a72605de8f
19 changed files with 130 additions and 1861 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; }
+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); }
-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;
+9 -25
View File
@@ -641,7 +641,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 +874,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 +891,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 +1202,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 +1402,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 +1446,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 */
+1 -1
View File
@@ -3172,7 +3172,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; }
+1 -8
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">
@@ -518,13 +518,6 @@ function usageTitle(s: SnippetListItem): string {
</template> </template>
<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;
-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>
+15
View File
@@ -156,6 +156,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.
@@ -202,6 +208,15 @@ 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 — the first pay-down found 5-file "families" of exactly
# this and nobody would consolidate them. Keep the selector in the
# hash for one-liners, so they group only with same-name copies;
# two declarations and up stay selector-agnostic.
elif _declaration_count(hashed) < 2:
hashed = block
else: else:
hashed = block hashed = block
out.append(Definition( out.append(Definition(
+9 -3
View File
@@ -484,12 +484,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 { color: red; margin: 0; }\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():