Files
FabledScribe/frontend/src/views/KnowledgeView.vue
T
bvandeusen 1ec44071d2
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / integration (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 1m1s
feat(ui): a note's check is editable, dated and sweepable (#3167, milestone 317 step 4)
Rule 27: no UI, no ship. Three surfaces.

THE EDITOR ASKS, but only where the answer can be saved: the fields appear
for a plain note and not for a task or a snippet, matching the service gate
from step 2 so the form never offers a write the save would reject. The
labels are phrased as the QUESTION rather than the field name — "how would
someone check this is still true?" and, underneath, "could this become false
without anyone editing it?". "Verify with" gets filled in on every note; the
question gets filled in on the few that can go stale. `expires_when` appears
only once a check exists, and asks for a state rather than a date in the
placeholder itself.

THE NOTE SHOWS ITS AGE beside the field — "checked 2026-08-28" or "never
checked", italic, and nothing at all when no check exists. No red/amber ramp,
matching RuleSweepPane: a colour scale would restate the sweep's ordering and
force an invented staleness threshold. "Never" is marked because it is
categorically different from a date, not a worse one.

THE SWEEP is a pane in the Knowledge view, not beside the rules sweep —
operator's call, taken over a unified "everything due" surface and over a
second pane under /rules. Notes stay where notes live. The cost, accepted
knowingly: no single screen shows every unconfirmed record. It REPLACES the
feed rather than filtering it, because a facet answers "show me this kind"
and this answers "show me what nobody has confirmed" — a question the type
chips cannot narrow without under-reporting.

Two REST routes for it, since step 3 built only the service and the MCP door.

Along the way: NoteEditorView spelled its write payload out at three call
sites (save, create, auto-save), so every new field had to be added three
times — which is how one of them ends up not carrying it. Now one `payload()`
and one `snapshot()`.

Known and filed, not fixed: NoteSweepPane copies ~12 scoped CSS rules from
RuleSweepPane (#3207). The clean extraction needs prefixed names, because
`.age`, `.row-title`, `.lede` and `.actions` all exist scoped in other
components and an unscoped global would leak into them — which means editing
the shipped rules sweep, blind, inside a step whose acceptance is the
operator looking at a different surface.
2026-08-28 22:04:00 -04:00

1118 lines
39 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { useRouter } from "vue-router";
import { apiGet } from "@/api/client";
import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note";
import KindBadge from "@/components/KindBadge.vue";
import NoteSweepPane from "@/components/NoteSweepPane.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import GraphView from "@/views/GraphView.vue";
import {
FileText,
CheckSquare,
Workflow,
Search,
Share2,
ShieldCheck,
ChevronLeft,
ChevronRight,
X,
} from "lucide-vue-next";
const router = useRouter();
// ─── Types ────────────────────────────────────────────────────────────────────
interface KnowledgeItem {
id: number;
note_type: "note" | "task" | "process" | "snippet";
title: string;
snippet: string;
tags: string[];
project_id: number | null;
created_at: string;
updated_at: string;
// Set only when another user owns this record — their suggestion, not one of
// yours. Absent means it's yours.
shared?: boolean;
owner?: string | null;
// Task-specific
status?: string;
priority?: string;
due_date?: string;
task_kind?: TaskKind;
}
// ─── The facet vocabulary ─────────────────────────────────────────────────────
// Mirrors services/knowledge._FACETS, which is where it is defined for real.
// A facet spans BOTH typing axes — a record TYPE (note / process / snippet) or
// a task KIND (`task` for any, else issue / spike) — because that is what this
// feed actually holds.
//
// `plan` is still a valid facet at the API, for the 90 legacy plan-tasks, but
// it has no chip: retired in 0066, it kept a chip of its own for longer than
// `issue` — 17% of every task here — went without one (#3128). Those rows are
// still reachable under Tasks, wearing a Plan badge.
type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process";
// The facets that select TASKS. Kinds are subsets of `task`, so any of them
// means the duplicate report should be comparing tasks.
const TASK_FACETS = new Set<Facet>(["task", "issue", "spike"]);
const FACET_CHIPS: [Exclude<Facet, "">, string][] = [
["note", "Notes"],
["task", "Tasks"],
["issue", "Issues"],
["spike", "Spikes"],
["snippet", "Snippets"],
["process", "Processes"],
];
// ─── View mode ────────────────────────────────────────────────────────────────
// The sweep is cross-cutting — a note that has gone false does not care which
// facet it sits under — so it REPLACES the browse list rather than filtering
// it. Filtering would mean the answer depended on which chip was active, which
// is the under-reporting the sweep exists to prevent (milestone 317 step 4).
const sweepActive = ref(false);
// ─── Filter state ─────────────────────────────────────────────────────────────
const activeType = ref<Facet>("");
const activeTag = ref("");
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref("");
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
// ─── Near-duplicate report ────────────────────────────────────────────────────
// On demand, never automatic — a corpus-wide pairwise scan, and most visits to
// this page aren't a tidy-up (same reasoning as SnippetListView's report).
interface DupMember {
id: number; title: string;
created_at?: string | null; updated_at?: string | null;
task_kind?: string | null;
}
interface DupGroup {
note_ids: number[];
members: DupMember[];
top_score: number;
existing_supersessions?: { superseder_id: number; superseded_id: number }[];
}
const dupGroups = ref<DupGroup[]>([]);
const dupSuggestion = ref("");
const dupLoading = ref(false);
const dupChecked = ref(false);
// The report follows the type filter: viewing tasks — under ANY task facet,
// including a single kind — checks tasks. Everything else checks notes, the
// kind with the most to find.
const dupKind = computed(() => (TASK_FACETS.has(activeType.value) ? "task" : "note"));
async function loadDuplicates() {
dupLoading.value = true;
try {
const data = await apiGet<{ groups: DupGroup[]; suggestion: string }>(
`/api/notes/duplicates?kind=${dupKind.value}`
);
dupGroups.value = data.groups;
dupSuggestion.value = data.suggestion;
dupChecked.value = true;
} catch {
dupChecked.value = false;
} finally {
dupLoading.value = false;
}
}
// A stale report is worse than none: switching the type filter changes which
// kind the button checks, so the old kind's groups must not linger under it.
watch(dupKind, () => { dupChecked.value = false; dupGroups.value = []; });
// ─── Type counts ──────────────────────────────────────────────────────────────
// One number per facet, plus the grand total. Partial because the server sends
// a key only for a facet it has rows for. Kinds are subsets of `task` and are
// deliberately absent from `total` — including them would count an issue twice.
type KnowledgeCounts = Partial<Record<Exclude<Facet, "">, number>> & { total: number };
const typeCounts = ref<KnowledgeCounts>({ total: 0 });
async function fetchCounts() {
try {
const p = new URLSearchParams();
if (activeTag.value) p.set("tags", activeTag.value);
typeCounts.value = await apiGet<KnowledgeCounts>(`/api/knowledge/counts?${p}`);
} catch { /* silent */ }
}
// ─── New note dropdown ────────────────────────────────────────────────────────
const newNoteMenuOpen = ref(false);
function createNew(type: string) {
newNoteMenuOpen.value = false;
if (type === "task") {
router.push("/tasks/new");
} else {
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
}
}
function onClickOutsideNewNote(e: MouseEvent) {
const wrap = document.querySelector('.new-note-wrap');
if (wrap && !wrap.contains(e.target as Node)) {
newNoteMenuOpen.value = false;
}
}
// ─── Two-tier pagination ──────────────────────────────────────────────────────
const ID_BATCH = 100; // IDs fetched per server round-trip
const INITIAL_CONTENT = 50; // items loaded on first render
const CONTENT_PAGE = 24; // items loaded per sentinel trigger
const REFILL_THRESHOLD = 48; // fetch more IDs when queue drops below this
const items = ref<KnowledgeItem[]>([]);
const allTags = ref<string[]>([]);
const idQueue = ref<number[]>([]); // unloaded IDs ready to be content-fetched
const idOffset = ref(0); // next offset for ID batch requests
const totalKnowledge = ref(0); // total matching items on server
const allIdsFetched = ref(false); // no more ID pages to fetch
const idsFetching = ref(false); // ID batch request in-flight
const contentFetching = ref(false); // content batch request in-flight
const sentinelVisible = ref(false); // updated by IntersectionObserver
const loading = computed(() => idsFetching.value || contentFetching.value);
let fetchGen = 0; // incremented on each reset to invalidate stale responses
function buildFilterParams(): URLSearchParams {
const p = new URLSearchParams();
if (activeType.value) p.set("type", activeType.value);
if (activeTag.value) p.set("tags", activeTag.value);
p.set("sort", sortMode.value);
if (searchQuery.value.trim()) p.set("q", searchQuery.value.trim());
return p;
}
async function fetchIdBatch(gen: number) {
if (idsFetching.value || allIdsFetched.value) return;
idsFetching.value = true;
try {
const p = buildFilterParams();
p.set("limit", String(ID_BATCH));
p.set("offset", String(idOffset.value));
const data = await apiGet<{ ids: number[]; total: number; has_more: boolean }>(
`/api/knowledge/ids?${p}`
);
if (gen !== fetchGen) return;
idQueue.value.push(...data.ids);
idOffset.value += data.ids.length;
totalKnowledge.value = data.total;
if (!data.has_more) allIdsFetched.value = true;
} catch { /* silent */ }
finally { if (gen === fetchGen) idsFetching.value = false; }
}
async function loadNextContent(gen: number, count: number) {
if (contentFetching.value || idQueue.value.length === 0) return;
const toLoad = idQueue.value.splice(0, count); // claim IDs immediately
contentFetching.value = true;
try {
const data = await apiGet<{ items: KnowledgeItem[] }>(
`/api/knowledge/batch?ids=${toLoad.join(",")}`
);
if (gen !== fetchGen) return;
items.value.push(...data.items);
// Proactively refill ID queue before it runs out
if (idQueue.value.length < REFILL_THRESHOLD && !allIdsFetched.value && !idsFetching.value) {
fetchIdBatch(gen);
}
} catch { /* silent */ }
finally {
if (gen !== fetchGen) return;
contentFetching.value = false;
// Sentinel may still be visible if new items didn't push it off screen
await nextTick();
if (sentinelVisible.value && idQueue.value.length > 0 && gen === fetchGen) {
loadNextContent(gen, CONTENT_PAGE);
}
}
}
async function reset() {
fetchGen++;
const gen = fetchGen;
items.value = [];
idQueue.value = [];
idOffset.value = 0;
totalKnowledge.value = 0;
allIdsFetched.value = false;
await fetchIdBatch(gen);
if (gen !== fetchGen) return;
await loadNextContent(gen, INITIAL_CONTENT);
}
async function fetchTags() {
try {
const data = await apiGet<{ tags: string[] }>("/api/knowledge/tags");
allTags.value = data.tags;
} catch { /* silent */ }
}
async function resetAndReobserve() {
await reset();
await nextTick();
setupObserver();
}
function onSearchInput() {
if (searchDebounce) clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => resetAndReobserve(), 380);
}
watch([activeType, sortMode], () => resetAndReobserve());
// Closing the sweep remounts the feed, and with it the scroll sentinel — a
// fresh element the old observer is not watching. Without this the list loads
// its first page and then never loads another.
watch(sweepActive, (open) => { if (!open) resetAndReobserve(); });
watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
// ─── Today bar ────────────────────────────────────────────────────────────────
const overdueCount = ref(0);
async function fetchTodayBar() {
try {
const taskData = await apiGet<{ total: number }>(
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
).catch(() => ({ total: 0 }));
overdueCount.value = taskData.total ?? 0;
} catch { /* silent */ }
}
// ─── Graph panel ──────────────────────────────────────────────────────────────
const _GRAPH_OPEN_KEY = 'fa_knowledge_graph_open'
const _GRAPH_EXP_KEY = 'fa_knowledge_graph_expanded'
const graphOpen = ref(localStorage.getItem(_GRAPH_OPEN_KEY) === 'true')
const graphExpanded = ref(localStorage.getItem(_GRAPH_EXP_KEY) === 'true')
function toggleGraph() {
graphOpen.value = !graphOpen.value
localStorage.setItem(_GRAPH_OPEN_KEY, String(graphOpen.value))
}
function toggleGraphExpand() {
graphExpanded.value = !graphExpanded.value
localStorage.setItem(_GRAPH_EXP_KEY, String(graphExpanded.value))
}
// ─── Navigation helpers ───────────────────────────────────────────────────────
function isOverdue(item: KnowledgeItem): boolean {
if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
return new Date(item.due_date) < new Date(new Date().toDateString());
}
// Each record kind opens in ITS OWN editor. A snippet used to fall through to
// /notes/:id, whose save is a plain PATCH of the body — which left the snippet's
// derived `data` mirror describing the previous version (#3128). The service now
// recomposes the mirror either way, so this is no longer the guard; it is simply
// that the note editor cannot edit a snippet's signature, language or locations,
// and offering it as the way in was always wrong. Processes stay here on
// purpose: they have no editor of their own and the note editor knows the type.
function openItem(item: KnowledgeItem) {
if (item.note_type === 'task') {
router.push(`/tasks/${item.id}`);
} else if (item.note_type === 'snippet') {
router.push(`/snippets/${item.id}`);
} else {
router.push(`/notes/${item.id}`);
}
}
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
const days = Math.floor(diff / 86_400_000);
if (days === 0) return "Today";
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
// ─── Sentinel observer ────────────────────────────────────────────────────────
const sentinelEl = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
function setupObserver() {
observer?.disconnect();
if (!sentinelEl.value) return;
observer = new IntersectionObserver(
([entry]) => {
sentinelVisible.value = entry.isIntersecting;
if (entry.isIntersecting) loadNextContent(fetchGen, CONTENT_PAGE);
},
{ root: null, rootMargin: "200px" }
);
observer.observe(sentinelEl.value);
}
// ─── Lifecycle ────────────────────────────────────────────────────────────────
onMounted(async () => {
document.addEventListener('click', onClickOutsideNewNote);
await reset();
fetchTags();
fetchCounts();
fetchTodayBar();
await nextTick();
setupObserver();
});
onUnmounted(() => {
document.removeEventListener('click', onClickOutsideNewNote);
if (searchDebounce) clearTimeout(searchDebounce);
observer?.disconnect();
});
</script>
<template>
<div class="knowledge-root" :class="{ 'graph-open': graphOpen, 'graph-expanded': graphExpanded && graphOpen }">
<!-- Today bar -->
<div v-if="overdueCount > 0" class="today-bar">
<div class="today-actions">
<router-link to="/tasks" class="overdue-badge">
{{ overdueCount }} overdue
</router-link>
</div>
</div>
<!-- Main layout -->
<div class="knowledge-layout">
<!-- Filter panel -->
<aside class="filter-panel">
<!-- New note button -->
<div class="new-note-wrap">
<button class="btn-new-note" @click="newNoteMenuOpen = !newNoteMenuOpen">
<span class="btn-new-icon">+</span> New
</button>
<div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('note')">
<FileText :size="16" />
Note
</button>
<button @click="createNew('task')">
<CheckSquare :size="16" />
Task
</button>
<button @click="createNew('process')">
<Workflow :size="16" />
Process
</button>
</div>
</div>
<div class="filter-section">
<div class="filter-label">Type</div>
<button
class="filter-btn"
:class="{ active: activeType === '' }"
@click="activeType = ''"
>
<span class="filter-btn-label">All</span>
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
</button>
<button
v-for="[val, label] in FACET_CHIPS"
:key="val"
class="filter-btn"
:class="{ active: activeType === val }"
@click="activeType = val"
>
<span class="filter-btn-label">{{ label }}</span>
<span v-if="(typeCounts[val] ?? 0) > 1" class="filter-count">{{ typeCounts[val] }}</span>
</button>
</div>
<div v-if="allTags.length > 0" class="filter-section">
<div class="filter-label">Tags</div>
<button
class="filter-btn filter-tag"
:class="{ active: activeTag === '' }"
@click="activeTag = ''"
>All tags</button>
<button
v-for="tag in allTags"
:key="tag"
class="filter-btn filter-tag"
:class="{ active: activeTag === tag }"
@click="activeTag = activeTag === tag ? '' : tag"
>{{ tag }}</button>
</div>
</aside>
<!-- Content area -->
<div class="knowledge-content">
<!-- Toolbar -->
<div class="knowledge-toolbar">
<div class="search-wrap">
<Search class="search-icon" :size="16" />
<input
v-model="searchQuery"
@input="onSearchInput"
type="text"
class="search-input"
placeholder="Search knowledge…"
/>
</div>
<select v-model="sortMode" class="sort-select">
<option value="modified">Recently modified</option>
<option value="created">Recently created</option>
<option value="alpha">Alphabetical</option>
<option value="type">By type</option>
</select>
<button class="btn-ghost btn-compact" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
<Share2 :size="16" />
Graph
</button>
<button
class="btn-ghost btn-compact"
:class="{ active: sweepActive }"
title="Notes that assert a fact about something outside your control, least-recently-confirmed first. Most notes are decisions and never appear."
@click="sweepActive = !sweepActive"
>
<ShieldCheck :size="16" />
Due
</button>
<button
class="btn-ghost btn-compact"
:disabled="dupLoading"
title="Find notes or tasks already recorded that closely resemble each other — the report proposes, it never changes anything"
@click="loadDuplicates"
>
{{ dupLoading ? "Checking…" : "Find duplicates" }}
</button>
</div>
<!-- The sweep replaces the feed. It is not a facet: a facet answers
"show me this kind", and this answers "show me what nobody has
confirmed" a question the type chips cannot narrow without
under-reporting it. -->
<NoteSweepPane v-if="sweepActive" @open-note="(id) => router.push(`/notes/${id}`)" />
<!-- Near-duplicate report. A proposal surface only: unlike snippets
(which merge losslessly), notes are never merged the right fix is
supersession, extraction into a reference note, or leaving parallel
records alone, and choosing needs the records READ. That reading is
the assistant's job; this panel shows the human what exists. -->
<div v-if="dupChecked && !dupLoading" class="dup-panel">
<p v-if="!dupGroups.length" class="dup-empty">
No near-duplicate {{ dupKind }}s found — nothing recorded resembles
anything else closely enough to flag.
</p>
<template v-else>
<p class="dup-head">
{{ dupGroups.length }} possible duplicate
{{ dupGroups.length > 1 ? "sets" : "set" }} among your
{{ dupKind }}s. {{ dupSuggestion }}
</p>
<div v-for="(g, i) in dupGroups" :key="i" class="dup-group">
<div class="dup-members">
<router-link
v-for="m in g.members"
:key="m.id"
class="dup-member"
:to="dupKind === 'task' ? `/tasks/${m.id}` : `/notes/${m.id}`"
>
#{{ m.id }} {{ m.title }}
</router-link>
</div>
<span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
<span
v-if="g.existing_supersessions?.length"
class="dup-claimed"
title="A supersession has already been declared inside this set — it is not an open question"
>already ruled on</span>
</div>
</template>
</div>
<!-- The whole feed stands down while the sweep is open: two answers
to two different questions on one screen is neither. Wrapped
rather than given an extra v-if branch, because the scroll
sentinel lives inside the grid and the observer must not be left
holding a ref to something that never renders. -->
<template v-if="!sweepActive">
<!-- Loading / empty -->
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">No matches. Try clearing the filters.</p>
<p v-else class="empty-narrator">Your story is unwritten. Create your first note to begin.</p>
</div>
<!-- Card grid -->
<div v-else class="card-grid">
<div
v-for="item in items"
:key="item.id"
class="k-card"
:class="`k-card--${item.note_type}`"
@click="openItem(item)"
>
<!-- Type badge -->
<span class="type-badge" :class="item.task_kind === 'plan' ? 'badge--plan' : `badge--${item.note_type}`">
<span v-if="item.note_type === 'note'">Note</span>
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
<span v-else-if="item.note_type === 'process'">Process</span>
<span v-else-if="item.note_type === 'snippet'">Snippet</span>
</span>
<!-- Kind sits BESIDE the type badge, not inside it: the type badge
speaks the vocabulary of this view's type filter (note / task /
plan / process), and kind is the other axis. `plan` is passed
as null because the badge to the left already says it two
chips reading "Plan" would look like two facts. -->
<KindBadge :kind="item.task_kind === 'plan' ? null : item.task_kind" />
<div class="k-card-body">
<div class="k-card-title">{{ item.title }}</div>
<!-- Task specifics -->
<div v-if="item.note_type === 'task'" class="k-card-task">
<div class="task-badges">
<StatusBadge v-if="item.status" :status="item.status as TaskStatus" compact />
<PriorityBadge
v-if="item.priority && item.priority !== 'none'"
:priority="item.priority as TaskPriority"
compact
/>
</div>
<span
v-if="item.due_date"
class="task-due"
:class="{ 'task-overdue': isOverdue(item) }"
>{{ formatDate(item.due_date) }}</span>
<p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
</div>
<!-- Note snippet -->
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
</div>
<div class="k-card-footer">
<div class="k-card-tags">
<span v-for="tag in item.tags.slice(0, 3)" :key="tag" class="tag-pill">{{ tag }}</span>
</div>
<span
v-if="item.shared"
class="shared-tag"
:title="`Shared by ${item.owner ?? 'another user'} — their record, not yours`"
>by {{ item.owner ?? "another user" }}</span>
<span class="k-card-date">{{ formatDate(item.updated_at) }}</span>
</div>
</div>
<!-- Sentinel IntersectionObserver triggers next content batch -->
<div ref="sentinelEl" class="scroll-sentinel">
<span v-if="contentFetching" class="sentinel-loading">Loading</span>
</div>
</div>
</template>
</div>
<!-- Graph panel -->
<aside v-if="graphOpen" class="graph-panel" :class="{ expanded: graphExpanded }">
<div class="graph-panel-header">
<span>Graph</span>
<div style="display:flex;gap:4px;align-items:center">
<button
class="btn-text"
@click="toggleGraphExpand"
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
>
<ChevronLeft v-if="graphExpanded" :size="16" />
<ChevronRight v-else :size="16" />
</button>
<button class="btn-text" @click="toggleGraph" title="Close graph">
<X :size="16" />
</button>
</div>
</div>
<div class="graph-embed">
<GraphView :embedded="true" />
</div>
</aside>
</div><!-- end knowledge-layout -->
</div>
</template>
<style src="@/assets/dup-report.css" />
<style scoped>
/* ── Root layout ─────────────────────────────────────────── */
.knowledge-root {
display: flex;
flex-direction: column;
height: calc(100vh - var(--fs-layout-header));
overflow: hidden;
}
/* ── Today bar ───────────────────────────────────────────── */
.today-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 20px;
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
font-size: 0.82rem;
flex-wrap: wrap;
}
.today-actions { display: flex; align-items: center; gap: 10px; }
.overdue-badge {
padding: 2px 9px;
border-radius: 12px;
background: rgba(239,68,68,0.12);
border: 1px solid rgba(239,68,68,0.25);
color: #f87171;
text-decoration: none;
font-size: 0.78rem;
}
/* ── Main layout ─────────────────────────────────────────── */
.knowledge-layout {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* ── Filter panel ────────────────────────────────────────── */
.filter-panel {
width: var(--fs-layout-sidebar);
flex-shrink: 0;
padding: 16px 12px;
border-right: 1px solid var(--fs-border-color);
overflow-y: auto;
background: var(--fs-surface-raised);
}
.filter-section { margin-bottom: 20px; }
.filter-section + .filter-section::before {
content: '· · ·';
display: block;
text-align: center;
color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
font-size: 0.9rem;
letter-spacing: 0.4em;
padding: 4px 0 12px;
}
.filter-label {
font-family: 'Fraunces', Georgia, serif;
font-size: 0.95rem;
color: var(--fs-accent);
margin-bottom: 8px;
padding: 0 4px;
}
/* ── New item button ─────────────────────────────────────── */
.new-note-wrap {
position: relative;
margin-bottom: 16px;
}
.btn-new-note {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 10px;
border: none;
background: var(--fs-gradient-cta);
color: var(--fs-text-on-action);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: box-shadow 0.15s;
}
.btn-new-note:hover { box-shadow: var(--fs-glow-cta-hover); }
.btn-new-icon {
font-size: 1.1rem;
line-height: 1;
font-weight: 300;
}
.new-note-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: 10px;
overflow: hidden;
z-index: 50;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
padding: 4px 0;
}
.new-note-menu button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 14px;
background: none;
border: none;
color: var(--fs-text-primary);
cursor: pointer;
font-size: 0.84rem;
text-align: left;
transition: background 0.12s, color 0.12s;
}
.new-note-menu button:hover {
background: var(--fs-accent-soft);
color: var(--fs-accent);
}
.new-note-menu button svg {
flex-shrink: 0;
opacity: 0.6;
}
.new-note-menu button:hover svg {
opacity: 1;
stroke: var(--fs-accent);
}
.filter-btn {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
text-align: left;
padding: 5px 8px;
border-radius: 7px;
border: none;
background: transparent;
color: var(--fs-text-primary);
cursor: pointer;
font-size: 0.85rem;
margin-bottom: 2px;
transition: background 0.12s, color 0.12s;
opacity: 0.75;
}
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
.filter-btn.active {
background: var(--fs-accent-wash);
color: var(--fs-accent);
opacity: 1;
}
.filter-btn-label { flex: 1; }
.filter-count {
font-size: 0.7rem;
padding: 1px 6px;
border-radius: 10px;
background: rgba(255,255,255,0.07);
color: var(--fs-text-tertiary);
font-weight: 500;
min-width: 20px;
text-align: center;
flex-shrink: 0;
}
.filter-btn.active .filter-count {
background: color-mix(in srgb, var(--fs-accent) 20%, transparent);
color: var(--fs-accent-fg);
}
.filter-tag { font-size: 0.78rem; }
/* ── Content area ────────────────────────────────────────── */
.knowledge-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
/* ── Toolbar ─────────────────────────────────────────────── */
.knowledge-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
flex-shrink: 0;
border-bottom: 1px solid var(--fs-border-color);
}
.search-wrap {
flex: 1;
position: relative;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--fs-text-tertiary);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 7px 12px 7px 32px;
border-radius: 8px;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
font-size: 0.88rem;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: var(--fs-accent); }
.sort-select {
padding: 7px 10px;
border-radius: 8px;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
font-size: 0.85rem;
cursor: pointer;
outline: none;
}
/* ── Card grid ───────────────────────────────────────────── */
.card-grid {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
align-content: start;
}
.k-card {
position: relative;
background: var(--fs-surface-hover);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-xl);
padding: 14px;
cursor: pointer;
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 160px;
overflow: hidden;
}
.k-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px color-mix(in srgb, var(--fs-accent) 25%, transparent), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: color-mix(in srgb, var(--fs-accent) 35%, transparent);
}
/* Type-specific card DNA */
.k-card--note { border-color: color-mix(in srgb, var(--fs-accent) 20%, transparent); }
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
/* Top gradient bars */
.k-card--note::before,
.k-card--task::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 3px;
border-radius: 14px 14px 0 0;
}
.k-card--note::before {
right: 0;
background: linear-gradient(90deg, var(--fs-accent), #7A6DA8);
}
.k-card--task::before {
right: 0;
background: linear-gradient(90deg, #d4a017, #fbbf24);
}
/* Type badge */
.type-badge {
position: absolute;
top: 10px;
right: 10px;
font-size: 0.68rem;
padding: 2px 7px;
border-radius: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge--note { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: #7A6DA8; }
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
/* Snippet and process are NEUTRAL on purpose. Both were unstyled — and the
snippet had no label either, so all 90 of them rendered an empty chip in
this feed (#3128). Giving them hues would put a third and fourth colour
beside KindBadge's warm/cool pair on the same card; a record type that
isn't an alarm reads better as plain. Standard body pair, so the contrast
is the one the palette already guarantees. */
.badge--snippet,
.badge--process { background: var(--fs-surface-raised); color: var(--fs-text-secondary); }
.k-card-body { flex: 1; padding-right: 40px; }
.k-card-title {
font-weight: 500;
font-size: 0.92rem;
margin-bottom: 5px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.k-card-snippet {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.45;
margin: 0;
}
.k-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.k-card-tags { display: flex; gap: 4px; flex-wrap: wrap; min-width: 0; }
.tag-pill {
font-size: 0.68rem;
padding: 1px 6px;
border-radius: 8px;
background: rgba(255,255,255,0.05);
color: var(--fs-text-tertiary);
}
.k-card-date { font-size: 0.72rem; color: var(--fs-text-secondary); white-space: nowrap; opacity: 0.7; }
/* Only rendered for a record another user owns, so an unmarked card is
unambiguously the viewer's own. */
.shared-tag {
font-size: 0.68rem;
padding: 0.08rem 0.35rem;
border-radius: 4px;
white-space: nowrap;
background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent);
color: var(--fs-text-secondary-fg);
}
/* ── Task card ──────────────────────────────────────────── */
.k-card-task {
display: flex;
flex-direction: column;
gap: 6px;
}
.task-badges {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.task-due {
font-size: 0.78rem;
color: var(--fs-text-secondary);
}
.task-overdue {
color: var(--fs-overdue);
font-weight: 500;
}
/* ── Empty / loading ─────────────────────────────────────── */
.knowledge-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--fs-text-tertiary);
text-align: center;
gap: 6px;
}
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
.empty-narrator {
font-family: 'Fraunces', Georgia, serif;
font-size: 1rem;
color: var(--fs-text-secondary);
opacity: 0.85;
}
/* ── Sentinel ────────────────────────────────────────────── */
.scroll-sentinel {
grid-column: 1 / -1;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.sentinel-loading {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
/* ── Graph panel ─────────────────────────────────────────── */
.graph-panel {
width: 500px;
flex-shrink: 0;
border-left: 1px solid var(--fs-border-color);
display: flex;
flex-direction: column;
background: var(--fs-surface-raised);
transition: width 0.2s ease;
}
.graph-panel.expanded {
width: min(960px, 60vw);
}
.graph-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
font-size: 0.85rem;
font-weight: 500;
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
}
/* RESTORED (#2444). The panel is a flex COLUMN and its header is
`flex-shrink: 0`, so this is the item that takes the remaining height — and
without it the `height: 100%` below resolves against `auto` and does
nothing, which made the comment underneath a spec for a rule that could not
work. `min-height: 0` is the companion that lets a flex item shrink under
its content instead of overflowing the panel. */
.graph-embed {
flex: 1;
min-height: 0;
}
/* Override GraphView's 100vh height so it fills the panel instead */
.graph-embed :deep(.graph-page) {
height: 100%;
}
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */
.dup-claimed {
font-size: 0.72rem;
color: var(--fs-text-tertiary);
border: 1px solid var(--fs-border-color);
border-radius: 4px;
padding: 0.05rem 0.4rem;
white-space: nowrap;
}
</style>