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,1905 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — Category Sidebar
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // Complete category sidebar: build/rebuild, CRUD modals,
6
+ // color picker, drag-reorder, category logos, visibility toggle.
7
+ //
8
+ // Events emitted:
9
+ // categories:changed — after any category metadata mutation (create/edit/delete/reorder)
10
+ // graph:refresh — request variant to re-apply graph data
11
+ // sidebar:rebuilt — after the sidebar DOM has been rebuilt
12
+ // graph:nodeThreeObject — request variant to refresh node visuals (3D only)
13
+ // trash:refresh — request trash badge update after category delete
14
+ //
15
+ // Events listened:
16
+ // sidebar:rebuild — trigger a full sidebar rebuild
17
+
18
+ import { state, emit, on } from './state.js';
19
+ import { KEYS, COLOR_PALETTE } from './constants.js';
20
+ import { storage } from './storage.js';
21
+ import { normalizeNodes } from './utils.js';
22
+ import { catColor, upgradeSelect } from './colors.js';
23
+ import { isGuest, hasPermission, showGuestToast } from './ui-sync.js';
24
+ import {
25
+ fetchCategories,
26
+ createCategory as apiCreateCategory,
27
+ updateCategory as apiUpdateCategory,
28
+ patchCategory as apiPatchCategory,
29
+ deleteCategory as apiDeleteCategory,
30
+ exportCategory as apiExportCategory,
31
+ uploadCategoryLogo as apiUploadCategoryLogo,
32
+ deleteCategoryLogo as apiDeleteCategoryLogo,
33
+ fetchMemories,
34
+ updateMemory,
35
+ } from './api.js';
36
+
37
+ const $ = (id) => document.getElementById(id);
38
+
39
+ // ─── Category logos — preloaded map for parent anchor nodes ──────
40
+ const _categoryLogos = new Map();
41
+
42
+ function preloadCategoryLogos(categories) {
43
+ const names = new Set(categories.map(c => c.name));
44
+ for (const [k] of _categoryLogos) { if (!names.has(k)) _categoryLogos.delete(k); }
45
+ for (const cat of categories) {
46
+ if (!cat.logo) continue;
47
+ const existing = _categoryLogos.get(cat.name);
48
+ if (existing && existing.src === cat.logo) continue;
49
+ const img = new Image();
50
+ const entry = { img, ready: false, src: cat.logo };
51
+ img.onload = () => { entry.ready = true; };
52
+ img.onerror = () => { _categoryLogos.delete(cat.name); };
53
+ img.src = cat.logo;
54
+ _categoryLogos.set(cat.name, entry);
55
+ }
56
+ }
57
+
58
+ /** Expose the logo map so the 3D variant can read it for anchor textures. */
59
+ export { _categoryLogos as categoryLogos };
60
+
61
+ /**
62
+ * Fetch categories from the API and populate state.
63
+ * This mirrors what the legacy monolithic fetchCategories() did —
64
+ * the api.js version is a dumb data fetcher, so we need this wrapper
65
+ * to fill state.allCategoryNames, state.categoryDescriptions, and
66
+ * state.categoryMetadata with parent/color/is_parent/logo data.
67
+ */
68
+ export async function loadCategories() {
69
+ try {
70
+ const data = await fetchCategories();
71
+ state.allCategoryNames = data.categories.map(c => c.name);
72
+ state.categoryDescriptions = {};
73
+ state.categoryMetadata = {};
74
+ data.categories.forEach(c => {
75
+ state.categoryDescriptions[c.name] = c.description;
76
+ state.categoryMetadata[c.name] = {
77
+ parent: c.parent,
78
+ color: c.color,
79
+ is_parent: c.is_parent,
80
+ logo: c.logo,
81
+ };
82
+ });
83
+ preloadCategoryLogos(data.categories);
84
+ } catch (e) {
85
+ console.error('loadCategories error:', e);
86
+ }
87
+ }
88
+
89
+ // ─── Graph removal scheduling ────────────────────────────────────
90
+ // When a category is toggled off we schedule a delayed removal so the
91
+ // fade-out animation plays before nodes are actually removed from the graph.
92
+ let graphRemovalTimer = null;
93
+
94
+ function scheduleGraphRemoval(delay = 600) {
95
+ emit('links:dirty'); // immediately hide links for hidden categories
96
+ if (graphRemovalTimer) clearTimeout(graphRemovalTimer);
97
+ graphRemovalTimer = setTimeout(() => {
98
+ graphRemovalTimer = null;
99
+ emit('graph:refresh');
100
+ }, delay);
101
+ }
102
+
103
+ function cancelScheduledRemoval() {
104
+ if (graphRemovalTimer) {
105
+ clearTimeout(graphRemovalTimer);
106
+ graphRemovalTimer = null;
107
+ }
108
+ }
109
+
110
+ // ─── Saved category order ────────────────────────────────────────
111
+
112
+ function getCategoryOrder() {
113
+ try { return JSON.parse(storage.getItem(KEYS.CATEGORY_ORDER) || '[]'); }
114
+ catch { return []; }
115
+ }
116
+
117
+ function saveCategoryOrder(order) {
118
+ storage.setItem(KEYS.CATEGORY_ORDER, JSON.stringify(order));
119
+ }
120
+
121
+ // ─── Color picker ────────────────────────────────────────────────
122
+
123
+ function closeColorPicker() {
124
+ const existing = document.querySelector('.color-edit-picker');
125
+ if (existing) existing.remove();
126
+ }
127
+
128
+ function openColorPicker(cat, chipEl) {
129
+ if (isGuest() && !hasPermission('memories')) return;
130
+ closeColorPicker();
131
+
132
+ const picker = document.createElement('div');
133
+ picker.className = 'color-edit-picker';
134
+
135
+ const row = document.createElement('div');
136
+ row.className = 'color-swatch-row';
137
+
138
+ const currentColor = catColor(cat);
139
+
140
+ COLOR_PALETTE.forEach(color => {
141
+ const swatch = document.createElement('button');
142
+ swatch.className = 'color-swatch' + (color === currentColor ? ' selected' : '');
143
+ swatch.style.background = color;
144
+ swatch.addEventListener('click', (e) => {
145
+ e.stopPropagation();
146
+ applyCategoryColor(cat, color);
147
+ closeColorPicker();
148
+ });
149
+ row.appendChild(swatch);
150
+ });
151
+
152
+ picker.appendChild(row);
153
+
154
+ // Reset to default button
155
+ const overrides = JSON.parse(storage.getItem(KEYS.CATEGORY_COLORS) || '{}');
156
+ if (overrides[cat]) {
157
+ const resetBtn = document.createElement('button');
158
+ resetBtn.className = 'color-edit-reset';
159
+ resetBtn.textContent = 'Reset to default';
160
+ resetBtn.addEventListener('click', (e) => {
161
+ e.stopPropagation();
162
+ applyCategoryColor(cat, null);
163
+ closeColorPicker();
164
+ });
165
+ picker.appendChild(resetBtn);
166
+ }
167
+
168
+ chipEl.after(picker);
169
+
170
+ // Close on outside click (deferred so current click doesn't trigger it)
171
+ setTimeout(() => {
172
+ function onOutsideClick(e) {
173
+ if (!picker.contains(e.target)) {
174
+ closeColorPicker();
175
+ document.removeEventListener('click', onOutsideClick);
176
+ }
177
+ }
178
+ document.addEventListener('click', onOutsideClick);
179
+ }, 0);
180
+ }
181
+
182
+ function applyCategoryColor(cat, color) {
183
+ const overrides = JSON.parse(storage.getItem(KEYS.CATEGORY_COLORS) || '{}');
184
+ if (color === null) {
185
+ delete overrides[cat];
186
+ } else {
187
+ overrides[cat] = color;
188
+ }
189
+ storage.setItem(KEYS.CATEGORY_COLORS, JSON.stringify(overrides));
190
+
191
+ // Rebuild sidebar to reflect new color on the dot
192
+ const presentCats = new Set(state.allNodes.map(n => n.payload.category));
193
+ buildCategorySidebar(presentCats);
194
+
195
+ // Force graph to recreate node objects with new colors
196
+ emit('graph:nodeThreeObject');
197
+ }
198
+
199
+ // ─── Description editor (inline below chip) ─────────────────────
200
+
201
+ function closeDescEditor() {
202
+ const existing = document.querySelector('.category-desc-editor');
203
+ if (existing) existing.remove();
204
+ }
205
+
206
+ function openDescEditor(cat, chipEl) {
207
+ closeDescEditor();
208
+ closeColorPicker();
209
+
210
+ const editor = document.createElement('div');
211
+ editor.className = 'category-desc-editor';
212
+ const currentDesc = state.categoryDescriptions[cat] || '';
213
+ editor.innerHTML = `
214
+ <label>Description for "${cat}"</label>
215
+ <textarea class="desc-textarea">${currentDesc}</textarea>
216
+ <div class="category-desc-actions">
217
+ <button class="action-btn action-btn--ghost desc-cancel-btn">Cancel</button>
218
+ <button class="action-btn action-btn--primary desc-save-btn">Save</button>
219
+ </div>
220
+ `;
221
+
222
+ const textarea = editor.querySelector('.desc-textarea');
223
+ const saveBtn = editor.querySelector('.desc-save-btn');
224
+ const cancelBtn = editor.querySelector('.desc-cancel-btn');
225
+
226
+ cancelBtn.addEventListener('click', () => closeDescEditor());
227
+ saveBtn.addEventListener('click', () => saveDescription(cat, textarea.value.trim()));
228
+
229
+ textarea.addEventListener('keydown', (e) => {
230
+ if (e.key === 'Enter' && !e.shiftKey) {
231
+ e.preventDefault();
232
+ saveDescription(cat, textarea.value.trim());
233
+ }
234
+ if (e.key === 'Escape') {
235
+ e.preventDefault();
236
+ closeDescEditor();
237
+ }
238
+ e.stopPropagation();
239
+ });
240
+ textarea.addEventListener('keyup', (e) => e.stopPropagation());
241
+
242
+ chipEl.after(editor);
243
+ textarea.focus();
244
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
245
+ }
246
+
247
+ async function saveDescription(cat, description) {
248
+ if (!description) return;
249
+ const saveBtn = document.querySelector('.category-desc-editor .desc-save-btn');
250
+ if (saveBtn) { saveBtn.textContent = 'Saving...'; saveBtn.disabled = true; }
251
+
252
+ try {
253
+ const data = await apiPatchCategory(cat, { description });
254
+ state.allCategoryNames = data.categories.map(c => c.name);
255
+ state.categoryDescriptions = {};
256
+ data.categories.forEach(c => state.categoryDescriptions[c.name] = c.description);
257
+ closeDescEditor();
258
+ } catch (e) {
259
+ console.error('saveDescription error:', e);
260
+ if (saveBtn) { saveBtn.textContent = 'Error'; saveBtn.disabled = false; }
261
+ }
262
+ }
263
+
264
+ // ─── Category change modal (move memory to different category) ───
265
+
266
+ export function openCategoryChangeModal(node) {
267
+ const current = node.payload.category;
268
+
269
+ const overlay = document.createElement('div');
270
+ overlay.className = 'tag-delete-overlay';
271
+
272
+ let listHtml = '';
273
+ state.allCategoryNames.forEach(cat => {
274
+ const desc = state.categoryDescriptions[cat] || '';
275
+ const isActive = cat === current;
276
+ listHtml += `
277
+ <div class="cat-modal-option${isActive ? ' active' : ''}" data-cat="${cat}">
278
+ <div class="cat-opt-dot" style="color:${catColor(cat)}; background:${catColor(cat)}"></div>
279
+ <span class="cat-opt-name">${cat}</span>
280
+ ${desc ? `<span class="cat-opt-desc">${desc}</span>` : ''}
281
+ </div>`;
282
+ });
283
+
284
+ overlay.innerHTML = `
285
+ <div class="tag-delete-modal cat-change-modal">
286
+ <div class="cat-change-modal-title">Move to category</div>
287
+ <div class="cat-change-modal-current">
288
+ <div class="cat-dot" style="color:${catColor(current)}; background:${catColor(current)}"></div>
289
+ ${current}
290
+ </div>
291
+ <div class="cat-change-modal-list">${listHtml}</div>
292
+ </div>
293
+ `;
294
+
295
+ document.body.appendChild(overlay);
296
+
297
+ overlay.querySelectorAll('.cat-modal-option').forEach(opt => {
298
+ opt.addEventListener('click', () => {
299
+ const cat = opt.dataset.cat;
300
+ overlay.remove();
301
+ if (cat !== current) {
302
+ changeMemoryCategory(node, cat);
303
+ }
304
+ });
305
+ });
306
+
307
+ overlay.addEventListener('click', (e) => {
308
+ if (e.target === overlay) overlay.remove();
309
+ });
310
+ }
311
+
312
+ async function changeMemoryCategory(node, newCategory) {
313
+ const oldCategory = node.payload.category;
314
+
315
+ // Optimistic update
316
+ node.payload.category = newCategory;
317
+
318
+ // Update detail panel label + color
319
+ const color = catColor(newCategory);
320
+ const headerDot = $('detail-header-dot');
321
+ if (headerDot) { headerDot.style.color = color; headerDot.style.background = color; }
322
+ const label = $('detail-category-label');
323
+ if (label) {
324
+ label.textContent = newCategory + (node.payload.subcategory ? ' / ' + node.payload.subcategory : '');
325
+ label.style.color = color;
326
+ }
327
+
328
+ // Rebuild sidebar + graph
329
+ const presentCats = new Set(state.allNodes.map(n => n.payload.category));
330
+ buildCategorySidebar(presentCats);
331
+ emit('graph:nodeThreeObject');
332
+
333
+ // Persist to server
334
+ try {
335
+ await updateMemory(node.id, { category: newCategory });
336
+ } catch (e) {
337
+ console.error('changeMemoryCategory error:', e);
338
+ // Rollback
339
+ node.payload.category = oldCategory;
340
+ const rColor = catColor(oldCategory);
341
+ if (headerDot) { headerDot.style.color = rColor; headerDot.style.background = rColor; }
342
+ if (label) {
343
+ label.textContent = oldCategory + (node.payload.subcategory ? ' / ' + node.payload.subcategory : '');
344
+ label.style.color = rColor;
345
+ }
346
+ const rCats = new Set(state.allNodes.map(n => n.payload.category));
347
+ buildCategorySidebar(rCats);
348
+ emit('graph:nodeThreeObject');
349
+ }
350
+ }
351
+
352
+ // ─── Helper: apply API response to local state ──────────────────
353
+
354
+ function applyApiResponse(data) {
355
+ state.allCategoryNames = data.categories.map(c => c.name);
356
+ state.categoryDescriptions = {};
357
+ state.categoryMetadata = {};
358
+ data.categories.forEach(c => {
359
+ state.categoryDescriptions[c.name] = c.description;
360
+ state.categoryMetadata[c.name] = {
361
+ parent: c.parent,
362
+ color: c.color,
363
+ is_parent: c.is_parent,
364
+ logo: c.logo,
365
+ };
366
+ });
367
+ preloadCategoryLogos(data.categories);
368
+ const presentCats = new Set(state.allNodes.map(n => n.payload.category));
369
+ buildCategorySidebar(presentCats);
370
+ emit('graph:refresh');
371
+ }
372
+
373
+ // ─── Create category ────────────────────────────────────────────
374
+
375
+ async function createCategoryAction(name, description, color, parent, isParent) {
376
+ if (isGuest() && !hasPermission('memories')) {
377
+ showGuestToast('Category creation is disabled by the host');
378
+ return;
379
+ }
380
+ const payload = { name, description };
381
+ if (parent) payload.parent = parent;
382
+ if (color) payload.color = color;
383
+ if (isParent) payload.is_parent = true;
384
+
385
+ const data = await apiCreateCategory(payload);
386
+
387
+ state.allCategoryNames = data.categories.map(c => c.name);
388
+ state.categoryDescriptions = {};
389
+ state.categoryMetadata = {};
390
+ data.categories.forEach(c => {
391
+ state.categoryDescriptions[c.name] = c.description;
392
+ state.categoryMetadata[c.name] = {
393
+ parent: c.parent,
394
+ color: c.color,
395
+ description: c.description,
396
+ is_parent: c.is_parent,
397
+ logo: c.logo,
398
+ };
399
+ });
400
+ preloadCategoryLogos(data.categories);
401
+
402
+ // Save chosen color
403
+ if (color) {
404
+ const overrides = JSON.parse(storage.getItem(KEYS.CATEGORY_COLORS) || '{}');
405
+ overrides[name] = color;
406
+ storage.setItem(KEYS.CATEGORY_COLORS, JSON.stringify(overrides));
407
+ }
408
+
409
+ // Add to active set & rebuild
410
+ state.activeCategories.add(name);
411
+ const presentCats = new Set(state.allNodes.map(n => n.payload.category));
412
+ buildCategorySidebar(presentCats);
413
+ emit('categories:changed');
414
+ }
415
+
416
+ // ─── Edit category UI (modal) ────────────────────────────────────
417
+
418
+ async function editCategoryUI(name) {
419
+ if (isGuest() && !hasPermission('memories')) {
420
+ showGuestToast('Category editing is disabled by the host');
421
+ return;
422
+ }
423
+ const overlay = document.createElement('div');
424
+ overlay.className = 'tag-delete-overlay';
425
+
426
+ const meta = state.categoryMetadata[name] || {};
427
+ const currentDesc = state.categoryDescriptions[name] || '';
428
+ const currentParent = meta.parent || '';
429
+ const currentColor = meta.color || catColor(name);
430
+
431
+ // Get available parent categories (excluding this one and its descendants)
432
+ const getDescendants = (catName) => {
433
+ const directChildren = state.allCategoryNames.filter(c => {
434
+ const m = state.categoryMetadata[c] || {};
435
+ return m.parent === catName;
436
+ });
437
+ const allDescendants = [...directChildren];
438
+ directChildren.forEach(child => {
439
+ allDescendants.push(...getDescendants(child));
440
+ });
441
+ return allDescendants;
442
+ };
443
+
444
+ const descendants = getDescendants(name);
445
+ const descendantSet = new Set(descendants);
446
+ const availableParents = state.allCategoryNames.filter(c => c !== name && !descendantSet.has(c));
447
+
448
+ overlay.innerHTML = `
449
+ <div class="ecm">
450
+ <div class="ecm-header">
451
+ <span class="ecm-dot" style="background:${currentColor}"></span>
452
+ <span class="ecm-title">${name}</span>
453
+ <button class="ecm-close cat-modal-cancel">&times;</button>
454
+ </div>
455
+
456
+ <div class="ecm-body">
457
+ <div class="ecm-name-row">
458
+ <div class="ecm-field ecm-field--name">
459
+ <input type="text" class="edit-cat-name ecm-input" value="${name}" placeholder="Category name">
460
+ </div>
461
+ <button class="ecm-color-btn edit-cat-color-trigger" aria-label="Pick color">
462
+ <span class="ecm-color-swatch" style="background:${currentColor}"></span>
463
+ </button>
464
+ </div>
465
+
466
+ <div class="ecm-palette" hidden>
467
+ <div class="ecm-palette-grid">
468
+ ${COLOR_PALETTE.map(c => `<button class="ecm-palette-swatch${c === currentColor ? ' active' : ''}" data-color="${c}" style="background:${c}"></button>`).join('')}
469
+ <div class="ecm-palette-custom">
470
+ <span class="ecm-palette-custom-hash">#</span>
471
+ <input type="text" class="ecm-palette-custom-input" maxlength="6" placeholder="hex" value="${currentColor.replace('#', '')}">
472
+ <span class="ecm-palette-custom-preview" style="background:${currentColor}"></span>
473
+ </div>
474
+ </div>
475
+ </div>
476
+
477
+ <div class="ecm-field">
478
+ <label>Description</label>
479
+ <textarea class="edit-cat-desc ecm-input ecm-textarea" rows="4" placeholder="What this category is for...">${currentDesc}</textarea>
480
+ </div>
481
+
482
+ <div class="ecm-divider"></div>
483
+
484
+ <div class="ecm-grid">
485
+ <div class="ecm-field">
486
+ <label>Parent</label>
487
+ <select class="edit-cat-parent ecm-input ecm-select">
488
+ <option value="">None (top-level)</option>
489
+ ${availableParents.map(c => `<option value="${c}" ${c === currentParent ? 'selected' : ''}>${c}</option>`).join('')}
490
+ </select>
491
+ </div>
492
+ <div class="ecm-field ecm-field--toggle edit-cat-isparent-row" ${currentParent ? 'hidden' : ''}>
493
+ <label class="ecm-toggle">
494
+ <input type="checkbox" class="edit-cat-isparent" ${meta.is_parent ? 'checked' : ''}>
495
+ <span class="ecm-toggle-track"><span class="ecm-toggle-thumb"></span></span>
496
+ <span class="ecm-toggle-label">Parent cluster</span>
497
+ </label>
498
+ </div>
499
+ </div>
500
+
501
+ <div class="ecm-logo-wrap edit-cat-logo-row" ${!(meta.is_parent && !currentParent) ? 'hidden' : ''}>
502
+ <div class="ecm-logo-left">
503
+ ${meta.logo
504
+ ? `<img class="edit-cat-logo-preview ecm-logo-img" src="${meta.logo}?t=${Date.now()}">`
505
+ : `<span class="edit-cat-logo-preview ecm-logo-empty">No logo</span>`
506
+ }
507
+ </div>
508
+ <div class="ecm-logo-actions">
509
+ <label class="ecm-link-btn">
510
+ ${meta.logo ? 'Replace' : 'Upload'}
511
+ <input type="file" class="edit-cat-logo-file" accept="image/*" hidden>
512
+ </label>
513
+ ${meta.logo ? `<button class="ecm-link-btn ecm-link-btn--danger edit-cat-logo-remove">Remove</button>` : ''}
514
+ </div>
515
+ </div>
516
+ </div>
517
+
518
+ <div class="ecm-footer">
519
+ <button class="action-btn action-btn--ghost cat-modal-cancel">Cancel</button>
520
+ <button class="action-btn action-btn--primary cat-modal-confirm">Save</button>
521
+ </div>
522
+ </div>
523
+ `;
524
+
525
+ document.body.appendChild(overlay);
526
+
527
+ const $nameInput = overlay.querySelector('.edit-cat-name');
528
+ const $descInput = overlay.querySelector('.edit-cat-desc');
529
+ const $parentSelect = overlay.querySelector('.edit-cat-parent');
530
+ const $isParentCheck = overlay.querySelector('.edit-cat-isparent');
531
+ const $isParentRow = overlay.querySelector('.edit-cat-isparent-row');
532
+
533
+ upgradeSelect($parentSelect);
534
+
535
+ // Color palette toggle + swatch selection
536
+ let selectedColor = currentColor;
537
+ const $colorBtn = overlay.querySelector('.ecm-color-btn');
538
+ const $colorSwatch = overlay.querySelector('.ecm-color-swatch');
539
+ const $headerDot = overlay.querySelector('.ecm-dot');
540
+ const $palette = overlay.querySelector('.ecm-palette');
541
+ const $paletteSwatches = overlay.querySelectorAll('.ecm-palette-swatch');
542
+
543
+ $colorBtn.addEventListener('click', () => {
544
+ $palette.hidden = !$palette.hidden;
545
+ });
546
+
547
+ const $customInput = overlay.querySelector('.ecm-palette-custom-input');
548
+ const $customPreview = overlay.querySelector('.ecm-palette-custom-preview');
549
+
550
+ function applyColor(hex) {
551
+ selectedColor = hex;
552
+ $paletteSwatches.forEach(s => s.classList.toggle('active', s.dataset.color === hex));
553
+ if ($headerDot) $headerDot.style.background = hex;
554
+ if ($colorSwatch) $colorSwatch.style.background = hex;
555
+ if ($customPreview) $customPreview.style.background = hex;
556
+ if ($customInput) $customInput.value = hex.replace('#', '');
557
+ }
558
+
559
+ $paletteSwatches.forEach(sw => {
560
+ sw.addEventListener('click', () => {
561
+ applyColor(sw.dataset.color);
562
+ $palette.hidden = true;
563
+ });
564
+ });
565
+
566
+ // Custom hex input — live preview on valid hex, apply on Enter
567
+ $customInput.addEventListener('input', () => {
568
+ const v = $customInput.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
569
+ $customInput.value = v;
570
+ if (v.length === 3 || v.length === 6) {
571
+ const hex = '#' + v;
572
+ $customPreview.style.background = hex;
573
+ }
574
+ });
575
+ $customInput.addEventListener('keydown', (e) => {
576
+ if (e.key === 'Enter') {
577
+ e.preventDefault();
578
+ const v = $customInput.value.trim();
579
+ if (v.length === 3 || v.length === 6) {
580
+ applyColor('#' + v);
581
+ $palette.hidden = true;
582
+ }
583
+ }
584
+ e.stopPropagation();
585
+ });
586
+ $customInput.addEventListener('keyup', (e) => e.stopPropagation());
587
+
588
+ const $logoRow = overlay.querySelector('.edit-cat-logo-row');
589
+ const $logoFile = overlay.querySelector('.edit-cat-logo-file');
590
+ const $logoRemoveBtn = overlay.querySelector('.edit-cat-logo-remove');
591
+
592
+ // Show/hide is_parent checkbox and logo row based on parent selection
593
+ $parentSelect.addEventListener('change', () => {
594
+ if ($parentSelect.value) {
595
+ $isParentRow.hidden = true;
596
+ $isParentCheck.checked = false;
597
+ if ($logoRow) $logoRow.hidden = true;
598
+ } else {
599
+ $isParentRow.hidden = false;
600
+ if ($logoRow) $logoRow.hidden = !$isParentCheck.checked;
601
+ }
602
+ });
603
+
604
+ // Show/hide logo row based on is_parent checkbox
605
+ $isParentCheck.addEventListener('change', () => {
606
+ if ($logoRow) $logoRow.hidden = !$isParentCheck.checked;
607
+ });
608
+
609
+ // Logo upload — fires immediately on file select
610
+ if ($logoFile) {
611
+ $logoFile.addEventListener('change', async () => {
612
+ const file = $logoFile.files[0];
613
+ if (!file) return;
614
+ const uploadLabel = $logoFile.closest('label');
615
+ const origText = uploadLabel.textContent.trim();
616
+ uploadLabel.childNodes[0].textContent = 'Uploading\u2026 ';
617
+ try {
618
+ const buf = await file.arrayBuffer();
619
+ const data = await apiUploadCategoryLogo(name, buf, file.type || 'image/png');
620
+
621
+ // Update preview
622
+ const preview = overlay.querySelector('.edit-cat-logo-preview');
623
+ if (preview.tagName === 'IMG') {
624
+ preview.src = data.logo + '?t=' + Date.now();
625
+ } else {
626
+ const img = document.createElement('img');
627
+ img.className = 'edit-cat-logo-preview ecm-logo-img';
628
+ img.src = data.logo + '?t=' + Date.now();
629
+ preview.replaceWith(img);
630
+ }
631
+ // Add remove button if not present
632
+ if (!overlay.querySelector('.edit-cat-logo-remove')) {
633
+ const rmBtn = document.createElement('button');
634
+ rmBtn.className = 'ecm-link-btn ecm-link-btn--danger edit-cat-logo-remove';
635
+ rmBtn.textContent = 'Remove';
636
+ uploadLabel.after(rmBtn);
637
+ attachLogoRemoveHandler(rmBtn);
638
+ }
639
+
640
+ if (state.categoryMetadata[name]) state.categoryMetadata[name].logo = data.logo;
641
+ preloadCategoryLogos(data.categories);
642
+ uploadLabel.childNodes[0].textContent = 'Replace ';
643
+ // Rebuild graph to show new logo on anchor
644
+ emit('graph:nodeThreeObject');
645
+ emit('graph:refresh');
646
+ } catch (err) {
647
+ alert('Logo upload failed: ' + err.message);
648
+ uploadLabel.childNodes[0].textContent = origText + ' ';
649
+ }
650
+ });
651
+ }
652
+
653
+ function attachLogoRemoveHandler(btn) {
654
+ btn.addEventListener('click', async () => {
655
+ btn.textContent = 'Removing\u2026';
656
+ btn.disabled = true;
657
+ try {
658
+ await apiDeleteCategoryLogo(name);
659
+ const preview = overlay.querySelector('.edit-cat-logo-preview');
660
+ const span = document.createElement('span');
661
+ span.className = 'edit-cat-logo-preview ecm-logo-empty';
662
+ span.textContent = 'No logo';
663
+ preview.replaceWith(span);
664
+ btn.remove();
665
+ if (state.categoryMetadata[name]) delete state.categoryMetadata[name].logo;
666
+ _categoryLogos.delete(name);
667
+ const uploadLabel = overlay.querySelector('.edit-cat-logo-file')?.closest('label');
668
+ if (uploadLabel) uploadLabel.childNodes[0].textContent = 'Upload ';
669
+ emit('graph:nodeThreeObject');
670
+ emit('graph:refresh');
671
+ } catch (err) {
672
+ alert('Failed to remove logo: ' + err.message);
673
+ btn.textContent = 'Remove';
674
+ btn.disabled = false;
675
+ }
676
+ });
677
+ }
678
+
679
+ if ($logoRemoveBtn) attachLogoRemoveHandler($logoRemoveBtn);
680
+
681
+ overlay.querySelector('.cat-modal-cancel').addEventListener('click', () => overlay.remove());
682
+ overlay.querySelector('.cat-modal-confirm').addEventListener('click', async () => {
683
+ const newName = $nameInput.value.trim();
684
+ const newDesc = $descInput.value.trim();
685
+ const newParent = $parentSelect.value;
686
+ const newColor = selectedColor;
687
+ const newIsParent = $isParentCheck.checked;
688
+
689
+ if (!newName || !newDesc) {
690
+ alert('Name and description are required');
691
+ return;
692
+ }
693
+
694
+ const btn = overlay.querySelector('.cat-modal-confirm');
695
+ btn.textContent = 'Saving...';
696
+ btn.disabled = true;
697
+
698
+ const updates = {};
699
+ if (newName !== name) updates.new_name = newName;
700
+ if (newDesc !== currentDesc) updates.description = newDesc;
701
+ if (newParent !== currentParent) updates.parent = newParent;
702
+ if (newColor !== currentColor) updates.color = newColor;
703
+ if (newIsParent !== !!meta.is_parent) updates.is_parent = newIsParent;
704
+
705
+ try {
706
+ const data = await apiUpdateCategory(name, updates);
707
+
708
+ // Update local state
709
+ state.allCategoryNames = data.categories.map(c => c.name);
710
+ state.categoryDescriptions = {};
711
+ state.categoryMetadata = {};
712
+ data.categories.forEach(c => {
713
+ state.categoryDescriptions[c.name] = c.description;
714
+ state.categoryMetadata[c.name] = { parent: c.parent, color: c.color, is_parent: c.is_parent, logo: c.logo };
715
+ });
716
+ preloadCategoryLogos(data.categories);
717
+
718
+ // Update localStorage colors
719
+ try {
720
+ const overrides = JSON.parse(storage.getItem(KEYS.CATEGORY_COLORS) || '{}');
721
+ if (newColor !== currentColor) {
722
+ overrides[newName || name] = newColor;
723
+ }
724
+ if (newName !== name && overrides[name]) {
725
+ overrides[newName] = overrides[name];
726
+ delete overrides[name];
727
+ }
728
+ storage.setItem(KEYS.CATEGORY_COLORS, JSON.stringify(overrides));
729
+ } catch {}
730
+
731
+ // If renamed, update activeCategories
732
+ if (newName !== name) {
733
+ if (state.activeCategories.has(name)) {
734
+ state.activeCategories.delete(name);
735
+ state.activeCategories.add(newName);
736
+ }
737
+ }
738
+
739
+ const presentCats = new Set(state.allNodes.map(n => n.payload.category));
740
+ buildCategorySidebar(presentCats);
741
+ emit('graph:refresh');
742
+ emit('categories:changed');
743
+
744
+ overlay.remove();
745
+ } catch (e) {
746
+ alert('Error updating category: ' + e.message);
747
+ btn.textContent = 'Save Changes';
748
+ btn.disabled = false;
749
+ }
750
+ });
751
+
752
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
753
+ }
754
+
755
+ // ─── Delete category UI (modal) ──────────────────────────────────
756
+
757
+ function deleteCategoryUI(name, count, chipEl) {
758
+ if (isGuest() && !hasPermission('memories')) {
759
+ showGuestToast('Category deletion is disabled by the host');
760
+ return;
761
+ }
762
+ const overlay = document.createElement('div');
763
+ overlay.className = 'tag-delete-overlay';
764
+ const allCats = state.allCategoryNames.filter(c => c !== name);
765
+
766
+ // Check if this category has children
767
+ const children = state.allCategoryNames.filter(c => {
768
+ const meta = state.categoryMetadata[c] || {};
769
+ return meta.parent === name;
770
+ });
771
+
772
+ const hasChildren = children.length > 0;
773
+ const hasMemories = count > 0;
774
+
775
+ // Helper: build children reassignment HTML
776
+ const buildChildrenSelect = () => {
777
+ if (!hasChildren) return '';
778
+ const getDescendants = (catName) => {
779
+ const direct = state.allCategoryNames.filter(c => (state.categoryMetadata[c] || {}).parent === catName);
780
+ const all = [...direct];
781
+ direct.forEach(child => all.push(...getDescendants(child)));
782
+ return all;
783
+ };
784
+ const descendantSet = new Set(getDescendants(name));
785
+ const available = state.allCategoryNames.filter(c => c !== name && !descendantSet.has(c));
786
+ return `
787
+ <div style="font-size:var(--fs-sm);color:var(--t-secondary);margin:12px 0 6px;text-align:left">
788
+ ${children.length} child categor${children.length === 1 ? 'y' : 'ies'}: <strong>${children.join(', ')}</strong><br>Reassign children to:
789
+ </div>
790
+ <select class="cat-modal-select-children modal-select">
791
+ <option value="">-- Make top-level --</option>
792
+ ${available.map(c => `<option value="${c}">${c}</option>`).join('')}
793
+ </select>
794
+ `;
795
+ };
796
+
797
+ if (!hasMemories && !hasChildren) {
798
+ // Simple delete — no memories, no children
799
+ overlay.innerHTML = `
800
+ <div class="tag-delete-modal">
801
+ <div class="tag-delete-modal-title">Delete category</div>
802
+ <div class="tag-delete-modal-tag">${name}</div>
803
+ <div class="tag-delete-modal-actions">
804
+ <button class="action-btn action-btn--ghost cat-modal-cancel">Cancel</button>
805
+ <button class="action-btn action-btn--danger cat-modal-confirm">Delete</button>
806
+ </div>
807
+ </div>
808
+ `;
809
+ document.body.appendChild(overlay);
810
+ overlay.querySelector('.cat-modal-cancel').addEventListener('click', () => overlay.remove());
811
+ overlay.querySelector('.cat-modal-confirm').addEventListener('click', () => {
812
+ overlay.remove();
813
+ deleteCategoryAction(name);
814
+ });
815
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
816
+
817
+ } else if (!hasMemories && hasChildren) {
818
+ // Only children to handle, no memories
819
+ overlay.innerHTML = `
820
+ <div class="tag-delete-modal" style="min-width:340px">
821
+ <div class="tag-delete-modal-title">Delete category</div>
822
+ <div class="tag-delete-modal-tag">${name}</div>
823
+ ${buildChildrenSelect()}
824
+ <div class="tag-delete-modal-actions" style="margin-top:14px">
825
+ <button class="action-btn action-btn--ghost cat-modal-cancel">Cancel</button>
826
+ <button class="action-btn action-btn--danger cat-modal-confirm">Delete</button>
827
+ </div>
828
+ </div>
829
+ `;
830
+ document.body.appendChild(overlay);
831
+ const childSel = overlay.querySelector('.cat-modal-select-children');
832
+ if (childSel) upgradeSelect(childSel);
833
+ overlay.querySelector('.cat-modal-cancel').addEventListener('click', () => overlay.remove());
834
+ overlay.querySelector('.cat-modal-confirm').addEventListener('click', async () => {
835
+ const targetChildren = overlay.querySelector('.cat-modal-select-children')?.value;
836
+ const btn = overlay.querySelector('.cat-modal-confirm');
837
+ btn.textContent = 'Deleting...';
838
+ btn.disabled = true;
839
+ await deleteCategoryAction(name, null, targetChildren);
840
+ overlay.remove();
841
+ });
842
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
843
+
844
+ } else {
845
+ // Has memories — show 3-option flow
846
+ const svgTrash = `<svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-.7 12.1a2 2 0 0 1-2 1.9H7.7a2 2 0 0 1-2-1.9L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/></svg>`;
847
+ const svgExport = `<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>`;
848
+ const svgMigrate = `<svg viewBox="0 0 24 24"><path d="M5 9l4-4 4 4"/><path d="M9 5v10a4 4 0 0 0 4 4h6"/><polyline points="15 15 19 19 15 23"/></svg>`;
849
+
850
+ overlay.innerHTML = `
851
+ <div class="tag-delete-modal" style="min-width:380px">
852
+ <div class="tag-delete-modal-title">Delete category</div>
853
+ <div class="tag-delete-modal-tag">${name}</div>
854
+ <div style="font-size:var(--fs-sm);color:var(--t-secondary);margin-bottom:4px">
855
+ ${count} memor${count === 1 ? 'y' : 'ies'} in this category. What would you like to do?
856
+ </div>
857
+ <div class="cat-delete-options">
858
+ <div class="cat-delete-option cat-delete-option--delete" data-action="delete">
859
+ <div class="cat-delete-option-icon">${svgTrash}</div>
860
+ <div class="cat-delete-option-text">
861
+ <div class="cat-delete-option-label">Trash all memories</div>
862
+ <div class="cat-delete-option-desc">Move all ${count} memor${count === 1 ? 'y' : 'ies'} to trash</div>
863
+ </div>
864
+ </div>
865
+ <div class="cat-delete-option cat-delete-option--export" data-action="export">
866
+ <div class="cat-delete-option-icon">${svgExport}</div>
867
+ <div class="cat-delete-option-text">
868
+ <div class="cat-delete-option-label">Export as Markdown</div>
869
+ <div class="cat-delete-option-desc">Download all memories as .md then trash</div>
870
+ </div>
871
+ </div>
872
+ <div class="cat-delete-option cat-delete-option--migrate" data-action="migrate">
873
+ <div class="cat-delete-option-icon">${svgMigrate}</div>
874
+ <div class="cat-delete-option-text">
875
+ <div class="cat-delete-option-label">Migrate to another category</div>
876
+ <div class="cat-delete-option-desc">Move memories before deleting this category</div>
877
+ </div>
878
+ </div>
879
+ </div>
880
+ ${buildChildrenSelect()}
881
+ <div class="cat-delete-step" id="cat-delete-step-area"></div>
882
+ <div class="tag-delete-modal-actions">
883
+ <button class="action-btn action-btn--ghost cat-modal-cancel">Cancel</button>
884
+ <button class="action-btn action-btn--danger cat-modal-confirm" disabled style="opacity:0.4">Continue</button>
885
+ </div>
886
+ </div>
887
+ `;
888
+
889
+ document.body.appendChild(overlay);
890
+ const modal = overlay.querySelector('.tag-delete-modal');
891
+ const stepArea = modal.querySelector('#cat-delete-step-area');
892
+ const confirmBtn = modal.querySelector('.cat-modal-confirm');
893
+ let selectedAction = null;
894
+
895
+ // Upgrade children select if present
896
+ const childSel3 = modal.querySelector('.cat-modal-select-children');
897
+ if (childSel3) upgradeSelect(childSel3);
898
+
899
+ // Option selection
900
+ modal.querySelectorAll('.cat-delete-option').forEach(opt => {
901
+ opt.addEventListener('click', () => {
902
+ modal.querySelectorAll('.cat-delete-option').forEach(o => o.classList.remove('selected'));
903
+ opt.classList.add('selected');
904
+ selectedAction = opt.dataset.action;
905
+ confirmBtn.disabled = false;
906
+ confirmBtn.style.opacity = '1';
907
+ stepArea.innerHTML = '';
908
+
909
+ if (selectedAction === 'delete') {
910
+ confirmBtn.textContent = 'Trash all';
911
+ confirmBtn.className = 'action-btn action-btn--danger cat-modal-confirm';
912
+ } else if (selectedAction === 'export') {
913
+ confirmBtn.textContent = 'Export & Trash';
914
+ confirmBtn.className = 'action-btn action-btn--danger cat-modal-confirm';
915
+ } else if (selectedAction === 'migrate') {
916
+ confirmBtn.textContent = 'Migrate & Delete';
917
+ confirmBtn.className = 'action-btn action-btn--danger cat-modal-confirm';
918
+ stepArea.innerHTML = `
919
+ <div style="font-size:var(--fs-sm);color:var(--t-secondary);margin:4px 0 4px;text-align:left">Move memories to:</div>
920
+ <select class="cat-modal-select-memories modal-select">
921
+ ${allCats.map(c => `<option value="${c}">${c}</option>`).join('')}
922
+ </select>
923
+ `;
924
+ const memSel = stepArea.querySelector('.cat-modal-select-memories');
925
+ if (memSel) upgradeSelect(memSel);
926
+ }
927
+ });
928
+ });
929
+
930
+ // Cancel
931
+ overlay.querySelector('.cat-modal-cancel').addEventListener('click', () => overlay.remove());
932
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
933
+
934
+ // Confirm
935
+ confirmBtn.addEventListener('click', async () => {
936
+ if (!selectedAction) return;
937
+ const targetChildren = hasChildren ? modal.querySelector('.cat-modal-select-children')?.value : undefined;
938
+
939
+ if (selectedAction === 'delete') {
940
+ confirmBtn.textContent = 'Trashing...';
941
+ confirmBtn.disabled = true;
942
+ await deleteCategoryAction(name, null, targetChildren, { deleteMemories: true });
943
+ overlay.remove();
944
+
945
+ } else if (selectedAction === 'export') {
946
+ confirmBtn.textContent = 'Exporting...';
947
+ confirmBtn.disabled = true;
948
+ try {
949
+ const blob = await apiExportCategory(name);
950
+ const url = URL.createObjectURL(blob);
951
+ const a = document.createElement('a');
952
+ a.href = url;
953
+ a.download = `${name}-memories.md`;
954
+ document.body.appendChild(a);
955
+ a.click();
956
+ a.remove();
957
+ URL.revokeObjectURL(url);
958
+ // Now trash
959
+ confirmBtn.textContent = 'Trashing...';
960
+ await deleteCategoryAction(name, null, targetChildren, { deleteMemories: true });
961
+ overlay.remove();
962
+ } catch (err) {
963
+ console.error('Export failed:', err);
964
+ alert('Export failed: ' + err.message);
965
+ confirmBtn.textContent = 'Export & Trash';
966
+ confirmBtn.disabled = false;
967
+ }
968
+
969
+ } else if (selectedAction === 'migrate') {
970
+ const targetMemories = modal.querySelector('.cat-modal-select-memories')?.value;
971
+ if (!targetMemories) return;
972
+ confirmBtn.textContent = 'Migrating...';
973
+ confirmBtn.disabled = true;
974
+ await deleteCategoryAction(name, targetMemories, targetChildren);
975
+ overlay.remove();
976
+ }
977
+ });
978
+ }
979
+ }
980
+
981
+ // ─── Delete category (server call + state update) ────────────────
982
+
983
+ async function deleteCategoryAction(name, reassignTo, reassignChildrenTo, options = {}) {
984
+ const body = {};
985
+ if (reassignTo) body.reassign_to = reassignTo;
986
+ if (reassignChildrenTo !== undefined) body.reassign_children_to = reassignChildrenTo;
987
+ if (options.deleteMemories) body.delete_memories = true;
988
+
989
+ try {
990
+ const data = await apiDeleteCategory(name, body);
991
+
992
+ state.allCategoryNames = data.categories.map(c => c.name);
993
+ state.categoryDescriptions = {};
994
+ data.categories.forEach(c => state.categoryDescriptions[c.name] = c.description);
995
+ // Update categoryMetadata
996
+ state.categoryMetadata = {};
997
+ data.categories.forEach(c => {
998
+ state.categoryMetadata[c.name] = { parent: c.parent, color: c.color, is_parent: c.is_parent, logo: c.logo };
999
+ });
1000
+ preloadCategoryLogos(data.categories);
1001
+
1002
+ // Remove color override
1003
+ try {
1004
+ const overrides = JSON.parse(storage.getItem(KEYS.CATEGORY_COLORS) || '{}');
1005
+ delete overrides[name];
1006
+ storage.setItem(KEYS.CATEGORY_COLORS, JSON.stringify(overrides));
1007
+ } catch {}
1008
+ state.activeCategories.delete(name);
1009
+
1010
+ // Re-fetch memories if anything changed (reassigned or deleted)
1011
+ if (reassignTo || options.deleteMemories || (data.reassigned && data.reassigned > 0) || (data.deleted && data.deleted > 0)) {
1012
+ try {
1013
+ const memData = await fetchMemories();
1014
+ state.allNodes = normalizeNodes(memData.nodes);
1015
+ state.allLinks = memData.links;
1016
+ } catch {}
1017
+ }
1018
+
1019
+ const presentCats = new Set(state.allNodes.map(n => n.payload.category));
1020
+ buildCategorySidebar(presentCats);
1021
+ emit('graph:refresh');
1022
+ emit('categories:changed');
1023
+
1024
+ // Update trash badge if memories were trashed
1025
+ if (options.deleteMemories) {
1026
+ emit('trash:refresh');
1027
+ }
1028
+ } catch (e) {
1029
+ console.error('deleteCategory error:', e);
1030
+ alert('Error deleting category: ' + e.message);
1031
+ }
1032
+ }
1033
+
1034
+ // ═══════════════════════════════════════════
1035
+ // MAIN: buildCategorySidebar
1036
+ // ═══════════════════════════════════════════
1037
+
1038
+ let categoryDragDidMove = false;
1039
+
1040
+ /**
1041
+ * Rebuild the category sidebar DOM from current state.
1042
+ * @param {Set<string>} [presentCats] — categories that have at least one memory node.
1043
+ * If omitted, derived from state.allNodes.
1044
+ */
1045
+ export function buildCategorySidebar(presentCats) {
1046
+ const $categoryList = $('category-list');
1047
+ if (!$categoryList) return;
1048
+ $categoryList.innerHTML = '';
1049
+
1050
+ if (!presentCats) {
1051
+ presentCats = new Set(state.allNodes.map(n => n.payload.category));
1052
+ }
1053
+
1054
+ // Count per category
1055
+ const counts = {};
1056
+ state.allNodes.forEach(n => {
1057
+ const cat = n.payload.category;
1058
+ counts[cat] = (counts[cat] || 0) + 1;
1059
+ });
1060
+
1061
+ // Merge present categories with all known categories (including 0-count ones)
1062
+ const allCats = new Set(presentCats);
1063
+ state.allCategoryNames.forEach(c => allCats.add(c));
1064
+
1065
+ // Group categories by parent
1066
+ const categoryGroups = {};
1067
+ const orphans = [];
1068
+
1069
+ [...allCats].forEach(cat => {
1070
+ const meta = state.categoryMetadata[cat] || {};
1071
+ if (meta.parent) {
1072
+ if (!categoryGroups[meta.parent]) categoryGroups[meta.parent] = [];
1073
+ categoryGroups[meta.parent].push(cat);
1074
+ } else {
1075
+ orphans.push(cat);
1076
+ }
1077
+ // Ensure is_parent categories always have an entry in categoryGroups (even if empty)
1078
+ if (meta.is_parent && !categoryGroups[cat]) {
1079
+ categoryGroups[cat] = [];
1080
+ }
1081
+ });
1082
+
1083
+ // Apply saved order, then append any new categories at the end sorted by count
1084
+ const savedOrder = getCategoryOrder();
1085
+ const ordered = savedOrder.filter(c => allCats.has(c));
1086
+ const remaining = [...allCats].filter(c => !savedOrder.includes(c))
1087
+ .sort((a, b) => (counts[b] || 0) - (counts[a] || 0));
1088
+ const sorted = [...ordered, ...remaining];
1089
+
1090
+ // Regroup sorted: parent clusters contiguous (header + all children), orphans at end.
1091
+ // Prevents orphan categories from interleaving with cluster children (false 3rd nesting level).
1092
+ const regrouped = [];
1093
+ const _grouped = new Set();
1094
+ const _pNames = new Set();
1095
+ sorted.forEach(c => {
1096
+ const m = state.categoryMetadata[c] || {};
1097
+ if (m.is_parent || categoryGroups[c]) _pNames.add(c);
1098
+ if (m.parent) _pNames.add(m.parent);
1099
+ });
1100
+ const _pOrd = [], _pSeen = new Set();
1101
+ sorted.forEach(c => {
1102
+ const m = state.categoryMetadata[c] || {};
1103
+ const p = m.parent || (_pNames.has(c) ? c : null);
1104
+ if (p && !_pSeen.has(p)) { _pSeen.add(p); _pOrd.push(p); }
1105
+ });
1106
+ _pOrd.forEach(p => {
1107
+ regrouped.push(p); _grouped.add(p);
1108
+ sorted.forEach(c => { if ((state.categoryMetadata[c] || {}).parent === p) { regrouped.push(c); _grouped.add(c); } });
1109
+ });
1110
+ sorted.forEach(c => { if (!_grouped.has(c)) regrouped.push(c); });
1111
+
1112
+ // Track which parent clusters we've rendered
1113
+ const renderedClusters = new Set();
1114
+
1115
+ regrouped.forEach(cat => {
1116
+ const meta = state.categoryMetadata[cat] || {};
1117
+
1118
+ // If this category has a parent and we haven't rendered that cluster header yet
1119
+ if (meta.parent && !renderedClusters.has(meta.parent)) {
1120
+ const clusterHeader = document.createElement('div');
1121
+ clusterHeader.className = 'category-cluster-header';
1122
+ clusterHeader.style.borderLeft = `3px solid ${catColor(meta.parent)}`;
1123
+ clusterHeader.dataset.parent = meta.parent;
1124
+
1125
+ if (!state.activeCategories.has(meta.parent)) {
1126
+ clusterHeader.classList.add('inactive');
1127
+ }
1128
+
1129
+ const parentMeta = state.categoryMetadata[meta.parent] || {};
1130
+ const isEphemeral = !!parentMeta._ephemeral;
1131
+ clusterHeader.innerHTML = `
1132
+ <span class="cluster-dot" style="background:${catColor(meta.parent)}; color:${catColor(meta.parent)}" data-tooltip="Color"></span>
1133
+ <span class="cluster-name">${meta.parent}</span>
1134
+ ${isEphemeral
1135
+ ? '<span style="margin-left:auto;font-size:10px;opacity:0.5;color:var(--t-muted)">bridge</span>'
1136
+ : `<span class="cluster-actions">
1137
+ <button class="cluster-edit-btn" data-tooltip="Edit">&#9998;</button>
1138
+ <button class="cluster-delete-btn" data-tooltip="Delete">&times;</button>
1139
+ </span>`}
1140
+ `;
1141
+
1142
+ // Click on cluster header to toggle parent and all children
1143
+ clusterHeader.addEventListener('click', (e) => {
1144
+ if (categoryDragDidMove) { categoryDragDidMove = false; return; }
1145
+ if (e.target.closest('.cluster-actions') || e.target.closest('.cluster-dot')) return;
1146
+
1147
+ const parentCat = meta.parent;
1148
+ const childCategories = categoryGroups[parentCat] || [];
1149
+
1150
+ if (state.activeCategories.has(parentCat)) {
1151
+ state.activeCategories.delete(parentCat);
1152
+ childCategories.forEach(child => state.activeCategories.delete(child));
1153
+ clusterHeader.classList.add('inactive');
1154
+ childCategories.forEach(child => {
1155
+ const childChip = $categoryList.querySelector(`.category-chip[data-cat="${child}"]`);
1156
+ if (childChip) childChip.classList.add('inactive');
1157
+ });
1158
+ scheduleGraphRemoval();
1159
+ } else {
1160
+ state.activeCategories.add(parentCat);
1161
+ childCategories.forEach(child => state.activeCategories.add(child));
1162
+ clusterHeader.classList.remove('inactive');
1163
+ childCategories.forEach(child => {
1164
+ const childChip = $categoryList.querySelector(`.category-chip[data-cat="${child}"]`);
1165
+ if (childChip) childChip.classList.remove('inactive');
1166
+ });
1167
+ cancelScheduledRemoval();
1168
+ emit('graph:refresh');
1169
+ }
1170
+ });
1171
+
1172
+ // Color dot click
1173
+ clusterHeader.querySelector('.cluster-dot').addEventListener('click', (e) => {
1174
+ e.stopPropagation();
1175
+ openColorPicker(meta.parent, clusterHeader);
1176
+ });
1177
+
1178
+ // Edit/Delete buttons (not present for ephemeral/bridge categories)
1179
+ const clusterEditBtn = clusterHeader.querySelector('.cluster-edit-btn');
1180
+ if (clusterEditBtn) {
1181
+ clusterEditBtn.addEventListener('click', (e) => {
1182
+ e.stopPropagation();
1183
+ editCategoryUI(meta.parent);
1184
+ });
1185
+ }
1186
+ const clusterDeleteBtn = clusterHeader.querySelector('.cluster-delete-btn');
1187
+ if (clusterDeleteBtn) {
1188
+ clusterDeleteBtn.addEventListener('click', (e) => {
1189
+ e.stopPropagation();
1190
+ const parentCount = counts[meta.parent] || 0;
1191
+ deleteCategoryUI(meta.parent, parentCount, clusterHeader);
1192
+ });
1193
+ }
1194
+
1195
+ $categoryList.appendChild(clusterHeader);
1196
+ renderedClusters.add(meta.parent);
1197
+ }
1198
+
1199
+ // If this category is a parent (is_parent flag or has children), render its own cluster header
1200
+ if ((meta.is_parent || (categoryGroups[cat] && categoryGroups[cat].length > 0)) && !renderedClusters.has(cat)) {
1201
+ const clusterHeader = document.createElement('div');
1202
+ clusterHeader.className = 'category-cluster-header';
1203
+ clusterHeader.style.borderLeft = `3px solid ${catColor(cat)}`;
1204
+ clusterHeader.dataset.parent = cat;
1205
+
1206
+ if (!state.activeCategories.has(cat)) {
1207
+ clusterHeader.classList.add('inactive');
1208
+ }
1209
+
1210
+ const childCount = (categoryGroups[cat] || []).length;
1211
+ const isEphemeralCat = !!(state.categoryMetadata[cat] || {})._ephemeral;
1212
+ clusterHeader.innerHTML = `
1213
+ <span class="cluster-dot" style="background:${catColor(cat)}; color:${catColor(cat)}" data-tooltip="Color"></span>
1214
+ <span class="cluster-name">${cat}</span>
1215
+ ${isEphemeralCat
1216
+ ? '<span style="margin-left:auto;font-size:10px;opacity:0.5;color:var(--t-muted)">bridge</span>'
1217
+ : `<span class="cluster-actions">
1218
+ <button class="cluster-edit-btn" data-tooltip="Edit">&#9998;</button>
1219
+ <button class="cluster-delete-btn" data-tooltip="Delete">&times;</button>
1220
+ </span>`}
1221
+ `;
1222
+
1223
+ clusterHeader.addEventListener('click', (e) => {
1224
+ if (categoryDragDidMove) { categoryDragDidMove = false; return; }
1225
+ if (e.target.closest('.cluster-actions') || e.target.closest('.cluster-dot')) return;
1226
+ const childCategories = categoryGroups[cat] || [];
1227
+ if (state.activeCategories.has(cat)) {
1228
+ state.activeCategories.delete(cat);
1229
+ childCategories.forEach(child => state.activeCategories.delete(child));
1230
+ clusterHeader.classList.add('inactive');
1231
+ childCategories.forEach(child => {
1232
+ const childChip = $categoryList.querySelector(`.category-chip[data-cat="${child}"]`);
1233
+ if (childChip) childChip.classList.add('inactive');
1234
+ });
1235
+ scheduleGraphRemoval();
1236
+ } else {
1237
+ state.activeCategories.add(cat);
1238
+ childCategories.forEach(child => state.activeCategories.add(child));
1239
+ clusterHeader.classList.remove('inactive');
1240
+ childCategories.forEach(child => {
1241
+ const childChip = $categoryList.querySelector(`.category-chip[data-cat="${child}"]`);
1242
+ if (childChip) childChip.classList.remove('inactive');
1243
+ });
1244
+ cancelScheduledRemoval();
1245
+ emit('graph:refresh');
1246
+ }
1247
+ });
1248
+
1249
+ clusterHeader.querySelector('.cluster-dot').addEventListener('click', (e) => {
1250
+ e.stopPropagation();
1251
+ openColorPicker(cat, clusterHeader);
1252
+ });
1253
+ const editBtnStandalone = clusterHeader.querySelector('.cluster-edit-btn');
1254
+ if (editBtnStandalone) {
1255
+ editBtnStandalone.addEventListener('click', (e) => {
1256
+ e.stopPropagation();
1257
+ editCategoryUI(cat);
1258
+ });
1259
+ }
1260
+ const deleteBtnStandalone = clusterHeader.querySelector('.cluster-delete-btn');
1261
+ if (deleteBtnStandalone) {
1262
+ deleteBtnStandalone.addEventListener('click', (e) => {
1263
+ e.stopPropagation();
1264
+ deleteCategoryUI(cat, counts[cat] || 0, clusterHeader);
1265
+ });
1266
+ }
1267
+
1268
+ $categoryList.appendChild(clusterHeader);
1269
+ renderedClusters.add(cat);
1270
+
1271
+ // Show empty state hint if parent has no children yet
1272
+ if (childCount === 0) {
1273
+ const emptyHint = document.createElement('div');
1274
+ emptyHint.className = 'cluster-empty-hint';
1275
+ emptyHint.textContent = 'Drag categories here';
1276
+ $categoryList.appendChild(emptyHint);
1277
+ }
1278
+ }
1279
+
1280
+ // Skip rendering chips for parent categories (they only appear as cluster headers)
1281
+ if (categoryGroups[cat] !== undefined) {
1282
+ return;
1283
+ }
1284
+
1285
+ const chip = document.createElement('div');
1286
+ chip.className = 'category-chip' + (meta.parent ? ' category-chip-child' : '');
1287
+ chip.dataset.cat = cat;
1288
+
1289
+ if (!state.activeCategories.has(cat)) {
1290
+ chip.classList.add('inactive');
1291
+ }
1292
+
1293
+ const isEphemeralChip = !!(meta._ephemeral || (meta.parent && (state.categoryMetadata[meta.parent] || {})._ephemeral));
1294
+ chip.innerHTML = `
1295
+ <div class="category-dot" style="color:${catColor(cat)}; background:${catColor(cat)}" data-tooltip="Color"></div>
1296
+ <span class="category-label">${cat}${isEphemeralChip ? '' : '<span class="cat-edit-icon" data-tooltip="Edit">&#9998;</span>'}</span>
1297
+ <span class="category-count">${counts[cat] || 0}</span>
1298
+ ${isEphemeralChip ? '' : '<button class="category-delete" data-tooltip="Delete">&times;</button>'}
1299
+ `;
1300
+
1301
+ // Color dot click -> open color picker
1302
+ chip.querySelector('.category-dot').addEventListener('click', (e) => {
1303
+ e.stopPropagation();
1304
+ openColorPicker(cat, chip);
1305
+ });
1306
+
1307
+ // Edit icon click -> open full category editor (not for ephemeral)
1308
+ const catEditIcon = chip.querySelector('.cat-edit-icon');
1309
+ if (catEditIcon) {
1310
+ catEditIcon.addEventListener('click', (e) => {
1311
+ e.stopPropagation();
1312
+ editCategoryUI(cat);
1313
+ });
1314
+ }
1315
+
1316
+ chip.addEventListener('click', (e) => {
1317
+ if (categoryDragDidMove) { categoryDragDidMove = false; return; }
1318
+ if (e.target.closest('.category-delete')) return;
1319
+ if (e.target.closest('.category-dot')) return;
1320
+ if (e.target.closest('.cat-edit-icon')) return;
1321
+ if (state.activeCategories.has(cat)) {
1322
+ state.activeCategories.delete(cat);
1323
+ chip.classList.add('inactive');
1324
+ scheduleGraphRemoval();
1325
+ } else {
1326
+ state.activeCategories.add(cat);
1327
+ chip.classList.remove('inactive');
1328
+ cancelScheduledRemoval();
1329
+ emit('graph:refresh');
1330
+ }
1331
+ });
1332
+
1333
+ const catDeleteBtn = chip.querySelector('.category-delete');
1334
+ if (catDeleteBtn) {
1335
+ catDeleteBtn.addEventListener('click', (e) => {
1336
+ e.stopPropagation();
1337
+ deleteCategoryUI(cat, counts[cat] || 0, chip);
1338
+ });
1339
+ }
1340
+
1341
+ $categoryList.appendChild(chip);
1342
+ });
1343
+
1344
+ // Wire up drag-to-reorder
1345
+ initCategoryDrag();
1346
+
1347
+ emit('sidebar:rebuilt');
1348
+ }
1349
+
1350
+ // ═══════════════════════════════════════════
1351
+ // DRAG-TO-REORDER
1352
+ // ═══════════════════════════════════════════
1353
+
1354
+ function initCategoryDrag() {
1355
+ const $categoryList = $('category-list');
1356
+ if (!$categoryList) return;
1357
+
1358
+ let dragEl = null; // The element being dragged (chip or cluster header)
1359
+ let dragType = null; // 'chip' or 'cluster'
1360
+ let startY = 0;
1361
+ let startX = 0;
1362
+ let cloneEl = null; // Floating clone that follows cursor
1363
+ let initialRect = null; // Bounding rect captured before drag starts
1364
+ let dragElHeight = 0; // Height of dragged element for gap sizing
1365
+ categoryDragDidMove = false;
1366
+
1367
+ const chips = () => [...$categoryList.querySelectorAll('.category-chip')];
1368
+ const clusterHeaders = () => [...$categoryList.querySelectorAll('.category-cluster-header')];
1369
+
1370
+ function clearIndicators() {
1371
+ chips().forEach(c => c.classList.remove('drag-gap-before', 'drag-gap-after'));
1372
+ clusterHeaders().forEach(h => h.classList.remove('drag-over-cluster', 'drag-gap-before', 'drag-gap-after'));
1373
+ }
1374
+
1375
+ // Get all DOM elements belonging to a cluster (header + its child chips + empty hints)
1376
+ function getClusterElements(headerEl) {
1377
+ const els = [headerEl];
1378
+ let next = headerEl.nextElementSibling;
1379
+ while (next) {
1380
+ if (next.classList.contains('category-cluster-header')) break;
1381
+ if (next.classList.contains('category-chip') && !next.classList.contains('category-chip-child')) break;
1382
+ els.push(next);
1383
+ next = next.nextElementSibling;
1384
+ }
1385
+ return els;
1386
+ }
1387
+
1388
+ function getDropTarget(y) {
1389
+ const headers = clusterHeaders();
1390
+
1391
+ if (dragType === 'cluster') {
1392
+ const dragClusterEls = new Set(getClusterElements(dragEl));
1393
+
1394
+ for (const header of headers) {
1395
+ if (dragClusterEls.has(header)) continue;
1396
+ const rect = header.getBoundingClientRect();
1397
+ const mid = rect.top + rect.height / 2;
1398
+ if (y >= rect.top - 8 && y <= rect.bottom + 8) {
1399
+ return { type: 'reorder-cluster', header, position: y < mid ? 'before' : 'after' };
1400
+ }
1401
+ }
1402
+
1403
+ for (const chip of chips()) {
1404
+ if (chip.classList.contains('category-chip-child')) continue;
1405
+ if (dragClusterEls.has(chip)) continue;
1406
+ const rect = chip.getBoundingClientRect();
1407
+ const mid = rect.top + rect.height / 2;
1408
+ if (y >= rect.top - 4 && y <= rect.bottom + 4) {
1409
+ return { type: 'reorder-cluster', header: chip, position: y < mid ? 'before' : 'after' };
1410
+ }
1411
+ }
1412
+
1413
+ return null;
1414
+ }
1415
+
1416
+ // Dragging a chip: check cluster headers to reparent
1417
+ for (const header of headers) {
1418
+ const rect = header.getBoundingClientRect();
1419
+ if (y >= rect.top - 4 && y <= rect.bottom + 20) {
1420
+ const clusterName = header.querySelector('.cluster-name').textContent;
1421
+ return { type: 'cluster', header, clusterName };
1422
+ }
1423
+ }
1424
+
1425
+ // Check empty cluster hints
1426
+ for (const hint of [...$categoryList.querySelectorAll('.cluster-empty-hint')]) {
1427
+ const rect = hint.getBoundingClientRect();
1428
+ if (y >= rect.top && y <= rect.bottom) {
1429
+ const header = hint.previousElementSibling;
1430
+ if (header && header.classList.contains('category-cluster-header')) {
1431
+ const clusterName = header.querySelector('.cluster-name').textContent;
1432
+ return { type: 'cluster', header, clusterName };
1433
+ }
1434
+ }
1435
+ }
1436
+
1437
+ // Check top empty area (make top-level)
1438
+ const firstChip = chips()[0];
1439
+ const firstHeader = headers[0];
1440
+ const firstElement = firstChip || firstHeader;
1441
+ if (firstElement) {
1442
+ const firstRect = firstElement.getBoundingClientRect();
1443
+ const listRect = $categoryList.getBoundingClientRect();
1444
+ if (y < firstRect.top && y >= listRect.top) {
1445
+ return { type: 'make-toplevel' };
1446
+ }
1447
+ }
1448
+
1449
+ // Reorder among sibling chips
1450
+ for (const chip of chips()) {
1451
+ if (chip === dragEl) continue;
1452
+ const rect = chip.getBoundingClientRect();
1453
+ const mid = rect.top + rect.height / 2;
1454
+ if (y < mid) return { type: 'reorder', chip, position: 'before' };
1455
+ }
1456
+ const last = chips().filter(c => c !== dragEl).pop();
1457
+ return last ? { type: 'reorder', chip: last, position: 'after' } : null;
1458
+ }
1459
+
1460
+ // Create a floating clone of the dragged element
1461
+ function createDragClone(el) {
1462
+ const rect = el.getBoundingClientRect();
1463
+ const clone = el.cloneNode(true);
1464
+ clone.classList.remove('inactive', 'drag-source');
1465
+ clone.classList.add('cat-drag-clone');
1466
+ clone.style.width = rect.width + 'px';
1467
+ clone.style.left = rect.left + 'px';
1468
+ clone.style.top = rect.top + 'px';
1469
+ clone.style.transform = 'translate(0px, 0px) scale(1.04) rotate(0.8deg)';
1470
+ // Strip interactive elements — clone is purely visual
1471
+ clone.querySelectorAll('.category-delete, .cat-edit-icon, .cluster-actions').forEach(n => n.remove());
1472
+ document.body.appendChild(clone);
1473
+ return clone;
1474
+ }
1475
+
1476
+ // Cleanup all drag artifacts
1477
+ function cleanupDrag() {
1478
+ if (dragEl) {
1479
+ dragEl.classList.remove('drag-source');
1480
+ if (dragType === 'cluster') {
1481
+ getClusterElements(dragEl).forEach(el => el.classList.remove('drag-source'));
1482
+ }
1483
+ }
1484
+ clearIndicators();
1485
+ if (cloneEl) { cloneEl.remove(); cloneEl = null; }
1486
+ $categoryList.classList.remove('cat-dragging');
1487
+ $categoryList.style.removeProperty('--drag-gap');
1488
+ dragEl = null;
1489
+ dragType = null;
1490
+ initialRect = null;
1491
+ }
1492
+
1493
+ $categoryList.addEventListener('pointerdown', (e) => {
1494
+ // Skip interactive sub-elements
1495
+ if (e.target.closest('.category-delete')) return;
1496
+ if (e.target.closest('.category-dot')) return;
1497
+ if (e.target.closest('.cat-edit-icon')) return;
1498
+ if (e.target.closest('.cluster-actions')) return;
1499
+ if (e.target.closest('.cluster-dot')) return;
1500
+
1501
+ const chip = e.target.closest('.category-chip');
1502
+ const header = e.target.closest('.category-cluster-header');
1503
+ const el = chip || header;
1504
+ if (!el) return;
1505
+
1506
+ e.preventDefault();
1507
+ dragEl = el;
1508
+ dragType = chip ? 'chip' : 'cluster';
1509
+ startY = e.clientY;
1510
+ startX = e.clientX;
1511
+ categoryDragDidMove = false;
1512
+ el.setPointerCapture(e.pointerId);
1513
+
1514
+ function onMove(ev) {
1515
+ if (!dragEl) return;
1516
+ if (!categoryDragDidMove && Math.abs(ev.clientY - startY) < 5) return;
1517
+
1518
+ if (!categoryDragDidMove) {
1519
+ categoryDragDidMove = true;
1520
+
1521
+ // Capture geometry before any visual changes
1522
+ initialRect = dragEl.getBoundingClientRect();
1523
+ dragElHeight = initialRect.height + 4;
1524
+
1525
+ // Create floating clone
1526
+ cloneEl = createDragClone(dragEl);
1527
+
1528
+ // Ghost the source element(s)
1529
+ dragEl.classList.add('drag-source');
1530
+ if (dragType === 'cluster') {
1531
+ getClusterElements(dragEl).forEach(el => el.classList.add('drag-source'));
1532
+ }
1533
+
1534
+ // Enable gap transitions and set dynamic gap size
1535
+ $categoryList.classList.add('cat-dragging');
1536
+ $categoryList.style.setProperty('--drag-gap', dragElHeight + 'px');
1537
+ }
1538
+
1539
+ // Move clone with cursor (GPU-accelerated transform)
1540
+ const dx = ev.clientX - startX;
1541
+ const dy = ev.clientY - startY;
1542
+ cloneEl.style.transform = `translate(${dx}px, ${dy}px) scale(1.04) rotate(0.8deg)`;
1543
+
1544
+ // Show animated gap at drop target
1545
+ clearIndicators();
1546
+ const target = getDropTarget(ev.clientY);
1547
+ if (target) {
1548
+ if (target.type === 'cluster') {
1549
+ target.header.classList.add('drag-over-cluster');
1550
+ } else if (target.type === 'reorder' || target.type === 'reorder-cluster') {
1551
+ const targetEl = target.chip || target.header;
1552
+ targetEl.classList.add(target.position === 'before' ? 'drag-gap-before' : 'drag-gap-after');
1553
+ } else if (target.type === 'make-toplevel') {
1554
+ const first = chips()[0] || clusterHeaders()[0];
1555
+ if (first && first !== dragEl) first.classList.add('drag-gap-before');
1556
+ }
1557
+ }
1558
+ }
1559
+
1560
+ async function onUp(ev) {
1561
+ el.removeEventListener('pointermove', onMove);
1562
+ el.removeEventListener('pointerup', onUp);
1563
+ el.removeEventListener('pointercancel', onUp);
1564
+
1565
+ if (!dragEl) return;
1566
+
1567
+ const droppedEl = dragEl;
1568
+ const droppedType = dragType;
1569
+ const didMove = categoryDragDidMove;
1570
+
1571
+ if (didMove) {
1572
+ const target = getDropTarget(ev.clientY);
1573
+
1574
+ // ── Spring-snap drop animation ──
1575
+ if (cloneEl && target && initialRect) {
1576
+ let gapEl = null, gapPosition = null;
1577
+ if (target.type === 'reorder' || target.type === 'reorder-cluster') {
1578
+ gapEl = target.chip || target.header;
1579
+ gapPosition = target.position;
1580
+ } else if (target.type === 'make-toplevel') {
1581
+ gapEl = chips()[0] || clusterHeaders()[0];
1582
+ gapPosition = 'before';
1583
+ } else if (target.type === 'cluster') {
1584
+ gapEl = target.header;
1585
+ gapPosition = 'after';
1586
+ }
1587
+
1588
+ if (gapEl) {
1589
+ const gapRect = gapEl.getBoundingClientRect();
1590
+ const targetTop = gapPosition === 'before'
1591
+ ? gapRect.top - dragElHeight
1592
+ : gapRect.bottom;
1593
+ const targetDy = targetTop - initialRect.top;
1594
+ const targetDx = gapRect.left - initialRect.left;
1595
+
1596
+ // Animate clone to landing position with spring overshoot
1597
+ cloneEl.style.transition = 'transform 0.22s cubic-bezier(0.22, 1.15, 0.36, 1), opacity 0.14s ease 0.06s';
1598
+ cloneEl.style.transform = `translate(${targetDx}px, ${targetDy}px) scale(1) rotate(0deg)`;
1599
+ cloneEl.style.opacity = '0';
1600
+ await new Promise(r => setTimeout(r, 220));
1601
+ }
1602
+ } else if (cloneEl && initialRect) {
1603
+ // No valid target — animate clone back to origin
1604
+ cloneEl.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0, 1), opacity 0.12s ease';
1605
+ cloneEl.style.transform = 'translate(0px, 0px) scale(1) rotate(0deg)';
1606
+ cloneEl.style.opacity = '0';
1607
+ await new Promise(r => setTimeout(r, 200));
1608
+ }
1609
+
1610
+ // ── Execute drop action ──
1611
+ if (target) {
1612
+ if (droppedType === 'cluster' && target.type === 'reorder-cluster') {
1613
+ const clusterEls = getClusterElements(droppedEl);
1614
+ const refEl = target.header;
1615
+ if (target.position === 'before') {
1616
+ for (const cel of clusterEls) {
1617
+ $categoryList.insertBefore(cel, refEl);
1618
+ }
1619
+ } else {
1620
+ let insertAfter = refEl;
1621
+ if (refEl.classList.contains('category-cluster-header')) {
1622
+ const targetClusterEls = getClusterElements(refEl);
1623
+ insertAfter = targetClusterEls[targetClusterEls.length - 1];
1624
+ }
1625
+ for (let i = clusterEls.length - 1; i >= 0; i--) {
1626
+ insertAfter.after(clusterEls[i]);
1627
+ }
1628
+ }
1629
+ const newOrder = chips().map(c => c.dataset.cat);
1630
+ saveCategoryOrder(newOrder);
1631
+
1632
+ } else if (droppedType === 'chip' && target.type === 'cluster') {
1633
+ const draggedCat = droppedEl.dataset.cat;
1634
+ const newParent = target.clusterName;
1635
+ const currentMeta = state.categoryMetadata[draggedCat] || {};
1636
+
1637
+ if (currentMeta.parent !== newParent) {
1638
+ try {
1639
+ const data = await apiUpdateCategory(draggedCat, { parent: newParent });
1640
+ applyApiResponse(data);
1641
+ } catch (err) {
1642
+ console.error('Error reparenting category:', err);
1643
+ alert('Error reparenting category: ' + err.message);
1644
+ }
1645
+ }
1646
+
1647
+ } else if (droppedType === 'chip' && target.type === 'make-toplevel') {
1648
+ const draggedCat = droppedEl.dataset.cat;
1649
+ const currentMeta = state.categoryMetadata[draggedCat] || {};
1650
+
1651
+ if (currentMeta.parent) {
1652
+ try {
1653
+ const data = await apiUpdateCategory(draggedCat, { parent: '' });
1654
+ applyApiResponse(data);
1655
+ } catch (err) {
1656
+ console.error('Error making category top-level:', err);
1657
+ alert('Error making category top-level: ' + err.message);
1658
+ }
1659
+ }
1660
+
1661
+ } else if (droppedType === 'chip' && target.type === 'reorder' && target.chip !== droppedEl) {
1662
+ if (target.position === 'before') {
1663
+ $categoryList.insertBefore(droppedEl, target.chip);
1664
+ } else {
1665
+ target.chip.after(droppedEl);
1666
+ }
1667
+ const newOrder = chips().map(c => c.dataset.cat);
1668
+ saveCategoryOrder(newOrder);
1669
+ }
1670
+ }
1671
+ }
1672
+
1673
+ // ── Cleanup ──
1674
+ cleanupDrag();
1675
+
1676
+ // ── Settle animation: element pops into place ──
1677
+ if (didMove && droppedEl && droppedEl.parentNode) {
1678
+ droppedEl.classList.add('just-dropped');
1679
+ droppedEl.addEventListener('animationend', () => droppedEl.classList.remove('just-dropped'), { once: true });
1680
+ }
1681
+ }
1682
+
1683
+ el.addEventListener('pointermove', onMove);
1684
+ el.addEventListener('pointerup', onUp);
1685
+ el.addEventListener('pointercancel', onUp);
1686
+ });
1687
+ }
1688
+
1689
+ // ═══════════════════════════════════════════
1690
+ // CATEGORY CREATE FORM WIRING
1691
+ // ═══════════════════════════════════════════
1692
+
1693
+ function initCreateForm() {
1694
+ const $addBtn = $('category-add-btn');
1695
+ const $addParentBtn = $('category-add-parent-btn');
1696
+ const $form = $('category-create-form');
1697
+ const $formTitle = $('cat-form-title');
1698
+ const $nameInput = $('cat-name-input');
1699
+ const $descInput = $('cat-desc-input');
1700
+ const $parentSelect = $('cat-parent-select');
1701
+ const $swatchRow = $('color-swatch-row');
1702
+ const $error = $('cat-form-error');
1703
+ const $createBtn = $('cat-form-create');
1704
+ const $cancelBtn = $('cat-form-cancel');
1705
+
1706
+ if (!$addBtn || !$form) return;
1707
+
1708
+ let selectedColor = COLOR_PALETTE[0];
1709
+ let formMode = 'child'; // 'child' or 'parent'
1710
+
1711
+ // Build swatches
1712
+ COLOR_PALETTE.forEach((color, i) => {
1713
+ const btn = document.createElement('button');
1714
+ btn.className = 'color-swatch' + (i === 0 ? ' selected' : '');
1715
+ btn.style.background = color;
1716
+ btn.addEventListener('click', () => {
1717
+ $swatchRow.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('selected'));
1718
+ btn.classList.add('selected');
1719
+ selectedColor = color;
1720
+ });
1721
+ $swatchRow.appendChild(btn);
1722
+ });
1723
+
1724
+ // Populate parent dropdown
1725
+ function populateParentDropdown() {
1726
+ $parentSelect.innerHTML = '<option value="">(None - standalone category)</option>';
1727
+ state.allCategoryNames.forEach(catName => {
1728
+ const opt = document.createElement('option');
1729
+ opt.value = catName;
1730
+ opt.textContent = catName;
1731
+ $parentSelect.appendChild(opt);
1732
+ });
1733
+ }
1734
+
1735
+ function openForm(mode) {
1736
+ formMode = mode;
1737
+ $form.dataset.mode = mode;
1738
+ $form.classList.add('open');
1739
+
1740
+ if (mode === 'parent') {
1741
+ $formTitle.textContent = 'CREATE PARENT CATEGORY';
1742
+ $nameInput.placeholder = 'parent-category-name';
1743
+ $descInput.placeholder = 'What does this group contain?';
1744
+ } else {
1745
+ $formTitle.textContent = 'CREATE CATEGORY';
1746
+ $nameInput.placeholder = 'category-name';
1747
+ $descInput.placeholder = 'Short description...';
1748
+ populateParentDropdown();
1749
+ upgradeSelect($parentSelect);
1750
+ }
1751
+
1752
+ $nameInput.focus();
1753
+ }
1754
+
1755
+ function closeForm() {
1756
+ $form.classList.remove('open');
1757
+ $nameInput.value = '';
1758
+ $descInput.value = '';
1759
+ $parentSelect.value = '';
1760
+ $error.textContent = '';
1761
+ $createBtn.disabled = true;
1762
+ $nameInput.classList.remove('invalid');
1763
+ }
1764
+
1765
+ function validateForm() {
1766
+ const name = $nameInput.value;
1767
+ const desc = $descInput.value.trim();
1768
+ if (!name) { $error.textContent = ''; $createBtn.disabled = true; return; }
1769
+ if (name.length < 2) { $error.textContent = 'Min 2 characters'; $createBtn.disabled = true; return; }
1770
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) { $error.textContent = 'Lowercase, starts with letter, letters/digits/hyphens only'; $createBtn.disabled = true; $nameInput.classList.add('invalid'); return; }
1771
+ $nameInput.classList.remove('invalid');
1772
+ if (!desc) { $error.textContent = 'Description required'; $createBtn.disabled = true; return; }
1773
+ $error.textContent = '';
1774
+ $createBtn.disabled = false;
1775
+ }
1776
+
1777
+ $nameInput.addEventListener('input', () => {
1778
+ $nameInput.value = $nameInput.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
1779
+ validateForm();
1780
+ });
1781
+ $descInput.addEventListener('input', validateForm);
1782
+
1783
+ $addBtn.addEventListener('click', () => {
1784
+ if ($form.classList.contains('open')) {
1785
+ closeForm();
1786
+ } else {
1787
+ openForm('child');
1788
+ }
1789
+ });
1790
+
1791
+ $addParentBtn.addEventListener('click', () => {
1792
+ if ($form.classList.contains('open')) {
1793
+ closeForm();
1794
+ } else {
1795
+ openForm('parent');
1796
+ }
1797
+ });
1798
+
1799
+ $cancelBtn.addEventListener('click', () => {
1800
+ closeForm();
1801
+ });
1802
+
1803
+ $createBtn.addEventListener('click', async () => {
1804
+ const name = $nameInput.value;
1805
+ const desc = $descInput.value.trim();
1806
+ const parent = formMode === 'child' ? ($parentSelect.value || null) : null;
1807
+ const isParent = formMode === 'parent';
1808
+ $createBtn.disabled = true;
1809
+ $createBtn.textContent = 'Creating...';
1810
+ try {
1811
+ await createCategoryAction(name, desc, selectedColor, parent, isParent);
1812
+ closeForm();
1813
+ } catch (e) {
1814
+ $error.textContent = e.message;
1815
+ }
1816
+ $createBtn.textContent = 'Create';
1817
+ validateForm();
1818
+ });
1819
+ }
1820
+
1821
+ // ═══════════════════════════════════════════
1822
+ // SIDEBAR VISIBILITY TOGGLE + BUTTON WIRING
1823
+ // ═══════════════════════════════════════════
1824
+
1825
+ function initSidebarToggle() {
1826
+ const catSidebar = $('category-sidebar');
1827
+ const catToggleBtn = $('toggle-categories-btn');
1828
+ const catCloseBtn = $('sidebar-close');
1829
+
1830
+ if (!catSidebar) return;
1831
+
1832
+ function setCategoriesVisible(show) {
1833
+ catSidebar.style.display = show ? '' : 'none';
1834
+ if (catToggleBtn) catToggleBtn.classList.toggle('active', show);
1835
+ storage.setItem(KEYS.CATEGORIES_VISIBLE, show ? '1' : '0');
1836
+ }
1837
+
1838
+ // Restore saved state (default: visible)
1839
+ const saved = storage.getItem(KEYS.CATEGORIES_VISIBLE);
1840
+ if (saved === '0') setCategoriesVisible(false);
1841
+
1842
+ if (catToggleBtn) {
1843
+ catToggleBtn.addEventListener('click', () => {
1844
+ const isVisible = catSidebar.style.display !== 'none';
1845
+ setCategoriesVisible(!isVisible);
1846
+ });
1847
+ }
1848
+
1849
+ if (catCloseBtn) {
1850
+ catCloseBtn.addEventListener('click', () => {
1851
+ setCategoriesVisible(false);
1852
+ });
1853
+ }
1854
+ }
1855
+
1856
+ // ═══════════════════════════════════════════
1857
+ // SELECT-ALL / CLEAR-ALL BUTTONS
1858
+ // ═══════════════════════════════════════════
1859
+
1860
+ function initSelectAllClear() {
1861
+ const clearBtn = $('category-clear-btn');
1862
+ const selectAllBtn = $('category-select-all-btn');
1863
+
1864
+ if (clearBtn) {
1865
+ clearBtn.addEventListener('click', () => {
1866
+ state.activeCategories.clear();
1867
+ document.querySelectorAll('.category-chip').forEach(c => c.classList.add('inactive'));
1868
+ document.querySelectorAll('.category-cluster-header').forEach(c => c.classList.add('inactive'));
1869
+ scheduleGraphRemoval();
1870
+ });
1871
+ }
1872
+
1873
+ if (selectAllBtn) {
1874
+ selectAllBtn.addEventListener('click', () => {
1875
+ const presentCats = new Set(state.allNodes.map(n => n.payload.category));
1876
+ state.activeCategories = new Set([...presentCats, ...state.allCategoryNames]);
1877
+ document.querySelectorAll('.category-chip').forEach(c => c.classList.remove('inactive'));
1878
+ document.querySelectorAll('.category-cluster-header').forEach(c => c.classList.remove('inactive'));
1879
+ cancelScheduledRemoval();
1880
+ emit('graph:refresh');
1881
+ });
1882
+ }
1883
+ }
1884
+
1885
+ // ═══════════════════════════════════════════
1886
+ // INIT — call once at boot
1887
+ // ═══════════════════════════════════════════
1888
+
1889
+ /**
1890
+ * Initialise the entire category sidebar system.
1891
+ * Call once after the DOM is ready and shared state is populated.
1892
+ */
1893
+ export function initSidebar() {
1894
+ initSidebarToggle();
1895
+ initSelectAllClear();
1896
+ initCreateForm();
1897
+
1898
+ // Listen for rebuild requests from other modules
1899
+ on('sidebar:rebuild', (presentCats) => {
1900
+ buildCategorySidebar(presentCats);
1901
+ });
1902
+ }
1903
+
1904
+ // Re-export helpers that other modules may need
1905
+ export { preloadCategoryLogos, scheduleGraphRemoval, cancelScheduledRemoval, openColorPicker, editCategoryUI, deleteCategoryUI };