@medicine-wheel/app 0.6.3 → 0.7.0

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.
@@ -17,8 +17,11 @@ import {
17
17
  Route,
18
18
  Save,
19
19
  ShieldCheck,
20
+ SlidersHorizontal,
21
+ X,
20
22
  } from "lucide-react";
21
23
  import { type RelationalNode, type RelationalEdge, DIRECTION_COLORS } from "@/lib/types";
24
+ import { cn } from "@/lib/utils";
22
25
  import {
23
26
  applyWheelLayout,
24
27
  buildGraphData,
@@ -95,10 +98,34 @@ export default function GraphPage() {
95
98
  const [graph, setGraph] = useState<MWGraphData>({ nodes: [], links: [] });
96
99
  const [selectedNode, setSelectedNode] = useState<MWGraphNode | null>(null);
97
100
  const [focusedNodeId, setFocusedNodeId] = useState<string | undefined>(undefined);
101
+ /**
102
+ * Scope is not focus.
103
+ *
104
+ * `focusedNodeId` zooms the viewport at a node and leaves all 205 mounted —
105
+ * useful, and not an answer to "I cannot navigate", because the field is still
106
+ * there behind the zoom. `scopeId` replaces the graph with one node's
107
+ * neighbourhood, so the canvas holds only what was asked for. Two names
108
+ * because they are two questions; collapsing them would make the control
109
+ * silently mean different things depending on how the page was entered.
110
+ */
111
+ const [scopeId, setScopeId] = useState<string | null>(null);
112
+ // 1, not 2. With the chronicle root at degree 82, depth 2 from any episode
113
+ // used to return the whole corpus — see the route's DEFAULT_DEPTH.
114
+ const [scopeDepth, setScopeDepth] = useState(1);
115
+ const [scopeMeta, setScopeMeta] = useState<{
116
+ count: number;
117
+ truncated: boolean;
118
+ hubs: { id: string; name: string; degree: number; reason: string; unexpanded: number }[];
119
+ rootName: string | null;
120
+ } | null>(null);
121
+ const [storeTotal, setStoreTotal] = useState<number | null>(null);
98
122
  const [highlightDirection, setHighlightDirection] = useState<DirectionParam | undefined>(undefined);
99
123
  const [radialSnap, setRadialSnap] = useState<"off" | "ring" | "sector">("off");
100
124
  const [loading, setLoading] = useState(true);
101
- const [showLabels, setShowLabels] = useState(true);
125
+ // `null` means "nobody has chosen" — the default then follows the crowding
126
+ // (see labelsAutoSuppressed). Once the user touches the toggle their choice
127
+ // sticks, including across a scope change.
128
+ const [showLabels, setShowLabels] = useState<boolean | null>(null);
102
129
  const [animationsEnabled, setAnimationsEnabled] = useState(
103
130
  DEFAULT_GRAPH_ANIMATION_ENABLED,
104
131
  );
@@ -109,6 +136,40 @@ export default function GraphPage() {
109
136
  );
110
137
  const layoutStoreRef = useRef(layoutStore);
111
138
 
139
+ // Mobile presentation: the side panel becomes a slide-up sheet so the
140
+ // canvas can own the screen, and the canvas height fills the viewport.
141
+ const [isMobile, setIsMobile] = useState(false);
142
+ const [sheetOpen, setSheetOpen] = useState(false);
143
+ const [graphHeight, setGraphHeight] = useState<number | string>(600);
144
+ const selectedPanelRef = useRef<HTMLDivElement>(null);
145
+
146
+ useEffect(() => {
147
+ const mq = window.matchMedia("(max-width: 1023px)");
148
+ const apply = () => {
149
+ setIsMobile(mq.matches);
150
+ setGraphHeight(
151
+ mq.matches ? Math.max(360, Math.round(window.innerHeight * 0.66)) : 600,
152
+ );
153
+ };
154
+ apply();
155
+ mq.addEventListener("change", apply);
156
+ window.addEventListener("resize", apply);
157
+ return () => {
158
+ mq.removeEventListener("change", apply);
159
+ window.removeEventListener("resize", apply);
160
+ };
161
+ }, []);
162
+
163
+ // When a being is tapped on mobile the sheet opens; bring its details into
164
+ // view once the slide-up transition settles.
165
+ useEffect(() => {
166
+ if (!isMobile || !sheetOpen || !selectedNode) return;
167
+ const t = window.setTimeout(() => {
168
+ selectedPanelRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
169
+ }, 320);
170
+ return () => window.clearTimeout(t);
171
+ }, [isMobile, sheetOpen, selectedNode]);
172
+
112
173
  const saveLayoutStore = useCallback(
113
174
  (
114
175
  nextStore: GraphLayoutStore,
@@ -129,22 +190,54 @@ export default function GraphPage() {
129
190
 
130
191
  const loadData = useCallback(async () => {
131
192
  try {
132
- const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes"), fetch("/api/edges")]);
193
+ // Scoped and whole-wheel are different questions, not the same question
194
+ // with a filter. `focusedNodeId` already existed and only zooms the
195
+ // viewport — every one of the 205 nodes stays mounted, so "focus" was
196
+ // never a way to look at less. Scope replaces the data.
197
+ if (scopeId) {
198
+ const res = await fetch(
199
+ `/api/nodes/${encodeURIComponent(scopeId)}/web?depth=${scopeDepth}`,
200
+ );
201
+ if (!res.ok) {
202
+ toast.error(
203
+ res.status === 404
204
+ ? "That node is not in the wheel."
205
+ : "Could not read that neighbourhood.",
206
+ );
207
+ setScopeId(null);
208
+ return;
209
+ }
210
+ const web = await res.json();
211
+ setGraph(buildGraphData(web.nodes ?? [], web.edges ?? []));
212
+ setScopeMeta({
213
+ count: web.count ?? 0,
214
+ truncated: Boolean(web.truncated),
215
+ hubs: web.hubs ?? [],
216
+ rootName: web.root?.name ?? null,
217
+ });
218
+ return;
219
+ }
220
+
221
+ const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes?limit=all"), fetch("/api/edges?limit=all")]);
133
222
  const nodesResponse = await nodesRes.json();
134
223
  const edgesData: RelationalEdge[] = await edgesRes.json();
135
224
 
136
- // API returns { nodes: [...], provider: '...', count: N }
225
+ // API returns { nodes: [...], provider: '...', count: N, total, truncated }
137
226
  const nodesData: RelationalNode[] = Array.isArray(nodesResponse)
138
227
  ? nodesResponse
139
228
  : nodesResponse.nodes || [];
140
229
 
141
230
  setGraph(buildGraphData(nodesData, edgesData));
231
+ setScopeMeta(null);
232
+ setStoreTotal(
233
+ Array.isArray(nodesResponse) ? nodesData.length : (nodesResponse.total ?? nodesData.length),
234
+ );
142
235
  } catch {
143
236
  toast.error("Failed to load graph data");
144
237
  } finally {
145
238
  setLoading(false);
146
239
  }
147
- }, []);
240
+ }, [scopeId, scopeDepth]);
148
241
 
149
242
  useEffect(() => {
150
243
  loadData();
@@ -167,12 +260,31 @@ export default function GraphPage() {
167
260
  const params = new URLSearchParams(window.location.search);
168
261
  const node = params.get("node");
169
262
  if (node) setFocusedNodeId(node);
263
+ // ?scope=<id> arrives from an episode page's "Open in graph" and means
264
+ // "show me this and its neighbours", not "zoom at this".
265
+ const scope = params.get("scope");
266
+ if (scope) {
267
+ setScopeId(scope);
268
+ setFocusedNodeId(scope);
269
+ }
170
270
  const direction = params.get("direction");
171
271
  if (direction && (DIRECTION_NAMES as readonly string[]).includes(direction)) {
172
272
  setHighlightDirection(direction as DirectionParam);
173
273
  }
174
274
  }, []);
175
275
 
276
+ // Escape leaves the neighbourhood and returns the whole wheel. It is the only
277
+ // way out that needs no aiming, which matters when the reason you scoped was
278
+ // that you could not find anything.
279
+ useEffect(() => {
280
+ if (!scopeId) return;
281
+ const onKey = (event: KeyboardEvent) => {
282
+ if (event.key === "Escape") setScopeId(null);
283
+ };
284
+ window.addEventListener("keydown", onKey);
285
+ return () => window.removeEventListener("keydown", onKey);
286
+ }, [scopeId]);
287
+
176
288
  // Surface the deep-linked node in the side panel once data arrives.
177
289
  useEffect(() => {
178
290
  if (!focusedNodeId || graph.nodes.length === 0) return;
@@ -180,10 +292,58 @@ export default function GraphPage() {
180
292
  if (node) setSelectedNode(node);
181
293
  }, [focusedNodeId, graph.nodes]);
182
294
 
183
- const graphData = useMemo<MWGraphData>(
184
- () => (focusedNodeId ? { ...graph, focusedNodeId } : graph),
185
- [graph, focusedNodeId],
186
- );
295
+ /**
296
+ * The node you asked about must be findable in the picture.
297
+ *
298
+ * Before this, arriving from an episode page put your episode somewhere in a
299
+ * field of identical dots — the graph knew which node you meant (it zoomed at
300
+ * it) and gave you no way to see which one it was once you panned. Marking is
301
+ * done here rather than in `graph-viz` because `MedicineWheelNode` already
302
+ * reads `size`, `color` and `opacity` off each node; the renderer needs no
303
+ * new concept, only better data.
304
+ *
305
+ * Hubs are dimmed rather than hidden. The chronicle root genuinely is in your
306
+ * neighbourhood — every episode belongs to it — but it is context, not the
307
+ * subject, and drawing it at full weight makes the one true container compete
308
+ * with the thing you were looking at.
309
+ */
310
+ const graphData = useMemo<MWGraphData>(() => {
311
+ const hubIds = new Set((scopeMeta?.hubs ?? []).map((h) => h.id));
312
+ const marked =
313
+ scopeId || hubIds.size > 0
314
+ ? {
315
+ ...graph,
316
+ nodes: graph.nodes.map((n) => {
317
+ if (n.id === scopeId) {
318
+ return {
319
+ ...n,
320
+ size: (n.size ?? 8) * 1.9,
321
+ color: "#ffd75e",
322
+ opacity: 1,
323
+ };
324
+ }
325
+ if (hubIds.has(n.id)) {
326
+ return { ...n, size: (n.size ?? 8) * 1.25, opacity: 0.45 };
327
+ }
328
+ return n;
329
+ }),
330
+ }
331
+ : graph;
332
+
333
+ return focusedNodeId ? { ...marked, focusedNodeId } : marked;
334
+ }, [graph, focusedNodeId, scopeId, scopeMeta]);
335
+
336
+ /**
337
+ * Labels are legible up to a point and then they are texture.
338
+ *
339
+ * At 83 nodes crushed into one quadrant the labels stacked into unreadable
340
+ * bands — worse than absent, because they cost the whole canvas its clarity
341
+ * while conveying nothing. Off past the threshold, and the existing toggle
342
+ * still wins: this sets the default, it does not take the control away.
343
+ */
344
+ const LABEL_LEGIBLE_UP_TO = 35;
345
+ const labelsAutoSuppressed = graph.nodes.length > LABEL_LEGIBLE_UP_TO;
346
+ const labelsVisible = showLabels ?? !labelsAutoSuppressed;
187
347
 
188
348
  useEffect(() => {
189
349
  layoutStoreRef.current = layoutStore;
@@ -380,40 +540,103 @@ export default function GraphPage() {
380
540
  );
381
541
 
382
542
  return (
383
- <div className="min-h-screen bg-[#0a0a1a] text-white p-6">
543
+ <div className="min-h-screen bg-[#0a0a1a] text-white p-3 sm:p-6">
384
544
  <div className="max-w-7xl mx-auto">
385
- <div className="flex items-center justify-between mb-6">
545
+ <div className="flex flex-col gap-3 mb-4 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
386
546
  <div>
387
- <h1 className="text-2xl font-bold flex items-center gap-2">
388
- <span className="text-3xl">🔮</span> Medicine Wheel Graph
547
+ <h1 className="text-xl sm:text-2xl font-bold flex items-center gap-2">
548
+ <span className="text-2xl sm:text-3xl">🔮</span> Medicine Wheel Graph
389
549
  </h1>
390
- <p className="text-gray-400 text-sm mt-1">
391
- Interactive relational web — drag, pan, zoom across the four directions
550
+ <p className="text-gray-400 text-xs sm:text-sm mt-1">
551
+ Interactive relational web — tap a being, drag, pan, and pinch to zoom
392
552
  </p>
393
553
  </div>
394
- <div className="flex gap-2">
554
+ <div className="flex flex-wrap gap-2">
395
555
  <button
396
- onClick={() => setShowLabels(!showLabels)}
397
- className={`px-3 py-1.5 rounded text-sm ${showLabels ? "bg-white/10" : "bg-white/5"}`}
556
+ onClick={() => setShowLabels(!labelsVisible)}
557
+ className={`min-h-11 px-3 py-1.5 rounded text-sm ${labelsVisible ? "bg-white/10" : "bg-white/5"}`}
558
+ title={
559
+ showLabels === null && labelsAutoSuppressed
560
+ ? `Off by default above ${LABEL_LEGIBLE_UP_TO} nodes — they overlap into bands`
561
+ : undefined
562
+ }
398
563
  >
399
- Labels {showLabels ? "ON" : "OFF"}
564
+ Labels {labelsVisible ? "ON" : "OFF"}
400
565
  </button>
401
566
  <button
402
567
  onClick={() =>
403
568
  setRadialSnap((s) => (s === "off" ? "ring" : s === "ring" ? "sector" : "off"))
404
569
  }
405
- className={`px-3 py-1.5 rounded text-sm ${radialSnap !== "off" ? "bg-white/10" : "bg-white/5"}`}
570
+ className={`min-h-11 px-3 py-1.5 rounded text-sm ${radialSnap !== "off" ? "bg-white/10" : "bg-white/5"}`}
406
571
  title="Radial snapping: the wheel guides dragged beings — ring keeps them in their band, sector also inside their quadrant"
407
572
  >
408
573
  Snap {radialSnap === "off" ? "OFF" : radialSnap.toUpperCase()}
409
574
  </button>
410
- <button onClick={loadData} className="px-3 py-1.5 rounded text-sm bg-white/5 hover:bg-white/10 inline-flex items-center gap-2">
575
+ <button onClick={loadData} className="min-h-11 px-3 py-1.5 rounded text-sm bg-white/5 hover:bg-white/10 inline-flex items-center gap-2">
411
576
  <RefreshCw className="h-4 w-4" /> Refresh
412
577
  </button>
413
578
  </div>
414
579
  </div>
415
580
 
416
- <div className="flex gap-6">
581
+ {scopeId && (
582
+ <div className="mb-3 rounded-xl border border-yellow-400/30 bg-yellow-400/5 px-3 py-2 text-sm">
583
+ {/* min-w-0 on the growing child, so a long chronicle id truncates
584
+ instead of pushing the depth control and the exit off-screen —
585
+ the banner clipped its own warning at this width before. */}
586
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
587
+ <span className="min-w-0 flex-1 text-gray-300">
588
+ <strong className="text-yellow-300">
589
+ {scopeMeta?.count ?? graph.nodes.length}
590
+ </strong>{" "}
591
+ nodes, {scopeDepth} {scopeDepth === 1 ? "hop" : "hops"} from{" "}
592
+ <span className="text-gray-100">{scopeMeta?.rootName ?? scopeId}</span>
593
+ </span>
594
+ <label className="inline-flex shrink-0 items-center gap-1.5 text-xs text-gray-400">
595
+ Depth
596
+ <select
597
+ value={scopeDepth}
598
+ onChange={(e) => setScopeDepth(Number(e.target.value))}
599
+ className="rounded bg-white/5 px-1.5 py-0.5 text-gray-200"
600
+ aria-label="Neighbourhood depth"
601
+ >
602
+ {[1, 2, 3, 4, 5, 6].map((d) => (
603
+ <option key={d} value={d}>
604
+ {d}
605
+ </option>
606
+ ))}
607
+ </select>
608
+ </label>
609
+ <button
610
+ onClick={() => setScopeId(null)}
611
+ className="min-h-8 shrink-0 rounded bg-white/5 px-2 py-1 text-xs hover:bg-white/10 inline-flex items-center gap-1.5"
612
+ >
613
+ <X className="h-3.5 w-3.5" /> Whole wheel
614
+ <span className="text-gray-500">(Esc)</span>
615
+ </button>
616
+ </div>
617
+
618
+ {/* Say what was held back. A neighbourhood that quietly stopped at a
619
+ container is the same class of lie as a list that quietly
620
+ stopped at 100 rows. */}
621
+ {(scopeMeta?.hubs?.length ?? 0) > 0 && (
622
+ <p className="mt-1.5 text-xs text-gray-500">
623
+ {scopeMeta!.hubs.map((h) => (
624
+ <span key={h.id} className="mr-3 inline-block">
625
+ <span className="text-gray-400">{h.name}</span> holds {h.degree} relations —
626
+ shown, not expanded{h.unexpanded > 0 ? ` (${h.unexpanded} beyond it)` : ""}
627
+ </span>
628
+ ))}
629
+ </p>
630
+ )}
631
+ {scopeMeta?.truncated && (
632
+ <p className="mt-1 text-xs text-gray-500">
633
+ stopped at the depth limit — there is more further out
634
+ </p>
635
+ )}
636
+ </div>
637
+ )}
638
+
639
+ <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
417
640
  <div className="relative flex-1 rounded-xl border border-white/10 overflow-hidden">
418
641
  {!loading && graph.nodes.length > 0 && (
419
642
  <label className="absolute right-3 top-3 z-10 inline-flex items-center gap-2 rounded-md border border-white/10 bg-[#0a0a1a]/80 px-2 py-1 text-[11px] text-gray-300 shadow-sm backdrop-blur">
@@ -428,9 +651,9 @@ export default function GraphPage() {
428
651
  </label>
429
652
  )}
430
653
  {loading ? (
431
- <div className="flex items-center justify-center h-[600px] text-gray-500">Loading graph data...</div>
654
+ <div className="flex items-center justify-center text-gray-500" style={{ height: graphHeight }}>Loading graph data...</div>
432
655
  ) : graph.nodes.length === 0 ? (
433
- <div className="flex flex-col items-center justify-center h-[600px] text-gray-500">
656
+ <div className="flex flex-col items-center justify-center text-gray-500" style={{ height: graphHeight }}>
434
657
  <span className="text-4xl mb-3">🌀</span>
435
658
  <p>No relational nodes yet.</p>
436
659
  <p className="text-sm mt-1">Create nodes via the Nodes page.</p>
@@ -438,12 +661,16 @@ export default function GraphPage() {
438
661
  ) : (
439
662
  <MedicineWheelFlowGraph
440
663
  data={graphData}
441
- height={600}
664
+ height={graphHeight}
442
665
  darkMode
443
- showNodeLabels={showLabels}
666
+ showNodeLabels={labelsVisible}
667
+ showMiniMap={!isMobile}
444
668
  animationsEnabled={animationsEnabled}
445
669
  nodePositions={activeLayout.positions}
446
- onNodeClick={(node) => setSelectedNode(node)}
670
+ onNodeClick={(node) => {
671
+ setSelectedNode(node);
672
+ if (isMobile) setSheetOpen(true);
673
+ }}
447
674
  onNodeDoubleClick={navigateToNode}
448
675
  onNodePositionsChange={handleNodePositionsChange}
449
676
  enableConnections
@@ -458,7 +685,53 @@ export default function GraphPage() {
458
685
  )}
459
686
  </div>
460
687
 
461
- <div className="w-72 space-y-4">
688
+ {/* Mobile: floating toggle opens the panels as a slide-up sheet so
689
+ the canvas keeps the whole screen. */}
690
+ {isMobile && !sheetOpen && (
691
+ <button
692
+ onClick={() => setSheetOpen(true)}
693
+ className="fixed bottom-4 right-4 z-30 inline-flex min-h-11 max-w-[70vw] items-center gap-2 rounded-full border border-white/15 bg-[#12122a]/95 px-4 py-2 text-sm font-medium text-white shadow-lg backdrop-blur"
694
+ aria-label="Open graph panels"
695
+ >
696
+ <SlidersHorizontal className="h-4 w-4 shrink-0" />
697
+ <span className="truncate">{selectedNode ? selectedNode.label : "Panels"}</span>
698
+ </button>
699
+ )}
700
+
701
+ {/* Mobile: dim backdrop behind the open sheet. */}
702
+ {isMobile && sheetOpen && (
703
+ <div
704
+ onClick={() => setSheetOpen(false)}
705
+ className="fixed inset-0 z-40 bg-black/60"
706
+ aria-hidden="true"
707
+ />
708
+ )}
709
+
710
+ <div
711
+ className={cn(
712
+ isMobile
713
+ ? cn(
714
+ "fixed inset-x-0 bottom-0 z-50 max-h-[82vh] overflow-y-auto rounded-t-2xl border-t border-white/10 bg-[#0a0a1a] px-4 pb-8 shadow-2xl transition-transform duration-300 ease-out",
715
+ sheetOpen ? "translate-y-0" : "translate-y-full",
716
+ )
717
+ : "w-72",
718
+ )}
719
+ aria-hidden={isMobile && !sheetOpen ? "true" : undefined}
720
+ >
721
+ {isMobile && (
722
+ <div className="sticky top-0 z-10 -mx-4 mb-3 flex items-center justify-between gap-2 border-b border-white/10 bg-[#0a0a1a] px-4 pb-3 pt-3">
723
+ <span className="text-sm font-semibold text-gray-300">Graph panels</span>
724
+ <button
725
+ onClick={() => setSheetOpen(false)}
726
+ aria-label="Close panels"
727
+ className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-white/10 text-white hover:bg-white/15"
728
+ >
729
+ <X className="h-5 w-5" />
730
+ </button>
731
+ </div>
732
+ )}
733
+
734
+ <div className="space-y-4">
462
735
  <div className="rounded-lg border border-white/10 p-4">
463
736
  <h3 className="text-sm font-semibold text-gray-400 mb-3">Dispositions</h3>
464
737
  <div className="space-y-3">
@@ -539,13 +812,21 @@ export default function GraphPage() {
539
812
  </div>
540
813
 
541
814
  {selectedNode && (
542
- <div className="rounded-xl border border-white/10 p-4">
815
+ <div ref={selectedPanelRef} className="rounded-xl border border-white/10 p-4 scroll-mt-16">
543
816
  <h3 className="text-sm font-semibold text-gray-400 mb-2">Selected Node</h3>
544
817
  <div className="space-y-2">
545
818
  <div><span className="text-xs text-gray-500">Name</span><p className="font-medium">{selectedNode.label}</p></div>
546
819
  <div><span className="text-xs text-gray-500">Type</span><p className="capitalize">{selectedNode.type}</p></div>
547
820
  {selectedNode.direction && <div><span className="text-xs text-gray-500">Direction</span><p className="capitalize">{selectedNode.direction}</p></div>}
548
821
  <div><span className="text-xs text-gray-500">ID</span><p className="text-xs text-gray-400 font-mono break-all">{selectedNode.id}</p></div>
822
+ {scopeId !== selectedNode.id && (
823
+ <button
824
+ onClick={() => setScopeId(selectedNode.id)}
825
+ className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-yellow-400/15 px-3 py-2 text-sm text-yellow-200 hover:bg-yellow-400/25"
826
+ >
827
+ <Route className="h-4 w-4" /> Show only its neighbourhood
828
+ </button>
829
+ )}
549
830
  <Link
550
831
  href={`/nodes?node=${encodeURIComponent(selectedNode.id)}`}
551
832
  className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/15"
@@ -557,14 +838,31 @@ export default function GraphPage() {
557
838
  )}
558
839
 
559
840
  <div className="rounded-xl border border-white/10 p-4">
560
- <h3 className="text-sm font-semibold text-gray-400 mb-3">Graph Stats</h3>
841
+ <h3 className="text-sm font-semibold text-gray-400 mb-3">
842
+ Graph Stats
843
+ {/* This panel read "100 Nodes / 100 Relations" against a store of
844
+ 205 and 191 until 2026-09-03, because the page fetched without
845
+ a limit and took the provider's default page in silence. A
846
+ count with nothing to compare it to cannot be doubted, which
847
+ is what made it a lie rather than a number. */}
848
+ {scopeId && <span className="ml-2 font-normal text-yellow-300/80">scoped</span>}
849
+ </h3>
561
850
  <div className="grid grid-cols-2 gap-3 text-center">
562
- <div><p className="text-2xl font-bold">{graph.nodes.length}</p><p className="text-xs text-gray-500">Nodes</p></div>
851
+ <div>
852
+ <p className="text-2xl font-bold">{graph.nodes.length}</p>
853
+ <p className="text-xs text-gray-500">
854
+ Nodes
855
+ {storeTotal !== null && graph.nodes.length < storeTotal && (
856
+ <span className="text-gray-600"> of {storeTotal}</span>
857
+ )}
858
+ </p>
859
+ </div>
563
860
  <div><p className="text-2xl font-bold">{graph.links.length}</p><p className="text-xs text-gray-500">Relations</p></div>
564
861
  <div><p className="text-2xl font-bold">{ceremoniedCount}</p><p className="text-xs text-gray-500">Ceremonied</p></div>
565
862
  <div><p className="text-2xl font-bold">{directionCount}</p><p className="text-xs text-gray-500">Directions</p></div>
566
863
  </div>
567
864
  </div>
865
+ </div>
568
866
  </div>
569
867
  </div>
570
868
  </div>
@@ -50,13 +50,16 @@ export default function NodesPage() {
50
50
  const loadNodes = useCallback(async () => {
51
51
  setLoading(true);
52
52
  try {
53
- const params = new URLSearchParams();
53
+ // The list is client-side paginated below, so it needs the whole store.
54
+ // Without this the route applies its 100-row default and the table silently
55
+ // ends at 100 of 205 with no page control to reveal the rest.
56
+ const params = new URLSearchParams({ limit: "all" });
54
57
  if (filterType !== "all") params.set("type", filterType);
55
58
  if (filterDirection !== "all") params.set("direction", filterDirection);
56
59
 
57
60
  const [nodesRes, edgesRes] = await Promise.all([
58
61
  fetch(`/api/nodes?${params.toString()}`),
59
- fetch("/api/edges"),
62
+ fetch("/api/edges?limit=all"),
60
63
  ]);
61
64
  const nodesResponse = await nodesRes.json();
62
65
  const edgesData: RelationalEdge[] = edgesRes.ok ? await edgesRes.json() : [];
@@ -64,7 +64,7 @@ export default function RelationsPage() {
64
64
  const loadData = useCallback(async () => {
65
65
  setLoading(true);
66
66
  try {
67
- const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes"), fetch("/api/edges")]);
67
+ const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes?limit=all"), fetch("/api/edges?limit=all")]);
68
68
  const nodesResponse = await nodesRes.json();
69
69
  const edgesData = await edgesRes.json();
70
70
  const nodesData: NodeRecord[] = Array.isArray(nodesResponse) ? nodesResponse : (nodesResponse.nodes || []);
@@ -13,6 +13,7 @@ import { WorkspacesPanel, WORKSPACES, type Workspace } from "@/components/worksp
13
13
  const NAV_ITEMS = [
14
14
  { href: "/", label: "Wheel", color: "var(--mw-east)" },
15
15
  { href: "/graph", label: "Graph", color: "var(--mw-primary)" },
16
+ { href: "/episodes", label: "Episodes", color: "var(--mw-node-ancestor)" },
16
17
  { href: "/nodes", label: "Nodes", color: "var(--mw-node-land)" },
17
18
  { href: "/relations", label: "Relations", color: "var(--mw-node-future)" },
18
19
  { href: "/ceremonies", label: "Ceremonies", color: "var(--mw-south)" },
@@ -47,22 +47,20 @@ export function WorkspacesPanel({
47
47
  return () => window.removeEventListener("keydown", onKey);
48
48
  }, [open, onClose]);
49
49
 
50
+ // Robustness: when closed, render nothing at all so the panel can never
51
+ // occupy layout space — not even if the CSS bundle that hides it (transform)
52
+ // fails to load. This is what keeps the switcher discreet on first paint.
53
+ if (!open) return null;
54
+
50
55
  return (
51
56
  <>
52
57
  <div
53
- aria-hidden={!open}
54
58
  onClick={onClose}
55
- className={cn(
56
- "fixed inset-0 z-40 bg-background/60 backdrop-blur-sm transition-opacity",
57
- open ? "opacity-100" : "pointer-events-none opacity-0"
58
- )}
59
+ className="fixed inset-0 z-40 bg-background/60 backdrop-blur-sm"
59
60
  />
60
61
  <aside
61
62
  aria-label="Workspaces"
62
- className={cn(
63
- "fixed left-0 top-0 z-50 h-full w-[340px] border-r border-border bg-card transition-transform",
64
- open ? "translate-x-0" : "-translate-x-full"
65
- )}
63
+ className="fixed left-0 top-0 z-50 h-full w-[340px] max-w-[85vw] border-r border-border bg-card"
66
64
  >
67
65
  <div className="flex items-center justify-between border-b border-border px-5 py-4">
68
66
  <div className="mw-h2">Workspaces</div>
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared paging contract for the wheel's read routes.
3
+ *
4
+ * The provider's `getAllNodes()` / `getAllEdges()` default to 100 rows. A bare
5
+ * `GET /api/nodes` therefore returned 100 of the store's 205 nodes, and the graph
6
+ * page — which sends no parameters — drew that window while its own panel read
7
+ * "100 Nodes / 100 Relations". Of the 100 it drew, 53 had no drawable edge,
8
+ * because 27 of the 100 delivered edges pointed at nodes that were never sent.
9
+ *
10
+ * Nothing was broken. Every layer did what it was told. The lie was that no layer
11
+ * ever said how much it had left out.
12
+ *
13
+ * So both routes accept the same `limit`, and both report enough for a caller to
14
+ * know it holds a page: `?limit=all` for the whole store, `?limit=<n>` for n.
15
+ * This lives in `lib/` rather than in either route because a second hand-written
16
+ * copy of a validation rule is how the two drift apart — the same defect this
17
+ * repo just removed from `PersonRole`.
18
+ */
19
+ import { NextResponse } from "next/server";
20
+
21
+ /** The provider's own default page size, restated so a route can report honestly. */
22
+ export const DEFAULT_PAGE_SIZE = 100;
23
+
24
+ /** Sentinel meaning "the whole store", distinct from "no limit given". */
25
+ export const LIMIT_ALL = "all";
26
+
27
+ /**
28
+ * Returns the requested page size, `null` for "everything", or a 400 response.
29
+ *
30
+ * An unparseable limit is refused rather than coerced. Falling back to the
31
+ * default on bad input would reintroduce the silent truncation this parameter
32
+ * exists to close, and would do it precisely when a caller had tried to ask.
33
+ */
34
+ export function parseLimit(raw: string | null): number | null | NextResponse {
35
+ if (raw === null || raw === "") return DEFAULT_PAGE_SIZE;
36
+ if (raw === LIMIT_ALL) return null;
37
+
38
+ const parsed = Number(raw);
39
+ if (!Number.isInteger(parsed) || parsed < 1) {
40
+ return NextResponse.json(
41
+ {
42
+ error: `Invalid limit: ${raw} — nothing was returned.`,
43
+ hint: `limit must be a positive integer, or "${LIMIT_ALL}" for the whole store.`,
44
+ },
45
+ { status: 400 },
46
+ );
47
+ }
48
+ return parsed;
49
+ }
@@ -50,6 +50,27 @@ export function normalizeCeremonyLog(value: unknown): CeremonyLog | null {
50
50
  };
51
51
  }
52
52
 
53
+ export function ceremonyEpisodePath(value: unknown): string | undefined {
54
+ if (!value || typeof value !== "object") return undefined;
55
+ const researchContext = (value as Record<string, unknown>).research_context;
56
+ if (typeof researchContext !== "string") return undefined;
57
+
58
+ try {
59
+ const parsed = JSON.parse(researchContext) as Record<string, unknown> | null;
60
+ if (!parsed || typeof parsed !== "object") return undefined;
61
+ const episodePath = parsed.episode_path ?? parsed.episodePath;
62
+ return typeof episodePath === "string" && episodePath.length > 0
63
+ ? episodePath
64
+ : undefined;
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ export function ceremonyBelongsToEpisode(value: unknown, episodePath: string): boolean {
71
+ return ceremonyEpisodePath(value) === episodePath;
72
+ }
73
+
53
74
  export function extractCeremonyLogs(response: unknown): CeremonyLog[] {
54
75
  const candidate = Array.isArray(response)
55
76
  ? response