Files
FabledScribe/frontend/src/views/KnowledgeView.vue
T
bvandeusenandClaude Opus 5 5f8b824523
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 30s
CI & Build / TypeScript typecheck (push) Successful in 45s
CI & Build / integration (push) Successful in 1m50s
CI & Build / Python tests (push) Successful in 2m13s
CI & Build / Build & push image (push) Successful in 42s
refactor(ui): the remaining views migrate; 2 dead classes, 2 off-palette hovers
Twelve more files onto the shared buttons. What the pass turned up:

DEAD, verified not merely unnamed:
- .btn-reconsolidate (TaskEditorView). A comment eleven hundred lines up in the
  same file says the feature was removed in Phase 8. The CSS outlived it.
- .btn-remove-slot (SettingsView), style rules only, no template anywhere.

OFF-PALETTE, the #2319 shape: TrashView's restore and purge hovers used
`var(--color-primary, #6366f1)` and `var(--color-danger, #ef4444)` — Tailwind
indigo and Tailwind red, from no palette in this system. The fallback is what
renders if the token is ever absent, and it renders something plausible
forever. Now the action and destructive colours, no fallback.

A REAL BREAKAGE MY OWN CHECK COULD NOT SEE, worth recording. Deleting a rule
whose selector was part of a comma-separated group left the leading selectors
behind:

    .btn-log-edit,
    <nothing>
    .log-textarea { … }

which silently swallows the next rule. Brace counting passed — there are no
braces in a dangling fragment. Found by scanning for selector lines ending in
`,` not followed by another selector; three instances across two files, one of
them interleaved with comments so the first sweep missed it. The sweep is now
part of the verification, not a one-off.

Kept bespoke, deliberately: .btn-pin/.btn-unpin (pill-shaped history badges),
.btn-add-share and .btn-new-note (gradient CTAs — brand moments, which the
house style does sanction), .btn-icon/.btn-bell (icon buttons, a different
component), .btn-add-system/.btn-add-milestone (dashed "add" affordances).
These are not drift; they are other things wearing a btn- prefix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 22:53:13 -04:00

945 lines
30 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 GraphView from "@/views/GraphView.vue";
import {
FileText,
CheckSquare,
Workflow,
Search,
Share2,
ChevronLeft,
ChevronRight,
X,
} from "lucide-vue-next";
const router = useRouter();
// ─── Types ────────────────────────────────────────────────────────────────────
interface KnowledgeItem {
id: number;
note_type: "note" | "task" | "process";
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?: "work" | "plan";
}
// ─── Filter state ─────────────────────────────────────────────────────────────
const activeType = ref<"" | "note" | "task" | "plan" | "process">("");
const activeTag = ref("");
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref("");
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
// ─── Type counts ──────────────────────────────────────────────────────────────
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
const typeCounts = ref<KnowledgeCounts>({ note: 0, task: 0, plan: 0, process: 0, 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());
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());
}
function openItem(item: KnowledgeItem) {
if (item.note_type === 'task') {
router.push(`/tasks/${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, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['process','Processes','process']] as [string,string,string][])"
:key="val"
class="filter-btn"
:class="{ active: activeType === val }"
@click="activeType = (val as '' | 'note' | 'task' | 'plan' | 'process')"
>
<span class="filter-btn-label">{{ label }}</span>
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</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>
</div>
<!-- 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>
<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">
<span class="status-badge" :class="`status--${item.status}`">
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
</span>
<span
v-if="item.priority && item.priority !== 'none'"
class="priority-badge"
:class="`priority--${item.priority}`"
>{{ item.priority }}</span>
</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>
</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 scoped>
/* ── Root layout ─────────────────────────────────────────── */
.knowledge-root {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height, 56px));
overflow: hidden;
}
/* ── Today bar ───────────────────────────────────────────── */
.today-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 20px;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
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;
}
.today-link {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
opacity: 0.85;
transition: opacity 0.15s;
}
.today-link:hover { opacity: 1; }
/* ── Main layout ─────────────────────────────────────────── */
.knowledge-layout {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* ── Filter panel ────────────────────────────────────────── */
.filter-panel {
width: var(--sidebar-width);
flex-shrink: 0;
padding: 16px 12px;
border-right: 1px solid var(--color-border, rgba(255,255,255,0.06));
overflow-y: auto;
background: var(--color-bg-secondary);
}
.filter-section { margin-bottom: 20px; }
.filter-section + .filter-section::before {
content: '· · ·';
display: block;
text-align: center;
color: rgba(91, 74, 138, 0.3);
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(--color-primary);
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(--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(--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(--color-bg-card);
border: 1px solid var(--color-border);
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(--color-text);
cursor: pointer;
font-size: 0.84rem;
text-align: left;
transition: background 0.12s, color 0.12s;
}
.new-note-menu button:hover {
background: var(--color-primary-tint);
color: var(--color-primary);
}
.new-note-menu button svg {
flex-shrink: 0;
opacity: 0.6;
}
.new-note-menu button:hover svg {
opacity: 1;
stroke: var(--color-primary);
}
.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(--color-text);
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(--color-primary-wash);
color: var(--color-primary);
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(--color-muted);
font-weight: 500;
min-width: 20px;
text-align: center;
flex-shrink: 0;
}
.filter-btn.active .filter-count {
background: rgba(91, 74, 138, 0.2);
color: var(--color-primary);
}
.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(--color-border, rgba(255,255,255,0.06));
}
.search-wrap {
flex: 1;
position: relative;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--color-muted);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 7px 12px 7px 32px;
border-radius: 8px;
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
color: var(--color-text);
font-size: 0.88rem;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: var(--color-primary); }
.sort-select {
padding: 7px 10px;
border-radius: 8px;
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
color: var(--color-text);
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(--color-surface, rgba(255,255,255,0.03));
border: 1px solid var(--color-border, rgba(255,255,255,0.07));
border-radius: var(--radius-lg, 14px);
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 rgba(91, 74, 138, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: rgba(91, 74, 138, 0.35);
}
/* Type-specific card DNA */
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
.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, #5B4A8A, #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: rgba(91, 74, 138,0.15); color: #7A6DA8; }
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
.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(--color-muted);
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(--color-muted);
}
.k-card-date { font-size: 0.72rem; color: var(--color-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(--color-text-secondary) 15%, transparent);
color: var(--color-text-secondary);
}
/* ── Task card ──────────────────────────────────────────── */
.k-card-task {
display: flex;
flex-direction: column;
gap: 6px;
}
.task-badges {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.status-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 500;
}
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
.priority-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 500;
}
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
.task-due {
font-size: 0.78rem;
color: var(--color-text-secondary);
}
.task-overdue {
color: var(--color-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(--color-muted);
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(--color-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(--color-muted);
}
/* ── Graph panel ─────────────────────────────────────────── */
.graph-panel {
width: 500px;
flex-shrink: 0;
border-left: 1px solid var(--color-border, rgba(255,255,255,0.06));
display: flex;
flex-direction: column;
background: var(--color-bg-secondary);
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(--color-border, rgba(255,255,255,0.06));
flex-shrink: 0;
}
/* Override GraphView's 100vh height so it fills the panel instead */
.graph-embed :deep(.graph-page) {
height: 100%;
}
</style>