synabun 1.3.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.
Files changed (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,1454 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — Detail Cards (Multi-Instance)
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // Spawns independent memory detail cards. Each card is draggable,
6
+ // resizable, compactable, and closeable. Clicking the same memory
7
+ // again brings its card to front instead of duplicating.
8
+
9
+ import { state, emit, on } from './state.js';
10
+ import { updateMemory, deleteMemory } from './api.js';
11
+ import { KEYS } from './constants.js';
12
+ import { storage } from './storage.js';
13
+ import { catColor } from './colors.js';
14
+ import { truncate, formatMemoryContent, exportMemoryAsMarkdown } from './utils.js';
15
+ import { t } from './i18n.js';
16
+ import { savePanelLayout, restorePanelLayout } from './ui-panels.js';
17
+ import { sendSync as _sendSync, hasPermission, isGuest } from './ui-sync.js';
18
+
19
+ // Wrap sendSync — skip card events when guest lacks cards permission
20
+ function sendSync(msg) {
21
+ if (isGuest() && !hasPermission('cards')) return;
22
+ _sendSync(msg);
23
+ }
24
+
25
+ // ─── Scoped query helper ────────────────
26
+ const q = (card, role) => card.querySelector(`[data-role="${role}"]`);
27
+
28
+ // ─── Multi-card state ───────────────────
29
+ const _openCards = new Map(); // memoryId → { el, node, isCompact, isEditing, savedExpanded }
30
+ let _topZ = 220;
31
+ const MAX_CARDS = 20;
32
+ let _isRemoteCardOp = false; // prevents echo loops for sync events
33
+
34
+ // ─── Variant callbacks ───────────────────
35
+ let _callbacks = {
36
+ applyGraphData: () => {},
37
+ updateStats: () => {},
38
+ buildCategorySidebar: () => {},
39
+ clearMultiSelect: () => {},
40
+ fetchTrashItems: () => {},
41
+ refreshNodeAppearance: () => {},
42
+ };
43
+
44
+ export function setDetailCallbacks(cbs) {
45
+ Object.assign(_callbacks, cbs);
46
+ }
47
+
48
+
49
+ // ═══════════════════════════════════════════
50
+ // BOOKMARKS (localStorage-backed)
51
+ // ═══════════════════════════════════════════
52
+
53
+ function getBookmarks() {
54
+ try { return JSON.parse(storage.getItem(KEYS.BOOKMARKS) || '[]'); }
55
+ catch { return []; }
56
+ }
57
+
58
+ function saveBookmarks(arr) {
59
+ storage.setItem(KEYS.BOOKMARKS, JSON.stringify(arr));
60
+ }
61
+
62
+ function isBookmarked(nodeId) {
63
+ return getBookmarks().some(b => b.id === nodeId);
64
+ }
65
+
66
+ function toggleBookmark(node) {
67
+ const bookmarks = getBookmarks();
68
+ const idx = bookmarks.findIndex(b => b.id === node.id);
69
+ if (idx !== -1) {
70
+ bookmarks.splice(idx, 1);
71
+ } else {
72
+ const p = node.payload || {};
73
+ const preview = (p.content || '').replace(/\n/g, ' ').slice(0, 80);
74
+ bookmarks.unshift({
75
+ id: node.id,
76
+ title: preview || node.id,
77
+ category: p.category || 'uncategorized',
78
+ added: Date.now(),
79
+ });
80
+ }
81
+ saveBookmarks(bookmarks);
82
+ return isBookmarked(node.id);
83
+ }
84
+
85
+ export function updateDetailBookmarkBtn(nodeId) {
86
+ const card = _openCards.get(nodeId);
87
+ if (!card) return;
88
+ const btn = q(card.el, 'bookmark-btn');
89
+ if (!btn) return;
90
+ const svg = btn.querySelector('svg');
91
+ if (isBookmarked(nodeId)) {
92
+ btn.classList.add('active');
93
+ if (svg) svg.classList.add('filled');
94
+ } else {
95
+ btn.classList.remove('active');
96
+ if (svg) svg.classList.remove('filled');
97
+ }
98
+ }
99
+
100
+ export { getBookmarks, saveBookmarks, isBookmarked, toggleBookmark };
101
+
102
+
103
+ // ═══════════════════════════════════════════
104
+ // TAG DELETE MODAL
105
+ // ═══════════════════════════════════════════
106
+
107
+ function showTagDeleteModal(tagName, onConfirm) {
108
+ const overlay = document.createElement('div');
109
+ overlay.className = 'tag-delete-overlay';
110
+ overlay.innerHTML = `
111
+ <div class="tag-delete-modal">
112
+ <div class="tag-delete-modal-title">Remove tag</div>
113
+ <div class="tag-delete-modal-tag">${tagName}</div>
114
+ <div class="tag-delete-modal-actions">
115
+ <button class="action-btn action-btn--ghost tag-modal-cancel">Cancel</button>
116
+ <button class="action-btn action-btn--danger tag-modal-confirm">Delete</button>
117
+ </div>
118
+ </div>
119
+ `;
120
+ document.body.appendChild(overlay);
121
+ overlay.querySelector('.tag-modal-cancel').addEventListener('click', () => overlay.remove());
122
+ overlay.querySelector('.tag-modal-confirm').addEventListener('click', () => { overlay.remove(); onConfirm(); });
123
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
124
+ }
125
+
126
+
127
+ // ═══════════════════════════════════════════
128
+ // MARKDOWN CONTENT EDITOR (edit mode)
129
+ // ═══════════════════════════════════════════
130
+
131
+ function enterEditMode(card, node) {
132
+ const cardState = _openCards.get(node.id);
133
+ if (!cardState) return;
134
+ cardState.isEditing = true;
135
+
136
+ const contentEl = q(card, 'content');
137
+ const wrap = contentEl.parentElement;
138
+ contentEl.style.display = 'none';
139
+
140
+ const prev = wrap.querySelector('.md-editor-wrap');
141
+ if (prev) prev.remove();
142
+
143
+ const editor = document.createElement('div');
144
+ editor.className = 'md-editor-wrap';
145
+
146
+ const toolbar = document.createElement('div');
147
+ toolbar.className = 'md-toolbar';
148
+
149
+ const btnBold = document.createElement('button');
150
+ btnBold.textContent = 'B';
151
+ btnBold.title = 'Bold (**text**)';
152
+ btnBold.style.fontWeight = '700';
153
+
154
+ const btnList = document.createElement('button');
155
+ btnList.textContent = '\u2022 List';
156
+ btnList.title = 'Toggle list (- item)';
157
+
158
+ const btnHeading = document.createElement('button');
159
+ btnHeading.textContent = 'H';
160
+ btnHeading.title = 'Cycle heading (## / ### / none)';
161
+
162
+ toolbar.append(btnBold, btnList, btnHeading);
163
+
164
+ const textarea = document.createElement('textarea');
165
+ textarea.className = 'md-textarea';
166
+ textarea.value = node.payload.content || '';
167
+ textarea.spellcheck = false;
168
+
169
+ function autoResize() {
170
+ textarea.style.height = 'auto';
171
+ textarea.style.height = Math.min(360, Math.max(120, textarea.scrollHeight)) + 'px';
172
+ }
173
+ textarea.addEventListener('input', autoResize);
174
+
175
+ const actions = document.createElement('div');
176
+ actions.className = 'md-editor-actions';
177
+
178
+ const btnCancel = document.createElement('button');
179
+ btnCancel.className = 'md-btn-cancel';
180
+ btnCancel.textContent = 'Cancel';
181
+
182
+ const btnSave = document.createElement('button');
183
+ btnSave.className = 'md-btn-save';
184
+ btnSave.textContent = 'Save';
185
+
186
+ actions.append(btnCancel, btnSave);
187
+ editor.append(toolbar, textarea, actions);
188
+ wrap.appendChild(editor);
189
+
190
+ requestAnimationFrame(() => { textarea.focus(); autoResize(); });
191
+
192
+ // Toolbar actions
193
+ btnBold.addEventListener('click', () => {
194
+ const start = textarea.selectionStart;
195
+ const end = textarea.selectionEnd;
196
+ const val = textarea.value;
197
+ const sel = val.substring(start, end);
198
+ if (sel.startsWith('**') && sel.endsWith('**') && sel.length > 4) {
199
+ textarea.value = val.substring(0, start) + sel.slice(2, -2) + val.substring(end);
200
+ textarea.selectionStart = start; textarea.selectionEnd = end - 4;
201
+ } else if (start >= 2 && val.substring(start - 2, start) === '**' && val.substring(end, end + 2) === '**') {
202
+ textarea.value = val.substring(0, start - 2) + sel + val.substring(end + 2);
203
+ textarea.selectionStart = start - 2; textarea.selectionEnd = end - 2;
204
+ } else {
205
+ textarea.value = val.substring(0, start) + '**' + sel + '**' + val.substring(end);
206
+ textarea.selectionStart = start + 2; textarea.selectionEnd = end + 2;
207
+ }
208
+ textarea.focus();
209
+ });
210
+
211
+ btnList.addEventListener('click', () => {
212
+ const start = textarea.selectionStart;
213
+ const end = textarea.selectionEnd;
214
+ const val = textarea.value;
215
+ const lineStart = val.lastIndexOf('\n', start - 1) + 1;
216
+ const lineEnd = val.indexOf('\n', end);
217
+ const blockEnd = lineEnd === -1 ? val.length : lineEnd;
218
+ const block = val.substring(lineStart, blockEnd);
219
+ const lines = block.split('\n');
220
+ const allList = lines.every(l => /^- /.test(l));
221
+ const newLines = allList ? lines.map(l => l.replace(/^- /, '')) : lines.map(l => '- ' + l);
222
+ const newBlock = newLines.join('\n');
223
+ textarea.value = val.substring(0, lineStart) + newBlock + val.substring(blockEnd);
224
+ const diff = newBlock.length - block.length;
225
+ textarea.selectionStart = lineStart; textarea.selectionEnd = blockEnd + diff;
226
+ textarea.focus();
227
+ });
228
+
229
+ btnHeading.addEventListener('click', () => {
230
+ const start = textarea.selectionStart;
231
+ const end = textarea.selectionEnd;
232
+ const val = textarea.value;
233
+ const lineStart = val.lastIndexOf('\n', start - 1) + 1;
234
+ const lineEnd = val.indexOf('\n', end);
235
+ const blockEnd = lineEnd === -1 ? val.length : lineEnd;
236
+ const block = val.substring(lineStart, blockEnd);
237
+ const lines = block.split('\n');
238
+ const newLines = lines.map(l => {
239
+ if (l.startsWith('### ')) return l.slice(4);
240
+ if (l.startsWith('## ')) return '### ' + l.slice(3);
241
+ return '## ' + l;
242
+ });
243
+ const newBlock = newLines.join('\n');
244
+ textarea.value = val.substring(0, lineStart) + newBlock + val.substring(blockEnd);
245
+ const diff = newBlock.length - block.length;
246
+ textarea.selectionStart = lineStart; textarea.selectionEnd = blockEnd + diff;
247
+ textarea.focus();
248
+ });
249
+
250
+ btnCancel.addEventListener('click', () => exitEditMode(card, node, false));
251
+ btnSave.addEventListener('click', () => exitEditMode(card, node, true));
252
+ textarea.addEventListener('keydown', (e) => {
253
+ if (e.key === 'Escape') { e.preventDefault(); exitEditMode(card, node, false); }
254
+ });
255
+ }
256
+
257
+ async function exitEditMode(card, node, save) {
258
+ const contentEl = q(card, 'content');
259
+ const wrap = contentEl.parentElement;
260
+ const editor = wrap.querySelector('.md-editor-wrap');
261
+
262
+ if (save && editor) {
263
+ const textarea = editor.querySelector('.md-textarea');
264
+ const newContent = textarea.value;
265
+ try {
266
+ await updateMemory(node.id, { content: newContent });
267
+ node.payload.content = newContent;
268
+ } catch (err) {
269
+ console.error('Content save failed:', err);
270
+ }
271
+ }
272
+
273
+ if (editor) editor.remove();
274
+ contentEl.style.display = '';
275
+ contentEl.innerHTML = formatMemoryContent(node.payload.content);
276
+
277
+ const cardState = _openCards.get(node.id);
278
+ if (cardState) cardState.isEditing = false;
279
+ }
280
+
281
+
282
+ // ═══════════════════════════════════════════
283
+ // DELETE MEMORY
284
+ // ═══════════════════════════════════════════
285
+
286
+ async function deleteMemoryFromUI(node) {
287
+ try {
288
+ await deleteMemory(node.id);
289
+ state.allNodes = state.allNodes.filter(n => n.id !== node.id);
290
+ state.allLinks = state.allLinks.filter(l =>
291
+ l.source !== node.id && l.target !== node.id &&
292
+ l.source?.id !== node.id && l.target?.id !== node.id
293
+ );
294
+ closeMemoryCard(node.id);
295
+ const presentCats = new Set(state.allNodes.map(n => n.payload.category));
296
+ _callbacks.buildCategorySidebar(presentCats);
297
+ _callbacks.applyGraphData();
298
+ _callbacks.updateStats();
299
+ try { _callbacks.fetchTrashItems(); } catch {}
300
+ } catch (e) {
301
+ console.error('deleteMemory error:', e);
302
+ }
303
+ }
304
+
305
+
306
+ // ═══════════════════════════════════════════
307
+ // CATEGORY CHANGE MODAL
308
+ // ═══════════════════════════════════════════
309
+
310
+ function openCategoryChangeModal(card, node) {
311
+ const current = node.payload.category;
312
+
313
+ const overlay = document.createElement('div');
314
+ overlay.className = 'tag-delete-overlay';
315
+
316
+ const parents = [];
317
+ const childrenOf = {};
318
+ state.allCategoryNames.forEach(cat => {
319
+ const meta = state.categoryMeta?.[cat];
320
+ if (meta?.parent) {
321
+ if (!childrenOf[meta.parent]) childrenOf[meta.parent] = [];
322
+ childrenOf[meta.parent].push(cat);
323
+ } else if (meta?.is_parent) {
324
+ parents.push(cat);
325
+ }
326
+ });
327
+
328
+ let listHtml = '';
329
+ state.allCategoryNames.forEach(cat => {
330
+ const desc = state.categoryDescriptions[cat] || '';
331
+ const isActive = cat === current;
332
+ const meta = state.categoryMeta?.[cat];
333
+ const isParent = meta?.is_parent;
334
+ const isChild = !!meta?.parent;
335
+
336
+ if (isParent) {
337
+ const children = childrenOf[cat] || [];
338
+ if (children.length > 0) {
339
+ listHtml += `<div class="cat-modal-group-label">${cat}</div>`;
340
+ } else {
341
+ listHtml += `
342
+ <div class="cat-modal-option${isActive ? ' active' : ''}" data-cat="${cat}">
343
+ <div class="cat-opt-dot" style="color:${catColor(cat)}; background:${catColor(cat)}"></div>
344
+ <div class="cat-opt-info">
345
+ <span class="cat-opt-name">${cat}</span>
346
+ ${desc ? `<span class="cat-opt-desc">${desc}</span>` : ''}
347
+ </div>
348
+ </div>`;
349
+ }
350
+ return;
351
+ }
352
+
353
+ listHtml += `
354
+ <div class="cat-modal-option${isActive ? ' active' : ''}${isChild ? ' cat-modal-child' : ''}" data-cat="${cat}">
355
+ <div class="cat-opt-dot" style="color:${catColor(cat)}; background:${catColor(cat)}"></div>
356
+ <div class="cat-opt-info">
357
+ <span class="cat-opt-name">${cat}</span>
358
+ ${desc ? `<span class="cat-opt-desc">${desc}</span>` : ''}
359
+ </div>
360
+ ${isActive ? '<svg class="cat-opt-check" viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg>' : ''}
361
+ </div>`;
362
+ });
363
+
364
+ overlay.innerHTML = `
365
+ <div class="tag-delete-modal cat-change-modal">
366
+ <div class="cat-change-modal-header">
367
+ <div class="cat-change-modal-title">Move to category</div>
368
+ <div class="cat-change-modal-from">
369
+ <span class="cat-change-from-label">Currently in</span>
370
+ <div class="cat-change-from-badge">
371
+ <div class="cat-dot" style="color:${catColor(current)}; background:${catColor(current)}"></div>
372
+ ${current}
373
+ </div>
374
+ </div>
375
+ </div>
376
+ <div class="cat-change-modal-list">${listHtml}</div>
377
+ </div>
378
+ `;
379
+
380
+ document.body.appendChild(overlay);
381
+
382
+ overlay.querySelectorAll('.cat-modal-option').forEach(opt => {
383
+ opt.addEventListener('click', () => {
384
+ const cat = opt.dataset.cat;
385
+ overlay.remove();
386
+ if (cat !== current) {
387
+ changeMemoryCategory(card, node, cat);
388
+ }
389
+ });
390
+ });
391
+
392
+ overlay.addEventListener('click', (e) => {
393
+ if (e.target === overlay) overlay.remove();
394
+ });
395
+ }
396
+
397
+ async function changeMemoryCategory(card, node, newCategory) {
398
+ const oldCategory = node.payload.category;
399
+ node.payload.category = newCategory;
400
+
401
+ const color = catColor(newCategory);
402
+ const headerDot = q(card, 'header-dot');
403
+ if (headerDot) { headerDot.style.color = color; headerDot.style.background = color; }
404
+ const label = q(card, 'category-label');
405
+ if (label) {
406
+ const txt = newCategory + (node.payload.subcategory ? ' / ' + node.payload.subcategory : '');
407
+ label.textContent = txt;
408
+ label.setAttribute('data-tooltip', txt);
409
+ label.removeAttribute('title');
410
+ label.style.color = color;
411
+ }
412
+
413
+ const presentCats = new Set(state.allNodes.map(n => n.payload.category));
414
+ _callbacks.buildCategorySidebar(presentCats);
415
+ _callbacks.refreshNodeAppearance();
416
+
417
+ try {
418
+ await updateMemory(node.id, { category: newCategory });
419
+ } catch (e) {
420
+ console.error('changeMemoryCategory error:', e);
421
+ node.payload.category = oldCategory;
422
+ const rColor = catColor(oldCategory);
423
+ if (headerDot) { headerDot.style.color = rColor; headerDot.style.background = rColor; }
424
+ if (label) { label.textContent = oldCategory + (node.payload.subcategory ? ' / ' + node.payload.subcategory : ''); label.style.color = rColor; }
425
+ const rCats = new Set(state.allNodes.map(n => n.payload.category));
426
+ _callbacks.buildCategorySidebar(rCats);
427
+ _callbacks.refreshNodeAppearance();
428
+ }
429
+ }
430
+
431
+
432
+ // ═══════════════════════════════════════════
433
+ // CREATE DETAIL CARD DOM
434
+ // ═══════════════════════════════════════════
435
+
436
+ function createDetailCard(panelId) {
437
+ const el = document.createElement('div');
438
+ el.id = panelId;
439
+ el.className = 'detail-card glass resizable';
440
+
441
+ el.innerHTML = `
442
+ <div class="resize-handle resize-handle-t" data-resize="t"></div>
443
+ <div class="resize-handle resize-handle-b" data-resize="b"></div>
444
+ <div class="resize-handle resize-handle-l" data-resize="l"></div>
445
+ <div class="resize-handle resize-handle-r" data-resize="r"></div>
446
+ <div class="resize-handle resize-handle-tl" data-resize="tl"></div>
447
+ <div class="resize-handle resize-handle-tr" data-resize="tr"></div>
448
+ <div class="resize-handle resize-handle-bl" data-resize="bl"></div>
449
+ <div class="resize-handle resize-handle-br" data-resize="br"></div>
450
+ <div class="detail-header drag-handle" data-drag="${panelId}">
451
+ <div class="detail-header-actions">
452
+ <button class="detail-action-btn" data-role="edit-btn" data-tooltip="${t('detail.edit')}"><svg viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
453
+ <button class="detail-action-btn detail-action-btn--gold" data-role="bookmark-btn" data-tooltip="${t('detail.bookmark')}"><svg viewBox="0 0 24 24"><path d="M5 5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16l-7-4-7 4V5z"/></svg></button>
454
+ <button class="detail-action-btn" data-role="move-cat-btn" data-tooltip="${t('detail.moveCat')}"><svg viewBox="0 0 24 24"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg></button>
455
+ <button class="detail-action-btn" data-role="export-btn" data-tooltip="${t('detail.exportMd')}"><svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
456
+ <button class="detail-action-btn detail-action-btn--danger" data-role="delete-btn" data-tooltip="${t('common.delete')}"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>
457
+ <span class="detail-action-btn detail-drag-to-term" draggable="true" data-role="drag-to-term" data-tooltip="Drag to terminal"><svg viewBox="0 0 24 24"><path d="M4 17l6-6-6-6"/><line x1="12" y1="19" x2="20" y2="19"/></svg></span>
458
+ <button class="detail-action-btn" data-role="compact-btn" data-tooltip="${t('detail.compact')}"><svg viewBox="0 0 24 24"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg></button>
459
+ <div class="detail-action-sep"></div>
460
+ <button class="detail-action-btn pin-btn" data-pin="${panelId}" data-tooltip="${t('common.pin')}"><svg viewBox="0 0 24 24"><path d="M9 4v4.5L7.5 10H6v2h4v7l2 1 2-1v-7h4v-2h-1.5L15 8.5V4H9z" stroke-linejoin="round" stroke-linecap="round"/></svg></button>
461
+ <button class="detail-action-btn detail-action-btn--close" data-role="close-btn" data-tooltip="${t('common.close')}">&times;</button>
462
+ </div>
463
+ <div class="detail-header-category">
464
+ <div class="detail-header-dot" data-role="header-dot"></div>
465
+ <span class="detail-header-name" data-role="category-label"></span>
466
+ </div>
467
+ </div>
468
+ <div class="detail-body">
469
+ <div class="detail-subheader">
470
+ <div data-role="importance"></div>
471
+ <button class="detail-chip" data-role="focus-btn" data-tooltip="${t('detail.focusLinked')}"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4m-7.07-14.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83"/></svg>${t('detail.linked')}</button>
472
+ </div>
473
+ <div class="content-edit-wrap">
474
+ <div data-role="content"></div>
475
+ </div>
476
+ <div data-role="tags-section" class="detail-section">
477
+ <div class="detail-label">${t('detail.tags')}</div>
478
+ <div class="tag-chips" data-role="tag-chips"></div>
479
+ </div>
480
+ <div data-role="meta-section" class="detail-section">
481
+ <div class="detail-label">${t('detail.metadata')}</div>
482
+ <div class="detail-meta" data-role="meta-content"></div>
483
+ </div>
484
+ <div data-role="files-section" class="detail-section" style="display:none">
485
+ <div class="detail-label">${t('detail.relatedFiles')}</div>
486
+ <div class="detail-meta" data-role="files-content"></div>
487
+ </div>
488
+ </div>
489
+ `;
490
+
491
+ return el;
492
+ }
493
+
494
+
495
+ // ═══════════════════════════════════════════
496
+ // Z-INDEX & POSITIONING
497
+ // ═══════════════════════════════════════════
498
+
499
+ function bringToFront(cardEl) {
500
+ _topZ++;
501
+ cardEl.style.zIndex = _topZ;
502
+ }
503
+
504
+ function getNextCardPosition() {
505
+ const count = _openCards.size;
506
+ const offset = count * 30;
507
+ return {
508
+ left: Math.min(window.innerWidth - 540, 200 + (offset % 300)) + 'px',
509
+ top: Math.min(window.innerHeight - 300, 80 + (offset % 200)) + 'px',
510
+ };
511
+ }
512
+
513
+
514
+ // ═══════════════════════════════════════════
515
+ // PERSISTENCE
516
+ // ═══════════════════════════════════════════
517
+
518
+ function persistOpenCards() {
519
+ const data = [..._openCards.entries()].map(([id, c]) => ({
520
+ memoryId: id,
521
+ panelId: c.el.id,
522
+ left: c.el.style.left,
523
+ top: c.el.style.top,
524
+ width: c.el.style.width,
525
+ height: c.el.style.height,
526
+ isCompact: c.isCompact,
527
+ }));
528
+ storage.setItem(KEYS.OPEN_CARDS, JSON.stringify(data));
529
+ }
530
+
531
+ /** Return a snapshot of all open cards for workspace saving. */
532
+ export function getOpenCardsSnapshot() {
533
+ return [..._openCards.entries()].map(([id, c]) => ({
534
+ memoryId: id,
535
+ left: c.el.style.left,
536
+ top: c.el.style.top,
537
+ width: c.el.style.width,
538
+ height: c.el.style.height,
539
+ isCompact: c.isCompact,
540
+ }));
541
+ }
542
+
543
+ /** Return the number of currently open cards. */
544
+ export function getOpenCardCount() {
545
+ return _openCards.size;
546
+ }
547
+
548
+ export function restoreOpenCards() {
549
+ const raw = storage.getItem(KEYS.OPEN_CARDS);
550
+ if (!raw) return;
551
+ try {
552
+ const saved = JSON.parse(raw);
553
+ for (const item of saved) {
554
+ const node = state.allNodes.find(n => n.id === item.memoryId);
555
+ if (!node) continue;
556
+ openMemoryCard(node, {
557
+ left: item.left, top: item.top,
558
+ width: item.width, height: item.height,
559
+ isCompact: item.isCompact,
560
+ });
561
+ }
562
+ } catch {}
563
+ }
564
+
565
+
566
+ // ═══════════════════════════════════════════
567
+ // OPEN MEMORY CARD
568
+ // ═══════════════════════════════════════════
569
+
570
+ export function openMemoryCard(node, savedPosition) {
571
+ // If already open, bring to front
572
+ if (_openCards.has(node.id)) {
573
+ const existing = _openCards.get(node.id);
574
+ bringToFront(existing.el);
575
+ state.selectedNodeId = node.id;
576
+ storage.setItem(KEYS.SELECTED_NODE, node.id);
577
+ return;
578
+ }
579
+
580
+ // Soft limit
581
+ if (_openCards.size >= MAX_CARDS) {
582
+ console.warn(`SynaBun: Maximum ${MAX_CARDS} cards open. Close some first.`);
583
+ // Still allow it but warn
584
+ }
585
+
586
+ const panelId = 'detail-card-' + node.id.slice(0, 8);
587
+ const card = createDetailCard(panelId);
588
+ const p = node.payload;
589
+ const color = catColor(p.category);
590
+
591
+ // ── Position ──
592
+ if (savedPosition) {
593
+ if (savedPosition.left) card.style.left = savedPosition.left;
594
+ if (savedPosition.top) card.style.top = savedPosition.top;
595
+ if (savedPosition.width) card.style.width = savedPosition.width;
596
+ if (savedPosition.height) card.style.height = savedPosition.height;
597
+ } else {
598
+ const pos = getNextCardPosition();
599
+ card.style.left = pos.left;
600
+ card.style.top = pos.top;
601
+ }
602
+
603
+ // ── Header ──
604
+ const headerDot = q(card, 'header-dot');
605
+ headerDot.style.color = color;
606
+ headerDot.style.background = color;
607
+ const catLabel = q(card, 'category-label');
608
+ const catText = (p._isOpenClaw ? '[OC] ' : '') + p.category + (p.subcategory ? ' / ' + p.subcategory : '');
609
+ catLabel.textContent = catText;
610
+ catLabel.setAttribute('data-tooltip', catText);
611
+ catLabel.style.color = color;
612
+
613
+ // ── OpenClaw ──
614
+ const isOC = !!p._isOpenClaw;
615
+ if (isOC) {
616
+ q(card, 'move-cat-btn').style.display = 'none';
617
+ q(card, 'delete-btn').style.display = 'none';
618
+ q(card, 'edit-btn').style.display = 'none';
619
+
620
+ const ocSourceEl = document.createElement('div');
621
+ ocSourceEl.style.cssText = 'font-size:11px;color:#f97316;margin-bottom:8px;font-family:"JetBrains Mono",monospace;';
622
+ const source = p._openClawFile ? p._openClawFile : p.category.includes('longterm') ? 'MEMORY.md' : 'Daily Log';
623
+ ocSourceEl.textContent = 'Source: OpenClaw \u2022 ' + source;
624
+ const contentEl = q(card, 'content');
625
+ contentEl.parentNode.insertBefore(ocSourceEl, contentEl);
626
+ }
627
+
628
+ // ── Guest read-only: hide mutation buttons ──
629
+ const _guestRO = isGuest() && !hasPermission('memories');
630
+ if (_guestRO) {
631
+ q(card, 'edit-btn').style.display = 'none';
632
+ q(card, 'delete-btn').style.display = 'none';
633
+ q(card, 'move-cat-btn').style.display = 'none';
634
+ }
635
+
636
+ // ── Actions ──
637
+ q(card, 'move-cat-btn').onclick = () => openCategoryChangeModal(card, node);
638
+
639
+ q(card, 'delete-btn').onclick = () => {
640
+ const overlay = document.createElement('div');
641
+ overlay.className = 'tag-delete-overlay';
642
+ overlay.innerHTML = `
643
+ <div class="tag-delete-modal" style="max-width:380px">
644
+ <div class="tag-delete-modal-title"><svg viewBox="0 0 24 24" style="width:14px;height:14px;stroke:var(--accent-red);stroke-width:2;fill:none;vertical-align:-2px"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg> Move to Trash</div>
645
+ <p style="font-size:var(--fs-sm);color:var(--t-secondary);margin-bottom:6px">Move this memory to trash?</p>
646
+ <p style="font-size:var(--fs-xs);color:var(--t-muted);margin-bottom:18px">You can restore it from the trash panel.</p>
647
+ <div class="tag-delete-modal-actions">
648
+ <button class="action-btn action-btn--ghost del-cancel">Cancel</button>
649
+ <button class="action-btn action-btn--danger del-confirm">Move to Trash</button>
650
+ </div>
651
+ </div>
652
+ `;
653
+ document.body.appendChild(overlay);
654
+ overlay.querySelector('.del-cancel').onclick = () => overlay.remove();
655
+ overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
656
+ overlay.querySelector('.del-confirm').onclick = () => {
657
+ overlay.remove();
658
+ deleteMemoryFromUI(node);
659
+ };
660
+ };
661
+
662
+ // Bookmark
663
+ const bookmarkBtn = q(card, 'bookmark-btn');
664
+ bookmarkBtn.addEventListener('click', () => {
665
+ toggleBookmark(node);
666
+ updateDetailBookmarkBtn(node.id);
667
+ });
668
+ // Set initial bookmark state
669
+ if (isBookmarked(node.id)) {
670
+ bookmarkBtn.classList.add('active');
671
+ const svg = bookmarkBtn.querySelector('svg');
672
+ if (svg) svg.classList.add('filled');
673
+ }
674
+
675
+ // Export
676
+ q(card, 'export-btn').onclick = () => exportMemoryAsMarkdown(node);
677
+
678
+ // Drag to terminal
679
+ const dragGrip = q(card, 'drag-to-term');
680
+ if (dragGrip) {
681
+ dragGrip.addEventListener('dragstart', (e) => {
682
+ e.stopPropagation();
683
+ e.dataTransfer.setData('application/x-synabun-memory', node.id);
684
+ e.dataTransfer.effectAllowed = 'copy';
685
+ card.classList.add('detail-dragging-to-term');
686
+ });
687
+ dragGrip.addEventListener('dragend', () => {
688
+ card.classList.remove('detail-dragging-to-term');
689
+ });
690
+ }
691
+
692
+ // Focus
693
+ const focusBtn = q(card, 'focus-btn');
694
+ focusBtn.classList.toggle('active', state.focusedNodeId === node.id);
695
+ focusBtn.onclick = () => {
696
+ if (state.focusedNodeId === node.id) {
697
+ state.focusedNodeId = null;
698
+ focusBtn.classList.remove('active');
699
+ } else {
700
+ // Deactivate focus button on all other cards
701
+ _openCards.forEach((c) => {
702
+ const fb = q(c.el, 'focus-btn');
703
+ if (fb) fb.classList.remove('active');
704
+ });
705
+ state.focusedNodeId = node.id;
706
+ focusBtn.classList.add('active');
707
+ }
708
+ _callbacks.applyGraphData();
709
+ _callbacks.updateStats();
710
+ };
711
+
712
+ // ── Importance dots ──
713
+ const imp = p.importance || 5;
714
+ const impEl = q(card, 'importance');
715
+ impEl.innerHTML = '';
716
+ for (let i = 1; i <= 10; i++) {
717
+ const dot = document.createElement('span');
718
+ dot.style.cssText = `display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;background:${i <= imp ? '#FFD700' : 'rgba(255,255,255,0.08)'};box-shadow:${i <= imp ? '0 0 4px rgba(255,215,0,0.4)' : 'none'}`;
719
+ impEl.appendChild(dot);
720
+ }
721
+
722
+ // ── Content ──
723
+ const contentEl = q(card, 'content');
724
+ contentEl.innerHTML = formatMemoryContent(p.content);
725
+
726
+ // Edit button
727
+ q(card, 'edit-btn').onclick = () => enterEditMode(card, node);
728
+
729
+ // ── Tags ──
730
+ const tagContainer = q(card, 'tag-chips');
731
+ const currentTags = (p.tags && Array.isArray(p.tags)) ? [...p.tags] : [];
732
+
733
+ function renderEditableTags() {
734
+ tagContainer.innerHTML = '';
735
+ currentTags.forEach(tag => {
736
+ const chip = document.createElement('span');
737
+ chip.className = 'tag-chip';
738
+ const text = document.createElement('span');
739
+ text.className = 'tag-text';
740
+ text.textContent = tag;
741
+ chip.appendChild(text);
742
+
743
+ const rm = document.createElement('span');
744
+ rm.className = 'tag-remove';
745
+ rm.textContent = '\u00d7';
746
+ rm.addEventListener('click', (e) => {
747
+ e.stopPropagation();
748
+ showTagDeleteModal(tag, async () => {
749
+ const idx = currentTags.indexOf(tag);
750
+ if (idx !== -1) currentTags.splice(idx, 1);
751
+ try {
752
+ await updateMemory(node.id, { tags: currentTags });
753
+ node.payload.tags = [...currentTags];
754
+ } catch (e) { console.error('Tag remove failed:', e); }
755
+ renderEditableTags();
756
+ });
757
+ });
758
+ chip.appendChild(rm);
759
+ tagContainer.appendChild(chip);
760
+ });
761
+
762
+ const input = document.createElement('input');
763
+ input.className = 'tag-add-input';
764
+ input.placeholder = '+ add tag';
765
+ input.maxLength = 40;
766
+ input.addEventListener('keydown', async (e) => {
767
+ if (e.key === 'Enter' && input.value.trim()) {
768
+ const newTag = input.value.trim().toLowerCase();
769
+ if (currentTags.includes(newTag)) { input.value = ''; return; }
770
+ currentTags.push(newTag);
771
+ try {
772
+ await updateMemory(node.id, { tags: currentTags });
773
+ node.payload.tags = [...currentTags];
774
+ } catch (e) { console.error('Tag add failed:', e); }
775
+ renderEditableTags();
776
+ }
777
+ });
778
+ tagContainer.appendChild(input);
779
+ }
780
+
781
+ if (isOC || _guestRO) {
782
+ // Read-only tags (no remove button, no add input)
783
+ tagContainer.innerHTML = '';
784
+ currentTags.forEach(tag => {
785
+ const chip = document.createElement('span');
786
+ chip.className = 'tag-chip';
787
+ const text = document.createElement('span');
788
+ text.className = 'tag-text';
789
+ text.textContent = tag;
790
+ chip.appendChild(text);
791
+ tagContainer.appendChild(chip);
792
+ });
793
+ } else {
794
+ renderEditableTags();
795
+ }
796
+
797
+ // ── Metadata ──
798
+ const meta = q(card, 'meta-content');
799
+ meta.innerHTML = '';
800
+ const addMeta = (label, value) => {
801
+ if (!value) return;
802
+ const row = document.createElement('div');
803
+ row.className = 'detail-meta-row';
804
+ row.innerHTML = `<span style="color:rgba(255,255,255,0.3)">${label}</span><span>${value}</span>`;
805
+ meta.appendChild(row);
806
+ };
807
+ addMeta('Project', p.project);
808
+ addMeta('Source', p.source);
809
+ addMeta('Created', p.created_at ? new Date(p.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : null);
810
+ addMeta('Updated', p.updated_at ? new Date(p.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : null);
811
+ addMeta('Accessed', p.access_count ? `${p.access_count} times` : null);
812
+
813
+ // ID row with copy
814
+ {
815
+ const idRow = document.createElement('div');
816
+ idRow.className = 'detail-meta-row';
817
+ idRow.innerHTML = `<span style="color:rgba(255,255,255,0.3)">ID</span><span>${truncate(node.id, 20)}<button class="meta-copy-btn" data-tooltip="Copy full ID">Copy</button></span>`;
818
+ idRow.querySelector('.meta-copy-btn').addEventListener('click', () => {
819
+ navigator.clipboard.writeText(node.id).then(() => {
820
+ const btn = idRow.querySelector('.meta-copy-btn');
821
+ btn.textContent = 'Copied!';
822
+ btn.classList.add('copied');
823
+ setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
824
+ });
825
+ });
826
+ meta.appendChild(idRow);
827
+ }
828
+
829
+ // ── Related files ──
830
+ const filesDiv = q(card, 'files-section');
831
+ const filesContent = q(card, 'files-content');
832
+ if (p.related_files && p.related_files.length) {
833
+ filesContent.innerHTML = p.related_files.map(f =>
834
+ `<div style="font-family:'JetBrains Mono','SF Mono',Consolas,monospace;font-size:12px;color:rgba(255,255,255,0.45);padding:2px 0">${f}</div>`
835
+ ).join('');
836
+ filesDiv.style.display = '';
837
+ } else {
838
+ filesDiv.style.display = 'none';
839
+ }
840
+
841
+ // ── Close button ──
842
+ q(card, 'close-btn').addEventListener('click', () => closeMemoryCard(node.id));
843
+
844
+ // ── Compact toggle ──
845
+ const compactBtn = q(card, 'compact-btn');
846
+ const body = card.querySelector('.detail-body');
847
+ const shrinkIcon = '<svg viewBox="0 0 24 24"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
848
+ const expandIcon = '<svg viewBox="0 0 24 24"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
849
+
850
+ const cardState = {
851
+ el: card,
852
+ node,
853
+ isCompact: false,
854
+ isEditing: false,
855
+ savedExpanded: null,
856
+ _animating: false,
857
+ };
858
+
859
+ compactBtn.addEventListener('click', () => {
860
+ if (cardState._animating) return;
861
+ cardState._animating = true;
862
+
863
+ const goingCompact = !cardState.isCompact;
864
+ compactBtn.innerHTML = goingCompact ? expandIcon : shrinkIcon;
865
+ compactBtn.dataset.tooltip = goingCompact ? 'Expand' : 'Compact';
866
+
867
+ if (goingCompact) {
868
+ // ── EXPAND → COMPACT (FLIP) ──
869
+ const startRect = card.getBoundingClientRect();
870
+
871
+ // Freeze at current size, disable transitions, apply compact layout
872
+ card.classList.add('no-transition');
873
+ card.classList.add('compact');
874
+ card.style.width = startRect.width + 'px';
875
+ card.style.height = startRect.height + 'px';
876
+ card.style.maxHeight = 'none';
877
+ if (body) { body.classList.add('drag-handle'); body.dataset.drag = panelId; }
878
+ void card.offsetHeight; // force reflow
879
+
880
+ // Measure compact target
881
+ card.style.width = '';
882
+ card.style.height = '';
883
+ void card.offsetHeight;
884
+ const endRect = card.getBoundingClientRect();
885
+
886
+ // Reset to start dimensions
887
+ card.style.width = startRect.width + 'px';
888
+ card.style.height = startRect.height + 'px';
889
+ void card.offsetHeight;
890
+
891
+ // Re-enable transitions and animate to compact
892
+ card.classList.remove('no-transition');
893
+ requestAnimationFrame(() => {
894
+ card.style.width = endRect.width + 'px';
895
+ card.style.height = endRect.height + 'px';
896
+ });
897
+
898
+ const onEnd = (e) => {
899
+ if (e.propertyName !== 'width' && e.propertyName !== 'height') return;
900
+ card.removeEventListener('transitionend', onEnd);
901
+ card.style.width = '';
902
+ card.style.height = '';
903
+ card.style.maxHeight = '';
904
+ cardState.isCompact = true;
905
+ cardState._animating = false;
906
+ persistOpenCards();
907
+ if (!_isRemoteCardOp) sendSync({ type: 'card:compacted', memoryId: node.id });
908
+ };
909
+ card.addEventListener('transitionend', onEnd);
910
+
911
+ // Safety timeout in case transitionend doesn't fire
912
+ setTimeout(() => {
913
+ if (cardState._animating) {
914
+ card.style.width = '';
915
+ card.style.height = '';
916
+ card.style.maxHeight = '';
917
+ cardState.isCompact = true;
918
+ cardState._animating = false;
919
+ persistOpenCards();
920
+ if (!_isRemoteCardOp) sendSync({ type: 'card:compacted', memoryId: node.id });
921
+ }
922
+ }, 300);
923
+
924
+ } else {
925
+ // ── COMPACT → EXPAND (FLIP) ──
926
+ const startRect = card.getBoundingClientRect();
927
+
928
+ // Disable transitions, remove compact, let CSS default take over
929
+ card.classList.add('no-transition');
930
+ card.classList.remove('compact');
931
+ card.style.width = '';
932
+ card.style.height = '';
933
+ card.style.maxHeight = '';
934
+ if (body) { body.classList.remove('drag-handle'); delete body.dataset.drag; }
935
+ void card.offsetHeight;
936
+
937
+ // Measure natural expanded size (CSS default: 520px width, auto height)
938
+ const endRect = card.getBoundingClientRect();
939
+
940
+ // Reset to compact starting dimensions
941
+ card.style.width = startRect.width + 'px';
942
+ card.style.height = startRect.height + 'px';
943
+ void card.offsetHeight;
944
+
945
+ // Re-enable transitions and animate to expanded
946
+ card.classList.remove('no-transition');
947
+ requestAnimationFrame(() => {
948
+ card.style.width = endRect.width + 'px';
949
+ card.style.height = endRect.height + 'px';
950
+ });
951
+
952
+ const onEnd = (e) => {
953
+ if (e.propertyName !== 'width' && e.propertyName !== 'height') return;
954
+ card.removeEventListener('transitionend', onEnd);
955
+ card.style.width = '';
956
+ card.style.height = '';
957
+ card.style.maxHeight = '';
958
+ cardState.isCompact = false;
959
+ cardState._animating = false;
960
+ persistOpenCards();
961
+ if (!_isRemoteCardOp) sendSync({ type: 'card:expanded', memoryId: node.id });
962
+ };
963
+ card.addEventListener('transitionend', onEnd);
964
+
965
+ // Safety timeout
966
+ setTimeout(() => {
967
+ if (cardState._animating) {
968
+ card.style.width = '';
969
+ card.style.height = '';
970
+ card.style.maxHeight = '';
971
+ cardState.isCompact = false;
972
+ cardState._animating = false;
973
+ persistOpenCards();
974
+ if (!_isRemoteCardOp) sendSync({ type: 'card:expanded', memoryId: node.id });
975
+ }
976
+ }, 300);
977
+ }
978
+ });
979
+
980
+ // Apply saved compact state
981
+ if (savedPosition?.isCompact) {
982
+ card.classList.add('compact');
983
+ cardState.isCompact = true;
984
+ compactBtn.innerHTML = expandIcon;
985
+ compactBtn.dataset.tooltip = 'Expand';
986
+ if (body) { body.classList.add('drag-handle'); body.dataset.drag = panelId; }
987
+ }
988
+
989
+ // ── Z-index: bring to front on click ──
990
+ card.addEventListener('mousedown', () => bringToFront(card));
991
+
992
+ // ── Pin: restore saved pin state ──
993
+ const pinKey = 'neural-pinned-' + panelId;
994
+ if (storage.getItem(pinKey) === 'true') {
995
+ card.classList.add('locked');
996
+ const pinBtn = card.querySelector('.pin-btn');
997
+ if (pinBtn) pinBtn.classList.add('pinned');
998
+ }
999
+
1000
+ // ── Add to DOM and state ──
1001
+ _openCards.set(node.id, cardState);
1002
+ document.body.appendChild(card);
1003
+ bringToFront(card);
1004
+
1005
+ // Trigger entrance animation on next frame
1006
+ requestAnimationFrame(() => card.classList.add('open'));
1007
+
1008
+ state.selectedNodeId = node.id;
1009
+ storage.setItem(KEYS.SELECTED_NODE, node.id);
1010
+ emit('detail:opened', { nodeId: node.id });
1011
+ persistOpenCards();
1012
+
1013
+ // Sync to other clients
1014
+ if (!_isRemoteCardOp) {
1015
+ sendSync({
1016
+ type: 'card:opened',
1017
+ memoryId: node.id,
1018
+ left: card.style.left,
1019
+ top: card.style.top,
1020
+ width: card.style.width,
1021
+ height: card.style.height,
1022
+ isCompact: cardState.isCompact,
1023
+ });
1024
+ }
1025
+ }
1026
+
1027
+
1028
+ // ═══════════════════════════════════════════
1029
+ // CLOSE MEMORY CARD
1030
+ // ═══════════════════════════════════════════
1031
+
1032
+ export function closeMemoryCard(memoryId) {
1033
+ const cardState = _openCards.get(memoryId);
1034
+ if (!cardState) return;
1035
+
1036
+ const card = cardState.el;
1037
+
1038
+ // Exit edit mode if active
1039
+ if (cardState.isEditing) {
1040
+ exitEditMode(card, cardState.node, false);
1041
+ }
1042
+
1043
+ // Fade out
1044
+ card.classList.remove('open');
1045
+
1046
+ // Remove after transition
1047
+ setTimeout(() => {
1048
+ card.remove();
1049
+ }, 200);
1050
+
1051
+ _openCards.delete(memoryId);
1052
+
1053
+ // Update selection
1054
+ if (state.selectedNodeId === memoryId) {
1055
+ if (_openCards.size > 0) {
1056
+ const lastKey = [..._openCards.keys()].pop();
1057
+ state.selectedNodeId = lastKey;
1058
+ storage.setItem(KEYS.SELECTED_NODE, lastKey);
1059
+ } else {
1060
+ state.selectedNodeId = null;
1061
+ storage.removeItem(KEYS.SELECTED_NODE);
1062
+ }
1063
+ }
1064
+
1065
+ // Clear focus if it was on this card
1066
+ if (state.focusedNodeId === memoryId) {
1067
+ state.focusedNodeId = null;
1068
+ _callbacks.applyGraphData();
1069
+ _callbacks.updateStats();
1070
+ }
1071
+
1072
+ if (state.multiSelected.size > 0) _callbacks.clearMultiSelect();
1073
+
1074
+ emit('detail:closed');
1075
+ persistOpenCards();
1076
+
1077
+ // Sync to other clients
1078
+ if (!_isRemoteCardOp) {
1079
+ sendSync({ type: 'card:closed', memoryId });
1080
+ }
1081
+ }
1082
+
1083
+ export function closeAllCards() {
1084
+ const ids = [..._openCards.keys()];
1085
+ ids.forEach(id => closeMemoryCard(id));
1086
+ }
1087
+
1088
+
1089
+ // ═══════════════════════════════════════════
1090
+ // BACKWARD COMPAT + INIT
1091
+ // ═══════════════════════════════════════════
1092
+
1093
+ // Backward compatibility aliases
1094
+ export const showDetailPanel = openMemoryCard;
1095
+ export const closeDetailPanel = closeAllCards;
1096
+
1097
+ export function initDetailPanel() {
1098
+ // Event bus listeners
1099
+ on('detail:show', ({ nodeId }) => {
1100
+ const node = state.allNodes.find(n => n.id === nodeId);
1101
+ if (node) openMemoryCard(node);
1102
+ });
1103
+
1104
+ on('detail:close', () => {
1105
+ closeAllCards();
1106
+ });
1107
+
1108
+ // Persist open card positions after resize/drag and sync to other clients
1109
+ on('detail:layout-changed', () => {
1110
+ persistOpenCards();
1111
+ // Send move/resize sync for all open cards (the event doesn't tell us which card changed,
1112
+ // so we sync all — the receiving end ignores unchanged values)
1113
+ if (!_isRemoteCardOp) {
1114
+ for (const [id, c] of _openCards) {
1115
+ sendSync({
1116
+ type: 'card:moved',
1117
+ memoryId: id,
1118
+ left: c.el.style.left,
1119
+ top: c.el.style.top,
1120
+ width: c.el.style.width,
1121
+ height: c.el.style.height,
1122
+ });
1123
+ }
1124
+ }
1125
+ });
1126
+
1127
+ // ── Card sync from other clients ──
1128
+ on('sync:card:opened', (msg) => {
1129
+ const node = state.allNodes.find(n => n.id === msg.memoryId);
1130
+ if (!node) return;
1131
+ _isRemoteCardOp = true;
1132
+ openMemoryCard(node, {
1133
+ left: msg.left, top: msg.top,
1134
+ width: msg.width, height: msg.height,
1135
+ isCompact: msg.isCompact,
1136
+ });
1137
+ _isRemoteCardOp = false;
1138
+ });
1139
+
1140
+ on('sync:card:closed', (msg) => {
1141
+ _isRemoteCardOp = true;
1142
+ closeMemoryCard(msg.memoryId);
1143
+ _isRemoteCardOp = false;
1144
+ });
1145
+
1146
+ on('sync:card:moved', (msg) => {
1147
+ const c = _openCards.get(msg.memoryId);
1148
+ if (!c) return;
1149
+ if (msg.left) c.el.style.left = msg.left;
1150
+ if (msg.top) c.el.style.top = msg.top;
1151
+ if (msg.width) c.el.style.width = msg.width;
1152
+ if (msg.height) c.el.style.height = msg.height;
1153
+ persistOpenCards();
1154
+ });
1155
+
1156
+ on('sync:card:resized', (msg) => {
1157
+ const c = _openCards.get(msg.memoryId);
1158
+ if (!c) return;
1159
+ if (msg.width) c.el.style.width = msg.width;
1160
+ if (msg.height) c.el.style.height = msg.height;
1161
+ persistOpenCards();
1162
+ });
1163
+
1164
+ on('sync:card:compacted', (msg) => {
1165
+ const c = _openCards.get(msg.memoryId);
1166
+ if (!c || c.isCompact) return;
1167
+ // Simple compact without animation for remote
1168
+ c.el.classList.add('compact');
1169
+ c.el.style.width = '';
1170
+ c.el.style.height = '';
1171
+ c.el.style.maxHeight = '';
1172
+ c.isCompact = true;
1173
+ const body = q(c.el, 'body');
1174
+ if (body) { body.classList.add('drag-handle'); body.dataset.drag = c.el.id; }
1175
+ const compactBtn = q(c.el, 'compact-btn');
1176
+ if (compactBtn) {
1177
+ compactBtn.innerHTML = '<svg viewBox="0 0 24 24"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
1178
+ compactBtn.dataset.tooltip = 'Expand';
1179
+ }
1180
+ persistOpenCards();
1181
+ });
1182
+
1183
+ on('sync:card:expanded', (msg) => {
1184
+ const c = _openCards.get(msg.memoryId);
1185
+ if (!c || !c.isCompact) return;
1186
+ c.el.classList.remove('compact');
1187
+ c.el.style.width = '';
1188
+ c.el.style.height = '';
1189
+ c.el.style.maxHeight = '';
1190
+ c.isCompact = false;
1191
+ const body = q(c.el, 'body');
1192
+ if (body) { body.classList.remove('drag-handle'); delete body.dataset.drag; }
1193
+ const compactBtn = q(c.el, 'compact-btn');
1194
+ if (compactBtn) {
1195
+ compactBtn.innerHTML = '<svg viewBox="0 0 24 24"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
1196
+ compactBtn.dataset.tooltip = 'Compact';
1197
+ }
1198
+ persistOpenCards();
1199
+ });
1200
+
1201
+ // ── Update all open cards when guest permission state arrives or changes ──
1202
+ function updateCardsPermissionState() {
1203
+ const ro = isGuest() && !hasPermission('memories');
1204
+ for (const [, entry] of _openCards) {
1205
+ const card = entry.el;
1206
+ const editBtn = q(card, 'edit-btn');
1207
+ const deleteBtn = q(card, 'delete-btn');
1208
+ const moveCatBtn = q(card, 'move-cat-btn');
1209
+ if (editBtn) editBtn.style.display = ro ? 'none' : '';
1210
+ if (deleteBtn) deleteBtn.style.display = ro ? 'none' : '';
1211
+ if (moveCatBtn) moveCatBtn.style.display = ro ? 'none' : '';
1212
+ // Hide tag add inputs, show/hide tag remove buttons
1213
+ card.querySelectorAll('.tag-remove').forEach(el => el.style.display = ro ? 'none' : '');
1214
+ card.querySelectorAll('.tag-add-input').forEach(el => el.style.display = ro ? 'none' : '');
1215
+ }
1216
+ }
1217
+ on('session:info', updateCardsPermissionState);
1218
+ on('permissions:changed', updateCardsPermissionState);
1219
+
1220
+ // ── /ws/cards — MCP remote control channel ──
1221
+ _connectCardsWs();
1222
+ }
1223
+
1224
+ // ═══════════════════════════════════════════
1225
+ // CARDS WEBSOCKET — MCP Remote Control
1226
+ // ═══════════════════════════════════════════
1227
+
1228
+ let _cardsWs = null;
1229
+ let _cardsWsRetry = null;
1230
+
1231
+ function _connectCardsWs() {
1232
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
1233
+ _cardsWs = new WebSocket(`${protocol}//${location.host}/ws/cards`);
1234
+
1235
+ _cardsWs.onopen = () => {
1236
+ console.log('[ws-cards] Connected');
1237
+ if (_cardsWsRetry) { clearTimeout(_cardsWsRetry); _cardsWsRetry = null; }
1238
+ _cardsWs.send(JSON.stringify({ type: 'init', openCount: _openCards.size }));
1239
+ // Report viewport for percentage-based card positioning
1240
+ _cardsWs.send(JSON.stringify({ type: 'viewport', width: window.innerWidth, height: window.innerHeight }));
1241
+ };
1242
+
1243
+ _cardsWs.onmessage = (event) => {
1244
+ try { _handleCardsWsMessage(JSON.parse(event.data)); } catch (e) {
1245
+ console.warn('[ws-cards] Message parse error:', e);
1246
+ }
1247
+ };
1248
+
1249
+ _cardsWs.onclose = () => {
1250
+ console.log('[ws-cards] Disconnected, retrying in 5s');
1251
+ _cardsWsRetry = setTimeout(_connectCardsWs, 5000);
1252
+ };
1253
+
1254
+ _cardsWs.onerror = () => { /* onclose will fire next */ };
1255
+ }
1256
+
1257
+ // Debounced viewport resize reporting for cards
1258
+ let _cardsViewportTimer = null;
1259
+ window.addEventListener('resize', () => {
1260
+ clearTimeout(_cardsViewportTimer);
1261
+ _cardsViewportTimer = setTimeout(() => {
1262
+ if (_cardsWs && _cardsWs.readyState === 1) {
1263
+ _cardsWs.send(JSON.stringify({ type: 'viewport', width: window.innerWidth, height: window.innerHeight }));
1264
+ }
1265
+ }, 300);
1266
+ });
1267
+
1268
+ function _sendCardsWs(msg) {
1269
+ if (_cardsWs?.readyState === 1) {
1270
+ _cardsWs.send(JSON.stringify(msg));
1271
+ }
1272
+ }
1273
+
1274
+ function _sendCardsAck(requestId, ok, result) {
1275
+ _sendCardsWs({ type: 'ack', requestId, ok, ...(ok ? { result } : { error: result }) });
1276
+ }
1277
+
1278
+ async function _handleCardsWsMessage(msg) {
1279
+ const { type, requestId } = msg;
1280
+
1281
+ // ── Open a card by memory UUID ──
1282
+ if (type === 'card:open-request') {
1283
+ let node = state.allNodes.find(n => n.id === msg.memoryId);
1284
+
1285
+ // If node not in loaded state, try fetching from server
1286
+ if (!node) {
1287
+ try {
1288
+ const res = await fetch(`/api/memory/${msg.memoryId}`);
1289
+ if (res.ok) {
1290
+ const data = await res.json();
1291
+ if (data.id && data.payload) {
1292
+ node = { id: data.id, payload: data.payload };
1293
+ } else if (data.point) {
1294
+ node = { id: data.point.id, payload: data.point.payload };
1295
+ }
1296
+ }
1297
+ } catch { /* ignore */ }
1298
+ }
1299
+
1300
+ if (!node) {
1301
+ _sendCardsAck(requestId, false, 'Memory not found. Try refreshing the Neural Interface.');
1302
+ return;
1303
+ }
1304
+
1305
+ const alreadyOpen = _openCards.has(msg.memoryId);
1306
+ _isRemoteCardOp = true;
1307
+
1308
+ const savedPos = {};
1309
+ if (msg.left !== undefined) savedPos.left = msg.left + 'px';
1310
+ if (msg.top !== undefined) savedPos.top = msg.top + 'px';
1311
+ if (msg.compact !== undefined) savedPos.isCompact = msg.compact;
1312
+
1313
+ openMemoryCard(node, Object.keys(savedPos).length ? savedPos : undefined);
1314
+ _isRemoteCardOp = false;
1315
+
1316
+ const c = _openCards.get(msg.memoryId);
1317
+ _sendCardsAck(requestId, true, {
1318
+ memoryId: msg.memoryId,
1319
+ left: c?.el.style.left || '',
1320
+ top: c?.el.style.top || '',
1321
+ width: c?.el.style.width || '',
1322
+ height: c?.el.style.height || '',
1323
+ isCompact: c?.isCompact || false,
1324
+ isPinned: c?.el.classList.contains('locked') || false,
1325
+ alreadyOpen,
1326
+ });
1327
+ return;
1328
+ }
1329
+
1330
+ // ── Close one or all cards ──
1331
+ if (type === 'card:close-request') {
1332
+ _isRemoteCardOp = true;
1333
+ if (!msg.memoryId) {
1334
+ const count = _openCards.size;
1335
+ closeAllCards();
1336
+ _isRemoteCardOp = false;
1337
+ _sendCardsAck(requestId, true, { closed: 'all', count });
1338
+ } else {
1339
+ if (!_openCards.has(msg.memoryId)) {
1340
+ _isRemoteCardOp = false;
1341
+ _sendCardsAck(requestId, false, 'Card not open');
1342
+ return;
1343
+ }
1344
+ closeMemoryCard(msg.memoryId);
1345
+ _isRemoteCardOp = false;
1346
+ _sendCardsAck(requestId, true, { memoryId: msg.memoryId });
1347
+ }
1348
+ return;
1349
+ }
1350
+
1351
+ // ── Update card state (position, size, compact, pin) ──
1352
+ if (type === 'card:update-request') {
1353
+ const c = _openCards.get(msg.memoryId);
1354
+ if (!c) {
1355
+ _sendCardsAck(requestId, false, 'Card not open');
1356
+ return;
1357
+ }
1358
+
1359
+ const u = msg.updates || {};
1360
+ _isRemoteCardOp = true;
1361
+
1362
+ // Position
1363
+ if (u.left !== undefined) c.el.style.left = u.left + 'px';
1364
+ if (u.top !== undefined) c.el.style.top = u.top + 'px';
1365
+
1366
+ // Size (only in expanded mode)
1367
+ if (u.width !== undefined && !c.isCompact) c.el.style.width = u.width + 'px';
1368
+ if (u.height !== undefined && !c.isCompact) c.el.style.height = u.height + 'px';
1369
+
1370
+ // Compact / Expand (skip animation for remote ops — just apply end state)
1371
+ if (u.compact === true && !c.isCompact) {
1372
+ c.el.classList.add('compact');
1373
+ c.el.style.width = '';
1374
+ c.el.style.height = '';
1375
+ c.el.style.maxHeight = '';
1376
+ const body = c.el.querySelector('.detail-body');
1377
+ if (body) { body.classList.add('drag-handle'); body.dataset.drag = c.el.id; }
1378
+ c.isCompact = true;
1379
+ // Update compact button icon
1380
+ const compactBtn = q(c.el, 'compact-btn');
1381
+ if (compactBtn) {
1382
+ compactBtn.innerHTML = '<svg viewBox="0 0 24 24"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
1383
+ compactBtn.dataset.tooltip = 'Expand';
1384
+ }
1385
+ } else if (u.compact === false && c.isCompact) {
1386
+ c.el.classList.remove('compact');
1387
+ c.el.style.width = '';
1388
+ c.el.style.height = '';
1389
+ c.el.style.maxHeight = '';
1390
+ const body = c.el.querySelector('.detail-body');
1391
+ if (body) { body.classList.remove('drag-handle'); delete body.dataset.drag; }
1392
+ c.isCompact = false;
1393
+ const compactBtn = q(c.el, 'compact-btn');
1394
+ if (compactBtn) {
1395
+ compactBtn.innerHTML = '<svg viewBox="0 0 24 24"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
1396
+ compactBtn.dataset.tooltip = 'Compact';
1397
+ }
1398
+ }
1399
+
1400
+ // Pin / Unpin
1401
+ if (u.pinned === true && !c.el.classList.contains('locked')) {
1402
+ c.el.classList.add('locked');
1403
+ const pinBtn = c.el.querySelector('.pin-btn');
1404
+ if (pinBtn) pinBtn.classList.add('pinned');
1405
+ storage.setItem('neural-pinned-' + c.el.id, 'true');
1406
+ } else if (u.pinned === false && c.el.classList.contains('locked')) {
1407
+ c.el.classList.remove('locked');
1408
+ const pinBtn = c.el.querySelector('.pin-btn');
1409
+ if (pinBtn) pinBtn.classList.remove('pinned');
1410
+ storage.removeItem('neural-pinned-' + c.el.id);
1411
+ }
1412
+
1413
+ persistOpenCards();
1414
+ _isRemoteCardOp = false;
1415
+
1416
+ _sendCardsAck(requestId, true, {
1417
+ memoryId: msg.memoryId,
1418
+ left: c.el.style.left,
1419
+ top: c.el.style.top,
1420
+ width: c.el.style.width,
1421
+ height: c.el.style.height,
1422
+ isCompact: c.isCompact,
1423
+ isPinned: c.el.classList.contains('locked'),
1424
+ });
1425
+ return;
1426
+ }
1427
+
1428
+ // ── Screenshot ──
1429
+ if (type === 'screenshot:request') {
1430
+ _captureCardsScreenshot(requestId);
1431
+ return;
1432
+ }
1433
+ }
1434
+
1435
+ async function _captureCardsScreenshot(requestId) {
1436
+ try {
1437
+ // Use html2canvas if available (loaded from CDN in html-shell.js)
1438
+ if (typeof html2canvas === 'function') {
1439
+ const canvas = await html2canvas(document.body, {
1440
+ backgroundColor: '#0a0a0f',
1441
+ useCORS: true,
1442
+ scale: 1,
1443
+ logging: false,
1444
+ });
1445
+ const base64 = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
1446
+ _sendCardsWs({ type: 'screenshot:response', requestId, data: base64 });
1447
+ } else {
1448
+ // Fallback: use canvas API to capture a simple screenshot
1449
+ _sendCardsWs({ type: 'screenshot:error', requestId, error: 'html2canvas not loaded. Add the script to html-shell.js.' });
1450
+ }
1451
+ } catch (err) {
1452
+ _sendCardsWs({ type: 'screenshot:error', requestId, error: err.message || 'Screenshot capture failed' });
1453
+ }
1454
+ }