fix: comprehensive QA audit — light mode, accessibility, error handling, code quality

- Fix light mode: theme-aware graph node labels, remove prose-invert for
  theme-safe markdown, add brand/semantic color overrides for light backgrounds
- Add 404 catch-all route redirecting unknown paths to /chat
- FalkorDB: add .catch() to connectPromise, add ensureConnected() to all
  store methods (createLiteral, relateNode, relateLiteral, deleteCollection)
- Accessibility: dialog role/aria-modal, toast aria-live, dismiss/zoom/search
  button aria-labels, close panel aria-label
- Lazy-load ForceGraph2D (splits 189KB into separate chunk, main bundle -26%)
- Cap conversation localStorage at 200 messages to prevent quota overflow
- Fix pnpm test: add --passWithNoTests to cli/mcp packages
- Add upload error notification instead of silent catch
- Remove unused class-variance-authority dep and dead tabs.tsx component
- Add @types/node to flow package devDependencies
- Remove stale FIXME comment in messages.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
elpresidank 2026-04-07 09:15:59 -05:00
parent 9ef9ef854f
commit 5e3929a883
17 changed files with 73 additions and 74 deletions

View file

@ -11,7 +11,6 @@
"dependencies": {
"@tanstack/react-query": "^5.75.0",
"@trustgraph/client": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"lucide-react": "^0.513.0",
"react": "^19.1.0",

View file

@ -25,6 +25,7 @@ export default function App() {
<Route path="/knowledge-cores" element={<ErrorBoundary><KnowledgeCoresPage /></ErrorBoundary>} />
<Route path="/flows" element={<ErrorBoundary><FlowsPage /></ErrorBoundary>} />
<Route path="/settings" element={<ErrorBoundary><SettingsPage /></ErrorBoundary>} />
<Route path="*" element={<Navigate to="/chat" replace />} />
</Route>
</Routes>

View file

@ -19,7 +19,7 @@ export function NotificationToasts() {
if (notifications.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" aria-live="polite">
{notifications.map((n) => (
<div
key={n.id}
@ -37,6 +37,7 @@ export function NotificationToasts() {
<button
onClick={() => removeNotification(n.id)}
className="shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss notification"
>
<X className="h-3.5 w-3.5" />
</button>

View file

@ -54,6 +54,9 @@ export function Dialog({
onClick={handleBackdrop}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
className={cn(
"relative w-full max-w-lg rounded-xl border border-border bg-surface-100 shadow-2xl",
className,
@ -61,7 +64,7 @@ export function Dialog({
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-fg">{title}</h2>
<h2 id="dialog-title" className="text-lg font-semibold text-fg">{title}</h2>
<button
onClick={onClose}
className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"

View file

@ -1,42 +0,0 @@
import { cn } from "@/lib/utils";
interface TabItem {
value: string;
label: string;
}
interface TabsProps {
items: TabItem[];
value: string;
onChange: (value: string) => void;
className?: string;
}
/**
* Minimal segmented-control / tab bar.
*/
export function Tabs({ items, value, onChange, className }: TabsProps) {
return (
<div
className={cn(
"flex rounded-lg border border-border bg-surface-100 p-0.5",
className,
)}
>
{items.map((item) => (
<button
key={item.value}
onClick={() => onChange(item.value)}
className={cn(
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
value === item.value
? "bg-brand-600 text-white"
: "text-fg-muted hover:text-fg",
)}
>
{item.label}
</button>
))}
</div>
);
}

View file

@ -95,10 +95,14 @@ export const useConversation = create<ConversationState>()(
{
name: "tg-conversation",
// Only persist messages and chatMode, not input or transient state
partialize: (state) => ({
messages: state.messages.filter((m) => !m.isStreaming),
chatMode: state.chatMode,
}),
partialize: (state) => {
const MAX_PERSISTED_MESSAGES = 200;
const filtered = state.messages.filter((m) => !m.isStreaming);
return {
messages: filtered.slice(-MAX_PERSISTED_MESSAGES),
chatMode: state.chatMode,
};
},
},
),
);

View file

@ -97,13 +97,13 @@
}
}
/* Prose overrides for dark mode markdown rendering */
/* Prose overrides for theme-aware markdown rendering */
@layer base {
.prose-invert code {
.prose code {
color: var(--color-brand-300);
}
.prose-invert pre {
.prose pre {
background: var(--color-surface-200);
}
}
@ -123,4 +123,13 @@ html.light {
--color-border: #e4e4e7;
--color-border-hover: #d4d4d8;
/* Brand adjustments for light backgrounds */
--color-brand-300: #3b63ed;
--color-brand-400: #2d4ec4;
/* Semantic colors stay vivid but slightly darker for contrast */
--color-success: #16a34a;
--color-warning: #ca8a04;
--color-error: #dc2626;
}

View file

@ -153,7 +153,7 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
{isUser ? (
<p className="whitespace-pre-wrap">{msg.content}</p>
) : (
<div className="prose prose-invert prose-sm max-w-none prose-p:my-1 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
<div className="prose prose-sm max-w-none text-fg prose-headings:text-fg prose-strong:text-fg prose-p:my-1 prose-a:text-brand-400 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
<Markdown>{msg.content || (msg.isStreaming ? "" : "(empty)")}</Markdown>
</div>
)}

View file

@ -1,4 +1,6 @@
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
@ -28,13 +30,16 @@ import type { Triple, Term } from "@trustgraph/client";
// Lazy-load ForceGraph2D to keep bundle size down
// ---------------------------------------------------------------------------
// react-force-graph-2d ships a default export
import ForceGraph2D, {
type ForceGraphMethods,
type NodeObject,
type LinkObject,
import type {
ForceGraphMethods,
NodeObject,
LinkObject,
ForceGraphProps,
} from "react-force-graph-2d";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@ -226,6 +231,7 @@ function NodeDetailPanel({
<button
onClick={onClose}
className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
aria-label="Close detail panel"
>
<X className="h-4 w-4" />
</button>
@ -401,7 +407,12 @@ export default function GraphPage() {
ctx.font = `${fontSize}px Inter, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillStyle = dim ? "rgba(100,100,100,0.3)" : "rgba(250,250,250,0.9)";
const isLight = document.documentElement.classList.contains("light");
ctx.fillStyle = dim
? "rgba(100,100,100,0.3)"
: isLight
? "rgba(24,24,27,0.9)"
: "rgba(250,250,250,0.9)";
ctx.fillText(node.label, x, y + radius + 1);
},
[selectedNode, matchingIds],
@ -456,6 +467,7 @@ export default function GraphPage() {
<button
onClick={() => setSearchTerm("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
aria-label="Clear search"
>
<X className="h-3 w-3" />
</button>
@ -468,6 +480,7 @@ export default function GraphPage() {
onClick={zoomIn}
className="px-2 py-1.5 text-fg-muted hover:text-fg"
title="Zoom in"
aria-label="Zoom in"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
@ -475,6 +488,7 @@ export default function GraphPage() {
onClick={zoomOut}
className="border-l border-r border-border px-2 py-1.5 text-fg-muted hover:text-fg"
title="Zoom out"
aria-label="Zoom out"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
@ -482,6 +496,7 @@ export default function GraphPage() {
onClick={zoomFit}
className="px-2 py-1.5 text-fg-muted hover:text-fg"
title="Fit to view"
aria-label="Fit to view"
>
<Maximize className="h-3.5 w-3.5" />
</button>
@ -532,6 +547,7 @@ export default function GraphPage() {
<div className="flex flex-1 overflow-hidden rounded-lg border border-border">
{/* Graph canvas */}
<div className="relative flex-1 bg-surface-0">
<Suspense fallback={<div className="flex h-full items-center justify-center"><Loader2 className="h-5 w-5 animate-spin text-fg-subtle" /></div>}>
<ForceGraph2D
ref={fgRef}
graphData={graphData}
@ -558,6 +574,7 @@ export default function GraphPage() {
width={undefined}
height={undefined}
/>
</Suspense>
{/* Search results badge overlay */}
{searchTerm && matchingIds.size > 0 && (

View file

@ -26,6 +26,7 @@ function UploadDialog({
open,
onClose,
onUpload,
onError,
}: {
open: boolean;
onClose: () => void;
@ -36,6 +37,7 @@ function UploadDialog({
comments: string,
tags: string[],
) => Promise<void>;
onError?: (msg: string) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
@ -78,7 +80,8 @@ function UploadDialog({
await onUpload(base64, file.type || "application/octet-stream", title, comments, tagList);
reset();
onClose();
} catch {
} catch (err) {
onError?.(err instanceof Error ? err.message : "Upload failed");
setUploading(false);
}
};
@ -455,6 +458,7 @@ export default function LibraryPage() {
open={uploadOpen}
onClose={() => setUploadOpen(false)}
onUpload={handleUpload}
onError={(msg) => notify.error("Upload failed", msg)}
/>
<ConfirmDeleteDialog