@schlessera/brain-ui-react 0.6.3 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/components/graph/graph-canvas.d.ts +20 -0
  2. package/dist/components/graph/graph-canvas.d.ts.map +1 -0
  3. package/dist/components/graph/graph-canvas.js +387 -0
  4. package/dist/components/graph/graph-canvas.js.map +1 -0
  5. package/dist/components/graph/graph-controls.d.ts +8 -0
  6. package/dist/components/graph/graph-controls.d.ts.map +1 -0
  7. package/dist/components/graph/graph-controls.js +132 -0
  8. package/dist/components/graph/graph-controls.js.map +1 -0
  9. package/dist/components/graph/graph-empty-state.d.ts +15 -0
  10. package/dist/components/graph/graph-empty-state.d.ts.map +1 -0
  11. package/dist/components/graph/graph-empty-state.js +21 -0
  12. package/dist/components/graph/graph-empty-state.js.map +1 -0
  13. package/dist/components/graph/graph-page.d.ts +7 -0
  14. package/dist/components/graph/graph-page.d.ts.map +1 -0
  15. package/dist/components/graph/graph-page.js +400 -0
  16. package/dist/components/graph/graph-page.js.map +1 -0
  17. package/dist/components/graph/lib/graph-helpers.d.ts +98 -0
  18. package/dist/components/graph/lib/graph-helpers.d.ts.map +1 -0
  19. package/dist/components/graph/lib/graph-helpers.js +240 -0
  20. package/dist/components/graph/lib/graph-helpers.js.map +1 -0
  21. package/dist/components/graph/node-popover.d.ts +11 -0
  22. package/dist/components/graph/node-popover.d.ts.map +1 -0
  23. package/dist/components/graph/node-popover.js +39 -0
  24. package/dist/components/graph/node-popover.js.map +1 -0
  25. package/dist/components/graph/use-graph-theme.d.ts +19 -0
  26. package/dist/components/graph/use-graph-theme.d.ts.map +1 -0
  27. package/dist/components/graph/use-graph-theme.js +38 -0
  28. package/dist/components/graph/use-graph-theme.js.map +1 -0
  29. package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
  30. package/dist/components/layout/mobile-tab-bar.js +16 -5
  31. package/dist/components/layout/mobile-tab-bar.js.map +1 -1
  32. package/dist/components/layout/side-rail.d.ts.map +1 -1
  33. package/dist/components/layout/side-rail.js +14 -4
  34. package/dist/components/layout/side-rail.js.map +1 -1
  35. package/dist/index.d.ts +3 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/stores/graph-store.d.ts +85 -0
  40. package/dist/stores/graph-store.d.ts.map +1 -0
  41. package/dist/stores/graph-store.js +236 -0
  42. package/dist/stores/graph-store.js.map +1 -0
  43. package/dist/stores/ui-store.d.ts +5 -0
  44. package/dist/stores/ui-store.d.ts.map +1 -1
  45. package/dist/stores/ui-store.js +2 -0
  46. package/dist/stores/ui-store.js.map +1 -1
  47. package/dist/styles.css +1 -1
  48. package/package.json +5 -2
  49. package/src/components/graph/graph-canvas.tsx +450 -0
  50. package/src/components/graph/graph-controls.tsx +436 -0
  51. package/src/components/graph/graph-empty-state.tsx +45 -0
  52. package/src/components/graph/graph-page.tsx +1045 -0
  53. package/src/components/graph/lib/graph-helpers.ts +311 -0
  54. package/src/components/graph/node-popover.tsx +135 -0
  55. package/src/components/graph/use-graph-theme.ts +51 -0
  56. package/src/components/layout/mobile-tab-bar.tsx +26 -3
  57. package/src/components/layout/side-rail.tsx +28 -4
  58. package/src/index.ts +3 -1
  59. package/src/stores/graph-store.ts +345 -0
  60. package/src/stores/ui-store.ts +8 -0
@@ -0,0 +1,436 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Loader2, Search, SlidersHorizontal, X } from "lucide-react";
3
+ import { api, type BrainSearchHit } from "../../lib/api-client.js";
4
+ import {
5
+ useGraphStore,
6
+ type DiscoveryColorBy,
7
+ type LocalDirection,
8
+ type SizeBy,
9
+ } from "../../stores/graph-store.js";
10
+ import { cn } from "../../lib/utils.js";
11
+
12
+ /**
13
+ * Per-mode option forms. Desktop: a floating card in the top-right of the
14
+ * canvas. Mobile: a slide-up sheet behind a filter button (tap targets kept
15
+ * at ≥44px). Only the options that matter per mode — everything else stays
16
+ * out until a later phase earns it.
17
+ */
18
+ export function GraphControls() {
19
+ const [mobileOpen, setMobileOpen] = useState(false);
20
+
21
+ return (
22
+ <>
23
+ {/* Mobile: floating filter button */}
24
+ <button
25
+ onClick={() => setMobileOpen(true)}
26
+ className="absolute right-3 top-3 z-10 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-surface-overlay text-foreground shadow-lg md:hidden"
27
+ title="Graph options"
28
+ >
29
+ <SlidersHorizontal className="h-4.5 w-4.5" />
30
+ </button>
31
+
32
+ {/* Desktop: docked card */}
33
+ <div className="absolute right-4 top-4 z-10 hidden w-64 rounded-xl border border-border bg-surface-overlay/95 p-4 shadow-xl backdrop-blur md:block">
34
+ <ControlsBody />
35
+ </div>
36
+
37
+ {/* Mobile: bottom sheet */}
38
+ {mobileOpen && (
39
+ <div className="fixed inset-0 z-40 md:hidden">
40
+ <div
41
+ className="absolute inset-0 bg-background/60"
42
+ onClick={() => setMobileOpen(false)}
43
+ />
44
+ <div className="absolute inset-x-0 bottom-0 max-h-[70dvh] overflow-y-auto rounded-t-2xl border-t border-border bg-surface p-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))]">
45
+ <div className="mb-3 flex items-center justify-between">
46
+ <h3 className="text-sm font-medium text-foreground">Graph options</h3>
47
+ <button
48
+ onClick={() => setMobileOpen(false)}
49
+ className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground"
50
+ title="Close"
51
+ >
52
+ <X className="h-4 w-4" />
53
+ </button>
54
+ </div>
55
+ <ControlsBody />
56
+ </div>
57
+ </div>
58
+ )}
59
+ </>
60
+ );
61
+ }
62
+
63
+ function ControlsBody() {
64
+ const mode = useGraphStore((s) => s.mode);
65
+ return (
66
+ <div className="flex flex-col gap-4">
67
+ <SceneSearch />
68
+ {mode === "local" && <LocalControls />}
69
+ {mode === "discovery" && <DiscoveryControls />}
70
+ {mode === "clusters" && <ClustersControls />}
71
+ {mode === "maintenance" && <MaintenanceControls />}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ /** Highlights matches in the current scene — no refetch. */
77
+ function SceneSearch() {
78
+ const sceneQuery = useGraphStore((s) => s.sceneQuery);
79
+ const setSceneQuery = useGraphStore((s) => s.setSceneQuery);
80
+ return (
81
+ <label className="flex items-center gap-2 rounded-lg border border-border bg-surface-raised px-2.5 py-2 focus-within:border-primary/40">
82
+ <Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
83
+ <input
84
+ value={sceneQuery}
85
+ onChange={(e) => setSceneQuery(e.target.value)}
86
+ placeholder="Highlight in graph…"
87
+ className="min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground"
88
+ autoComplete="off"
89
+ spellCheck={false}
90
+ />
91
+ {sceneQuery && (
92
+ <button
93
+ onClick={() => setSceneQuery("")}
94
+ className="shrink-0 text-muted-foreground hover:text-foreground"
95
+ title="Clear"
96
+ >
97
+ <X className="h-3 w-3" />
98
+ </button>
99
+ )}
100
+ </label>
101
+ );
102
+ }
103
+
104
+ function LocalControls() {
105
+ const local = useGraphStore((s) => s.local);
106
+ const setLocalParams = useGraphStore((s) => s.setLocalParams);
107
+
108
+ return (
109
+ <>
110
+ <Field label="Center note">
111
+ <NotePicker
112
+ value={local.center}
113
+ onPick={(path) => setLocalParams({ center: path })}
114
+ />
115
+ </Field>
116
+ <Field label="Depth">
117
+ <Segmented
118
+ options={[
119
+ { value: "1", label: "1" },
120
+ { value: "2", label: "2" },
121
+ { value: "3", label: "3" },
122
+ ]}
123
+ value={String(local.depth)}
124
+ onChange={(v) => setLocalParams({ depth: Number(v) as 1 | 2 | 3 })}
125
+ />
126
+ </Field>
127
+ <Field label="Direction">
128
+ <Segmented
129
+ options={[
130
+ { value: "in", label: "In" },
131
+ { value: "out", label: "Out" },
132
+ { value: "both", label: "Both" },
133
+ ]}
134
+ value={local.direction}
135
+ onChange={(v) => setLocalParams({ direction: v as LocalDirection })}
136
+ />
137
+ </Field>
138
+ </>
139
+ );
140
+ }
141
+
142
+ function DiscoveryControls() {
143
+ const discovery = useGraphStore((s) => s.discovery);
144
+ const setDiscoveryParams = useGraphStore((s) => s.setDiscoveryParams);
145
+ const colorBy = useGraphStore((s) => s.discoveryColorBy);
146
+ const setColorBy = useGraphStore((s) => s.setDiscoveryColorBy);
147
+ const defaultRoot = useGraphStore((s) => s.meta?.defaultRoot ?? null);
148
+
149
+ return (
150
+ <>
151
+ <Field label="Root">
152
+ <NotePicker
153
+ value={discovery.root ?? defaultRoot?.path ?? null}
154
+ placeholder={defaultRoot ? defaultRoot.path : "Pick a root note…"}
155
+ onPick={(path) => setDiscoveryParams({ root: path })}
156
+ />
157
+ {discovery.root && defaultRoot && (
158
+ <button
159
+ onClick={() => setDiscoveryParams({ root: null })}
160
+ className="mt-1 text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
161
+ >
162
+ Reset to default root
163
+ </button>
164
+ )}
165
+ </Field>
166
+ <Field label={`Max depth: ${discovery.maxDepth}`}>
167
+ <input
168
+ type="range"
169
+ min={1}
170
+ max={8}
171
+ value={discovery.maxDepth}
172
+ onChange={(e) => setDiscoveryParams({ maxDepth: Number(e.target.value) })}
173
+ className="w-full accent-(--color-primary)"
174
+ />
175
+ </Field>
176
+ <Field label="Color by">
177
+ <Segmented
178
+ options={[
179
+ { value: "distance", label: "Distance" },
180
+ { value: "folder", label: "Folder" },
181
+ ]}
182
+ value={colorBy}
183
+ onChange={(v) => setColorBy(v as DiscoveryColorBy)}
184
+ />
185
+ </Field>
186
+ {/* Direction is only meaningful with an explicit root: the default-root
187
+ distances were walked once at index time. */}
188
+ </>
189
+ );
190
+ }
191
+
192
+ function ClustersControls() {
193
+ const clusters = useGraphStore((s) => s.clusters);
194
+ const setClustersParams = useGraphStore((s) => s.setClustersParams);
195
+ const sizeBy = useGraphStore((s) => s.clustersSizeBy);
196
+ const setSizeBy = useGraphStore((s) => s.setClustersSizeBy);
197
+ return (
198
+ <>
199
+ <Field label="Node size">
200
+ <Segmented
201
+ options={[
202
+ { value: "degree", label: "Links" },
203
+ { value: "pagerank", label: "PageRank" },
204
+ ]}
205
+ value={sizeBy}
206
+ onChange={(v) => setSizeBy(v as SizeBy)}
207
+ />
208
+ </Field>
209
+ <Field label="Isolated notes">
210
+ <Toggle
211
+ checked={clusters.isolates}
212
+ onChange={(v) => setClustersParams({ isolates: v })}
213
+ label="Include notes without links"
214
+ />
215
+ </Field>
216
+ </>
217
+ );
218
+ }
219
+
220
+ function MaintenanceControls() {
221
+ const maintenance = useGraphStore((s) => s.maintenance);
222
+ const setMaintenanceParams = useGraphStore((s) => s.setMaintenanceParams);
223
+ const filters = useGraphStore((s) => s.maintenanceFilters);
224
+ const setFilters = useGraphStore((s) => s.setMaintenanceFilters);
225
+ return (
226
+ <>
227
+ <Field label="Findings">
228
+ <div className="flex flex-col">
229
+ <Toggle
230
+ checked={filters.orphans}
231
+ onChange={(v) => setFilters({ orphans: v })}
232
+ label="Orphans"
233
+ />
234
+ <Toggle
235
+ checked={filters.unreachable}
236
+ onChange={(v) => setFilters({ unreachable: v })}
237
+ label="Unreachable"
238
+ />
239
+ <Toggle
240
+ checked={filters.broken}
241
+ onChange={(v) => setFilters({ broken: v })}
242
+ label="Broken links"
243
+ />
244
+ <Toggle
245
+ checked={filters.stale}
246
+ onChange={(v) => setFilters({ stale: v })}
247
+ label="Stale notes"
248
+ />
249
+ </div>
250
+ </Field>
251
+ <Field label={`Stale after ${maintenance.staleDays} days`}>
252
+ <input
253
+ type="range"
254
+ min={30}
255
+ max={720}
256
+ step={30}
257
+ value={maintenance.staleDays}
258
+ onChange={(e) => setMaintenanceParams({ staleDays: Number(e.target.value) })}
259
+ className="w-full accent-(--color-primary)"
260
+ />
261
+ </Field>
262
+ </>
263
+ );
264
+ }
265
+
266
+ // --- Small form primitives ---------------------------------------------------
267
+
268
+ function Field({ label, children }: { label: string; children: React.ReactNode }) {
269
+ return (
270
+ <div>
271
+ <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
272
+ {label}
273
+ </div>
274
+ {children}
275
+ </div>
276
+ );
277
+ }
278
+
279
+ function Segmented({
280
+ options,
281
+ value,
282
+ onChange,
283
+ }: {
284
+ options: { value: string; label: string }[];
285
+ value: string;
286
+ onChange: (value: string) => void;
287
+ }) {
288
+ return (
289
+ <div className="flex rounded-lg border border-border bg-surface-raised p-0.5">
290
+ {options.map((opt) => (
291
+ <button
292
+ key={opt.value}
293
+ onClick={() => onChange(opt.value)}
294
+ className={cn(
295
+ "min-h-9 flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition-colors",
296
+ value === opt.value
297
+ ? "bg-surface-overlay text-foreground"
298
+ : "text-muted-foreground hover:text-foreground"
299
+ )}
300
+ >
301
+ {opt.label}
302
+ </button>
303
+ ))}
304
+ </div>
305
+ );
306
+ }
307
+
308
+ function Toggle({
309
+ checked,
310
+ onChange,
311
+ label,
312
+ }: {
313
+ checked: boolean;
314
+ onChange: (checked: boolean) => void;
315
+ label: string;
316
+ }) {
317
+ return (
318
+ <button
319
+ role="switch"
320
+ aria-checked={checked}
321
+ onClick={() => onChange(!checked)}
322
+ className="flex min-h-9 w-full items-center justify-between gap-2 text-left text-xs text-foreground"
323
+ >
324
+ {label}
325
+ <span
326
+ className={cn(
327
+ "relative h-5 w-9 shrink-0 rounded-full transition-colors",
328
+ checked ? "bg-primary" : "bg-surface-raised border border-border"
329
+ )}
330
+ >
331
+ <span
332
+ className={cn(
333
+ "absolute top-0.5 h-4 w-4 rounded-full bg-foreground transition-transform",
334
+ checked ? "translate-x-4" : "translate-x-0.5"
335
+ )}
336
+ />
337
+ </span>
338
+ </button>
339
+ );
340
+ }
341
+
342
+ /**
343
+ * Debounced note search backed by /api/brain/search — the graph twin of the
344
+ * search modal's flow, inlined so picking a center/root never leaves the view.
345
+ */
346
+ function NotePicker({
347
+ value,
348
+ placeholder = "Search notes…",
349
+ onPick,
350
+ }: {
351
+ value: string | null;
352
+ placeholder?: string;
353
+ onPick: (path: string) => void;
354
+ }) {
355
+ const [query, setQuery] = useState("");
356
+ const [editing, setEditing] = useState(false);
357
+ const [results, setResults] = useState<BrainSearchHit[]>([]);
358
+ const [loading, setLoading] = useState(false);
359
+ const controllerRef = useRef<AbortController | null>(null);
360
+
361
+ const trimmed = query.trim();
362
+
363
+ useEffect(() => {
364
+ if (!editing || trimmed.length < 2) {
365
+ controllerRef.current?.abort();
366
+ setResults([]);
367
+ setLoading(false);
368
+ return;
369
+ }
370
+ const t = setTimeout(async () => {
371
+ controllerRef.current?.abort();
372
+ const controller = new AbortController();
373
+ controllerRef.current = controller;
374
+ setLoading(true);
375
+ try {
376
+ const res = await api.brainSearch(trimmed, {
377
+ limit: 8,
378
+ signal: controller.signal,
379
+ });
380
+ if (!controller.signal.aborted) setResults(res.results);
381
+ } catch {
382
+ // Aborted or failed — the next keystroke retries.
383
+ } finally {
384
+ if (!controller.signal.aborted) setLoading(false);
385
+ }
386
+ }, 250);
387
+ return () => clearTimeout(t);
388
+ }, [editing, trimmed]);
389
+
390
+ return (
391
+ <div className="relative">
392
+ <label className="flex items-center gap-2 rounded-lg border border-border bg-surface-raised px-2.5 py-2 focus-within:border-primary/40">
393
+ <Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
394
+ <input
395
+ value={editing ? query : (value ?? "")}
396
+ onFocus={() => {
397
+ setEditing(true);
398
+ setQuery("");
399
+ }}
400
+ onBlur={() => {
401
+ // Delay so a click on a result lands before the list unmounts.
402
+ setTimeout(() => setEditing(false), 150);
403
+ }}
404
+ onChange={(e) => setQuery(e.target.value)}
405
+ placeholder={placeholder}
406
+ className="min-w-0 flex-1 bg-transparent font-mono text-[11px] text-foreground outline-none placeholder:font-body placeholder:text-xs placeholder:text-muted-foreground"
407
+ autoComplete="off"
408
+ spellCheck={false}
409
+ />
410
+ {loading && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />}
411
+ </label>
412
+ {editing && results.length > 0 && (
413
+ <div className="absolute inset-x-0 top-full z-20 mt-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-surface-overlay shadow-2xl">
414
+ {results.map((hit) => (
415
+ <button
416
+ key={hit.path}
417
+ onMouseDown={(e) => e.preventDefault()}
418
+ onClick={() => {
419
+ onPick(hit.path);
420
+ setEditing(false);
421
+ }}
422
+ className="flex w-full flex-col px-3 py-2 text-left transition-colors hover:bg-surface-raised"
423
+ >
424
+ <span className="truncate text-xs text-foreground">
425
+ {hit.title || hit.path}
426
+ </span>
427
+ <span className="truncate font-mono text-[10px] text-muted-foreground/60">
428
+ {hit.path}
429
+ </span>
430
+ </button>
431
+ ))}
432
+ </div>
433
+ )}
434
+ </div>
435
+ );
436
+ }
@@ -0,0 +1,45 @@
1
+ import { Waypoints, RefreshCw, ServerCrash, TriangleAlert } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+
4
+ /**
5
+ * Full-canvas cards for the states where there is no scene to draw:
6
+ * server too old, repo indexed by a pre-graph CLI, graph not yet computed,
7
+ * plain fetch failures, and mode-specific "nothing here" messages.
8
+ */
9
+ export function GraphEmptyState({
10
+ icon = "graph",
11
+ title,
12
+ children,
13
+ }: {
14
+ icon?: "graph" | "sync" | "server" | "warn";
15
+ title: string;
16
+ children?: ReactNode;
17
+ }) {
18
+ const Icon =
19
+ icon === "sync"
20
+ ? RefreshCw
21
+ : icon === "server"
22
+ ? ServerCrash
23
+ : icon === "warn"
24
+ ? TriangleAlert
25
+ : Waypoints;
26
+ return (
27
+ <div className="flex h-full items-center justify-center p-6">
28
+ <div className="flex max-w-sm flex-col items-center gap-3 rounded-xl border border-border bg-surface px-8 py-10 text-center">
29
+ <Icon className="h-8 w-8 text-muted-foreground" />
30
+ <h2 className="font-display text-lg text-foreground">{title}</h2>
31
+ {children && (
32
+ <div className="text-xs leading-relaxed text-muted-foreground">{children}</div>
33
+ )}
34
+ </div>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ export function Mono({ children }: { children: ReactNode }) {
40
+ return (
41
+ <code className="rounded bg-surface-raised px-1.5 py-0.5 font-mono text-[11px] text-foreground">
42
+ {children}
43
+ </code>
44
+ );
45
+ }