pmx-canvas 0.8.2 → 0.9.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 (338) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +601 -22
  3. package/CHANGELOG.md +59 -0
  4. package/Readme.md +131 -532
  5. package/dist/canvas/global.css +2724 -476
  6. package/dist/canvas/index.js +168 -107
  7. package/dist/canvas/mcp-app.html +2899 -596
  8. package/dist/json-render/index.js +20 -20
  9. package/dist/types/client/canvas/BarHint.d.ts +12 -2
  10. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
  11. package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
  12. package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
  13. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  14. package/dist/types/client/canvas/CommandBar.d.ts +3 -4
  15. package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
  16. package/dist/types/client/canvas/ContextChip.d.ts +3 -0
  17. package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
  19. package/dist/types/client/canvas/EmptyState.d.ts +4 -4
  20. package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
  21. package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
  22. package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
  23. package/dist/types/client/canvas/HomeView.d.ts +9 -7
  24. package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
  25. package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
  26. package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
  27. package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
  28. package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
  29. package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
  30. package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
  31. package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
  32. package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
  33. package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
  34. package/dist/types/client/canvas/canvas-area.d.ts +1 -1
  35. package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
  36. package/dist/types/client/canvas/graph-model.d.ts +37 -0
  37. package/dist/types/client/canvas/memory-layout.d.ts +23 -0
  38. package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
  39. package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
  40. package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
  41. package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
  42. package/dist/types/client/icons.d.ts +0 -10
  43. package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
  44. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
  45. package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
  46. package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
  47. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
  48. package/dist/types/client/state/boards-store.d.ts +81 -9
  49. package/dist/types/client/state/canvas-store.d.ts +14 -1
  50. package/dist/types/client/state/context-chip-store.d.ts +22 -0
  51. package/dist/types/client/state/context-status-store.d.ts +35 -0
  52. package/dist/types/client/state/human-store.d.ts +2 -1
  53. package/dist/types/client/state/intent-bridge.d.ts +11 -8
  54. package/dist/types/client/state/knowledge-store.d.ts +22 -0
  55. package/dist/types/client/state/near-pin-store.d.ts +44 -1
  56. package/dist/types/client/state/presence-store.d.ts +4 -4
  57. package/dist/types/client/state/presentation.d.ts +28 -0
  58. package/dist/types/client/state/relation-store.d.ts +10 -0
  59. package/dist/types/client/state/session-store.d.ts +41 -2
  60. package/dist/types/client/state/workspace-settings.d.ts +7 -0
  61. package/dist/types/client/types.d.ts +13 -1
  62. package/dist/types/client/utils/keyboard.d.ts +2 -0
  63. package/dist/types/client/utils/time-ago.d.ts +4 -0
  64. package/dist/types/mcp/context-uri-template.d.ts +3 -1
  65. package/dist/types/server/agent-presence.d.ts +10 -3
  66. package/dist/types/server/ax-context.d.ts +1 -1
  67. package/dist/types/server/canvas-db.d.ts +30 -7
  68. package/dist/types/server/canvas-operations.d.ts +24 -2
  69. package/dist/types/server/canvas-provenance.d.ts +22 -2
  70. package/dist/types/server/canvas-serialization.d.ts +2 -0
  71. package/dist/types/server/canvas-state.d.ts +93 -15
  72. package/dist/types/server/canvas-validation.d.ts +1 -1
  73. package/dist/types/server/context-brief.d.ts +94 -6
  74. package/dist/types/server/context-reads.d.ts +33 -7
  75. package/dist/types/server/edit-summary.d.ts +20 -0
  76. package/dist/types/server/index.d.ts +71 -10
  77. package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
  78. package/dist/types/server/lifecycle-status.d.ts +10 -0
  79. package/dist/types/server/memory-graph.d.ts +9 -0
  80. package/dist/types/server/mutation-history.d.ts +2 -0
  81. package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
  82. package/dist/types/server/operations/ops/boards.d.ts +2 -1
  83. package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
  84. package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
  85. package/dist/types/server/operations/ops/nodes.d.ts +5 -0
  86. package/dist/types/server/operations/ops/query.d.ts +23 -0
  87. package/dist/types/server/operations/registry.d.ts +1 -0
  88. package/dist/types/server/placement.d.ts +7 -0
  89. package/dist/types/server/scope-fence.d.ts +2 -1
  90. package/dist/types/server/spatial-analysis.d.ts +6 -3
  91. package/dist/types/server/tour-control.d.ts +32 -0
  92. package/dist/types/server/wiki-links.d.ts +14 -0
  93. package/dist/types/server/workbench-auth.d.ts +2 -0
  94. package/dist/types/shared/agent-context.d.ts +2 -0
  95. package/dist/types/shared/agent-presence.d.ts +15 -12
  96. package/dist/types/shared/auto-arrange.d.ts +1 -0
  97. package/dist/types/shared/boards.d.ts +4 -4
  98. package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
  99. package/dist/types/shared/knowledge-presets.d.ts +6 -0
  100. package/dist/types/shared/lifecycle-status.d.ts +24 -0
  101. package/dist/types/shared/memory-graph.d.ts +53 -0
  102. package/dist/types/shared/relations.d.ts +68 -0
  103. package/dist/types/shared/semantic-attention.d.ts +2 -0
  104. package/dist/types/shared/tour.d.ts +38 -0
  105. package/dist/types/shared/workspace-settings.d.ts +11 -0
  106. package/docs/RELEASE.md +21 -3
  107. package/docs/adr-001-bun-only-runtime.md +7 -1
  108. package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
  109. package/docs/api-reference.md +109 -0
  110. package/docs/ax-host-adapter-contract.md +1 -1
  111. package/docs/ax-state-contract.md +5 -4
  112. package/docs/cli.md +153 -15
  113. package/docs/demos/pmx-canvas-demo.mp4 +0 -0
  114. package/docs/design/AgentContext.dc.html +3 -3
  115. package/docs/design/BoardPins.dc.html +37 -22
  116. package/docs/design/Categories.dc.html +208 -0
  117. package/docs/design/CategoryEditor.dc.html +33 -0
  118. package/docs/design/Chrome.dc.html +52 -9
  119. package/docs/design/ContextChip.dc.html +224 -0
  120. package/docs/design/ContextChip600.dc.html +136 -0
  121. package/docs/design/EmptyState.dc.html +45 -0
  122. package/docs/design/Flows.dc.html +23 -1
  123. package/docs/design/HeaderMarks.dc.html +183 -0
  124. package/docs/design/Home.dc.html +19 -1
  125. package/docs/design/HomeGraph.dc.html +86 -202
  126. package/docs/design/HomeMap.dc.html +23 -1
  127. package/docs/design/HomeTeam.dc.html +32 -14
  128. package/docs/design/Lifecycle.dc.html +22 -0
  129. package/docs/design/Lifecycle600.dc.html +18 -0
  130. package/docs/design/LifecycleVariants.dc.html +157 -0
  131. package/docs/design/LinkAuthorship.dc.html +139 -0
  132. package/docs/design/LinksChanged.dc.html +203 -0
  133. package/docs/design/LinksOptions.dc.html +137 -0
  134. package/docs/design/LinksOptions600.dc.html +134 -0
  135. package/docs/design/NearPin.dc.html +9 -10
  136. package/docs/design/Pane600.dc.html +4 -3
  137. package/docs/design/Relations.dc.html +3 -2
  138. package/docs/design/Restore.dc.html +152 -0
  139. package/docs/design/SeeChange.dc.html +267 -0
  140. package/docs/design/Uses.dc.html +12 -6
  141. package/docs/design/canvas.json +146 -19
  142. package/docs/design.md +449 -16
  143. package/docs/environment.md +0 -1
  144. package/docs/evals/curation-effect.md +128 -0
  145. package/docs/http-api.md +459 -37
  146. package/docs/mcp.md +146 -18
  147. package/docs/node-types.md +58 -3
  148. package/docs/plans/design-gaps-2026-10-08.md +53 -0
  149. package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
  150. package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
  151. package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
  152. package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
  153. package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
  154. package/docs/product-context-vision-2026-09.md +17 -1
  155. package/docs/product-vision-2026-09.md +1082 -43
  156. package/docs/product-vision-review-2026-09.md +13 -1
  157. package/docs/screenshots/workbench-live-dark.png +0 -0
  158. package/docs/screenshots/workbench-live-light.png +0 -0
  159. package/docs/sdk.md +62 -2
  160. package/package.json +1 -1
  161. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  162. package/plugins/pmx-canvas/plugin.json +1 -1
  163. package/plugins/pmx-canvas/runtime.json +1 -1
  164. package/plugins/pmx-canvas/tools.json +601 -22
  165. package/skills/pmx-canvas/SKILL.md +216 -42
  166. package/skills/pmx-canvas/evals/evals.json +224 -0
  167. package/skills/pmx-canvas/references/full-reference.md +120 -21
  168. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
  169. package/skills/pmx-canvas/references/importing-context.md +132 -0
  170. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  171. package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
  172. package/skills/pmx-canvas-testing/SKILL.md +20 -0
  173. package/src/cli/agent.ts +2 -0
  174. package/src/cli/commands/ax.ts +12 -0
  175. package/src/cli/commands/boards.ts +88 -11
  176. package/src/cli/commands/edges.ts +28 -0
  177. package/src/cli/commands/groups.ts +7 -0
  178. package/src/cli/commands/nodes.ts +1 -0
  179. package/src/cli/commands/query.ts +34 -2
  180. package/src/cli/commands/record.ts +199 -48
  181. package/src/cli/index.ts +6 -1
  182. package/src/client/App.tsx +177 -70
  183. package/src/client/canvas/AttentionHistory.tsx +0 -2
  184. package/src/client/canvas/BarHint.tsx +40 -2
  185. package/src/client/canvas/BoardCopyDialog.tsx +1 -1
  186. package/src/client/canvas/BoardFolderDialog.tsx +7 -7
  187. package/src/client/canvas/BoardLinkDialog.tsx +138 -33
  188. package/src/client/canvas/BoardPinButton.tsx +33 -0
  189. package/src/client/canvas/BoardSwitcher.tsx +26 -40
  190. package/src/client/canvas/CanvasNode.tsx +360 -237
  191. package/src/client/canvas/CanvasViewport.tsx +45 -7
  192. package/src/client/canvas/CommandBar.tsx +29 -23
  193. package/src/client/canvas/CommandPalette.tsx +5 -12
  194. package/src/client/canvas/ConnectionBanner.tsx +3 -3
  195. package/src/client/canvas/ContextChip.tsx +296 -0
  196. package/src/client/canvas/ContextMenu.tsx +63 -5
  197. package/src/client/canvas/CrossBoardReferences.tsx +60 -0
  198. package/src/client/canvas/EdgeLayer.tsx +339 -76
  199. package/src/client/canvas/EmptyState.tsx +33 -18
  200. package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
  201. package/src/client/canvas/ExternalWriters.tsx +57 -42
  202. package/src/client/canvas/HomeGraphView.tsx +942 -0
  203. package/src/client/canvas/HomeMapView.tsx +641 -0
  204. package/src/client/canvas/HomeReviewView.tsx +167 -0
  205. package/src/client/canvas/HomeView.tsx +687 -160
  206. package/src/client/canvas/IntentLayer.tsx +19 -12
  207. package/src/client/canvas/KnowledgeDialog.tsx +488 -0
  208. package/src/client/canvas/LifecycleChrome.tsx +133 -0
  209. package/src/client/canvas/LifecycleFields.tsx +91 -0
  210. package/src/client/canvas/LifecyclePanel.tsx +543 -0
  211. package/src/client/canvas/MemoryInspector.tsx +104 -0
  212. package/src/client/canvas/Minimap.tsx +3 -3
  213. package/src/client/canvas/NearPinLayer.tsx +187 -0
  214. package/src/client/canvas/NodeContextMark.tsx +175 -97
  215. package/src/client/canvas/Presentation.tsx +23 -57
  216. package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
  217. package/src/client/canvas/RelationsPanel.tsx +82 -0
  218. package/src/client/canvas/SelectionBar.tsx +3 -6
  219. package/src/client/canvas/SessionPanel.tsx +90 -50
  220. package/src/client/canvas/SessionReceipt.tsx +311 -39
  221. package/src/client/canvas/ShortcutOverlay.tsx +1 -1
  222. package/src/client/canvas/SnapshotPanel.tsx +27 -36
  223. package/src/client/canvas/ToolRail.tsx +367 -192
  224. package/src/client/canvas/TopBar.tsx +354 -124
  225. package/src/client/canvas/canvas-area.ts +10 -3
  226. package/src/client/canvas/graph-geometry.ts +195 -0
  227. package/src/client/canvas/graph-model.ts +254 -0
  228. package/src/client/canvas/memory-layout.ts +106 -0
  229. package/src/client/canvas/use-chrome-height.ts +20 -0
  230. package/src/client/canvas/use-focus-trap.ts +31 -5
  231. package/src/client/canvas/use-memory-graph.ts +58 -0
  232. package/src/client/canvas/use-node-drag.ts +2 -1
  233. package/src/client/canvas/use-pan-zoom.ts +64 -7
  234. package/src/client/icons.tsx +0 -63
  235. package/src/client/mcp-app/style.css +0 -6
  236. package/src/client/nodes/AxStepControls.tsx +74 -50
  237. package/src/client/nodes/BoardNode.tsx +5 -1
  238. package/src/client/nodes/ContextNode.tsx +18 -11
  239. package/src/client/nodes/ExtAppFrame.tsx +6 -3
  240. package/src/client/nodes/FileNode.tsx +46 -42
  241. package/src/client/nodes/HtmlNode.tsx +32 -0
  242. package/src/client/nodes/ImageNode.tsx +1 -1
  243. package/src/client/nodes/InlineFormatBar.tsx +6 -9
  244. package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
  245. package/src/client/nodes/MarkdownNode.tsx +377 -109
  246. package/src/client/nodes/McpAppNode.tsx +50 -20
  247. package/src/client/nodes/MdFormatBar.tsx +21 -18
  248. package/src/client/nodes/PromptNode.tsx +1 -1
  249. package/src/client/nodes/ResponseNode.tsx +1 -1
  250. package/src/client/nodes/viewer-runtime-state.ts +54 -22
  251. package/src/client/state/attention-bridge.ts +64 -15
  252. package/src/client/state/boards-store.ts +193 -39
  253. package/src/client/state/canvas-store.ts +99 -12
  254. package/src/client/state/context-chip-store.ts +70 -0
  255. package/src/client/state/context-status-store.ts +53 -0
  256. package/src/client/state/human-store.ts +13 -7
  257. package/src/client/state/intent-bridge.ts +51 -30
  258. package/src/client/state/knowledge-store.ts +43 -0
  259. package/src/client/state/near-pin-store.ts +115 -6
  260. package/src/client/state/presence-store.ts +7 -10
  261. package/src/client/state/presentation.ts +91 -0
  262. package/src/client/state/relation-store.ts +81 -0
  263. package/src/client/state/session-store.ts +144 -18
  264. package/src/client/state/sse-bridge.ts +235 -32
  265. package/src/client/state/workspace-settings.ts +11 -0
  266. package/src/client/theme/global.css +2724 -476
  267. package/src/client/types.ts +12 -0
  268. package/src/client/utils/keyboard.ts +10 -0
  269. package/src/client/utils/time-ago.ts +14 -0
  270. package/src/json-render/renderer/index.tsx +31 -0
  271. package/src/mcp/canvas-app.ts +12 -2
  272. package/src/mcp/context-uri-template.ts +7 -4
  273. package/src/mcp/server.ts +63 -41
  274. package/src/mcp/workbench-app.ts +39 -6
  275. package/src/server/agent-presence.ts +128 -28
  276. package/src/server/ax-context.ts +27 -13
  277. package/src/server/board-export.ts +12 -1
  278. package/src/server/canvas-db.ts +331 -42
  279. package/src/server/canvas-operations.ts +195 -83
  280. package/src/server/canvas-provenance.ts +53 -22
  281. package/src/server/canvas-serialization.ts +2 -0
  282. package/src/server/canvas-state.ts +950 -122
  283. package/src/server/canvas-validation.ts +9 -2
  284. package/src/server/context-brief.ts +345 -38
  285. package/src/server/context-reads.ts +178 -30
  286. package/src/server/demo-state.json +355 -24
  287. package/src/server/edit-summary.ts +74 -0
  288. package/src/server/html-surface.ts +8 -1
  289. package/src/server/index.ts +181 -11
  290. package/src/server/knowledge-lifecycle.ts +304 -0
  291. package/src/server/lifecycle-status.ts +232 -0
  292. package/src/server/memory-graph.ts +166 -0
  293. package/src/server/mutation-history.ts +18 -6
  294. package/src/server/operations/composites.ts +32 -11
  295. package/src/server/operations/http.ts +7 -2
  296. package/src/server/operations/index.ts +4 -0
  297. package/src/server/operations/mcp.ts +26 -1
  298. package/src/server/operations/ops/ax-presence.ts +1 -1
  299. package/src/server/operations/ops/ax-read.ts +57 -6
  300. package/src/server/operations/ops/ax-timeline.ts +28 -2
  301. package/src/server/operations/ops/backup.ts +5 -3
  302. package/src/server/operations/ops/batch.ts +35 -4
  303. package/src/server/operations/ops/boards.ts +188 -47
  304. package/src/server/operations/ops/canvas-wire.ts +31 -1
  305. package/src/server/operations/ops/edges.ts +36 -2
  306. package/src/server/operations/ops/groups.ts +6 -1
  307. package/src/server/operations/ops/lifecycle.ts +262 -0
  308. package/src/server/operations/ops/memory-graph.ts +45 -0
  309. package/src/server/operations/ops/nodes.ts +107 -24
  310. package/src/server/operations/ops/query.ts +281 -68
  311. package/src/server/operations/ops/snapshots.ts +73 -1
  312. package/src/server/operations/ops/validate.ts +5 -1
  313. package/src/server/operations/ops/viewport.ts +97 -3
  314. package/src/server/operations/registry.ts +74 -25
  315. package/src/server/placement.ts +29 -0
  316. package/src/server/scope-fence.ts +78 -6
  317. package/src/server/server.ts +115 -16
  318. package/src/server/spatial-analysis.ts +48 -23
  319. package/src/server/tour-control.ts +82 -0
  320. package/src/server/wiki-links.ts +137 -0
  321. package/src/server/workbench-auth.ts +11 -0
  322. package/src/shared/agent-context.ts +3 -0
  323. package/src/shared/agent-presence.ts +15 -15
  324. package/src/shared/auto-arrange.ts +2 -0
  325. package/src/shared/boards.ts +4 -4
  326. package/src/shared/knowledge-lifecycle.ts +22 -0
  327. package/src/shared/knowledge-presets.ts +80 -0
  328. package/src/shared/lifecycle-status.ts +26 -0
  329. package/src/shared/memory-graph.ts +74 -0
  330. package/src/shared/relations.ts +98 -0
  331. package/src/shared/semantic-attention.ts +15 -1
  332. package/src/shared/tour.ts +23 -5
  333. package/src/shared/workspace-settings.ts +13 -0
  334. package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
  335. package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
  336. package/dist/types/client/canvas/kind-colors.d.ts +0 -3
  337. package/dist/types/server/agent-context.d.ts +0 -22
  338. package/src/client/canvas/ContextPinBar.tsx +0 -25
@@ -0,0 +1,942 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
2
+ import { memoryKey, type MemoryGraphEdge, type MemoryGraphNode } from '../../shared/memory-graph.js';
3
+ import { relationDefinition, relationText } from '../../shared/relations.js';
4
+ import { boardList, browsingMemory, openMemoryTarget } from '../state/boards-store';
5
+ import { isSelectionEscape } from '../utils/keyboard';
6
+ import { BoardPinButton } from './BoardPinButton';
7
+ import { MemoryInspector } from './MemoryInspector';
8
+ import { GRAPH_NODE, graphRoutes } from './graph-geometry';
9
+ import { graphEvidence, graphPositions, graphSceneKey, relaxGraphPositions, type GraphScene } from './graph-model';
10
+ import type { Point } from './memory-layout';
11
+ import { useMemoryGraph } from './use-memory-graph';
12
+
13
+ const linkNames = { portal: 'Portals', wiki: 'Wiki links', card: 'Card relations', previous: 'Previous boards' };
14
+ type LinkKind = keyof typeof linkNames;
15
+ type Camera = Point & { scale: number };
16
+ interface Frame {
17
+ scene: GraphScene;
18
+ camera: Camera | null;
19
+ query: string;
20
+ context: boolean;
21
+ folders: Set<string>;
22
+ hiddenLinks: Set<LinkKind>;
23
+ inspection: string | null;
24
+ limit: number;
25
+ }
26
+ interface GraphLink {
27
+ id: string;
28
+ from: string;
29
+ to: string;
30
+ edges: MemoryGraphEdge[];
31
+ bundle: boolean;
32
+ }
33
+ const newFrame = (scene: GraphScene): Frame => ({
34
+ scene,
35
+ camera: null,
36
+ query: '',
37
+ context: false,
38
+ folders: new Set(),
39
+ hiddenLinks: new Set(),
40
+ inspection: null,
41
+ limit: 80,
42
+ });
43
+
44
+ /** One explorer. Ownership defines scope; authored relationships define geometry. */
45
+ export function HomeGraphView() {
46
+ const boards = boardList.value;
47
+ const { graph, loading, error, retry } = useMemoryGraph('full');
48
+ const [frames, setFrames] = useState<Frame[]>([newFrame({ kind: 'overview' })]);
49
+ const frame = frames.at(-1)!;
50
+ const scene = frame.scene;
51
+ const sceneKey = graphSceneKey(scene);
52
+ const [sidebar, setSidebar] = useState(false);
53
+ const [reveal, setReveal] = useState(0);
54
+ const [layoutRevision, setLayoutRevision] = useState(0);
55
+ const [hover, setHover] = useState<string | null>(null);
56
+ const [notice, setNotice] = useState('');
57
+ const [stageSize, setStageSize] = useState({ width: 0, height: 0 });
58
+ const layouts = useRef(new Map<string, Map<string, Point>>());
59
+ const viewport = useRef<HTMLDivElement>(null);
60
+ const gesture = useRef<{
61
+ id: number;
62
+ start: Point;
63
+ origin: Point;
64
+ key?: string;
65
+ layout?: Map<string, Point>;
66
+ moved: boolean;
67
+ } | null>(null);
68
+ const motion = useRef<number | null>(null);
69
+ const motionTarget = useRef<Point | null>(null);
70
+ const stopMotion = () => {
71
+ if (motion.current !== null) cancelAnimationFrame(motion.current);
72
+ motion.current = null;
73
+ motionTarget.current = null;
74
+ };
75
+ const suppressClick = useRef(false);
76
+ const update = (patch: Partial<Frame>) => setFrames((old) => [...old.slice(0, -1), { ...old.at(-1)!, ...patch }]);
77
+ const back = () => {
78
+ setFrames((old) => (old.length > 1 ? old.slice(0, -1) : old));
79
+ setSidebar(false);
80
+ setHover(null);
81
+ setNotice('');
82
+ };
83
+ const go = (next: GraphScene) => {
84
+ if (graphSceneKey(next) === sceneKey) {
85
+ setReveal((value) => value + 1);
86
+ return;
87
+ }
88
+ setFrames((old) => [...old, newFrame(next)]);
89
+ setSidebar(false);
90
+ setHover(null);
91
+ setNotice('');
92
+ };
93
+ const breadcrumb = (next: GraphScene) => {
94
+ const index = frames.map((entry) => graphSceneKey(entry.scene)).lastIndexOf(graphSceneKey(next));
95
+ if (index >= 0) setFrames((old) => old.slice(0, index + 1));
96
+ else go(next);
97
+ setSidebar(false);
98
+ };
99
+ useLayoutEffect(() => {
100
+ browsingMemory.value = true;
101
+ }, []);
102
+ useLayoutEffect(() => {
103
+ const stage = viewport.current!;
104
+ const measure = () => setStageSize({ width: stage.clientWidth, height: stage.clientHeight });
105
+ const observer = new ResizeObserver(measure);
106
+ observer.observe(stage);
107
+ measure();
108
+ return () => observer.disconnect();
109
+ }, []);
110
+ useLayoutEffect(() => {
111
+ const key = (event: KeyboardEvent) => {
112
+ if (!isSelectionEscape(event)) return;
113
+ if (frame.inspection || frames.length > 1) {
114
+ event.preventDefault();
115
+ if (frame.inspection) update({ inspection: null });
116
+ else back();
117
+ }
118
+ };
119
+ window.addEventListener('keydown', key);
120
+ return () => window.removeEventListener('keydown', key);
121
+ }, [frame.inspection, frames.length]);
122
+ const nodes = useMemo(
123
+ () =>
124
+ (graph?.nodes ?? []).map((node) => {
125
+ const board = boards.find((entry) => entry.id === node.boardId);
126
+ return board
127
+ ? {
128
+ ...node,
129
+ folder: board.folder,
130
+ ...(node.kind === 'board'
131
+ ? { title: board.name, nodeCount: board.nodeCount, inContext: !!board.pin }
132
+ : {}),
133
+ }
134
+ : node;
135
+ }),
136
+ [graph, boards],
137
+ );
138
+ const byKey = useMemo(() => new Map(nodes.map((node) => [node.key, node])), [nodes]);
139
+ const facts = useMemo(() => graphEvidence(graph?.edges ?? []), [graph]);
140
+ const focusKey = scene.kind === 'overview' ? null : memoryKey(scene);
141
+ const focus = focusKey ? byKey.get(focusKey) : undefined;
142
+ const boardName = (id: string) => boards.find((board) => board.id === id)?.name ?? id;
143
+ const own = (node: MemoryGraphNode) => scene.kind !== 'overview' && node.boardId === scene.boardId;
144
+ const evidence =
145
+ scene.kind === 'overview'
146
+ ? facts.evidence
147
+ : facts.evidence.filter((edge) =>
148
+ scene.kind === 'board'
149
+ ? edge.source.boardId === scene.boardId || edge.target.boardId === scene.boardId
150
+ : edge.from === focusKey || edge.to === focusKey,
151
+ );
152
+ const scope = useMemo(() => {
153
+ if (scene.kind === 'overview')
154
+ return {
155
+ nodes: nodes.filter((node) => node.kind === 'board'),
156
+ links: [...facts.pairs].map(
157
+ ([id, entry]): GraphLink => ({
158
+ id,
159
+ from: memoryKey({ boardId: entry.boardId }),
160
+ to: memoryKey({ boardId: entry.peerId }),
161
+ edges: entry.edges,
162
+ bundle: true,
163
+ }),
164
+ ),
165
+ };
166
+ const keys = new Set<string>();
167
+ if (scene.kind === 'board') {
168
+ for (const node of nodes) if (own(node) && node.kind === 'card') keys.add(node.key);
169
+ } else if (focusKey && byKey.has(focusKey)) keys.add(focusKey);
170
+ const grouped = new Map<string, GraphLink>();
171
+ for (const edge of evidence) {
172
+ if (edge.missing || !byKey.has(edge.from) || !byKey.has(edge.to)) continue;
173
+ const from =
174
+ scene.kind === 'board' && edge.source.boardId !== scene.boardId
175
+ ? memoryKey({ boardId: edge.source.boardId })
176
+ : edge.from;
177
+ const to =
178
+ scene.kind === 'board' && edge.target.boardId !== scene.boardId
179
+ ? memoryKey({ boardId: edge.target.boardId })
180
+ : edge.to;
181
+ keys.add(from);
182
+ keys.add(to);
183
+ const id = scene.kind === 'card' ? edge.id : JSON.stringify([from, to].sort());
184
+ const old = grouped.get(id);
185
+ if (old) {
186
+ old.edges.push(edge);
187
+ old.bundle = true;
188
+ } else grouped.set(id, { id, from, to, edges: [edge], bundle: from !== edge.from || to !== edge.to });
189
+ }
190
+ return { nodes: nodes.filter((node) => keys.has(node.key)), links: [...grouped.values()] };
191
+ }, [nodes, graph, sceneKey]);
192
+ const degree = new Map<string, number>();
193
+ for (const link of scope.links)
194
+ for (const key of [link.from, link.to]) degree.set(key, (degree.get(key) ?? 0) + link.edges.length);
195
+ const connected = scope.nodes.filter((node) => degree.has(node.key) || node.key === focusKey);
196
+ const unlinked = scope.nodes.filter((node) => !degree.has(node.key) && node.key !== focusKey);
197
+ const matches = (node: MemoryGraphNode) =>
198
+ node.key === focusKey ||
199
+ ((!frame.context || node.inContext) &&
200
+ !frame.folders.has(node.folder?.split('/')[0] ?? '') &&
201
+ `${node.title} ${node.summary} ${boardName(node.boardId)} ${node.folder ?? ''}`
202
+ .toLocaleLowerCase()
203
+ .includes(frame.query.trim().toLocaleLowerCase()));
204
+ const ranked = connected
205
+ .filter(matches)
206
+ .sort(
207
+ (a, b) =>
208
+ Number(b.key === focusKey) - Number(a.key === focusKey) ||
209
+ (degree.get(b.key) ?? 0) - (degree.get(a.key) ?? 0) ||
210
+ a.key.localeCompare(b.key),
211
+ );
212
+ const visible = ranked.slice(0, frame.limit);
213
+ const keys = new Set(visible.map((node) => node.key));
214
+ const tray = unlinked.filter(matches);
215
+ const links = scope.links
216
+ .map((link) => ({ ...link, edges: link.edges.filter((edge) => !frame.hiddenLinks.has(edge.kind as LinkKind)) }))
217
+ .filter((link) => link.edges.length && keys.has(link.from) && keys.has(link.to));
218
+ useEffect(
219
+ () => () => {
220
+ stopMotion();
221
+ suppressClick.current ||= !!gesture.current?.moved;
222
+ gesture.current = null;
223
+ },
224
+ [scope, frame.limit, frame.query, frame.context, frame.folders, frame.hiddenLinks],
225
+ );
226
+ const animate = (held: string) => {
227
+ if (motion.current !== null) return;
228
+ let remaining = 180;
229
+ const step = () => {
230
+ const dragging = gesture.current?.key === held;
231
+ const retained = layouts.current.get(sceneKey)!;
232
+ if (motionTarget.current) retained.set(held, motionTarget.current);
233
+ motionTarget.current = null;
234
+ const moving = new Map(visible.map((node) => [node.key, retained.get(node.key)!]));
235
+ const movement = relaxGraphPositions(
236
+ moving,
237
+ links.map((link) => ({ ...link, weight: link.edges.length })),
238
+ held,
239
+ dragging ? 1 : remaining / 180,
240
+ );
241
+ for (const [key, point] of moving) retained.set(key, point);
242
+ setLayoutRevision((value) => value + 1);
243
+ if (dragging || (--remaining > 0 && movement > 0.02)) motion.current = requestAnimationFrame(step);
244
+ else motion.current = null;
245
+ };
246
+ motion.current = requestAnimationFrame(step);
247
+ };
248
+ const positions = useMemo(() => {
249
+ let retained = layouts.current.get(sceneKey);
250
+ if (!retained) {
251
+ retained = new Map();
252
+ layouts.current.set(sceneKey, retained);
253
+ }
254
+ if (scene.kind !== 'card')
255
+ return graphPositions(
256
+ connected,
257
+ scope.links.map((link) => ({ ...link, weight: link.edges.length })),
258
+ retained,
259
+ );
260
+ if (!retained.has(focusKey!)) retained.set(focusKey!, { x: 0, y: 0 });
261
+ const directed = evidence.filter(
262
+ (edge) =>
263
+ edge.kind !== 'card' ||
264
+ relationDefinition(
265
+ { kind: edge.relationKind, type: edge.type },
266
+ boards.find((board) => board.id === edge.source.boardId)?.relationVocabulary,
267
+ ).directed,
268
+ );
269
+ const incoming = new Set(directed.filter((edge) => edge.to === focusKey).map((edge) => edge.from));
270
+ const outgoing = new Set(directed.filter((edge) => edge.from === focusKey).map((edge) => edge.to));
271
+ const peers = connected.filter((node) => node.key !== focusKey).sort((a, b) => a.key.localeCompare(b.key));
272
+ const xStep = GRAPH_NODE.width + GRAPH_NODE.gap,
273
+ yStep = GRAPH_NODE.height + GRAPH_NODE.gap;
274
+ for (const bankId of ['incoming', 'outgoing', 'mutual'] as const) {
275
+ const bank = peers.filter(
276
+ (node) =>
277
+ (incoming.has(node.key) && !outgoing.has(node.key)
278
+ ? 'incoming'
279
+ : outgoing.has(node.key) && !incoming.has(node.key)
280
+ ? 'outgoing'
281
+ : 'mutual') === bankId,
282
+ );
283
+ const existing = bank.filter((node) => retained.has(node.key)).map((node) => retained.get(node.key)!);
284
+ const mutualY = retained.get(focusKey!)!.y + yStep;
285
+ let added = 0;
286
+ for (const [index, node] of bank.entries())
287
+ if (!retained.has(node.key)) {
288
+ const offset = existing.length
289
+ ? Math.max(...existing.map((point) => (bankId === 'mutual' ? point.x : point.y))) +
290
+ (added + 1) * (bankId === 'mutual' ? xStep : yStep)
291
+ : (index - (bank.length - 1) / 2) * (bankId === 'mutual' ? xStep : yStep);
292
+ const point =
293
+ bankId === 'mutual' ? { x: offset, y: mutualY } : { x: bankId === 'incoming' ? -360 : 360, y: offset };
294
+ while ([...retained.values()].some((p) => Math.abs(p.x - point.x) < xStep && Math.abs(p.y - point.y) < yStep))
295
+ point.y += yStep;
296
+ retained.set(node.key, point);
297
+ added++;
298
+ }
299
+ }
300
+ return new Map(retained);
301
+ }, [scope, sceneKey, layoutRevision]);
302
+ const labelFor = (link: GraphLink) =>
303
+ link.bundle
304
+ ? `${link.edges.length} links`
305
+ : link.edges[0].kind === 'card'
306
+ ? relationDefinition(
307
+ { kind: link.edges[0].relationKind, type: link.edges[0].type },
308
+ boards.find((board) => board.id === link.edges[0].source.boardId)?.relationVocabulary,
309
+ ).kind
310
+ : link.edges[0].kind;
311
+ const routes = useMemo(
312
+ () =>
313
+ graphRoutes(
314
+ new Map(visible.map((node) => [node.key, positions.get(node.key)!])),
315
+ links
316
+ .map((link) => ({ ...link, label: link.edges.length > 1 || scene.kind === 'card' ? labelFor(link) : '' }))
317
+ .sort((a, b) => a.id.localeCompare(b.id)),
318
+ ),
319
+ [positions, scope, frame.limit, frame.query, frame.context, frame.folders, frame.hiddenLinks],
320
+ );
321
+ const camera = frame.camera ?? { x: 0, y: 0, scale: 1 };
322
+ const outside = visible.filter((node) => {
323
+ const p = positions.get(node.key)!;
324
+ return (
325
+ (p.x - GRAPH_NODE.width / 2) * camera.scale + camera.x < 8 ||
326
+ (p.x + GRAPH_NODE.width / 2) * camera.scale + camera.x > stageSize.width - 8 ||
327
+ (p.y - GRAPH_NODE.height / 2) * camera.scale + camera.y < (scene.kind === 'card' ? 40 : 8) ||
328
+ (p.y + GRAPH_NODE.height / 2) * camera.scale + camera.y > stageSize.height - 64
329
+ );
330
+ }).length;
331
+ const fit = (initial = false) => {
332
+ const stage = viewport.current;
333
+ if (!stage || !visible.length) return;
334
+ const topMargin = scene.kind === 'card' ? 40 : 16;
335
+ // Fit inside the usable area, not exactly on its bottom counting boundary.
336
+ const availableHeight = stage.clientHeight - topMargin - 64 - 8;
337
+ const points = visible.flatMap((node) => {
338
+ const p = positions.get(node.key)!;
339
+ return [
340
+ { x: p.x - GRAPH_NODE.width / 2, y: p.y - GRAPH_NODE.height / 2 },
341
+ { x: p.x + GRAPH_NODE.width / 2, y: p.y + GRAPH_NODE.height / 2 },
342
+ ];
343
+ });
344
+ for (const route of routes.values()) {
345
+ points.push(...route.points);
346
+ if (route.label)
347
+ points.push({ x: route.label.left, y: route.label.top }, { x: route.label.right, y: route.label.bottom });
348
+ }
349
+ const left = Math.min(...points.map((p) => p.x)),
350
+ top = Math.min(...points.map((p) => p.y));
351
+ const width = Math.max(...points.map((p) => p.x)) - left,
352
+ height = Math.max(...points.map((p) => p.y)) - top;
353
+ const scale = Math.min(
354
+ 1,
355
+ Math.max(initial ? 1 : 0.005, Math.min((stage.clientWidth - 64) / width, availableHeight / height)),
356
+ );
357
+ update({
358
+ camera:
359
+ initial && scene.kind === 'card'
360
+ ? {
361
+ x: stage.clientWidth / 2 - positions.get(focusKey!)!.x,
362
+ y: topMargin + availableHeight / 2 - positions.get(focusKey!)!.y,
363
+ scale: 1,
364
+ }
365
+ : {
366
+ x: (stage.clientWidth - width * scale) / 2 - left * scale,
367
+ y: topMargin + (availableHeight - height * scale) / 2 - top * scale,
368
+ scale,
369
+ },
370
+ });
371
+ };
372
+ useLayoutEffect(() => {
373
+ if (graph && !frame.camera && visible.length) fit(true);
374
+ }, [graph, sceneKey, frame.camera]);
375
+ useEffect(() => {
376
+ if (!graph || scene.kind === 'overview' || focus) return;
377
+ const boardExists = byKey.has(memoryKey({ boardId: scene.boardId }));
378
+ setFrames((old) => {
379
+ const valid = old.filter((entry) => entry.scene.kind === 'overview' || byKey.has(memoryKey(entry.scene)));
380
+ const next: GraphScene =
381
+ scene.kind === 'card' && boardExists ? { kind: 'board', boardId: scene.boardId } : { kind: 'overview' };
382
+ return graphSceneKey(valid.at(-1)!.scene) === graphSceneKey(next) ? valid : [...valid, newFrame(next)];
383
+ });
384
+ setNotice('The focused endpoint was removed. Returned to its nearest available level.');
385
+ }, [graph, focusKey, focus]);
386
+ const panTo = (node: MemoryGraphNode) => {
387
+ const p = positions.get(node.key),
388
+ stage = viewport.current;
389
+ if (!p || !stage) return;
390
+ const scale = Math.max(1, camera.scale);
391
+ update({
392
+ camera: { x: stage.clientWidth / 2 - p.x * scale, y: stage.clientHeight / 2 - p.y * scale - 20, scale },
393
+ limit: Math.max(frame.limit, ranked.indexOf(node) + 1),
394
+ });
395
+ setSidebar(false);
396
+ };
397
+ const activate = (node: MemoryGraphNode) => {
398
+ if (node.kind === 'card' && node.cardId) go({ kind: 'card', boardId: node.boardId, cardId: node.cardId });
399
+ else go({ kind: 'board', boardId: node.boardId });
400
+ };
401
+ const zoomAt = (scale: number, center?: Point) => {
402
+ const stage = viewport.current;
403
+ if (!stage) return;
404
+ const point = center ?? { x: stage.clientWidth / 2, y: stage.clientHeight / 2 };
405
+ const next = Math.max(0.005, Math.min(3, scale));
406
+ update({
407
+ camera: {
408
+ x: point.x - ((point.x - camera.x) * next) / camera.scale,
409
+ y: point.y - ((point.y - camera.y) * next) / camera.scale,
410
+ scale: next,
411
+ },
412
+ });
413
+ };
414
+ const inspection = scope.links.find((link) => link.id === frame.inspection);
415
+ const detailEdges = inspection?.edges ?? evidence;
416
+ const selectedBoard = focus ? boards.find((board) => board.id === focus.boardId) : undefined;
417
+ const inspect = (link: GraphLink) => {
418
+ update({ inspection: link.id });
419
+ setReveal((value) => value + 1);
420
+ };
421
+ const filter = (label: string, checked: boolean, change: () => void) => (
422
+ <label class="home-memory-check">
423
+ <input type="checkbox" aria-label={label} checked={checked} onChange={change} />
424
+ <span>{label}</span>
425
+ </label>
426
+ );
427
+ const unavailable = scene.kind !== 'overview' && !focus;
428
+ const title =
429
+ scene.kind === 'overview'
430
+ ? 'Library'
431
+ : scene.kind === 'board'
432
+ ? boardName(scene.boardId)
433
+ : (focus?.title ?? 'Missing card');
434
+ return (
435
+ <main
436
+ class={`home-memory graph-view${sidebar ? ' sidebar-open' : ''}`}
437
+ aria-label="Library Graph"
438
+ style={{ '--graph-node-width': `${GRAPH_NODE.width}px`, '--graph-node-height': `${GRAPH_NODE.height}px` }}
439
+ >
440
+ <div class="home-memory-toolbar">
441
+ {frames.length > 1 && (
442
+ <button type="button" class="home-button" onClick={back}>
443
+ Back
444
+ </button>
445
+ )}
446
+ <nav class="graph-breadcrumb" aria-label="Graph path">
447
+ <button type="button" class="home-button" onClick={() => breadcrumb({ kind: 'overview' })}>
448
+ Library
449
+ </button>
450
+ {scene.kind !== 'overview' && (
451
+ <>
452
+ <span>›</span>
453
+ <button
454
+ type="button"
455
+ class="home-button"
456
+ onClick={() => breadcrumb({ kind: 'board', boardId: scene.boardId })}
457
+ >
458
+ {boardName(scene.boardId)}
459
+ </button>
460
+ </>
461
+ )}
462
+ {scene.kind === 'card' && (
463
+ <>
464
+ <span>›</span>
465
+ <strong>{title}</strong>
466
+ </>
467
+ )}
468
+ </nav>
469
+ <span>
470
+ {visible.length} of {connected.length} connected drawn · {outside} partly/outside view ·{' '}
471
+ {connected.length - ranked.length} filtered · {Math.max(0, ranked.length - visible.length)} hidden ·{' '}
472
+ {unlinked.length} without links
473
+ </span>
474
+ {ranked.length > frame.limit && (
475
+ <button type="button" class="home-button" onClick={() => update({ limit: frame.limit + 80 })}>
476
+ Show more connections
477
+ </button>
478
+ )}
479
+ <span class={`home-memory-live${error ? ' is-error' : ''}`} role="status">
480
+ {error ? 'Update failed' : loading ? 'Updating…' : 'Live'}
481
+ </span>
482
+ <button
483
+ type="button"
484
+ class="home-button home-memory-sidebar-toggle"
485
+ aria-expanded={sidebar}
486
+ onClick={() => setSidebar(!sidebar)}
487
+ >
488
+ Filters
489
+ </button>
490
+ </div>
491
+ <div class="home-memory-body">
492
+ <div
493
+ class="home-memory-stage"
494
+ ref={viewport}
495
+ aria-label="Pan the memory view"
496
+ onWheel={(event) => {
497
+ event.preventDefault();
498
+ const r = event.currentTarget.getBoundingClientRect();
499
+ zoomAt(camera.scale * Math.exp(-event.deltaY * 0.0015), {
500
+ x: event.clientX - r.left,
501
+ y: event.clientY - r.top,
502
+ });
503
+ }}
504
+ onPointerDown={(event) => {
505
+ if (event.button !== 0 || (event.target as Element).closest('button, [role="button"]')) return;
506
+ stopMotion();
507
+ suppressClick.current = false;
508
+ gesture.current = {
509
+ id: event.pointerId,
510
+ start: { x: event.clientX, y: event.clientY },
511
+ origin: camera,
512
+ moved: false,
513
+ };
514
+ event.currentTarget.setPointerCapture(event.pointerId);
515
+ }}
516
+ onPointerMove={(event) => {
517
+ const drag = gesture.current;
518
+ if (!drag || drag.id !== event.pointerId || !(event.buttons & 1)) return;
519
+ const dx = event.clientX - drag.start.x,
520
+ dy = event.clientY - drag.start.y;
521
+ if (Math.abs(dx) + Math.abs(dy) > 4) drag.moved = true;
522
+ if (!drag.moved) return;
523
+ const point = {
524
+ x: drag.origin.x + dx / (drag.key ? camera.scale : 1),
525
+ y: drag.origin.y + dy / (drag.key ? camera.scale : 1),
526
+ };
527
+ if (drag.key) {
528
+ motionTarget.current = point;
529
+ animate(drag.key);
530
+ } else update({ camera: { ...point, scale: camera.scale } });
531
+ }}
532
+ onPointerUp={() => {
533
+ if (!gesture.current) return;
534
+ suppressClick.current = gesture.current.moved;
535
+ gesture.current = null;
536
+ }}
537
+ onPointerCancel={() => {
538
+ const drag = gesture.current;
539
+ stopMotion();
540
+ if (drag?.layout) {
541
+ layouts.current.set(sceneKey, drag.layout);
542
+ setLayoutRevision((value) => value + 1);
543
+ } else if (drag) update({ camera: { ...drag.origin, scale: camera.scale } });
544
+ suppressClick.current = !!drag?.moved;
545
+ gesture.current = null;
546
+ }}
547
+ >
548
+ {scene.kind === 'card' && (
549
+ <div class="graph-direction-caption">Incoming starts left · outgoing starts right · drag to rearrange</div>
550
+ )}
551
+ <div
552
+ class="home-memory-world"
553
+ style={{ transform: `translate(${camera.x}px, ${camera.y}px) scale(${camera.scale})` }}
554
+ >
555
+ <svg class="home-memory-links graph-links" aria-label="Graph connections">
556
+ {links.map((link, index) => {
557
+ const route = routes.get(link.id);
558
+ if (!route) return null;
559
+ const edge = link.edges[0];
560
+ const definition = relationDefinition(
561
+ { kind: edge.relationKind, type: edge.type },
562
+ boards.find((board) => board.id === edge.source.boardId)?.relationVocabulary,
563
+ );
564
+ const directed = !link.bundle && (edge.kind !== 'card' || definition.directed);
565
+ const ink = link.bundle
566
+ ? 'var(--c-muted)'
567
+ : edge.kind === 'card'
568
+ ? `var(--c-rel-${definition.ink})`
569
+ : edge.kind === 'wiki'
570
+ ? 'var(--c-rel-cites)'
571
+ : 'var(--c-rel-derived)';
572
+ const name = `${link.edges.length} ${link.bundle ? 'links' : 'link'} between ${byKey.get(link.from)?.title} and ${byKey.get(link.to)?.title}`;
573
+ return (
574
+ <g
575
+ key={link.id}
576
+ class={`graph-link${link.bundle ? ' is-bundle' : ' is-exact'}${link.from === hover || link.to === hover ? ' is-highlighted' : ''}${frame.inspection === link.id ? ' is-selected' : ''}`}
577
+ data-from={link.from}
578
+ data-to={link.to}
579
+ data-count={link.edges.length}
580
+ data-link-id={link.id}
581
+ role="button"
582
+ tabindex={0}
583
+ aria-label={`Inspect ${name}`}
584
+ style={{ stroke: ink }}
585
+ onClick={() => inspect(link)}
586
+ onKeyDown={(event) => {
587
+ if (event.key === 'Enter' || event.key === ' ') {
588
+ event.preventDefault();
589
+ inspect(link);
590
+ }
591
+ }}
592
+ >
593
+ <defs>
594
+ <marker
595
+ id={`graph-arrow-${index}`}
596
+ viewBox="0 0 10 10"
597
+ refX="9"
598
+ refY="5"
599
+ markerWidth="6"
600
+ markerHeight="6"
601
+ orient="auto"
602
+ >
603
+ <path d="M 0 0 L 10 5 L 0 10 z" style={{ fill: ink, stroke: 'none' }} />
604
+ </marker>
605
+ </defs>
606
+ <path class="graph-link-hit" d={route.path} />
607
+ <path
608
+ class="graph-link-line"
609
+ d={route.path}
610
+ marker-end={directed ? `url(#graph-arrow-${index})` : undefined}
611
+ />
612
+ {route.label && (link.edges.length > 1 || scene.kind === 'card') && (
613
+ <text x={route.label.x} y={route.label.y}>
614
+ {labelFor(link)}
615
+ </text>
616
+ )}
617
+ </g>
618
+ );
619
+ })}
620
+ </svg>
621
+ {visible.map((node, index) => {
622
+ const point = positions.get(node.key)!;
623
+ const board = boards.find((entry) => entry.id === node.boardId);
624
+ const marked =
625
+ node.kind === 'board'
626
+ ? node.inContext
627
+ : !!board?.pinnedTitles?.some((entry) => entry.nodeId === node.cardId) ||
628
+ (node.inContext && !board?.pin);
629
+ return (
630
+ <article
631
+ key={node.key}
632
+ data-key={node.key}
633
+ class={`graph-node is-${node.kind}${focusKey === node.key ? ' is-selected' : ''}${marked ? ' in-context' : ''}${scene.kind !== 'overview' && !own(node) ? ' is-external' : ''}`}
634
+ style={{ left: point.x, top: point.y }}
635
+ onPointerEnter={() => setHover(node.key)}
636
+ onPointerLeave={() => setHover(null)}
637
+ >
638
+ <button
639
+ type="button"
640
+ class="home-memory-select"
641
+ aria-label={`${node.kind}: ${node.title}`}
642
+ aria-pressed={focusKey === node.key}
643
+ onClick={() => {
644
+ if (!suppressClick.current) activate(node);
645
+ suppressClick.current = false;
646
+ }}
647
+ onPointerDown={(event) => {
648
+ if (event.button !== 0) return;
649
+ event.stopPropagation();
650
+ stopMotion();
651
+ suppressClick.current = false;
652
+ gesture.current = {
653
+ id: event.pointerId,
654
+ start: { x: event.clientX, y: event.clientY },
655
+ origin: point,
656
+ key: node.key,
657
+ layout: new Map(layouts.current.get(sceneKey)),
658
+ moved: false,
659
+ };
660
+ event.currentTarget.setPointerCapture(event.pointerId);
661
+ }}
662
+ onKeyDown={(event) => {
663
+ if (!event.key.startsWith('Arrow')) return;
664
+ event.preventDefault();
665
+ const nextIndex =
666
+ (index + (event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 1) + visible.length) %
667
+ visible.length;
668
+ const next = visible[nextIndex];
669
+ if (next) {
670
+ panTo(next);
671
+ viewport.current
672
+ ?.querySelectorAll<HTMLButtonElement>('.home-memory-select')
673
+ [nextIndex]?.focus({ preventScroll: true });
674
+ }
675
+ }}
676
+ >
677
+ <span class="graph-glyph" aria-hidden="true" />
678
+ <strong>{node.title}</strong>
679
+ <span>
680
+ {node.kind === 'board'
681
+ ? `${node.nodeCount ?? 0} cards · ${facts.internal.get(node.boardId) ?? 0} internal`
682
+ : `${boardName(node.boardId)} · ${node.type ?? 'card'}`}
683
+ </span>
684
+ <span class={marked ? 'memory-context-legend' : ''}>
685
+ {marked ? '⌖ In context' : node.folder || 'Unfiled'}
686
+ </span>
687
+ </button>
688
+ </article>
689
+ );
690
+ })}
691
+ </div>
692
+ <div class="home-memory-zoom">
693
+ <button type="button" class="home-button" aria-label="Zoom out" onClick={() => zoomAt(camera.scale / 1.2)}>
694
+ −
695
+ </button>
696
+ <span>{Math.round(camera.scale * 100)}%</span>
697
+ <button type="button" class="home-button" aria-label="Zoom in" onClick={() => zoomAt(camera.scale * 1.2)}>
698
+ +
699
+ </button>
700
+ <button type="button" class="home-button" onClick={() => fit()}>
701
+ Fit view
702
+ </button>
703
+ </div>
704
+ {(notice || unavailable || !graph || !visible.length || error) && (
705
+ <div class="home-memory-status" role="status">
706
+ {notice ||
707
+ (unavailable ? (
708
+ 'This endpoint is no longer available. Use Back to return.'
709
+ ) : error ? (
710
+ <>
711
+ Memory update failed.{' '}
712
+ <button type="button" class="home-button" onClick={retry}>
713
+ Retry
714
+ </button>
715
+ </>
716
+ ) : !graph ? (
717
+ 'Loading memory…'
718
+ ) : tray.length ? (
719
+ 'No linked nodes in this view. Choose a node in No connections below.'
720
+ ) : (
721
+ 'No matching nodes.'
722
+ ))}
723
+ </div>
724
+ )}
725
+ </div>
726
+ <aside class="home-memory-sidebar" aria-label="Graph settings">
727
+ <section>
728
+ <h2>{scene.kind === 'overview' ? 'Find boards' : 'Find cards and neighbors'}</h2>
729
+ <label class="home-memory-search">
730
+ <span>Search graph</span>
731
+ <input
732
+ type="search"
733
+ value={frame.query}
734
+ placeholder="Search this graph…"
735
+ onInput={(event) => update({ query: event.currentTarget.value })}
736
+ />
737
+ </label>
738
+ {frame.query && (
739
+ <ul class="graph-search-results">
740
+ {[...connected, ...unlinked]
741
+ .filter(matches)
742
+ .slice(0, 30)
743
+ .map((node) => (
744
+ <li key={node.key}>
745
+ <button
746
+ type="button"
747
+ class="home-button"
748
+ onClick={() => {
749
+ if (degree.has(node.key) || node.key === focusKey) panTo(node);
750
+ else activate(node);
751
+ }}
752
+ >
753
+ {node.title}
754
+ </button>
755
+ </li>
756
+ ))}
757
+ </ul>
758
+ )}
759
+ {filter('Only in context', frame.context, () => update({ context: !frame.context }))}
760
+ </section>
761
+ <section>
762
+ <h2>Folders</h2>
763
+ {[...new Set(scope.nodes.map((node) => node.folder?.split('/')[0] ?? ''))].sort().map((folder) => (
764
+ <div key={folder}>
765
+ {filter(folder || 'Unfiled', !frame.folders.has(folder), () => {
766
+ const next = new Set(frame.folders);
767
+ if (next.has(folder)) next.delete(folder);
768
+ else next.add(folder);
769
+ update({ folders: next });
770
+ })}
771
+ </div>
772
+ ))}
773
+ </section>
774
+ <section>
775
+ <h2>Links</h2>
776
+ {(Object.keys(linkNames) as LinkKind[]).map((kind) => (
777
+ <div key={kind}>
778
+ {filter(linkNames[kind], !frame.hiddenLinks.has(kind), () => {
779
+ const next = new Set(frame.hiddenLinks);
780
+ if (next.has(kind)) next.delete(kind);
781
+ else next.add(kind);
782
+ update({ hiddenLinks: next });
783
+ })}
784
+ </div>
785
+ ))}
786
+ </section>
787
+ <section>
788
+ <h2>Library evidence</h2>
789
+ <p class="graph-accounting">
790
+ {facts.evidence.length} links ·{' '}
791
+ {[...facts.pairs.values()].reduce((sum, entry) => sum + entry.edges.length, 0)} between boards ·{' '}
792
+ {[...facts.internal.values()].reduce((sum, count) => sum + count, 0)} inside boards ·{' '}
793
+ {facts.missing.length} missing
794
+ </p>
795
+ <p class="home-memory-help">
796
+ Counts retain original evidence. Folder membership is not a relation. Filters change the drawing, not the
797
+ totals.
798
+ </p>
799
+ </section>
800
+ </aside>
801
+ </div>
802
+ {tray.length > 0 && (
803
+ <details class="graph-unlinked" open>
804
+ <summary>No connections ({tray.length})</summary>
805
+ <div>
806
+ {tray.map((node) => (
807
+ <button
808
+ key={node.key}
809
+ type="button"
810
+ class="home-button graph-tray-node"
811
+ aria-label={`${node.kind}: ${node.title}`}
812
+ onClick={() => activate(node)}
813
+ >
814
+ {node.title}
815
+ <small>{node.kind === 'board' ? `${node.nodeCount ?? 0} cards` : boardName(node.boardId)}</small>
816
+ </button>
817
+ ))}
818
+ </div>
819
+ </details>
820
+ )}
821
+ <div class="home-memory-legend" aria-label="Legend">
822
+ <span>
823
+ Click nodes to drill · drag nodes to rearrange · lines for evidence · drag background to pan · scroll to zoom
824
+ · Fit shows extent · search reveals at readable size
825
+ </span>
826
+ <span class="memory-context-legend">⌖ In context</span>
827
+ </div>
828
+ <MemoryInspector key="inspector" initiallyCollapsed reveal={reveal}>
829
+ {focus || inspection ? (
830
+ <section class="home-memory-details" role="complementary" aria-label="Selection details">
831
+ {inspection && (
832
+ <button
833
+ type="button"
834
+ class="home-memory-clear"
835
+ aria-label="Close details"
836
+ onClick={() => update({ inspection: null })}
837
+ >
838
+ ×
839
+ </button>
840
+ )}
841
+ <div class="home-memory-detail-header">
842
+ <div class="home-memory-selection-title">
843
+ <h3>
844
+ {inspection ? `${byKey.get(inspection.from)?.title} ↔ ${byKey.get(inspection.to)?.title}` : title} ·{' '}
845
+ {detailEdges.length} connections
846
+ </h3>
847
+ <p>
848
+ {inspection
849
+ ? 'Original evidence, not a new aggregate relation'
850
+ : `${focus?.folder || 'Unfiled'} / ${boardName(focus!.boardId)}`}
851
+ </p>
852
+ </div>
853
+ <div class="home-memory-actions">
854
+ {focus && (
855
+ <>
856
+ <button type="button" class="home-button is-primary" onClick={() => void openMemoryTarget(focus)}>
857
+ Open {focus.kind}
858
+ </button>
859
+ {selectedBoard && <BoardPinButton board={selectedBoard} />}
860
+ </>
861
+ )}
862
+ </div>
863
+ </div>
864
+ <div class="home-memory-detail-body">
865
+ {focus && (
866
+ <p class={focus.inContext ? 'memory-context-legend' : ''}>
867
+ {focus.inContext ? '⌖ In context' : 'Not in context'}
868
+ {focus.linksComplete === false ? ' · Partial discovery' : ''}
869
+ </p>
870
+ )}
871
+ <details class="home-memory-connections" open>
872
+ <summary>Connections{inspection ? ' within this pair' : ''}</summary>
873
+ <ul>
874
+ {detailEdges.map((edge) => {
875
+ const outgoing =
876
+ scene.kind === 'card'
877
+ ? edge.from === focusKey
878
+ : scene.kind === 'board'
879
+ ? edge.source.boardId === scene.boardId
880
+ : true;
881
+ const target = outgoing ? edge.target : edge.source;
882
+ const other = byKey.get(memoryKey(target));
883
+ const definition = relationDefinition(
884
+ { kind: edge.relationKind, type: edge.type },
885
+ boards.find((board) => board.id === edge.source.boardId)?.relationVocabulary,
886
+ );
887
+ const directed = edge.kind !== 'card' || definition.directed;
888
+ return (
889
+ <li key={edge.id}>
890
+ <span>
891
+ {directed ? (outgoing ? '→' : '←') : '↔'}{' '}
892
+ {edge.kind === 'card'
893
+ ? relationText({ ...edge, kind: edge.relationKind })
894
+ : `${edge.kind}${edge.label ? ` · ${edge.label}` : ''}`}
895
+ </span>
896
+ <small>
897
+ {byKey.get(edge.from)?.title ?? edge.source.cardId ?? boardName(edge.source.boardId)}{' '}
898
+ {directed ? '→' : '↔'}{' '}
899
+ {byKey.get(edge.to)?.title ?? edge.target.cardId ?? boardName(edge.target.boardId)} ·{' '}
900
+ {boardName(target.boardId)}
901
+ </small>
902
+ {!other || edge.missing ? (
903
+ <span class="home-memory-missing">
904
+ ⚠ Missing target: {target.boardId}
905
+ {target.cardId ? ` / ${target.cardId}` : ''}
906
+ </span>
907
+ ) : (
908
+ <button type="button" class="home-button" onClick={() => activate(other)}>
909
+ Follow {other.title}
910
+ </button>
911
+ )}
912
+ </li>
913
+ );
914
+ })}
915
+ {(graph?.unresolvedLinks ?? [])
916
+ .filter((link) =>
917
+ scene.kind === 'board'
918
+ ? link.source.boardId === scene.boardId
919
+ : memoryKey(link.source) === focusKey,
920
+ )
921
+ .map((link) => (
922
+ <li key={`${memoryKey(link.source)}:${link.ref}`} class="home-memory-missing">
923
+ ⚠ Unresolved wiki: [[{link.ref}]]
924
+ </li>
925
+ ))}
926
+ </ul>
927
+ </details>
928
+ </div>
929
+ </section>
930
+ ) : (
931
+ <div class="home-memory-empty-details">
932
+ <h3>Connections</h3>
933
+ <p class="home-memory-help">
934
+ Click a board to explore, then a card to see its exact neighbors. Click a connection line to read the
935
+ original evidence. Open content is always separate.
936
+ </p>
937
+ </div>
938
+ )}
939
+ </MemoryInspector>
940
+ </main>
941
+ );
942
+ }