diff --git a/bun.lock b/bun.lock index 34c0de6..cf39671 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "kib-monorepo", diff --git a/packages/dashboard/src/client/components/BrowsePage.tsx b/packages/dashboard/src/client/components/BrowsePage.tsx index 7671b25..426a989 100644 --- a/packages/dashboard/src/client/components/BrowsePage.tsx +++ b/packages/dashboard/src/client/components/BrowsePage.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, FileText, Tag } from "lucide-react"; +import { ArrowLeft, FileText } from "lucide-react"; import { Marked } from "marked"; import { useEffect, useMemo, useState } from "react"; import { type ArticleItem, api, type RawItem } from "../api.js"; @@ -6,14 +6,27 @@ import { type ArticleItem, api, type RawItem } from "../api.js"; const marked = new Marked(); const CATEGORY_COLORS: Record = { - concept: "bg-blue-100 text-blue-700", - topic: "bg-green-100 text-green-700", - reference: "bg-orange-100 text-orange-700", - output: "bg-purple-100 text-purple-700", + concept: "bg-blue-50 text-blue-600", + topic: "bg-green-50 text-green-600", + reference: "bg-orange-50 text-orange-600", + output: "bg-purple-50 text-purple-600", }; type Scope = "wiki" | "raw"; +function SkeletonList() { + return ( +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+ ))} +
+ ); +} + export function BrowsePage({ revision = 0 }: { revision?: number }) { const [scope, setScope] = useState("raw"); const [wikiArticles, setWikiArticles] = useState([]); @@ -30,7 +43,6 @@ export function BrowsePage({ revision = 0 }: { revision?: number }) { Promise.all([api.getArticles("wiki"), api.getRawSources()]).then(([wiki, raw]) => { setWikiArticles(wiki); setRawSources(raw); - // Default to whichever has content if (wiki.length === 0 && raw.length > 0) setScope("raw"); else if (wiki.length > 0) setScope("wiki"); setLoading(false); @@ -78,18 +90,19 @@ export function BrowsePage({ revision = 0 }: { revision?: number }) { return marked.parse(stripped) as string; }, [content]); + // Article detail view if (selectedPath) { return ( -
+
-

Browse

+
+

Browse

- {/* Scope tabs */} -
+ {/* Tabs */} +
-
+ {/* Filters */} +
setFilter(e.target.value)} - className="flex-1 px-3 py-2 border rounded-md text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)] focus:ring-offset-1" + className="flex-1 px-3 py-2 border rounded-md text-xs bg-white" /> {scope === "wiki" && categories.length > 1 && ( )}
+ {/* Content */} {loading ? ( -

Loading...

+ ) : scope === "wiki" ? ( filteredWiki.length === 0 ? ( -

+

{wikiArticles.length === 0 - ? "No compiled articles yet. Sources are available in the Sources tab." - : "No matching articles."} + ? "No compiled articles. Try the Sources tab." + : "No matches."}

) : ( -
+
{filteredWiki.map((article) => ( ))}
) ) : filteredRaw.length === 0 ? ( -

- {rawSources.length === 0 ? "No sources yet." : "No matching sources."} +

+ {rawSources.length === 0 ? "No sources yet." : "No matches."}

) : ( -
+
{filteredRaw.map((source) => ( diff --git a/packages/dashboard/src/client/components/GraphPage.tsx b/packages/dashboard/src/client/components/GraphPage.tsx index f320e10..46047f0 100644 --- a/packages/dashboard/src/client/components/GraphPage.tsx +++ b/packages/dashboard/src/client/components/GraphPage.tsx @@ -39,6 +39,7 @@ export function GraphPage({ const canvasRef = useRef(null); const [data, setData] = useState(null); const [hovered, setHovered] = useState(null); + const hoveredRef = useRef(null); const [loading, setLoading] = useState(true); // biome-ignore lint/correctness/useExhaustiveDependencies: revision triggers re-fetch on vault changes @@ -92,9 +93,9 @@ export function GraphPage({ ctx.translate(currentTransform.x, currentTransform.y); ctx.scale(currentTransform.k, currentTransform.k); - // Draw edges - ctx.strokeStyle = "#e0e0e0"; - ctx.lineWidth = 1; + // Edges + ctx.strokeStyle = "#e8e8e8"; + ctx.lineWidth = 0.5; for (const link of links) { const source = link.source as GraphNode; const target = link.target as GraphNode; @@ -105,20 +106,21 @@ export function GraphPage({ ctx.stroke(); } - // Draw nodes + // Nodes for (const node of nodes) { if (node.x == null || node.y == null) continue; - const radius = 6 + Math.min(node.id.length * 0.3, 6); - const color = CATEGORY_COLORS[node.category] ?? "#888"; + const radius = 4 + Math.min(node.id.length * 0.25, 4); + const color = CATEGORY_COLORS[node.category] ?? "#999"; + const nodeHovered = hoveredRef.current?.id === node.id; ctx.beginPath(); - ctx.arc(node.x, node.y, radius, 0, Math.PI * 2); - ctx.fillStyle = color; + ctx.arc(node.x, node.y, nodeHovered ? radius + 2 : radius, 0, Math.PI * 2); + ctx.fillStyle = nodeHovered ? color : `${color}cc`; ctx.fill(); // Label - ctx.fillStyle = "#333"; - ctx.font = "10px JetBrains Mono, monospace"; + ctx.fillStyle = "#666"; + ctx.font = `${nodeHovered ? "11" : "9"}px JetBrains Mono, monospace`; ctx.textAlign = "center"; ctx.fillText(node.id, node.x, node.y + radius + 12); } @@ -138,7 +140,7 @@ export function GraphPage({ select(canvas).call(zoomBehavior); - // Hover detection + // Hover const handleMouseMove = (e: MouseEvent) => { const rect = canvas.getBoundingClientRect(); const mx = (e.clientX - rect.left - currentTransform.x) / currentTransform.k; @@ -154,6 +156,7 @@ export function GraphPage({ break; } } + hoveredRef.current = found; setHovered(found); canvas.style.cursor = found ? "pointer" : "default"; }; @@ -186,31 +189,49 @@ export function GraphPage({ if (loading) { return ( -
-

Loading graph...

+
+

Loading graph...

); } if (!data || data.nodes.length === 0) { return ( -
-

Knowledge Graph

-

- No graph data yet. Compile some sources to build connections. -

+
+
+ + Graph placeholder + + + + + + + +
+

No graph data yet

+

Compile sources to build connections

); } return (
-
-

Knowledge Graph

-
+
+

Knowledge Graph

+
{Object.entries(CATEGORY_COLORS).map(([cat, color]) => (
- + {cat}
))} @@ -219,11 +240,11 @@ export function GraphPage({
{hovered && ( -
-

{hovered.id}

-

{hovered.category}

+
+

{hovered.id}

+

{hovered.category}

{hovered.summary && ( -

{hovered.summary}

+

{hovered.summary}

)}
)} diff --git a/packages/dashboard/src/client/components/IngestPage.tsx b/packages/dashboard/src/client/components/IngestPage.tsx index f2bf696..219b086 100644 --- a/packages/dashboard/src/client/components/IngestPage.tsx +++ b/packages/dashboard/src/client/components/IngestPage.tsx @@ -1,4 +1,4 @@ -import { Check, Loader2, Plus } from "lucide-react"; +import { ArrowRight, Check, Loader2 } from "lucide-react"; import { useState } from "react"; import { api, type IngestResult } from "../api.js"; @@ -28,55 +28,46 @@ export function IngestPage() { }; return ( -
-

Ingest

-

- Add a URL to ingest into your knowledge base. Supports web pages, YouTube videos, GitHub - repos, and more. -

+
+

Ingest

+

Add a URL to your knowledge base.

-
+ setUrl(e.target.value)} disabled={loading} - className="flex-1 px-3 py-2.5 border rounded-md text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)] focus:ring-offset-1 disabled:opacity-50" + className="flex-1 px-3 py-2.5 border rounded-md text-xs bg-white disabled:opacity-40" />
{error && ( -
-

{error}

+
+

{error}

)} {result && ( -
-
- - - {result.skipped ? "Already ingested" : "Ingested successfully"} +
+
+ + + {result.skipped ? "Already ingested" : "Ingested"}
-
-

- Title: {result.title} -

-

- Type: {result.sourceType} -

-

- Words: {result.wordCount.toLocaleString()} +

+

{result.title}

+

+ {result.sourceType} \u00b7 {result.wordCount.toLocaleString()} words

diff --git a/packages/dashboard/src/client/components/QueryPage.tsx b/packages/dashboard/src/client/components/QueryPage.tsx index a8f343e..ca0b761 100644 --- a/packages/dashboard/src/client/components/QueryPage.tsx +++ b/packages/dashboard/src/client/components/QueryPage.tsx @@ -1,4 +1,4 @@ -import { Loader2, Send } from "lucide-react"; +import { ArrowRight, Loader2, Square } from "lucide-react"; import { Marked } from "marked"; import { useMemo, useRef, useState } from "react"; import { api } from "../api.js"; @@ -47,73 +47,73 @@ export function QueryPage() { }; return ( -
-

Query

+
+

Query

-
+ setQuestion(e.target.value)} disabled={streaming} - className="flex-1 px-3 py-2.5 border rounded-md text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)] focus:ring-offset-1 disabled:opacity-50" + className="flex-1 px-3 py-2.5 border rounded-md text-xs bg-white disabled:opacity-40" /> {streaming ? ( ) : ( )}
{error && ( -
-

{error}

+
+

{error}

)} {(answer || streaming) && ( -
+
{streaming && !answer && ( -
- +
+ Thinking...
)} {answer && ( -
- )} - {streaming && answer && ( - +
+
+ {streaming && ( + + )} +
)}
)} {sources.length > 0 && ( -
-

- Sources -

-
+
+

Sources

+
{sources.map((src) => ( -

- {src} -

+ + {src.split("/").pop()} + ))}
diff --git a/packages/dashboard/src/client/components/SearchPage.tsx b/packages/dashboard/src/client/components/SearchPage.tsx index 8b83bd4..499d31f 100644 --- a/packages/dashboard/src/client/components/SearchPage.tsx +++ b/packages/dashboard/src/client/components/SearchPage.tsx @@ -12,6 +12,7 @@ export function SearchPage({ const [loading, setLoading] = useState(false); const [searched, setSearched] = useState(false); const debounceRef = useRef>(); + const inputRef = useRef(null); const doSearch = useCallback(async (q: string) => { if (!q.trim()) { @@ -33,57 +34,76 @@ export function SearchPage({ useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => doSearch(query), 250); + debounceRef.current = setTimeout(() => doSearch(query), 200); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [query, doSearch]); + // Auto-focus on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + return ( -
-

Search

+
+

Search

-
- +
+ setQuery(e.target.value)} - // biome-ignore lint/a11y/noAutofocus: search page should focus input on mount - autoFocus - className="w-full pl-10 pr-4 py-2.5 border rounded-md text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)] focus:ring-offset-1" + className="w-full pl-9 pr-16 py-2.5 border rounded-md text-xs bg-white" /> + + \u2318K +
- {loading &&

Searching...

} + {/* Empty state */} + {!searched && !loading && ( +
+ +

Search across your knowledge base

+
+ )} + + {loading && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ )} {!loading && searched && results.length === 0 && ( -

No results found.

+

No results

)} {!loading && results.length > 0 && ( -
+
{results.map((result) => ( ); })}
-
kib dashboard
+
+ kib v1.1.0 +
{/* Main content */} -
- {children} +
+
{children}
- {/* Toast notification */} - {toast && ( -
- {toast} + {/* Toast stack */} + {toasts.length > 0 && ( +
+ {toasts.map((toast) => ( +
+ {toast.message} + +
+ ))}
)}
diff --git a/packages/dashboard/src/client/components/StatusPage.tsx b/packages/dashboard/src/client/components/StatusPage.tsx index 42731b3..a51352a 100644 --- a/packages/dashboard/src/client/components/StatusPage.tsx +++ b/packages/dashboard/src/client/components/StatusPage.tsx @@ -1,8 +1,24 @@ -import { BookOpen, Database, FileText, Loader2, Play, Zap } from "lucide-react"; -import { useEffect, useState } from "react"; +import { BookOpen, Database, FileText, Play, Square, Zap } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api, type VaultStatus } from "../api.js"; import type { VaultEvent } from "../useEvents.js"; +interface LogEntry { + key: string; + text: string; +} + +let logCounter = 0; + +function SkeletonCard() { + return ( +
+
+
+
+ ); +} + export function StatusPage({ revision = 0, lastEvent, @@ -13,8 +29,14 @@ export function StatusPage({ const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [compiling, setCompiling] = useState(false); - const [compileLog, setCompileLog] = useState([]); + const [compileLog, setCompileLog] = useState([]); const [compileError, setCompileError] = useState(null); + const logRef = useRef(null); + + const pushLog = useCallback((text: string) => { + const key = `log-${++logCounter}`; + setCompileLog((prev) => [...prev.slice(-29), { key, text }]); + }, []); // biome-ignore lint/correctness/useExhaustiveDependencies: revision triggers re-fetch on vault changes useEffect(() => { @@ -24,6 +46,13 @@ export function StatusPage({ .catch((e) => setError((e as Error).message)); }, [revision]); + // biome-ignore lint/correctness/useExhaustiveDependencies: compileLog triggers scroll on new entries + useEffect(() => { + if (logRef.current) { + logRef.current.scrollTop = logRef.current.scrollHeight; + } + }, [compileLog]); + // Handle compile events useEffect(() => { if (!lastEvent) return; @@ -32,23 +61,19 @@ export function StatusPage({ setCompileLog([]); setCompileError(null); } else if (lastEvent.type === "compile_progress" && lastEvent.message) { - setCompileLog((prev) => [...prev.slice(-19), lastEvent.message!]); + pushLog(lastEvent.message); } else if (lastEvent.type === "compile_article" && lastEvent.title) { - setCompileLog((prev) => [ - ...prev.slice(-19), - `${lastEvent.op === "create" ? "+" : "\u2713"} ${lastEvent.title}`, - ]); + pushLog(`${lastEvent.op === "create" ? "+" : "\u2713"} ${lastEvent.title}`); } else if (lastEvent.type === "compile_done") { setCompiling(false); - setCompileLog((prev) => [ - ...prev, - `Done: ${lastEvent.sourcesCompiled} sources \u2192 ${lastEvent.articlesCreated} created, ${lastEvent.articlesUpdated} updated`, - ]); + pushLog( + `\u2192 ${lastEvent.sourcesCompiled} sources \u2192 ${lastEvent.articlesCreated} created, ${lastEvent.articlesUpdated} updated`, + ); } else if (lastEvent.type === "compile_error") { setCompiling(false); setCompileError(lastEvent.message ?? "Compile failed"); } - }, [lastEvent]); + }, [lastEvent, pushLog]); const handleCompile = async () => { setCompileError(null); @@ -69,16 +94,24 @@ export function StatusPage({ if (error) { return ( -
-

Error: {error}

+
+

Error: {error}

); } if (!status) { return ( -
-

Loading...

+
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+ +
+ ))} +
); } @@ -90,125 +123,138 @@ export function StatusPage({ label: "Articles", value: status.stats.totalArticles, icon: BookOpen, - color: "text-blue-500", }, { label: "Sources", value: status.stats.totalSources, icon: FileText, - color: "text-green-500", }, { label: "Words", value: status.stats.totalWords.toLocaleString(), icon: Database, - color: "text-orange-500", }, { label: "Provider", - value: status.provider.ready ? status.provider.name : "Not configured", + value: status.provider.ready ? status.provider.name : "None", icon: Zap, - color: status.provider.ready ? "text-purple-500" : "text-red-400", }, ]; return ( -
-
+
+ {/* Header */} +
-

{status.vault.name}

-

- Created {new Date(status.vault.created).toLocaleDateString()} - {status.vault.lastCompiled && - ` \u00b7 Last compiled ${new Date(status.vault.lastCompiled).toLocaleDateString()}`} +

{status.vault.name}

+

+ {status.vault.lastCompiled + ? `Last compiled ${new Date(status.vault.lastCompiled).toLocaleDateString()}` + : "Never compiled"} + {" \u00b7 "} + {status.provider.model}

- {status.provider.ready && !compiling && ( + {status.provider.ready && ( - )} - {compiling && ( - )}
+ {/* Compile error */} {compileError && ( -
-

{compileError}

+
+

{compileError}

)} + {/* Compile log */} {compileLog.length > 0 && ( -
- {compileLog.map((line) => ( -
- {line} +
+ {compileLog.map((entry) => ( +
+ {entry.text}
))} - {compiling && ( -
waiting for LLM...
- )} + {compiling &&
waiting for LLM...
}
)} + {/* Uncompiled notice */} {uncompiled > 0 && !compiling && compileLog.length === 0 && ( -
-

- {uncompiled} uncompiled source{uncompiled > 1 ? "s" : ""} pending. - {status.provider.ready - ? " Hit Compile to generate wiki articles." - : " Configure a provider to compile."} +

+

+ {uncompiled} source{uncompiled > 1 ? "s" : ""} pending compilation

)} -
+ {/* Stats grid */} +
{cards.map((card) => ( -
-
- - +
+
+ + {card.label}
-

{card.value}

+

{card.value}

))}
-
-

Configuration

-
-
- Provider - {status.provider.name} -
-
- Model - {status.provider.model} -
-
- API Key - {status.provider.apiKeyHint ?? "Not set"} -
-
- Status - - {status.provider.ready ? "Ready" : "Not configured"} - -
+ {/* Config */} +
+

Configuration

+
+ {[ + ["Provider", status.provider.name], + ["Model", status.provider.model], + ["API Key", status.provider.apiKeyHint ?? "Not set"], + ["Status", status.provider.ready ? "Ready" : "Not configured"], + ].map(([label, value]) => ( +
+ {label} + + {value} + +
+ ))}
diff --git a/packages/dashboard/src/client/globals.css b/packages/dashboard/src/client/globals.css index c034ab2..7172422 100644 --- a/packages/dashboard/src/client/globals.css +++ b/packages/dashboard/src/client/globals.css @@ -5,21 +5,14 @@ --font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", monospace; --color-background: #fafafa; - --color-foreground: #111111; - --color-muted: #777777; - --color-muted-foreground: #999999; - --color-border: #e5e5e5; - --color-sidebar: #111111; - --color-sidebar-fg: #e0e0e0; - --color-sidebar-hover: #222222; - --color-sidebar-active: #333333; - --color-accent: #2563eb; - --color-accent-light: #dbeafe; - - --color-cat-concept: #3b82f6; - --color-cat-topic: #22c55e; - --color-cat-reference: #f97316; - --color-cat-output: #a855f7; + --color-foreground: #111; + --color-muted: #888; + --color-border: #eee; + --color-sidebar: #111; + --color-sidebar-fg: #ddd; + --color-sidebar-hover: rgba(255, 255, 255, 0.05); + --color-sidebar-active: rgba(255, 255, 255, 0.08); + --color-accent: #111; } * { @@ -28,32 +21,121 @@ body { margin: 0; - background-color: var(--color-background); - color: var(--color-foreground); + background: #fafafa; + color: #111; font-family: var(--font-mono); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +/* Transitions on interactive elements */ +button, +a, +input, +select { + transition: all 0.15s ease; +} + +/* Focus ring */ +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: #111; +} + +/* Minimal scrollbar */ +::-webkit-scrollbar { + width: 4px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + border-radius: 2px; +} + +/* Page enter */ +@keyframes page-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.animate-page-in { + animation: page-in 0.15s ease-out; +} + +/* Toast */ +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.animate-fade-in { + animation: fade-in 0.15s ease-out; +} + +@keyframes fade-out { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(4px); + } +} +.animate-fade-out { + animation: fade-out 0.15s ease-in forwards; +} + +/* Skeleton loading */ +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} +.skeleton { + background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: 4px; +} + /* Markdown article content */ .article-content h1 { font-size: 1.5rem; - font-weight: 700; - margin: 1.5rem 0 0.75rem; + font-weight: 600; + margin: 2rem 0 0.75rem; + letter-spacing: -0.02em; } .article-content h2 { - font-size: 1.25rem; + font-size: 1.2rem; font-weight: 600; - margin: 1.25rem 0 0.5rem; + margin: 1.5rem 0 0.5rem; + letter-spacing: -0.01em; } .article-content h3 { - font-size: 1.1rem; + font-size: 1rem; font-weight: 600; - margin: 1rem 0 0.5rem; + margin: 1.25rem 0 0.5rem; } .article-content p { margin: 0.5rem 0; - line-height: 1.7; + line-height: 1.75; + color: #333; } .article-content ul, .article-content ol { @@ -62,57 +144,61 @@ body { } .article-content li { margin: 0.25rem 0; - line-height: 1.6; + line-height: 1.7; + color: #333; } .article-content pre { - background: #1e1e1e; - color: #d4d4d4; + background: #111; + color: #ccc; padding: 1rem; - border-radius: 0.375rem; + border-radius: 6px; overflow-x: auto; - margin: 0.75rem 0; - font-size: 0.85rem; + margin: 1rem 0; + font-size: 0.8rem; } .article-content code { background: #f0f0f0; - padding: 0.15rem 0.35rem; - border-radius: 0.25rem; - font-size: 0.9em; + padding: 0.1rem 0.35rem; + border-radius: 3px; + font-size: 0.88em; } .article-content pre code { background: none; padding: 0; } .article-content blockquote { - border-left: 3px solid var(--color-border); + border-left: 2px solid #ddd; padding-left: 1rem; - color: var(--color-muted); - margin: 0.75rem 0; + color: #888; + margin: 1rem 0; } .article-content a { - color: var(--color-accent); + color: #111; text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: #ccc; +} +.article-content a:hover { + text-decoration-color: #111; } .article-content hr { border: none; - border-top: 1px solid var(--color-border); - margin: 1.5rem 0; + border-top: 1px solid #eee; + margin: 2rem 0; } .article-content img { max-width: 100%; - border-radius: 0.375rem; + border-radius: 6px; } -@keyframes fade-in { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} -.animate-fade-in { - animation: fade-in 0.2s ease-out; +/* Compile terminal */ +.compile-log { + background: #111; + border-radius: 6px; + padding: 0.75rem 1rem; + font-size: 0.7rem; + line-height: 1.7; + color: #666; + max-height: 12rem; + overflow-y: auto; }