@medicine-wheel/app 0.4.12 β†’ 0.5.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.
@@ -3,11 +3,14 @@
3
3
  import { useCallback, useEffect, useState, Suspense, useMemo } from "react";
4
4
  import type { FormEvent } from "react";
5
5
  import { useSearchParams } from "next/navigation";
6
- import { type CeremonyLog, DIRECTION_COLORS, CEREMONY_ICONS, type DirectionName, type CeremonyType } from "@/lib/types";
6
+ import { type CeremonyLog, CEREMONY_ICONS, type DirectionName, type CeremonyType } from "@/lib/types";
7
7
  import { extractCeremonyLogs } from "@/lib/ceremony-response";
8
8
  import { relativeTime, absoluteTime } from "@/lib/format-time";
9
9
  import { toast } from "sonner";
10
10
 
11
+ const dirVar = (d: string) => `var(--mw-${d})`;
12
+ const dirInk = (d: string) => `var(--mw-${d}-ink)`;
13
+
11
14
  function CeremoniesContent() {
12
15
  const searchParams = useSearchParams();
13
16
  const [ceremonies, setCeremonies] = useState<CeremonyLog[]>([]);
@@ -58,7 +61,7 @@ function CeremoniesContent() {
58
61
  toast.success("Ceremony logged");
59
62
  setShowForm(false);
60
63
  await loadCeremonies();
61
- } else { toast.error("Failed to log ceremony"); }
64
+ } else { toast.error("Could not save the ceremony β€” check the form and try again"); }
62
65
  }
63
66
 
64
67
  const directions: DirectionName[] = ["east", "south", "west", "north"];
@@ -73,10 +76,10 @@ function CeremoniesContent() {
73
76
  }, [ceremonies]);
74
77
 
75
78
  const phaseFramings: Record<string, string> = {
76
- opening: "πŸ”₯ We are in the Opening phase β€” setting intentions and acknowledging relationships",
77
- council: "πŸ”₯ We are in the Council phase β€” deepening understanding through dialogue",
78
- integration: "πŸ”₯ We are in the Integration phase β€” weaving learnings together",
79
- closure: "πŸ”₯ We are in the Closure phase β€” honoring what has been shared",
79
+ opening: "We are in the Opening phase β€” setting intentions and acknowledging relationships",
80
+ council: "We are in the Council phase β€” deepening understanding through dialogue",
81
+ integration: "We are in the Integration phase β€” weaving learnings together",
82
+ closure: "We are in the Closure phase β€” honoring what has been shared",
80
83
  };
81
84
  const phases = ["opening", "council", "integration", "closure"];
82
85
  const nextPhase = phases[(phases.indexOf(currentPhase) + 1) % 4];
@@ -85,10 +88,10 @@ function CeremoniesContent() {
85
88
  <div className="p-6 max-w-5xl mx-auto">
86
89
  <div className="flex items-center justify-between mb-6">
87
90
  <div>
88
- <h1 className="text-2xl font-bold">Ceremonies</h1>
91
+ <h1 className="mw-h1">Ceremonies</h1>
89
92
  <p className="text-sm text-muted-foreground">{ceremonies.length} ceremony logs</p>
90
93
  </div>
91
- <button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium">+ Log Ceremony</button>
94
+ <button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">Log ceremony</button>
92
95
  </div>
93
96
 
94
97
  <div className="mb-6 p-4 rounded-lg border bg-card/50 backdrop-blur">
@@ -110,13 +113,13 @@ function CeremoniesContent() {
110
113
  <div className="flex gap-1">
111
114
  <button onClick={() => setFilterDir("all")} className={`px-3 py-1 rounded-full text-xs font-medium border ${filterDir === "all" ? "bg-secondary" : ""}`}>All</button>
112
115
  {directions.map((d) => (
113
- <button key={d} onClick={() => setFilterDir(d)} className={`px-3 py-1 rounded-full text-xs font-medium border capitalize ${filterDir === d ? "bg-secondary" : ""}`} style={filterDir === d ? { borderColor: DIRECTION_COLORS[d] } : {}}>
116
+ <button key={d} onClick={() => setFilterDir(d)} className={`px-3 py-1 rounded-full text-xs font-medium border capitalize hover:bg-secondary/60 ${filterDir === d ? "bg-secondary" : ""}`} style={filterDir === d ? { borderColor: dirVar(d) } : {}}>
114
117
  {d}
115
118
  </button>
116
119
  ))}
117
120
  </div>
118
121
  <select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-1 rounded-md border bg-background text-xs">
119
- <option value="all">All Types</option>
122
+ <option value="all">All types</option>
120
123
  {cTypes.map((t) => <option key={t} value={t}>{t.replace("_", " ")}</option>)}
121
124
  </select>
122
125
  {filtersActive && (
@@ -136,7 +139,7 @@ function CeremoniesContent() {
136
139
  <input name="medicines" placeholder="Medicines used (comma-separated)" className="px-3 py-2 rounded-md border bg-background text-sm" />
137
140
  <textarea name="intention" placeholder="Intention" rows={2} className="px-3 py-2 rounded-md border bg-background text-sm sm:col-span-2" />
138
141
  <input name="context" placeholder="Research context (optional)" className="px-3 py-2 rounded-md border bg-background text-sm" />
139
- <button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">Log Ceremony</button>
142
+ <button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:opacity-90">Log ceremony</button>
140
143
  </form>
141
144
  )}
142
145
 
@@ -144,13 +147,13 @@ function CeremoniesContent() {
144
147
  {loading && <div className="text-center py-12 text-muted-foreground">Loading ceremonies…</div>}
145
148
  {!loading && ceremonies.length === 0 && (
146
149
  <div className="text-center py-12 text-muted-foreground">
147
- <p>{filtersActive ? "No ceremonies match these filters." : "No ceremonies found. Log your first ceremony to begin."}</p>
150
+ <p>{filtersActive ? "No ceremonies match these filters." : "No ceremonies yet. Log the first one to open the circle."}</p>
148
151
  {filtersActive && <button onClick={clearFilters} className="text-sm mt-2 underline hover:text-foreground">Clear filters</button>}
149
152
  </div>
150
153
  )}
151
154
  {!loading && ceremonies.map((c) => (
152
155
  <div key={c.id} className="border rounded-lg bg-card overflow-hidden cursor-pointer hover:border-ring/50 transition-colors"
153
- style={{ borderTopColor: DIRECTION_COLORS[c.direction], borderTopWidth: 3 }} onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}>
156
+ style={{ borderTopColor: dirVar(c.direction), borderTopWidth: 3 }} onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}>
154
157
  <div className="p-4 flex items-center justify-between gap-3">
155
158
  <div className="flex items-center gap-3 min-w-0">
156
159
  <span className="text-2xl shrink-0">{CEREMONY_ICONS[c.type]}</span>
@@ -158,7 +161,7 @@ function CeremoniesContent() {
158
161
  <div className="flex items-center gap-2">
159
162
  <span className="font-medium text-sm capitalize">{c.type.replace("_", " ")}</span>
160
163
  <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium capitalize border"
161
- style={{ borderColor: DIRECTION_COLORS[c.direction], color: DIRECTION_COLORS[c.direction] }}>
164
+ style={{ borderColor: `var(--mw-${c.direction}-border)`, color: dirInk(c.direction) }}>
162
165
  {DIRECTION_ICONS[c.direction]} {c.direction}
163
166
  </span>
164
167
  </div>
package/app/globals.css CHANGED
@@ -1,44 +1,48 @@
1
- @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
2
1
  @import "tailwindcss";
3
2
  @import "../src/ui-components/src/tokens.css";
4
3
 
4
+ /*
5
+ * Tailwind theme mirrors tokens.css (the source of truth).
6
+ * Fonts arrive via next/font in app/layout.tsx as
7
+ * --font-fraunces / --font-inter / --font-jbmono.
8
+ */
5
9
  @theme inline {
6
- --color-background: oklch(0.145 0 0);
7
- --color-foreground: oklch(0.985 0 0);
8
- --color-card: oklch(0.185 0 0);
9
- --color-card-foreground: oklch(0.985 0 0);
10
- --color-popover: oklch(0.185 0 0);
11
- --color-popover-foreground: oklch(0.985 0 0);
12
- --color-primary: oklch(0.785 0.115 85);
13
- --color-primary-foreground: oklch(0.145 0 0);
14
- --color-secondary: oklch(0.269 0 0);
15
- --color-secondary-foreground: oklch(0.985 0 0);
16
- --color-muted: oklch(0.269 0 0);
17
- --color-muted-foreground: oklch(0.708 0 0);
18
- --color-accent: oklch(0.269 0 0);
19
- --color-accent-foreground: oklch(0.985 0 0);
20
- --color-destructive: oklch(0.396 0.141 25.723);
21
- --color-destructive-foreground: oklch(0.985 0 0);
22
- --color-border: oklch(0.3 0 0);
23
- --color-input: oklch(0.3 0 0);
24
- --color-ring: oklch(0.785 0.115 85);
10
+ --color-background: var(--mw-bg);
11
+ --color-foreground: var(--mw-fg);
12
+ --color-card: var(--mw-card);
13
+ --color-card-foreground: var(--mw-fg);
14
+ --color-popover: var(--mw-card);
15
+ --color-popover-foreground: var(--mw-fg);
16
+ --color-primary: var(--mw-primary);
17
+ --color-primary-foreground: var(--mw-primary-fg);
18
+ --color-secondary: var(--mw-secondary);
19
+ --color-secondary-foreground: var(--mw-fg);
20
+ --color-muted: var(--mw-secondary);
21
+ --color-muted-foreground: var(--mw-muted);
22
+ --color-accent: var(--mw-secondary);
23
+ --color-accent-foreground: var(--mw-fg);
24
+ --color-destructive: var(--mw-destructive);
25
+ --color-destructive-foreground: var(--mw-fg);
26
+ --color-border: var(--mw-border);
27
+ --color-input: var(--mw-border);
28
+ --color-ring: var(--mw-ring);
25
29
  --radius: 0.625rem;
26
30
 
27
- --color-east: oklch(0.85 0.15 85);
28
- --color-south: oklch(0.55 0.22 25);
29
- --color-west: oklch(0.25 0.05 270);
30
- --color-north: oklch(0.92 0.01 0);
31
+ --color-east: var(--mw-east);
32
+ --color-south: var(--mw-south);
33
+ --color-west: var(--mw-west);
34
+ --color-north: var(--mw-north);
31
35
 
32
- --color-node-human: oklch(0.72 0.15 55);
33
- --color-node-land: oklch(0.6 0.16 145);
34
- --color-node-spirit: oklch(0.6 0.18 300);
35
- --color-node-ancestor: oklch(0.48 0.09 55); /* deep copper β€” #8a5a2a */
36
- --color-node-future: oklch(0.7 0.12 230);
37
- --color-node-knowledge: oklch(0.83 0.18 110); /* lime-gold β€” #c8d42a */
36
+ --color-node-human: var(--mw-node-human);
37
+ --color-node-land: var(--mw-node-land);
38
+ --color-node-spirit: var(--mw-node-spirit);
39
+ --color-node-ancestor: var(--mw-node-ancestor);
40
+ --color-node-future: var(--mw-node-future);
41
+ --color-node-knowledge: var(--mw-node-knowledge);
38
42
 
39
- --font-sans: 'Nunito', system-ui, -apple-system, sans-serif;
40
- --font-display: 'Stereohead', 'Nunito', system-ui, sans-serif;
41
- --font-mono: 'JetBrains Mono', ui-monospace, monospace;
43
+ --font-sans: var(--font-inter, 'Inter'), system-ui, -apple-system, sans-serif;
44
+ --font-display: var(--font-fraunces, 'Fraunces'), 'Iowan Old Style', Georgia, serif;
45
+ --font-mono: var(--font-jbmono, 'JetBrains Mono'), ui-monospace, monospace;
42
46
  }
43
47
 
44
48
  @layer base {
@@ -49,10 +53,44 @@
49
53
  @apply bg-background text-foreground;
50
54
  font-family: var(--font-sans);
51
55
  }
52
- h1, h2 {
56
+ /* Display face is reserved for page titles + home hero (h1 only) */
57
+ h1 {
53
58
  font-family: var(--font-display);
54
- font-weight: normal;
55
- letter-spacing: 0.01em;
59
+ font-weight: var(--weight-medium);
60
+ letter-spacing: -0.01em;
61
+ }
62
+ h2 {
63
+ font-family: var(--font-sans);
64
+ font-weight: var(--weight-semibold);
65
+ }
66
+ }
67
+
68
+ /* Navigation β€” item color arrives via --nav-c (a token var, set inline) */
69
+ @layer components {
70
+ .nav-item {
71
+ white-space: nowrap;
72
+ padding: 0.5rem 0.75rem;
73
+ border-radius: var(--radius-sm);
74
+ font-size: var(--text-sm);
75
+ font-weight: var(--weight-medium);
76
+ color: var(--color-muted-foreground);
77
+ transition: background var(--duration-fast) ease, color var(--duration-fast) ease;
78
+ }
79
+ .nav-item:hover {
80
+ color: var(--color-foreground);
81
+ background: color-mix(in srgb, var(--nav-c, var(--mw-muted)) 12%, transparent);
82
+ }
83
+ .nav-item--active {
84
+ color: var(--color-foreground);
85
+ background: var(--color-secondary);
86
+ box-shadow: inset 0 -2px 0 var(--nav-c, var(--mw-ring));
87
+ }
88
+ .nav-scroll {
89
+ overflow-x: auto;
90
+ scrollbar-width: none;
91
+ }
92
+ .nav-scroll::-webkit-scrollbar {
93
+ display: none;
56
94
  }
57
95
  }
58
96
 
@@ -66,3 +104,7 @@
66
104
  }
67
105
  .animate-pulse-center { animation: pulse-center 3s ease-in-out infinite; }
68
106
  .animate-glow { animation: glow 2s ease-in-out infinite; }
107
+
108
+ @media (prefers-reduced-motion: reduce) {
109
+ .animate-pulse-center, .animate-glow { animation: none; }
110
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Medicine Wheel flow theme β€” React Flow v12 internals tuned to the
3
+ * midnight-indigo ground. Imported by app/graph/page.tsx AFTER
4
+ * `@xyflow/react/dist/style.css`.
5
+ *
6
+ * Palette rules (design-direction 2607):
7
+ * - Direction color = information. Nothing here claims a direction.
8
+ * - Ceremony gold #FFD700 is reserved for ceremony-honored elements,
9
+ * so selection/interaction chrome uses neutral bone/slate tones.
10
+ */
11
+
12
+ .react-flow.dark {
13
+ --xy-background-color: var(--mw-bg, #0a0a1a);
14
+ --xy-background-pattern-dots-color: rgba(255, 255, 255, 0.1);
15
+
16
+ --xy-edge-stroke: #55516a;
17
+ --xy-edge-stroke-selected: var(--mw-north, #f7f5f0);
18
+
19
+ --xy-connectionline-stroke: var(--mw-north, #f7f5f0);
20
+
21
+ --xy-controls-button-background-color: rgba(255, 255, 255, 0.06);
22
+ --xy-controls-button-background-color-hover: rgba(255, 255, 255, 0.14);
23
+ --xy-controls-button-color: #cbd5e1;
24
+ --xy-controls-button-color-hover: #ffffff;
25
+ --xy-controls-button-border-color: rgba(255, 255, 255, 0.1);
26
+ --xy-controls-box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
27
+
28
+ --xy-minimap-background-color: #101020;
29
+
30
+ --xy-selection-background-color: rgba(247, 245, 240, 0.05);
31
+ --xy-selection-border: 1px dashed rgba(247, 245, 240, 0.45);
32
+
33
+ --xy-attribution-background-color: transparent;
34
+ }
35
+
36
+ /* Controls: quiet panel that reads as part of the instrument */
37
+ .react-flow.dark .react-flow__controls {
38
+ border-radius: 10px;
39
+ overflow: hidden;
40
+ backdrop-filter: blur(4px);
41
+ }
42
+
43
+ .react-flow.dark .react-flow__controls-button {
44
+ width: 28px;
45
+ height: 28px;
46
+ }
47
+
48
+ .react-flow.dark .react-flow__controls-button:focus-visible {
49
+ outline: 2px solid var(--mw-ring, #cbd5e1);
50
+ outline-offset: -2px;
51
+ }
52
+
53
+ /* MiniMap: rounded, bordered, round node dots */
54
+ .react-flow.dark .react-flow__minimap {
55
+ border-radius: 12px;
56
+ overflow: hidden;
57
+ border: 1px solid rgba(255, 255, 255, 0.1);
58
+ }
59
+
60
+ /* ── Weaving relations ─────────────────────────────────────────────── */
61
+
62
+ /* Cardinal source handles: hidden until the node is hovered/selected */
63
+ .react-flow__node .mw-node-handle {
64
+ width: 9px;
65
+ height: 9px;
66
+ border-radius: 9999px;
67
+ background: var(--mw-north, #f7f5f0);
68
+ border: 1.5px solid var(--mw-bg, #0a0a1a);
69
+ opacity: 0;
70
+ /* React Flow positions handles via transform β€” never animate it here. */
71
+ transition: opacity 150ms ease;
72
+ cursor: crosshair;
73
+ }
74
+
75
+ .react-flow__node:hover .mw-node-handle,
76
+ .react-flow__node.selected .mw-node-handle,
77
+ .react-flow__node:focus-visible .mw-node-handle {
78
+ opacity: 0.9;
79
+ }
80
+
81
+ .react-flow__node .mw-node-handle:hover {
82
+ opacity: 1;
83
+ }
84
+
85
+ /* The in-flight thread: dashed, neutral β€” it only turns gold if the
86
+ relation is later ceremony-honored. */
87
+ .react-flow.dark .react-flow__connectionline path {
88
+ stroke-dasharray: 8 4;
89
+ stroke-width: 1.75;
90
+ }
91
+
92
+ /* ── Context menus ─────────────────────────────────────────────────── */
93
+
94
+ .mw-context-menu-item:not(:disabled):hover {
95
+ background: rgba(255, 255, 255, 0.08);
96
+ }
97
+
98
+ /* ── Node hover & focus states ─────────────────────────────────────── */
99
+
100
+ .react-flow__node {
101
+ cursor: grab;
102
+ }
103
+
104
+ .react-flow__node.dragging {
105
+ cursor: grabbing;
106
+ }
107
+
108
+ .react-flow__node > div {
109
+ transition: transform 150ms ease;
110
+ }
111
+
112
+ .react-flow__node:hover > div {
113
+ transform: scale(1.06);
114
+ }
115
+
116
+ /* ── Hover neighbor-dimming: the circle holds its relations in light ── */
117
+
118
+ .mw-hovering .react-flow__node:not(.mw-neighbor) {
119
+ opacity: 0.22;
120
+ transition: opacity 200ms ease;
121
+ }
122
+
123
+ .react-flow__node {
124
+ transition: opacity 200ms ease;
125
+ }
126
+
127
+ /* ── Selection halo breath ─────────────────────────────────────────── */
128
+
129
+ @keyframes mw-halo-breathe {
130
+ 0%,
131
+ 100% {
132
+ opacity: 0.6;
133
+ }
134
+ 50% {
135
+ opacity: 0.95;
136
+ }
137
+ }
138
+
139
+ .mw-selection-halo {
140
+ animation: mw-halo-breathe 2.4s var(--ease-in-out, ease-in-out) infinite;
141
+ }
142
+
143
+ @media (prefers-reduced-motion: reduce) {
144
+ .mw-selection-halo {
145
+ animation: none;
146
+ }
147
+
148
+ .react-flow__node > div,
149
+ .react-flow__node,
150
+ .mw-hovering .react-flow__node:not(.mw-neighbor) {
151
+ transition: none;
152
+ }
153
+
154
+ .react-flow__node:hover > div {
155
+ transform: none;
156
+ }
157
+ }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import "@xyflow/react/dist/style.css";
4
+ import "./medicine-wheel-flow.css";
4
5
 
5
6
  import { useEffect, useState, useCallback, useMemo, useRef } from "react";
6
7
  import type { ChangeEvent } from "react";
@@ -22,6 +23,7 @@ import {
22
23
  applyWheelLayout,
23
24
  buildGraphData,
24
25
  type MWGraphData,
26
+ type MWGraphLink,
25
27
  type MWGraphNode,
26
28
  type MWGraphNodePositions,
27
29
  } from "@medicine-wheel/graph-viz";
@@ -85,10 +87,16 @@ function graphNodePositions(data: MWGraphData): MWGraphNodePositions {
85
87
  return positions;
86
88
  }
87
89
 
90
+ const DIRECTION_NAMES = ["east", "south", "west", "north"] as const;
91
+ type DirectionParam = (typeof DIRECTION_NAMES)[number];
92
+
88
93
  export default function GraphPage() {
89
94
  const router = useRouter();
90
95
  const [graph, setGraph] = useState<MWGraphData>({ nodes: [], links: [] });
91
96
  const [selectedNode, setSelectedNode] = useState<MWGraphNode | null>(null);
97
+ const [focusedNodeId, setFocusedNodeId] = useState<string | undefined>(undefined);
98
+ const [highlightDirection, setHighlightDirection] = useState<DirectionParam | undefined>(undefined);
99
+ const [radialSnap, setRadialSnap] = useState<"off" | "ring" | "sector">("off");
92
100
  const [loading, setLoading] = useState(true);
93
101
  const [showLabels, setShowLabels] = useState(true);
94
102
  const [animationsEnabled, setAnimationsEnabled] = useState(
@@ -153,6 +161,30 @@ export default function GraphPage() {
153
161
  setAnimationsEnabled(loadStoredGraphAnimationPreference());
154
162
  }, []);
155
163
 
164
+ // Deep links: ?node=<id> focuses a being; ?direction=<dir> keeps that
165
+ // quadrant's beings lit (arriving from the home wheel).
166
+ useEffect(() => {
167
+ const params = new URLSearchParams(window.location.search);
168
+ const node = params.get("node");
169
+ if (node) setFocusedNodeId(node);
170
+ const direction = params.get("direction");
171
+ if (direction && (DIRECTION_NAMES as readonly string[]).includes(direction)) {
172
+ setHighlightDirection(direction as DirectionParam);
173
+ }
174
+ }, []);
175
+
176
+ // Surface the deep-linked node in the side panel once data arrives.
177
+ useEffect(() => {
178
+ if (!focusedNodeId || graph.nodes.length === 0) return;
179
+ const node = graph.nodes.find((n) => n.id === focusedNodeId);
180
+ if (node) setSelectedNode(node);
181
+ }, [focusedNodeId, graph.nodes]);
182
+
183
+ const graphData = useMemo<MWGraphData>(
184
+ () => (focusedNodeId ? { ...graph, focusedNodeId } : graph),
185
+ [graph, focusedNodeId],
186
+ );
187
+
156
188
  useEffect(() => {
157
189
  layoutStoreRef.current = layoutStore;
158
190
  }, [layoutStore]);
@@ -233,6 +265,31 @@ export default function GraphPage() {
233
265
  [router],
234
266
  );
235
267
 
268
+ const handleRelationCreate = useCallback(
269
+ async (sourceId: string, targetId: string) => {
270
+ try {
271
+ const res = await fetch("/api/edges", {
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/json" },
274
+ body: JSON.stringify({
275
+ from_id: sourceId,
276
+ to_id: targetId,
277
+ relationship_type: "speaks-with",
278
+ strength: 0.5,
279
+ }),
280
+ });
281
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
282
+ toast.success("Relation woven β€” speaks-with");
283
+ } catch {
284
+ toast.error("Could not save the relation β€” restoring the canvas");
285
+ } finally {
286
+ // Reload canonical data: confirms the new thread or reverts it.
287
+ await loadData();
288
+ }
289
+ },
290
+ [loadData],
291
+ );
292
+
236
293
  const handleAnimationsEnabledChange = useCallback(
237
294
  (event: ChangeEvent<HTMLInputElement>) => {
238
295
  const enabled = event.target.checked;
@@ -245,6 +302,83 @@ export default function GraphPage() {
245
302
  [],
246
303
  );
247
304
 
305
+ const handleNodeCreateRequest = useCallback(
306
+ async (request: {
307
+ name: string;
308
+ type: string;
309
+ direction?: string;
310
+ position: { x: number; y: number };
311
+ }) => {
312
+ try {
313
+ const res = await fetch("/api/nodes", {
314
+ method: "POST",
315
+ headers: { "Content-Type": "application/json" },
316
+ body: JSON.stringify({
317
+ name: request.name,
318
+ type: request.type,
319
+ direction: request.direction,
320
+ }),
321
+ });
322
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
323
+ const { node } = await res.json();
324
+ // Seat the new being exactly where the circle was opened.
325
+ if (node?.id) {
326
+ saveLayoutStore(
327
+ upsertActiveGraphLayout(layoutStoreRef.current, {
328
+ [node.id]: request.position,
329
+ }),
330
+ );
331
+ }
332
+ toast.success(`${request.name} placed in the ${request.direction ?? "center"}`);
333
+ } catch {
334
+ toast.error("Could not create the node");
335
+ } finally {
336
+ await loadData();
337
+ }
338
+ },
339
+ [loadData, saveLayoutStore],
340
+ );
341
+
342
+ const handleEdgeCeremonyRequest = useCallback(
343
+ async (link: MWGraphLink) => {
344
+ try {
345
+ const res = await fetch(
346
+ `/api/edges?from=${encodeURIComponent(link.source)}&to=${encodeURIComponent(link.target)}`,
347
+ {
348
+ method: "PATCH",
349
+ headers: { "Content-Type": "application/json" },
350
+ body: JSON.stringify({ ceremony_honored: true }),
351
+ },
352
+ );
353
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
354
+ toast.success("Ceremony honored β€” the thread turns gold");
355
+ } catch {
356
+ toast.error("Could not honor the ceremony");
357
+ } finally {
358
+ await loadData();
359
+ }
360
+ },
361
+ [loadData],
362
+ );
363
+
364
+ const handleEdgeDeleteRequest = useCallback(
365
+ async (link: MWGraphLink) => {
366
+ try {
367
+ const res = await fetch(
368
+ `/api/edges?from=${encodeURIComponent(link.source)}&to=${encodeURIComponent(link.target)}`,
369
+ { method: "DELETE" },
370
+ );
371
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
372
+ toast.success("Relation released");
373
+ } catch {
374
+ toast.error("Could not release the relation");
375
+ } finally {
376
+ await loadData();
377
+ }
378
+ },
379
+ [loadData],
380
+ );
381
+
248
382
  return (
249
383
  <div className="min-h-screen bg-[#0a0a1a] text-white p-6">
250
384
  <div className="max-w-7xl mx-auto">
@@ -264,6 +398,15 @@ export default function GraphPage() {
264
398
  >
265
399
  Labels {showLabels ? "ON" : "OFF"}
266
400
  </button>
401
+ <button
402
+ onClick={() =>
403
+ setRadialSnap((s) => (s === "off" ? "ring" : s === "ring" ? "sector" : "off"))
404
+ }
405
+ className={`px-3 py-1.5 rounded text-sm ${radialSnap !== "off" ? "bg-white/10" : "bg-white/5"}`}
406
+ title="Radial snapping: the wheel guides dragged beings β€” ring keeps them in their band, sector also inside their quadrant"
407
+ >
408
+ Snap {radialSnap === "off" ? "OFF" : radialSnap.toUpperCase()}
409
+ </button>
267
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">
268
411
  <RefreshCw className="h-4 w-4" /> Refresh
269
412
  </button>
@@ -294,7 +437,7 @@ export default function GraphPage() {
294
437
  </div>
295
438
  ) : (
296
439
  <MedicineWheelFlowGraph
297
- data={graph}
440
+ data={graphData}
298
441
  height={600}
299
442
  darkMode
300
443
  showNodeLabels={showLabels}
@@ -303,6 +446,14 @@ export default function GraphPage() {
303
446
  onNodeClick={(node) => setSelectedNode(node)}
304
447
  onNodeDoubleClick={navigateToNode}
305
448
  onNodePositionsChange={handleNodePositionsChange}
449
+ enableConnections
450
+ onRelationCreate={handleRelationCreate}
451
+ onNodeOpen={navigateToNode}
452
+ onNodeCreateRequest={handleNodeCreateRequest}
453
+ onEdgeCeremonyRequest={handleEdgeCeremonyRequest}
454
+ onEdgeDeleteRequest={handleEdgeDeleteRequest}
455
+ highlightDirection={highlightDirection}
456
+ radialSnap={radialSnap}
306
457
  />
307
458
  )}
308
459
  </div>
package/app/layout.tsx CHANGED
@@ -1,17 +1,46 @@
1
1
  import type { Metadata } from "next";
2
+ import { Fraunces, Inter, JetBrains_Mono } from "next/font/google";
2
3
  import "./globals.css";
3
4
  import { ThemeProvider } from "@/components/theme-provider";
4
5
  import { Toaster } from "sonner";
5
6
  import { Navigation } from "@/components/navigation";
6
7
 
8
+ // Display face β€” page titles + home hero only (see design tokens).
9
+ const fraunces = Fraunces({
10
+ subsets: ["latin"],
11
+ variable: "--font-fraunces",
12
+ axes: ["opsz"],
13
+ });
14
+
15
+ const inter = Inter({
16
+ subsets: ["latin"],
17
+ variable: "--font-inter",
18
+ });
19
+
20
+ const jetbrainsMono = JetBrains_Mono({
21
+ subsets: ["latin"],
22
+ variable: "--font-jbmono",
23
+ });
24
+
7
25
  export const metadata: Metadata = {
8
- title: "Medicine Wheel β€” Relational Research",
9
- description: "Interactive visual layer for the Medicine Wheel β€” ceremonies, Four Directions, relational web, and narrative arcs",
26
+ title: {
27
+ default: "Medicine Wheel β€” Relational Research",
28
+ template: "%s Β· Medicine Wheel",
29
+ },
30
+ description:
31
+ "Interactive visual layer for the Medicine Wheel β€” ceremonies, Four Directions, relational web, and narrative arcs",
32
+ icons: {
33
+ icon: "/favicon.svg",
34
+ },
10
35
  };
11
36
 
12
37
  export default function RootLayout({ children }: { children: React.ReactNode }) {
13
38
  return (
14
- <html lang="en" suppressHydrationWarning>
39
+ <html
40
+ lang="en"
41
+ suppressHydrationWarning
42
+ className={`${fraunces.variable} ${inter.variable} ${jetbrainsMono.variable}`}
43
+ >
15
44
  <body className="font-sans antialiased">
16
45
  <ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} forcedTheme="dark">
17
46
  <Navigation />
@@ -0,0 +1,7 @@
1
+ import type { Metadata } from "next";
2
+
3
+ export const metadata: Metadata = { title: "Beats" };
4
+
5
+ export default function BeatsLayout({ children }: { children: React.ReactNode }) {
6
+ return children;
7
+ }