@medicine-wheel/app 0.6.2 → 0.6.4

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.
@@ -0,0 +1,24 @@
1
+ import { NextResponse } from "next/server";
2
+ import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
3
+
4
+ type RouteContext = { params: Promise<{ id: string }> };
5
+
6
+ export async function GET(_request: Request, context: RouteContext) {
7
+ try {
8
+ const { id } = await context.params;
9
+ const store = await createProvider();
10
+ const ceremony = await store.getCeremony(id);
11
+
12
+ if (!ceremony) {
13
+ return NextResponse.json(
14
+ { error: `Ceremony not found: ${id}` },
15
+ { status: 404 },
16
+ );
17
+ }
18
+
19
+ return NextResponse.json({ ceremony, provider: detectProvider() });
20
+ } catch (error: unknown) {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ return NextResponse.json({ error: message }, { status: 500 });
23
+ }
24
+ }
@@ -1,14 +1,21 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
3
+ import { ceremonyBelongsToEpisode } from "@/lib/ceremony-response";
3
4
 
4
5
  export async function GET(request: Request) {
5
6
  try {
6
7
  const { searchParams } = new URL(request.url);
7
8
  const direction = searchParams.get("direction");
8
9
  const type = searchParams.get("type");
10
+ const episodePath = searchParams.get("episode_path");
9
11
 
10
12
  const store = await createProvider();
11
- let ceremonies = await store.getAllCeremonies();
13
+ // Provider list methods intentionally default to one page (100). A scoped
14
+ // episode query must search the complete ceremony store before filtering,
15
+ // or an older but valid ceremony silently disappears from its episode.
16
+ let ceremonies = await store.getAllCeremonies(
17
+ episodePath ? Number.MAX_SAFE_INTEGER : undefined,
18
+ );
12
19
 
13
20
  if (direction) {
14
21
  ceremonies = ceremonies.filter((c) => c.direction === direction);
@@ -18,6 +25,12 @@ export async function GET(request: Request) {
18
25
  ceremonies = ceremonies.filter((c) => c.type === type);
19
26
  }
20
27
 
28
+ if (episodePath) {
29
+ ceremonies = ceremonies.filter((ceremony) =>
30
+ ceremonyBelongsToEpisode(ceremony, episodePath),
31
+ );
32
+ }
33
+
21
34
  return NextResponse.json({
22
35
  ceremonies,
23
36
  provider: detectProvider(),
@@ -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,
@@ -109,6 +112,40 @@ export default function GraphPage() {
109
112
  );
110
113
  const layoutStoreRef = useRef(layoutStore);
111
114
 
115
+ // Mobile presentation: the side panel becomes a slide-up sheet so the
116
+ // canvas can own the screen, and the canvas height fills the viewport.
117
+ const [isMobile, setIsMobile] = useState(false);
118
+ const [sheetOpen, setSheetOpen] = useState(false);
119
+ const [graphHeight, setGraphHeight] = useState<number | string>(600);
120
+ const selectedPanelRef = useRef<HTMLDivElement>(null);
121
+
122
+ useEffect(() => {
123
+ const mq = window.matchMedia("(max-width: 1023px)");
124
+ const apply = () => {
125
+ setIsMobile(mq.matches);
126
+ setGraphHeight(
127
+ mq.matches ? Math.max(360, Math.round(window.innerHeight * 0.66)) : 600,
128
+ );
129
+ };
130
+ apply();
131
+ mq.addEventListener("change", apply);
132
+ window.addEventListener("resize", apply);
133
+ return () => {
134
+ mq.removeEventListener("change", apply);
135
+ window.removeEventListener("resize", apply);
136
+ };
137
+ }, []);
138
+
139
+ // When a being is tapped on mobile the sheet opens; bring its details into
140
+ // view once the slide-up transition settles.
141
+ useEffect(() => {
142
+ if (!isMobile || !sheetOpen || !selectedNode) return;
143
+ const t = window.setTimeout(() => {
144
+ selectedPanelRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
145
+ }, 320);
146
+ return () => window.clearTimeout(t);
147
+ }, [isMobile, sheetOpen, selectedNode]);
148
+
112
149
  const saveLayoutStore = useCallback(
113
150
  (
114
151
  nextStore: GraphLayoutStore,
@@ -380,21 +417,21 @@ export default function GraphPage() {
380
417
  );
381
418
 
382
419
  return (
383
- <div className="min-h-screen bg-[#0a0a1a] text-white p-6">
420
+ <div className="min-h-screen bg-[#0a0a1a] text-white p-3 sm:p-6">
384
421
  <div className="max-w-7xl mx-auto">
385
- <div className="flex items-center justify-between mb-6">
422
+ <div className="flex flex-col gap-3 mb-4 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
386
423
  <div>
387
- <h1 className="text-2xl font-bold flex items-center gap-2">
388
- <span className="text-3xl">🔮</span> Medicine Wheel Graph
424
+ <h1 className="text-xl sm:text-2xl font-bold flex items-center gap-2">
425
+ <span className="text-2xl sm:text-3xl">🔮</span> Medicine Wheel Graph
389
426
  </h1>
390
- <p className="text-gray-400 text-sm mt-1">
391
- Interactive relational web — drag, pan, zoom across the four directions
427
+ <p className="text-gray-400 text-xs sm:text-sm mt-1">
428
+ Interactive relational web — tap a being, drag, pan, and pinch to zoom
392
429
  </p>
393
430
  </div>
394
- <div className="flex gap-2">
431
+ <div className="flex flex-wrap gap-2">
395
432
  <button
396
433
  onClick={() => setShowLabels(!showLabels)}
397
- className={`px-3 py-1.5 rounded text-sm ${showLabels ? "bg-white/10" : "bg-white/5"}`}
434
+ className={`min-h-11 px-3 py-1.5 rounded text-sm ${showLabels ? "bg-white/10" : "bg-white/5"}`}
398
435
  >
399
436
  Labels {showLabels ? "ON" : "OFF"}
400
437
  </button>
@@ -402,18 +439,18 @@ export default function GraphPage() {
402
439
  onClick={() =>
403
440
  setRadialSnap((s) => (s === "off" ? "ring" : s === "ring" ? "sector" : "off"))
404
441
  }
405
- className={`px-3 py-1.5 rounded text-sm ${radialSnap !== "off" ? "bg-white/10" : "bg-white/5"}`}
442
+ className={`min-h-11 px-3 py-1.5 rounded text-sm ${radialSnap !== "off" ? "bg-white/10" : "bg-white/5"}`}
406
443
  title="Radial snapping: the wheel guides dragged beings — ring keeps them in their band, sector also inside their quadrant"
407
444
  >
408
445
  Snap {radialSnap === "off" ? "OFF" : radialSnap.toUpperCase()}
409
446
  </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">
447
+ <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
448
  <RefreshCw className="h-4 w-4" /> Refresh
412
449
  </button>
413
450
  </div>
414
451
  </div>
415
452
 
416
- <div className="flex gap-6">
453
+ <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
417
454
  <div className="relative flex-1 rounded-xl border border-white/10 overflow-hidden">
418
455
  {!loading && graph.nodes.length > 0 && (
419
456
  <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 +465,9 @@ export default function GraphPage() {
428
465
  </label>
429
466
  )}
430
467
  {loading ? (
431
- <div className="flex items-center justify-center h-[600px] text-gray-500">Loading graph data...</div>
468
+ <div className="flex items-center justify-center text-gray-500" style={{ height: graphHeight }}>Loading graph data...</div>
432
469
  ) : graph.nodes.length === 0 ? (
433
- <div className="flex flex-col items-center justify-center h-[600px] text-gray-500">
470
+ <div className="flex flex-col items-center justify-center text-gray-500" style={{ height: graphHeight }}>
434
471
  <span className="text-4xl mb-3">🌀</span>
435
472
  <p>No relational nodes yet.</p>
436
473
  <p className="text-sm mt-1">Create nodes via the Nodes page.</p>
@@ -438,12 +475,16 @@ export default function GraphPage() {
438
475
  ) : (
439
476
  <MedicineWheelFlowGraph
440
477
  data={graphData}
441
- height={600}
478
+ height={graphHeight}
442
479
  darkMode
443
480
  showNodeLabels={showLabels}
481
+ showMiniMap={!isMobile}
444
482
  animationsEnabled={animationsEnabled}
445
483
  nodePositions={activeLayout.positions}
446
- onNodeClick={(node) => setSelectedNode(node)}
484
+ onNodeClick={(node) => {
485
+ setSelectedNode(node);
486
+ if (isMobile) setSheetOpen(true);
487
+ }}
447
488
  onNodeDoubleClick={navigateToNode}
448
489
  onNodePositionsChange={handleNodePositionsChange}
449
490
  enableConnections
@@ -458,7 +499,53 @@ export default function GraphPage() {
458
499
  )}
459
500
  </div>
460
501
 
461
- <div className="w-72 space-y-4">
502
+ {/* Mobile: floating toggle opens the panels as a slide-up sheet so
503
+ the canvas keeps the whole screen. */}
504
+ {isMobile && !sheetOpen && (
505
+ <button
506
+ onClick={() => setSheetOpen(true)}
507
+ 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"
508
+ aria-label="Open graph panels"
509
+ >
510
+ <SlidersHorizontal className="h-4 w-4 shrink-0" />
511
+ <span className="truncate">{selectedNode ? selectedNode.label : "Panels"}</span>
512
+ </button>
513
+ )}
514
+
515
+ {/* Mobile: dim backdrop behind the open sheet. */}
516
+ {isMobile && sheetOpen && (
517
+ <div
518
+ onClick={() => setSheetOpen(false)}
519
+ className="fixed inset-0 z-40 bg-black/60"
520
+ aria-hidden="true"
521
+ />
522
+ )}
523
+
524
+ <div
525
+ className={cn(
526
+ isMobile
527
+ ? cn(
528
+ "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",
529
+ sheetOpen ? "translate-y-0" : "translate-y-full",
530
+ )
531
+ : "w-72",
532
+ )}
533
+ aria-hidden={isMobile && !sheetOpen ? "true" : undefined}
534
+ >
535
+ {isMobile && (
536
+ <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">
537
+ <span className="text-sm font-semibold text-gray-300">Graph panels</span>
538
+ <button
539
+ onClick={() => setSheetOpen(false)}
540
+ aria-label="Close panels"
541
+ className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-white/10 text-white hover:bg-white/15"
542
+ >
543
+ <X className="h-5 w-5" />
544
+ </button>
545
+ </div>
546
+ )}
547
+
548
+ <div className="space-y-4">
462
549
  <div className="rounded-lg border border-white/10 p-4">
463
550
  <h3 className="text-sm font-semibold text-gray-400 mb-3">Dispositions</h3>
464
551
  <div className="space-y-3">
@@ -539,7 +626,7 @@ export default function GraphPage() {
539
626
  </div>
540
627
 
541
628
  {selectedNode && (
542
- <div className="rounded-xl border border-white/10 p-4">
629
+ <div ref={selectedPanelRef} className="rounded-xl border border-white/10 p-4 scroll-mt-16">
543
630
  <h3 className="text-sm font-semibold text-gray-400 mb-2">Selected Node</h3>
544
631
  <div className="space-y-2">
545
632
  <div><span className="text-xs text-gray-500">Name</span><p className="font-medium">{selectedNode.label}</p></div>
@@ -565,6 +652,7 @@ export default function GraphPage() {
565
652
  <div><p className="text-2xl font-bold">{directionCount}</p><p className="text-xs text-gray-500">Directions</p></div>
566
653
  </div>
567
654
  </div>
655
+ </div>
568
656
  </div>
569
657
  </div>
570
658
  </div>
@@ -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>
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Medicine Wheel — Interactive visual layer for Indigenous relational research with Four Directions, ceremonies, and narrative arcs",
5
5
  "bin": {
6
6
  "mw": "dist/cli/mw.js",
@@ -85,29 +85,29 @@
85
85
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
86
86
  },
87
87
  "dependencies": {
88
- "@medicine-wheel/ceremonial-diary": "^0.6.2",
89
- "@medicine-wheel/ceremony-protocol": "^0.6.2",
90
- "@medicine-wheel/community-review": "^0.6.2",
91
- "@medicine-wheel/consent-lifecycle": "^0.6.2",
92
- "@medicine-wheel/creative-orientation": "^0.6.2",
93
- "@medicine-wheel/data-store": "^0.6.2",
94
- "@medicine-wheel/data-store-postgres": "^0.6.2",
95
- "@medicine-wheel/fire-keeper": "^0.6.2",
96
- "@medicine-wheel/github-ceremony": "^0.6.2",
97
- "@medicine-wheel/graph-viz": "^0.6.2",
98
- "@medicine-wheel/importance-unit": "^0.6.2",
99
- "@medicine-wheel/mcp": "^4.6.2",
100
- "@medicine-wheel/narrative-cluster": "^0.6.2",
101
- "@medicine-wheel/narrative-engine": "^0.6.2",
102
- "@medicine-wheel/ontology-core": "^0.6.2",
103
- "@medicine-wheel/perception-layer": "^0.6.2",
104
- "@medicine-wheel/prompt-decomposition": "^0.6.2",
105
- "@medicine-wheel/relational-index": "^0.6.2",
106
- "@medicine-wheel/relational-query": "^0.6.2",
107
- "@medicine-wheel/session-reader": "^0.6.2",
108
- "@medicine-wheel/storage-provider": "^0.6.2",
109
- "@medicine-wheel/transformation-tracker": "^0.6.2",
110
- "@medicine-wheel/ui-components": "^0.6.2",
88
+ "@medicine-wheel/ceremonial-diary": "^0.6.4",
89
+ "@medicine-wheel/ceremony-protocol": "^0.6.4",
90
+ "@medicine-wheel/community-review": "^0.6.4",
91
+ "@medicine-wheel/consent-lifecycle": "^0.6.4",
92
+ "@medicine-wheel/creative-orientation": "^0.6.4",
93
+ "@medicine-wheel/data-store": "^0.6.4",
94
+ "@medicine-wheel/data-store-postgres": "^0.6.4",
95
+ "@medicine-wheel/fire-keeper": "^0.6.4",
96
+ "@medicine-wheel/github-ceremony": "^0.6.4",
97
+ "@medicine-wheel/graph-viz": "^0.6.4",
98
+ "@medicine-wheel/importance-unit": "^0.6.4",
99
+ "@medicine-wheel/mcp": "^4.6.4",
100
+ "@medicine-wheel/narrative-cluster": "^0.6.4",
101
+ "@medicine-wheel/narrative-engine": "^0.6.4",
102
+ "@medicine-wheel/ontology-core": "^0.6.4",
103
+ "@medicine-wheel/perception-layer": "^0.6.4",
104
+ "@medicine-wheel/prompt-decomposition": "^0.6.4",
105
+ "@medicine-wheel/relational-index": "^0.6.4",
106
+ "@medicine-wheel/relational-query": "^0.6.4",
107
+ "@medicine-wheel/session-reader": "^0.6.4",
108
+ "@medicine-wheel/storage-provider": "^0.6.4",
109
+ "@medicine-wheel/transformation-tracker": "^0.6.4",
110
+ "@medicine-wheel/ui-components": "^0.6.4",
111
111
  "@neondatabase/serverless": "^0.10.0",
112
112
  "@xyflow/react": "^12.3.0",
113
113
  "clsx": "^2.1.1",