mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
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:
parent
9ef9ef854f
commit
5e3929a883
17 changed files with 73 additions and 74 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue