CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.
73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.
One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.
Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.
Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).
Refs #2533
1105 lines
36 KiB
Vue
1105 lines
36 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;
|
|
|
|
// ─── 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 checks tasks. Anything
|
|
// else (all / plan / process) checks notes — the kind with the most to find.
|
|
const dupKind = computed(() => (activeType.value === "task" ? "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 ──────────────────────────────────────────────────────────────
|
|
|
|
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>
|
|
<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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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(--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;
|
|
}
|
|
.today-link {
|
|
color: var(--fs-accent);
|
|
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(--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);
|
|
}
|
|
.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; }
|
|
|
|
.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);
|
|
}
|
|
|
|
/* ── 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(--fs-status-todo-bg); color: var(--fs-status-todo); }
|
|
.status--in_progress { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress); }
|
|
.status--done { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
|
|
.status--cancelled { background: var(--fs-status-todo-bg); color: var(--fs-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(--fs-priority-low-bg); color: var(--fs-priority-low); }
|
|
.priority--normal { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
|
|
.priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high); }
|
|
|
|
.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%;
|
|
}
|
|
|
|
|
|
/* ── Near-duplicate report ──────────────────────────────────────────────────
|
|
Mirrors SnippetListView's panel so the two reports read as one feature.
|
|
Scoped styles can't be shared across SFCs; if a third view ever grows this
|
|
panel, promote the family to components.css and record it (#2464's rule:
|
|
two-or-more is when a recipe earns the shared sheet). */
|
|
.dup-panel {
|
|
margin-bottom: 1.25rem;
|
|
padding: 0.85rem 1rem;
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: 8px;
|
|
background: var(--fs-surface-hover);
|
|
}
|
|
.dup-empty,
|
|
.dup-head {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 0.85rem;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
.dup-empty { margin-bottom: 0; }
|
|
.dup-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
padding: 0.5rem 0;
|
|
border-top: 1px solid var(--fs-border-color);
|
|
}
|
|
.dup-members {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
flex-wrap: wrap;
|
|
flex: 1 1 20rem;
|
|
min-width: 0;
|
|
}
|
|
.dup-member {
|
|
font-size: 0.8rem;
|
|
padding: 0.1rem 0.45rem;
|
|
border-radius: 4px;
|
|
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
|
color: var(--fs-text-primary);
|
|
text-decoration: none;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.dup-member:hover { background: var(--fs-surface-hover); }
|
|
.dup-score {
|
|
font-size: 0.75rem;
|
|
color: var(--fs-text-tertiary);
|
|
font-variant-numeric: tabular-nums;
|
|
white-space: nowrap;
|
|
}
|
|
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */
|
|
.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>
|