claude-memory-admin 1.0.1 → 1.2.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.
package/public/app.mjs CHANGED
@@ -1,8 +1,6 @@
1
- import { marked } from '/vendor/marked.js';
2
- import DOMPurify from '/vendor/purify.js';
3
1
  import { renderGraph } from '/graph.mjs';
4
-
5
- marked.setOptions({ gfm: true, breaks: false });
2
+ import { renderMarkdown } from '/markdown.mjs';
3
+ import * as ui from '/ui.mjs';
6
4
 
7
5
  const state = {
8
6
  stores: [],
@@ -17,12 +15,11 @@ const state = {
17
15
  search: null,
18
16
  pruneSort: 'oldest',
19
17
  pruneSelection: new Set(),
18
+ indexView: localStorage.getItem('memoryIndexView') === 'source' ? 'source' : 'rendered',
20
19
  };
21
20
 
22
21
  const el = (id) => document.getElementById(id);
23
22
 
24
- /* ---------------------------------------------------------------- helpers */
25
-
26
23
  async function api(path, options) {
27
24
  const response = await fetch(path, {
28
25
  headers: { 'content-type': 'application/json' },
@@ -35,10 +32,11 @@ async function api(path, options) {
35
32
 
36
33
  function toast(message, { error = false, action } = {}) {
37
34
  const node = document.createElement('div');
38
- node.className = `toast${error ? ' err' : ''}`;
35
+ node.className = ui.toast(error);
39
36
  node.append(document.createTextNode(message));
40
37
  if (action) {
41
38
  const button = document.createElement('button');
39
+ button.className = ui.toastAction;
42
40
  button.textContent = action.label;
43
41
  button.onclick = () => { node.remove(); action.run(); };
44
42
  node.append(button);
@@ -61,61 +59,28 @@ function node(tag, props = {}, children = []) {
61
59
  return element;
62
60
  }
63
61
 
64
- /**
65
- * Render a memory body. Wikilinks are swapped for placeholders before markdown
66
- * runs, then rehydrated as buttons afterwards, so they survive sanitising and
67
- * can carry click handlers.
68
- */
69
- function renderBody(container, memory, project) {
62
+ function memoryLookup(project) {
70
63
  const byName = new Map();
71
- for (const other of project.memories) {
72
- byName.set(other.name, other.file);
73
- byName.set(other.stem, other.file);
64
+ for (const memory of project.memories) {
65
+ byName.set(memory.name, memory.file);
66
+ byName.set(memory.stem, memory.file);
74
67
  }
68
+ return byName;
69
+ }
75
70
 
76
- const tokens = [];
77
- const withPlaceholders = memory.body.replace(/\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, target, alias) => {
78
- const key = `%%WIKI${tokens.length}%%`;
79
- tokens.push({ target: target.trim(), alias: (alias || '').trim() });
80
- return key;
81
- });
82
-
83
- const html = DOMPurify.sanitize(marked.parse(withPlaceholders));
84
- container.innerHTML = html;
85
-
86
- if (!tokens.length) return;
87
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
88
- const texts = [];
89
- while (walker.nextNode()) texts.push(walker.currentNode);
90
-
91
- for (const textNode of texts) {
92
- if (!/%%WIKI\d+%%/.test(textNode.nodeValue)) continue;
93
- const fragment = document.createDocumentFragment();
94
- const parts = textNode.nodeValue.split(/(%%WIKI\d+%%)/);
95
- for (const part of parts) {
96
- const match = part.match(/^%%WIKI(\d+)%%$/);
97
- if (!match) {
98
- if (part) fragment.append(document.createTextNode(part));
99
- continue;
100
- }
101
- const token = tokens[Number(match[1])];
102
- const targetFile = byName.get(token.target);
103
- const button = node('button', {
104
- class: `wikilink${targetFile ? '' : ' dangling'}`,
105
- text: token.alias || token.target,
106
- title: targetFile ? `Go to ${token.target}` : `No memory named "${token.target}"`,
107
- });
108
- if (targetFile) button.onclick = () => selectMemory(targetFile);
109
- fragment.append(button);
110
- }
111
- textNode.replaceWith(fragment);
112
- }
71
+ function openTarget(file) {
72
+ return file ? { open: () => selectMemory(file) } : null;
113
73
  }
114
74
 
115
- /* --------------------------------------------------------------- sidebar */
75
+ function renderBody(container, memory, project) {
76
+ const byName = memoryLookup(project);
77
+ renderMarkdown(container, memory.body, {
78
+ resolveWikilink: (target) => openTarget(byName.get(target)),
79
+ });
80
+ }
116
81
 
117
- /** The second line under a store's name: where it lives, in its own terms. */
118
82
  function storeSubtitle(store) {
83
+ if (store.kind === 'global') return store.dir;
119
84
  if (store.kind === 'auto') return store.path;
120
85
  const scope = store.kind === 'agent-user' ? 'user'
121
86
  : store.kind === 'agent-project' ? 'project' : 'local';
@@ -123,22 +88,23 @@ function storeSubtitle(store) {
123
88
  }
124
89
 
125
90
  function storeButton(store) {
126
- const health = store.memoryCount === 0 ? 'none' : 'ok';
91
+ const global = store.kind === 'global';
92
+ const health = global ? 'ok' : store.memoryCount === 0 ? 'none' : 'ok';
127
93
  const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
128
94
  return node('button', {
129
- class: `project${store.id === state.storeId ? ' active' : ''}${store.hasMemoryDir ? '' : ' dim'}`,
95
+ class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
130
96
  onclick: () => openStore(store.id),
131
97
  title: store.kind === 'auto' && store.resolvedBy === 'unresolved'
132
98
  ? 'Real path could not be resolved - showing the raw folder name'
133
99
  : store.dir,
134
100
  }, [
135
- node('span', { class: 'project-row' }, [
136
- node('span', { class: `dot ${health}` }),
137
- node('span', { class: 'project-name', text: store.label }),
138
- off ? node('span', { class: 'off-marker', text: 'off', title: 'Auto memory is disabled for this project' }) : null,
139
- node('span', { class: 'project-count', text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
101
+ node('span', { class: ui.storeRow }, [
102
+ node('span', { class: ui.dot(health) }),
103
+ node('span', { class: ui.storeName, text: store.label }),
104
+ off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
105
+ global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
140
106
  ]),
141
- node('span', { class: 'project-path', text: storeSubtitle(store) }),
107
+ node('span', { class: ui.storePath, text: storeSubtitle(store) }),
142
108
  ]);
143
109
  }
144
110
 
@@ -146,32 +112,25 @@ function renderStores() {
146
112
  const list = el('project-list');
147
113
  list.textContent = '';
148
114
 
149
- const visible = state.stores.filter((s) => state.showAll || s.hasMemoryDir);
115
+ const visible = state.stores.filter((s) => s.kind === 'global' || state.showAll || s.hasMemoryDir);
150
116
  if (!visible.length) {
151
- list.append(node('p', { class: 'muted', text: 'No memory stores found.', style: 'padding:12px' }));
117
+ list.append(node('p', { class: ui.note, text: 'No memory stores found.' }));
152
118
  return;
153
119
  }
154
120
 
155
- // Subagent memory is a different thing from a project's auto memory - a
156
- // different scope, written by a different agent - so it gets its own group
157
- // rather than being mixed into the project list.
158
121
  const groups = [
122
+ ['Global', visible.filter((s) => s.kind === 'global')],
159
123
  ['Projects', visible.filter((s) => s.kind === 'auto')],
160
- ['Subagents', visible.filter((s) => s.kind !== 'auto')],
124
+ ['Subagents', visible.filter((s) => s.kind !== 'auto' && s.kind !== 'global')],
161
125
  ];
162
126
 
163
- for (const [title, stores] of groups) {
164
- if (!stores.length) continue;
165
- // With nothing to tell apart, a single heading is just noise.
166
- if (groups.every(([, group]) => group.length) ) {
167
- list.append(node('div', { class: 'sidebar-group', text: title }));
168
- }
127
+ const filled = groups.filter(([, stores]) => stores.length);
128
+ for (const [title, stores] of filled) {
129
+ if (filled.length > 1) list.append(node('div', { class: ui.sidebarGroup, text: title }));
169
130
  for (const store of stores) list.append(storeButton(store));
170
131
  }
171
132
  }
172
133
 
173
- /* ------------------------------------------------------------------ tabs */
174
-
175
134
  const TABS = [
176
135
  { id: 'memories', label: 'Memories' },
177
136
  { id: 'index', label: 'MEMORY.md' },
@@ -179,18 +138,21 @@ const TABS = [
179
138
  { id: 'prune', label: 'Prune' },
180
139
  { id: 'context', label: 'Context' },
181
140
  { id: 'health', label: 'Health' },
141
+ { id: 'settings', label: 'Settings' },
182
142
  { id: 'trash', label: 'Trash' },
183
143
  ];
184
144
 
185
145
  function renderTabs() {
186
146
  const container = el('tabs');
187
147
  container.textContent = '';
148
+ const global = state.store.kind === 'global';
188
149
  const hasProjectDir = state.store.kind === 'auto'
189
150
  ? state.store.resolvedBy !== 'unresolved'
190
151
  : Boolean(state.store.projectPath);
191
152
 
192
153
  for (const tab of TABS) {
193
- if (tab.id === 'context' && !hasProjectDir) continue;
154
+ if (global && tab.id !== 'context') continue;
155
+ if (!global && tab.id === 'context' && !hasProjectDir) continue;
194
156
  let badge = null;
195
157
  if (tab.id === 'memories') badge = String(state.store.memories.length);
196
158
  if (tab.id === 'health' && state.store.health.issueCount) badge = String(state.store.health.issueCount);
@@ -198,43 +160,42 @@ function renderTabs() {
198
160
  if (tab.id === 'prune' && state.store.stats.index.overTarget) badge = '!';
199
161
 
200
162
  container.append(node('button', {
201
- class: `tab${state.tab === tab.id ? ' active' : ''}`,
163
+ class: ui.tab(state.tab === tab.id),
202
164
  onclick: () => { state.tab = tab.id; renderTabs(); renderTab(); },
203
165
  }, [
204
166
  document.createTextNode(tab.label),
205
- badge ? node('span', { class: `badge${tab.id === 'health' || tab.id === 'prune' ? ' warn' : ''}`, text: badge }) : null,
167
+ badge ? node('span', { class: ui.tabBadge(tab.id === 'health' || tab.id === 'prune'), text: badge }) : null,
206
168
  ]));
207
169
  }
208
170
  }
209
171
 
210
- /* -------------------------------------------------------------- memories */
211
-
212
172
  function memoryButton(memory) {
173
+ const active = state.selected === memory.file;
213
174
  return node('button', {
214
- class: `mem${state.selected === memory.file ? ' active' : ''}`,
175
+ class: ui.memoryItem(active),
176
+ 'data-memory': memory.file,
177
+ 'data-active': active ? '' : null,
215
178
  onclick: () => selectMemory(memory.file),
216
179
  }, [
217
- node('span', { class: 'mem-top' }, [
218
- node('span', { class: 'mem-name', text: memory.name }),
219
- node('span', { class: `badge type-${memory.type}`, text: memory.type }),
180
+ node('span', { class: ui.memoryTop }, [
181
+ node('span', { class: ui.memoryName, text: memory.name }),
182
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
220
183
  memory.status !== 'indexed'
221
- ? node('span', { class: `badge status-${memory.status}`, text: memory.status })
184
+ ? node('span', { class: ui.badge('warn'), text: memory.status })
222
185
  : null,
223
186
  ]),
224
- memory.description ? node('span', { class: 'mem-desc', text: memory.description }) : null,
187
+ memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
225
188
  ]);
226
189
  }
227
190
 
228
191
  function renderMemories(container) {
229
192
  const project = state.store;
230
193
  if (!project.memories.length) {
231
- container.append(node('p', { class: 'muted', text: 'This project has no memory files yet.' }));
194
+ container.append(node('p', { class: ui.note, text: 'This project has no memory files yet.' }));
232
195
  return;
233
196
  }
234
197
 
235
198
  const left = node('div');
236
- // Group by the MEMORY.md heading each entry sits under, so the list mirrors
237
- // how the index is actually organised.
238
199
  const groups = new Map();
239
200
  for (const memory of project.memories) {
240
201
  const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
@@ -242,12 +203,12 @@ function renderMemories(container) {
242
203
  groups.get(key).push(memory);
243
204
  }
244
205
  for (const [section, memories] of groups) {
245
- left.append(node('div', { class: 'section-label', text: `${section} · ${memories.length}` }));
206
+ left.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
246
207
  for (const memory of memories) left.append(memoryButton(memory));
247
208
  }
248
209
 
249
- const right = node('div', { id: 'detail' });
250
- container.append(node('div', { class: 'split' }, [left, right]));
210
+ const right = node('div', { id: 'detail', class: ui.detailPane });
211
+ container.append(node('div', { class: ui.split }, [left, right]));
251
212
  renderDetail();
252
213
  }
253
214
 
@@ -258,30 +219,28 @@ function renderDetail() {
258
219
 
259
220
  const memory = state.store.memories.find((m) => m.file === state.selected);
260
221
  if (!memory) {
261
- host.append(node('p', { class: 'muted', text: 'Select a memory to read it.' }));
222
+ host.append(node('p', { class: ui.note, text: 'Select a memory to read it.' }));
262
223
  return;
263
224
  }
264
225
 
265
- const card = node('div', { class: 'card' });
266
- card.append(node('div', { class: 'detail-head' }, [
267
- node('h3', { text: memory.name }),
268
- node('span', { class: `badge type-${memory.type}`, text: memory.type }),
226
+ const card = node('div', { class: ui.card });
227
+ card.append(node('div', { class: ui.detailHead }, [
228
+ node('h3', { class: ui.detailTitle, text: memory.name }),
229
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
269
230
  node('button', {
270
- class: 'btn danger small',
231
+ class: ui.buttonDangerSmall,
271
232
  text: 'Delete',
272
233
  onclick: () => openDeleteDialog(memory.file),
273
234
  }),
274
235
  ]));
275
236
 
276
- if (memory.description) card.append(node('p', { class: 'detail-desc', text: memory.description }));
237
+ if (memory.description) card.append(node('p', { class: ui.detailDesc, text: memory.description }));
277
238
 
278
- const meta = node('dl', { class: 'meta' });
239
+ const meta = node('dl', { class: ui.metaList });
279
240
  const rows = [
280
241
  ['file', memory.file],
281
242
  ['status', memory.status],
282
243
  ['modified', memory.modified
283
- // mtime is reset by any copy or restore, so a date that is only a file
284
- // timestamp is weaker evidence than one Claude Code actually stamped.
285
244
  ? `${memory.modified.replace('T', ' ').replace(/\..*$/, '')}${memory.modifiedFrom === 'mtime' ? ' (file mtime)' : ''}`
286
245
  : '-'],
287
246
  ['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
@@ -291,23 +250,23 @@ function renderDetail() {
291
250
  if (key !== 'type' && key !== 'modified') rows.push([key, value]);
292
251
  }
293
252
  for (const [key, value] of rows) {
294
- meta.append(node('dt', { text: key }), node('dd', { text: String(value) }));
253
+ meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
295
254
  }
296
255
  card.append(meta);
297
256
 
298
- const body = node('div', { class: 'body' });
257
+ const body = node('div', { class: ui.prose });
299
258
  card.append(body);
300
259
  renderBody(body, memory, state.store);
301
260
 
302
261
  const outbound = memory.outboundResolved || [];
303
262
  if (outbound.length || memory.inbound.length) {
304
- const links = node('div', { style: 'margin-top:20px;border-top:1px solid var(--border);padding-top:14px' });
263
+ const links = node('div', { class: ui.linkSection });
305
264
  if (outbound.length) {
306
- links.append(node('div', { class: 'section-label', text: 'Links out' }));
307
- const chips = node('div', { class: 'links' });
265
+ links.append(node('div', { class: ui.sectionLabel, text: 'Links out' }));
266
+ const chips = node('div', { class: ui.linkRow });
308
267
  for (const link of outbound) {
309
268
  chips.append(node('button', {
310
- class: `chip${link.file ? '' : ' dead'}`,
269
+ class: ui.chip(Boolean(link.file)),
311
270
  text: link.file ? link.target : `${link.target} (missing)`,
312
271
  onclick: link.file ? () => selectMemory(link.file) : null,
313
272
  }));
@@ -315,12 +274,12 @@ function renderDetail() {
315
274
  links.append(chips);
316
275
  }
317
276
  if (memory.inbound.length) {
318
- links.append(node('div', { class: 'section-label', text: 'Linked from' }));
319
- const chips = node('div', { class: 'links' });
277
+ links.append(node('div', { class: ui.sectionLabel, text: 'Linked from' }));
278
+ const chips = node('div', { class: ui.linkRow });
320
279
  for (const link of memory.inbound) {
321
280
  const source = state.store.memories.find((m) => m.file === link.from);
322
281
  chips.append(node('button', {
323
- class: 'chip',
282
+ class: ui.chip(true),
324
283
  text: source ? source.name : link.from,
325
284
  onclick: () => selectMemory(link.from),
326
285
  }));
@@ -333,45 +292,96 @@ function renderDetail() {
333
292
  host.append(card);
334
293
  }
335
294
 
336
- /* ----------------------------------------------------------- MEMORY.md */
295
+ const INDEX_VIEWS = [
296
+ { id: 'rendered', label: 'Rendered' },
297
+ { id: 'source', label: 'Source' },
298
+ ];
299
+
300
+ function setIndexView(view) {
301
+ state.indexView = view;
302
+ localStorage.setItem('memoryIndexView', view);
303
+ renderTab();
304
+ }
305
+
306
+ function indexViewPicker() {
307
+ return node('div', { class: ui.segmentGroup, role: 'tablist' }, INDEX_VIEWS.map((view) => node('button', {
308
+ class: ui.segment(state.indexView === view.id),
309
+ text: view.label,
310
+ role: 'tab',
311
+ 'aria-selected': String(state.indexView === view.id),
312
+ onclick: () => setIndexView(view.id),
313
+ })));
314
+ }
315
+
316
+ function cutMarker(cutoff) {
317
+ return node('div', { class: ui.cutLine }, [
318
+ node('span', { class: ui.cutLabel, text: `not loaded past here — ${cutoff.droppedLines} lines dropped on ${cutoff.by}` }),
319
+ ]);
320
+ }
337
321
 
338
322
  function renderIndex(container) {
339
323
  const project = state.store;
340
324
  if (!project.hasIndex) {
341
- container.append(node('p', { class: 'muted', text: 'This project has no MEMORY.md.' }));
325
+ container.append(node('p', { class: ui.note, text: 'This project has no MEMORY.md.' }));
342
326
  return;
343
327
  }
344
328
 
329
+ const card = node('div', { class: ui.card });
330
+ card.append(node('div', { class: ui.cardHeadRow }, [
331
+ node('p', { class: ui.note, text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }),
332
+ indexViewPicker(),
333
+ ]));
334
+
335
+ if (state.indexView === 'source') renderIndexSource(card, project);
336
+ else renderIndexRendered(card, project);
337
+
338
+ container.append(card);
339
+ }
340
+
341
+ function renderIndexRendered(card, project) {
342
+ const { cutoff } = project.stats.index;
343
+ const files = new Set(project.memories.map((memory) => memory.file));
344
+ const byName = memoryLookup(project);
345
+ const options = {
346
+ resolveWikilink: (target) => openTarget(byName.get(target)),
347
+ resolveHref: (href) => openTarget(files.has(href) ? href : null),
348
+ };
349
+
350
+ const lines = project.index.raw.split('\n');
351
+ const loaded = node('div', { class: ui.prose });
352
+ renderMarkdown(loaded, cutoff ? lines.slice(0, cutoff.rawLine).join('\n') : project.index.raw, options);
353
+ card.append(loaded);
354
+
355
+ if (!cutoff) return;
356
+ card.append(cutMarker(cutoff));
357
+ const dropped = node('div', { class: ui.proseDim });
358
+ renderMarkdown(dropped, lines.slice(cutoff.rawLine).join('\n'), options);
359
+ card.append(dropped);
360
+ }
361
+
362
+ function renderIndexSource(card, project) {
345
363
  const missing = new Set(project.health.danglingIndex.map((d) => d.index));
346
364
  const { cutoff } = project.stats.index;
347
- const card = node('div', { class: 'card' });
348
- card.append(node('p', { class: 'muted', style: 'margin-top:0', text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }));
349
365
 
350
366
  for (const line of project.index.lines) {
351
- // The cutoff is a raw line number, so it lands in the right place even
352
- // though frontmatter and comments are stripped before the limit is measured.
353
- if (cutoff && line.index === cutoff.rawLine) {
354
- card.append(node('div', { class: 'cut-line' }, [
355
- node('span', { class: 'cut-label', text: `not loaded past here — ${cutoff.droppedLines} lines dropped on ${cutoff.by}` }),
356
- ]));
357
- }
358
- const row = node('div', {
359
- class: `index-line kind-${line.kind}${missing.has(line.index) ? ' dangling' : ''}${cutoff && line.index >= cutoff.rawLine ? ' beyond-cut' : ''}`,
360
- }, [
361
- node('span', { class: 'ln', text: String(line.index + 1) }),
362
- node('span', { class: 'content', text: line.text || ' ' }),
363
- ]);
364
- if (line.kind === 'index' && !missing.has(line.index)) {
365
- row.querySelector('.content').onclick = () => selectMemory(line.file);
366
- }
367
- card.append(row);
367
+ if (cutoff && line.index === cutoff.rawLine) card.append(cutMarker(cutoff));
368
+
369
+ const dangling = missing.has(line.index);
370
+ const dropped = Boolean(cutoff) && line.index >= cutoff.rawLine;
371
+ const clickable = line.kind === 'index' && !dangling;
372
+ const content = node('span', {
373
+ class: ui.indexLineText({ kind: line.kind, clickable, dropped }),
374
+ text: line.text || ' ',
375
+ });
376
+ if (clickable) content.onclick = () => selectMemory(line.file);
377
+
378
+ card.append(node('div', { class: ui.indexLine({ dangling, dropped }) }, [
379
+ node('span', { class: ui.indexLineNumber, text: String(line.index + 1) }),
380
+ content,
381
+ ]));
368
382
  }
369
- container.append(card);
370
383
  }
371
384
 
372
-
373
- /* ------------------------------------------------------------- context */
374
-
375
385
  const SCOPE_ORDER = ['managed', 'user', 'project', 'local'];
376
386
 
377
387
  const CONTEXT_PROBLEMS = {
@@ -383,110 +393,128 @@ const CONTEXT_PROBLEMS = {
383
393
  'glob-budget': (p) => ['Rule has too many brace expansions', `${p.file} expands to ${p.expansions} patterns, past the 1,000 budget, so it is used unexpanded and its literal braces match no file`],
384
394
  'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
385
395
  'agents-md-not-imported': (p) => ['AGENTS.md is not loaded', `${p.file} exists, but Claude Code reads CLAUDE.md. Import it with @AGENTS.md, or symlink it.`],
396
+ 'unreferenced-user-file': (p) => ['Nothing in the chain reaches this file', `${p.file} sits next to CLAUDE.md and loads nothing. Import it with @${p.file.split('/').pop()}, or delete it.`],
386
397
  };
387
398
 
388
399
  async function renderContext(container) {
389
- container.append(node('p', { class: 'muted', text: 'Reading instruction files…' }));
400
+ container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
390
401
  let data;
391
402
  try {
392
403
  data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
393
404
  } catch (err) {
394
405
  container.textContent = '';
395
- return container.append(node('p', { class: 'muted', text: err.message }));
406
+ return container.append(node('p', { class: ui.note, text: err.message }));
396
407
  }
397
- // The tab may have been switched while the request was in flight.
398
408
  if (state.tab !== 'context') return;
399
409
  container.textContent = '';
400
410
 
401
- if (!data.projectDir) {
402
- return container.append(node('p', { class: 'muted', text: 'This store is not tied to a project directory, so there are no instruction files to resolve.' }));
411
+ const global = state.store.kind === 'global';
412
+ if (!data.projectDir && !global) {
413
+ return container.append(node('p', { class: ui.note, text: 'This store is not tied to a project directory, so there are no instruction files to resolve.' }));
403
414
  }
404
415
 
405
416
  const { totals } = data;
406
- container.append(node('div', { class: 'meter' }, [
407
- node('div', { class: 'meter-top' }, [
408
- node('span', { class: 'meter-value', text: `~${totals.alwaysTokens.toLocaleString()}` }),
409
- node('span', { class: 'meter-unit', text: 'estimated tokens of instructions, every session' }),
417
+ container.append(node('div', { class: ui.meter }, [
418
+ node('div', { class: ui.meterTop }, [
419
+ node('span', { class: ui.meterValue, text: `~${totals.alwaysTokens.toLocaleString()}` }),
420
+ node('span', { class: ui.meterUnit, text: 'estimated tokens of instructions, every session' }),
410
421
  ]),
411
- node('div', { class: 'meter-facts' }, [
412
- node('span', {}, [node('b', { text: String(totals.files) }), document.createTextNode(' files resolved')]),
413
- node('span', {}, [node('b', { text: String(totals.alwaysLines) }), document.createTextNode(' lines always loaded')]),
414
- node('span', {}, [node('b', { text: `${(totals.alwaysBytes / 1024).toFixed(1)} KB` }), document.createTextNode(' always loaded')]),
415
- node('span', {}, [node('b', { text: String(totals.conditionalFiles) }), document.createTextNode(' path-scoped rules, loaded only on a match')]),
422
+ node('div', { class: ui.meterFacts }, [
423
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.files) }), document.createTextNode(' files resolved')]),
424
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.alwaysLines) }), document.createTextNode(' lines always loaded')]),
425
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(totals.alwaysBytes / 1024).toFixed(1)} KB` }), document.createTextNode(' always loaded')]),
426
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.conditionalFiles) }), document.createTextNode(' path-scoped rules, loaded only on a match')]),
416
427
  ]),
417
- node('p', { class: 'meter-note', text: 'Unlike MEMORY.md, none of this is truncated: CLAUDE.md files load in full however long they are. The 200-line figure is Claude Code’s guidance, not a cutoff, because long files cost context every session and are followed less reliably.' }),
428
+ node('p', { class: ui.meterNote, text: 'Unlike MEMORY.md, none of this is truncated: CLAUDE.md files load in full however long they are. The 200-line figure is Claude Code’s guidance, not a cutoff, because long files cost context every session and are followed less reliably.' }),
418
429
  ]));
419
430
 
420
431
  if (data.problems.length) {
421
- container.append(node('div', { class: 'section-label', text: `Problems · ${data.problems.length}` }));
432
+ container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
422
433
  for (const problem of data.problems) {
423
434
  const describe = CONTEXT_PROBLEMS[problem.kind];
424
435
  const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
425
436
  const bad = problem.kind === 'missing' || problem.kind === 'invalid-glob' || problem.kind === 'glob-budget';
426
- container.append(node('div', { class: `issue${bad ? ' bad' : ''}` }, [
427
- node('div', { class: 'issue-body' }, [
428
- node('div', { class: 'issue-title', text: title }),
429
- node('div', { class: 'issue-detail', text: detail }),
437
+ container.append(node('div', { class: ui.issue(bad) }, [
438
+ node('div', { class: ui.issueBody }, [
439
+ node('div', { class: ui.issueTitle, text: title }),
440
+ node('div', { class: ui.issueDetail, text: detail }),
430
441
  ]),
431
442
  ]));
432
443
  }
433
444
  }
434
445
 
435
- container.append(node('div', { class: 'section-label', text: `Loaded in this order · ${data.files.length}` }));
436
- const card = node('div', { class: 'card' });
446
+ container.append(node('div', { class: ui.sectionLabel, text: `Loaded in this order · ${data.files.length}` }));
447
+ const card = node('div', { class: ui.card });
437
448
  for (const file of data.files) {
438
- const tags = [node('span', { class: `badge scope-${file.scope}`, text: file.scope })];
439
- if (file.kind === 'import') tags.push(node('span', { class: 'badge', text: `import · depth ${file.depth}` }));
440
- if (file.kind === 'rule') tags.push(node('span', { class: 'badge', text: 'rule' }));
441
- if (file.kind === 'managed-settings') tags.push(node('span', { class: 'badge', text: 'claudeMd setting' }));
442
- if (file.conditional) tags.push(node('span', { class: 'badge status-referenced', text: 'only on a path match' }));
443
-
444
- card.append(node('div', { class: 'ctx-row' }, [
445
- node('div', { class: 'ctx-main' }, [
446
- node('div', { class: 'ctx-tags' }, tags),
447
- node('div', { class: 'ctx-file', text: file.file }),
449
+ const tags = [node('span', { class: ui.scopeBadge(file.scope), text: file.scope })];
450
+ if (file.kind === 'import') tags.push(node('span', { class: ui.badge(), text: `import · depth ${file.depth}` }));
451
+ if (file.kind === 'rule') tags.push(node('span', { class: ui.badge(), text: 'rule' }));
452
+ if (file.kind === 'managed-settings') tags.push(node('span', { class: ui.badge(), text: 'claudeMd setting' }));
453
+ if (file.conditional) tags.push(node('span', { class: ui.badge('warn'), text: 'only on a path match' }));
454
+
455
+ const body = node('div', { class: ui.contextBody, hidden: true });
456
+ const bodyProse = node('div', { class: ui.prose });
457
+ body.append(bodyProse);
458
+ const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
459
+ let rendered = false;
460
+
461
+ card.append(node('button', {
462
+ class: ui.contextRowButton,
463
+ 'aria-expanded': 'false',
464
+ onclick: (event) => {
465
+ const open = body.hidden;
466
+ body.hidden = !open;
467
+ caret.textContent = open ? '\u25be' : '\u25b8';
468
+ event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
469
+ if (open && !rendered) {
470
+ rendered = true;
471
+ renderMarkdown(bodyProse, file.text);
472
+ }
473
+ },
474
+ }, [
475
+ node('div', { class: ui.contextMain }, [
476
+ node('div', { class: ui.contextTags }, tags),
477
+ node('div', { class: ui.contextFile }, [caret, document.createTextNode(file.file)]),
448
478
  ]),
449
- node('div', { class: 'ctx-size', text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
479
+ node('div', { class: ui.contextSize, text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
450
480
  ]));
481
+ card.append(body);
451
482
  }
452
483
  container.append(card);
453
484
 
454
485
  if (data.excluded.length) {
455
- container.append(node('div', { class: 'section-label', text: `Excluded by claudeMdExcludes · ${data.excluded.length}` }));
486
+ container.append(node('div', { class: ui.sectionLabel, text: `Excluded by claudeMdExcludes · ${data.excluded.length}` }));
456
487
  for (const file of data.excluded) {
457
- container.append(node('div', { class: 'issue' }, [
458
- node('div', { class: 'issue-body' }, [node('div', { class: 'issue-detail', text: file })]),
488
+ container.append(node('div', { class: ui.issue(false) }, [
489
+ node('div', { class: ui.issueBody }, [node('div', { class: ui.issueDetail, text: file })]),
459
490
  ]));
460
491
  }
461
492
  }
462
493
 
463
- container.append(node('p', { class: 'meter-note', text: 'This is re-derived from the documented resolution rules, not a report from Claude Code. Run /context in a session to see what it actually loaded.' }));
494
+ container.append(node('p', { class: ui.meterNote, text: 'This is re-derived from the documented resolution rules, not a report from Claude Code. Run /context in a session to see what it actually loaded.' }));
464
495
  }
465
496
 
466
- /* -------------------------------------------------------------- health */
467
-
468
497
  function issue(title, detail, { bad = false, action, secondary } = {}) {
469
- return node('div', { class: `issue${bad ? ' bad' : ''}` }, [
470
- node('div', { class: 'issue-body' }, [
471
- node('div', { class: 'issue-title', text: title }),
472
- node('div', { class: 'issue-detail', text: detail }),
498
+ return node('div', { class: ui.issue(bad) }, [
499
+ node('div', { class: ui.issueBody }, [
500
+ node('div', { class: ui.issueTitle, text: title }),
501
+ node('div', { class: ui.issueDetail, text: detail }),
473
502
  ]),
474
- secondary ? node('button', { class: 'btn small', text: secondary.label, onclick: secondary.run }) : null,
475
- action ? node('button', { class: 'btn small', text: action.label, onclick: action.run }) : null,
503
+ secondary ? node('button', { class: ui.buttonSmall, text: secondary.label, onclick: secondary.run }) : null,
504
+ action ? node('button', { class: ui.buttonSmall, text: action.label, onclick: action.run }) : null,
476
505
  ]);
477
506
  }
478
507
 
479
508
  function renderHealth(container) {
480
509
  const { health, memories } = state.store;
481
510
  if (!health.issues.length) {
482
- container.append(node('div', { class: 'card' }, [
483
- node('p', { style: 'margin:0', text: '\u2713 No consistency problems found.' }),
484
- node('p', { class: 'muted', style: 'margin:6px 0 0;font-size:13px', text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
511
+ container.append(node('div', { class: ui.card }, [
512
+ node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
513
+ node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
485
514
  ]));
486
515
  return;
487
516
  }
488
517
 
489
- // Rendered straight from health.issues so the tab and the badge always agree.
490
518
  for (const item of health.issues) {
491
519
  container.append(renderIssue(item, memories));
492
520
  }
@@ -557,7 +585,11 @@ function renderIssue(item, memories) {
557
585
  return issue(
558
586
  `Not referenced anywhere in MEMORY.md: ${item.file}`,
559
587
  `"${memory?.name || item.file}" exists on disk but nothing points to it, so Claude will not load it.`,
560
- { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
588
+ {
589
+ bad,
590
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
591
+ action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
592
+ },
561
593
  );
562
594
  }
563
595
 
@@ -566,7 +598,11 @@ function renderIssue(item, memories) {
566
598
  return issue(
567
599
  `Linked mid-sentence, not indexed: ${item.file}`,
568
600
  `"${memory?.name || item.file}" is mentioned inside prose in MEMORY.md but has no index bullet of its own.`,
569
- { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
601
+ {
602
+ bad,
603
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
604
+ action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
605
+ },
570
606
  );
571
607
  }
572
608
 
@@ -603,6 +639,402 @@ function renderIssue(item, memories) {
603
639
  return issue(item.kind, JSON.stringify(item));
604
640
  }
605
641
 
642
+ const SETTINGS_PROBLEMS = {
643
+ unparseable: (p) => [
644
+ `Not valid JSON: ${p.scope} settings`,
645
+ `${p.file} — Claude Code cannot read this file either, so every value in it is being ignored. ${p.detail}`,
646
+ ],
647
+ unreadable: (p) => [
648
+ `Cannot be read: ${p.scope} settings`,
649
+ `${p.file} — ${p.detail}`,
650
+ ],
651
+ 'not-object': (p) => [
652
+ `Not a settings object: ${p.scope} settings`,
653
+ `${p.file} — ${p.detail}`,
654
+ ],
655
+ 'invalid-auto-memory-directory': (p) => [
656
+ 'autoMemoryDirectory cannot be used',
657
+ `${p.file} — ${p.detail}. Claude Code accepts only an absolute or ~/-prefixed path.`,
658
+ ],
659
+ };
660
+
661
+ const show = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
662
+
663
+ async function renderSettings(container) {
664
+ container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
665
+ let data;
666
+ try {
667
+ data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
668
+ } catch (err) {
669
+ container.textContent = '';
670
+ return container.append(node('p', { class: ui.note, text: err.message }));
671
+ }
672
+ if (state.tab !== 'settings') return;
673
+ container.textContent = '';
674
+
675
+ container.append(node('p', { class: ui.noteTight, text: 'What Claude Code would read for this store, in precedence order: managed policy first, then project and local settings, then your user file. This tab is read-only — it reports what is configured, it never changes it.' }));
676
+
677
+ if (!data.projectDir) {
678
+ container.append(node('p', { class: ui.subWarn, text: 'This store has no resolved project directory, so its project and local settings layers could not be consulted. Values below come from managed policy and your user file only.' }));
679
+ }
680
+
681
+ if (data.problems.length) {
682
+ container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
683
+ for (const problem of data.problems) {
684
+ const describe = SETTINGS_PROBLEMS[problem.kind];
685
+ const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
686
+ container.append(node('div', { class: ui.issue(true) }, [
687
+ node('div', { class: ui.issueBody }, [
688
+ node('div', { class: ui.issueTitle, text: title }),
689
+ node('div', { class: ui.issueDetail, text: detail }),
690
+ ]),
691
+ ]));
692
+ }
693
+ }
694
+
695
+ if (data.env.overrides) {
696
+ container.append(node('div', { class: ui.sectionLabel, text: 'Environment override' }));
697
+ container.append(node('div', { class: ui.issue(false) }, [
698
+ node('div', { class: ui.issueBody }, [
699
+ node('div', { class: ui.issueTitle, text: `${data.env.name}=${data.env.value}` }),
700
+ node('div', { class: ui.issueDetail, text: `Set in this shell, so it outranks every settings file and forces ${data.env.overrides} off. The store will not grow while it is set.` }),
701
+ ]),
702
+ ]));
703
+ }
704
+
705
+ for (const entry of data.keys) {
706
+ const card = node('div', { class: ui.card });
707
+ const head = node('div', { class: ui.settingsKeyHead }, [
708
+ node('span', { class: ui.settingsKeyName, text: entry.key }),
709
+ node('span', {
710
+ class: ui.settingsEffective,
711
+ text: entry.effective ? show(entry.effective.value) : `${show(entry.fallback)} (default)`,
712
+ }),
713
+ ]);
714
+ if (entry.effective) {
715
+ head.append(node('span', { class: ui.scopeBadge(entry.effective.scope), text: entry.effective.scope }));
716
+ }
717
+ card.append(head);
718
+ card.append(node('p', { class: ui.noteTight, text: entry.detail }));
719
+
720
+ if (entry.normalized !== undefined && entry.effective && entry.normalized !== entry.effective.value) {
721
+ card.append(node('div', { class: ui.issue(false) }, [
722
+ node('div', { class: ui.issueBody }, [
723
+ node('div', { class: ui.issueTitle, text: `Configured ${show(entry.effective.value)}, but ${show(entry.normalized)} applies` }),
724
+ node('div', { class: ui.issueDetail, text: 'Claude Code ignores a value it cannot use and falls back to the default.' }),
725
+ ]),
726
+ ]));
727
+ }
728
+
729
+ if (!entry.values.length) {
730
+ card.append(node('p', { class: ui.note, text: 'No settings file sets this, so the built-in default applies.' }));
731
+ } else {
732
+ for (const value of entry.values) {
733
+ card.append(node('div', { class: ui.settingsLayerRow }, [
734
+ node('span', { class: ui.scopeBadge(value.scope), text: value.scope }),
735
+ node('span', { class: ui.settingsLayerValue(value.wins), text: show(value.value) }),
736
+ node('span', { class: ui.settingsLayerFile, text: value.file }),
737
+ value.wins ? node('span', { class: ui.badge('ok'), text: 'wins' }) : null,
738
+ ]));
739
+ }
740
+ }
741
+ container.append(card);
742
+ }
743
+
744
+ container.append(node('div', { class: ui.sectionLabel, text: `Files consulted · ${data.layers.length}` }));
745
+ const files = node('div', { class: ui.card });
746
+ for (const layer of data.layers) {
747
+ files.append(node('div', { class: ui.settingsLayerRow }, [
748
+ node('span', { class: ui.scopeBadge(layer.scope), text: layer.scope }),
749
+ node('span', { class: ui.settingsLayerValue(layer.status === 'ok'), text: layer.status }),
750
+ node('span', { class: ui.settingsLayerFile, text: layer.file }),
751
+ ]));
752
+ }
753
+ container.append(files);
754
+ }
755
+
756
+ const byteLength = (text) => new TextEncoder().encode(text).length;
757
+
758
+ function indexEntryAt(lineIndex) {
759
+ return state.store.index?.entries.find((entry) => entry.index === lineIndex) || null;
760
+ }
761
+
762
+ function sectionsAbove(limit) {
763
+ const seen = [];
764
+ for (const line of state.store.index?.lines || []) {
765
+ if (line.kind !== 'heading') continue;
766
+ if (limit !== null && line.index >= limit) continue;
767
+ if (line.section && !seen.includes(line.section)) seen.push(line.section);
768
+ }
769
+ return seen;
770
+ }
771
+
772
+ async function runIndexEdit(action, body, message) {
773
+ try {
774
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/${action}`, {
775
+ method: 'POST',
776
+ body: JSON.stringify(body),
777
+ });
778
+ await openStore(state.storeId, { keepTab: true });
779
+ toast(message(result), { action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) } });
780
+ return result;
781
+ } catch (err) {
782
+ toast(err.message, { error: true });
783
+ return null;
784
+ }
785
+ }
786
+
787
+ function openHookEditor(row, lineIndex) {
788
+ const entry = indexEntryAt(lineIndex);
789
+ if (!entry) return toast('MEMORY.md has changed - reload and try again', { error: true });
790
+
791
+ const stats = state.store.stats.index;
792
+ const editor = node('div', { class: ui.hookEditor });
793
+ const field = node('textarea', { class: ui.textArea, spellcheck: 'false' });
794
+ field.value = entry.hook;
795
+
796
+ const counter = node('span');
797
+ const projection = node('span');
798
+ const readout = node('div', { class: ui.charCount }, [counter, projection]);
799
+
800
+ const save = node('button', { class: ui.buttonPrimarySmall, text: 'Save' });
801
+ const cancel = node('button', { class: ui.buttonSmall, text: 'Cancel', onclick: () => renderTab() });
802
+
803
+ function update() {
804
+ const next = field.value.trim();
805
+ const delta = byteLength(next) - byteLength(entry.hook);
806
+ const projected = Math.max(stats.linePercent, ((stats.bytes + delta) / stats.byteLimit) * 100);
807
+
808
+ counter.textContent = `${next.length} characters, was ${entry.hook.length}`;
809
+ projection.textContent = `index ${Math.round(stats.worstPercent)}% → ${Math.round(projected)}%`;
810
+ readout.className = next.length > 200 ? ui.charCountOver : ui.charCount;
811
+ save.disabled = next === entry.hook;
812
+ }
813
+
814
+ field.addEventListener('input', update);
815
+ save.onclick = () => runIndexEdit(
816
+ 'index/hook',
817
+ { lineIndex, expectedText: entry.text, hook: field.value },
818
+ () => 'Hook shortened',
819
+ );
820
+
821
+ editor.append(
822
+ node('div', { class: ui.willTitle, text: 'Hook' }),
823
+ field,
824
+ readout,
825
+ node('div', { class: ui.hookEditorFoot }, [cancel, save]),
826
+ );
827
+ row.textContent = '';
828
+ row.append(editor);
829
+ update();
830
+ field.focus();
831
+ field.select();
832
+ }
833
+
834
+ function openMoveDialog(entry) {
835
+ const current = indexEntryAt(entry.index);
836
+ if (!current) return toast('MEMORY.md has changed - reload and try again', { error: true });
837
+
838
+ const { cutoff } = state.store.stats.index;
839
+ const sections = sectionsAbove(cutoff ? cutoff.rawLine : null);
840
+ const picker = node('select', { class: ui.select });
841
+ picker.append(node('option', { value: '', text: 'Top of MEMORY.md' }));
842
+ for (const section of sections) picker.append(node('option', { value: section, text: `Start of "${section}"` }));
843
+
844
+ const body = node('div', { class: ui.modalBody }, [
845
+ node('p', { class: ui.noteTight, text: `"${current.title}" sits at line ${current.index + 1}, past the cutoff at line ${cutoff ? cutoff.rawLine + 1 : '-'}, so Claude never loads it. Moving it up puts it back inside the loaded part of the index - which pushes whatever is now last past the cutoff instead.` }),
846
+ node('div', { class: ui.willBlock }, [
847
+ node('div', { class: ui.willTitle, text: 'Will move' }),
848
+ node('div', { class: ui.willItem('keep'), text: current.text }),
849
+ ]),
850
+ node('label', { class: ui.willTitle, text: 'Move to' }),
851
+ picker,
852
+ ]);
853
+
854
+ const modal = node('div', { class: ui.modal }, [
855
+ node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Move this entry above the cutoff' })]),
856
+ body,
857
+ node('footer', { class: ui.modalFoot }, [
858
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
859
+ node('button', {
860
+ class: ui.button({ tone: 'primary' }),
861
+ text: 'Move',
862
+ onclick: async () => {
863
+ closeModal();
864
+ await runIndexEdit(
865
+ 'index/move',
866
+ picker.value
867
+ ? { lineIndex: current.index, expectedText: current.text, section: picker.value }
868
+ : { lineIndex: current.index, expectedText: current.text, top: true },
869
+ () => 'Entry moved',
870
+ );
871
+ },
872
+ }),
873
+ ]),
874
+ ]);
875
+
876
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
877
+ el('modal-root').append(backdrop);
878
+ }
879
+
880
+ async function openAddEntryDialog(file) {
881
+ let preview;
882
+ try {
883
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/index/add-preview`, {
884
+ method: 'POST',
885
+ body: JSON.stringify({ file }),
886
+ });
887
+ } catch (err) {
888
+ return toast(err.message, { error: true });
889
+ }
890
+
891
+ const title = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
892
+ title.value = preview.name;
893
+ const hook = node('textarea', { class: ui.textArea, spellcheck: 'false' });
894
+ hook.value = preview.description;
895
+
896
+ const picker = node('select', { class: ui.select });
897
+ for (const section of preview.sections) picker.append(node('option', { value: section, text: section }));
898
+ picker.append(node('option', { value: '', text: 'End of MEMORY.md' }));
899
+ if (preview.sections.length) picker.value = preview.sections[preview.sections.length - 1];
900
+
901
+ const line = node('div', { class: ui.willItem('keep') });
902
+ const update = () => {
903
+ const label = title.value.trim().replace(/[[\]]/g, '') || preview.name;
904
+ const text = hook.value.trim();
905
+ line.textContent = text ? `- [${label}](${file}) — ${text}` : `- [${label}](${file})`;
906
+ };
907
+ title.addEventListener('input', update);
908
+ hook.addEventListener('input', update);
909
+ update();
910
+
911
+ const body = node('div', { class: ui.modalBody }, [
912
+ node('p', { class: ui.noteTight, text: preview.hasIndex
913
+ ? 'MEMORY.md is loaded at the start of every session, so a memory with no bullet here is one Claude never sees. This adds one line and nothing else.'
914
+ : 'This project has no MEMORY.md yet. Adding this entry creates one.' }),
915
+ node('label', { class: ui.willTitle, text: 'Title' }),
916
+ title,
917
+ node('label', { class: ui.willTitle, text: 'Hook' }),
918
+ hook,
919
+ node('label', { class: ui.willTitle, text: 'Section' }),
920
+ picker,
921
+ node('div', { class: ui.willBlock }, [
922
+ node('div', { class: ui.willTitle, text: 'Will be added' }),
923
+ line,
924
+ ]),
925
+ ]);
926
+
927
+ const modal = node('div', { class: ui.modal }, [
928
+ node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Add ${file} to MEMORY.md` })]),
929
+ body,
930
+ node('footer', { class: ui.modalFoot }, [
931
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
932
+ node('button', {
933
+ class: ui.button({ tone: 'primary' }),
934
+ text: 'Add entry',
935
+ onclick: async () => {
936
+ closeModal();
937
+ await runIndexEdit(
938
+ 'index/add',
939
+ { file, section: picker.value || null, title: title.value, hook: hook.value },
940
+ () => 'Added to MEMORY.md',
941
+ );
942
+ },
943
+ }),
944
+ ]),
945
+ ]);
946
+
947
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
948
+ el('modal-root').append(backdrop);
949
+ title.focus();
950
+ title.select();
951
+ }
952
+
953
+ async function openMergeDialog(into, from) {
954
+ let preview;
955
+ try {
956
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge-preview`, {
957
+ method: 'POST',
958
+ body: JSON.stringify({ into, from }),
959
+ });
960
+ } catch (err) {
961
+ return toast(err.message, { error: true });
962
+ }
963
+
964
+ const heading = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
965
+ heading.value = preview.heading;
966
+
967
+ const body = node('div', { class: ui.modalBody });
968
+ body.append(node('p', { class: ui.noteTight, text: `Everything in "${preview.fromName}" moves into "${preview.intoName}" under a new heading, and "${preview.fromName}" goes to the trash. This rewrites prose, which nothing else in this app does - the whole operation is one undo.` }));
969
+
970
+ const changes = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will change' })]);
971
+ changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} + ${preview.bodyLines} line(s) from ${preview.from}` }));
972
+ for (const entry of preview.inbound) {
973
+ changes.append(node('div', { class: ui.willItem('keep'), text: `${entry.file} ${entry.targets.map((t) => `[[${t}]]`).join(' ')} → [[${preview.intoName}]]` }));
974
+ }
975
+ for (const link of preview.selfLinks) {
976
+ changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} [[${link}]] → plain text, to avoid a self-link` }));
977
+ }
978
+ body.append(changes);
979
+
980
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
981
+ removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.from} → memory/.trash/` }));
982
+ for (const line of preview.indexLines) {
983
+ removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
984
+ }
985
+ if (!preview.indexLines.length) {
986
+ removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for the source - nothing to remove there.' }));
987
+ }
988
+ body.append(removals);
989
+
990
+ if (preview.inlineRefs.length) {
991
+ const kept = node('div', { class: ui.willBlock }, [
992
+ node('div', { class: ui.willTitle, text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' }),
993
+ ]);
994
+ for (const ref of preview.inlineRefs) {
995
+ kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
996
+ }
997
+ body.append(kept);
998
+ }
999
+
1000
+ body.append(node('label', { class: ui.willTitle, text: 'Heading for the merged section' }), heading);
1001
+
1002
+ const modal = node('div', { class: ui.modal }, [
1003
+ node('header', { class: ui.modalHead }, [
1004
+ node('h3', { class: ui.modalTitle, text: `Merge "${preview.fromName}" into "${preview.intoName}"?` }),
1005
+ ]),
1006
+ body,
1007
+ node('footer', { class: ui.modalFoot }, [
1008
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1009
+ node('button', {
1010
+ class: ui.button({ tone: 'primary' }),
1011
+ text: 'Merge',
1012
+ onclick: async () => {
1013
+ closeModal();
1014
+ try {
1015
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge`, {
1016
+ method: 'POST',
1017
+ body: JSON.stringify({ into, from, heading: heading.value }),
1018
+ });
1019
+ if (state.selected === from) state.selected = into;
1020
+ await openStore(state.storeId, { keepTab: true });
1021
+ toast(`Merged into ${preview.intoName}`, {
1022
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
1023
+ });
1024
+ } catch (err) {
1025
+ toast(err.message, { error: true });
1026
+ }
1027
+ },
1028
+ }),
1029
+ ]),
1030
+ ]);
1031
+
1032
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1033
+ el('modal-root').append(backdrop);
1034
+ heading.focus();
1035
+ heading.select();
1036
+ }
1037
+
606
1038
  async function restoreFromTrash(id) {
607
1039
  try {
608
1040
  const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/restore`, {
@@ -618,33 +1050,39 @@ async function restoreFromTrash(id) {
618
1050
  }
619
1051
  }
620
1052
 
621
- /* --------------------------------------------------------------- trash */
1053
+ const INDEX_EDIT_LABELS = {
1054
+ hook: 'MEMORY.md hook shortened',
1055
+ move: 'MEMORY.md entry moved',
1056
+ add: 'MEMORY.md entry added',
1057
+ };
622
1058
 
623
1059
  function renderTrash(container) {
624
1060
  const trash = state.store.trash;
625
1061
  if (!trash.length) {
626
- container.append(node('p', { class: 'muted', text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
1062
+ container.append(node('p', { class: ui.note, text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
627
1063
  return;
628
1064
  }
629
1065
  for (const record of trash) {
630
1066
  const when = String(record.deletedAt).replace('T', ' ').replace(/\..*$/, '');
631
1067
  const detail = record.kind === 'wikilink'
632
1068
  ? `unlinked in ${record.sourceFile} · ${when}`
633
- : `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
634
- container.append(node('div', { class: `issue${record.present ? '' : ' bad'}` }, [
635
- node('div', { class: 'issue-body' }, [
636
- node('div', { class: 'issue-title', text: record.label || record.id }),
637
- node('div', { class: 'issue-detail', text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
1069
+ : record.kind === 'index-edit'
1070
+ ? `${INDEX_EDIT_LABELS[record.op] || 'MEMORY.md edited'} · ${when}`
1071
+ : record.kind === 'merge'
1072
+ ? `merged into ${record.into} · ${when} · ${record.backups?.length || 0} file(s) rewritten`
1073
+ : `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
1074
+ container.append(node('div', { class: ui.issue(!record.present) }, [
1075
+ node('div', { class: ui.issueBody }, [
1076
+ node('div', { class: ui.issueTitle, text: record.label || record.id }),
1077
+ node('div', { class: ui.issueDetail, text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
638
1078
  ]),
639
1079
  record.present
640
- ? node('button', { class: 'btn small primary', text: 'Restore', onclick: () => restoreFromTrash(record.id) })
1080
+ ? node('button', { class: ui.buttonPrimarySmall, text: 'Restore', onclick: () => restoreFromTrash(record.id) })
641
1081
  : null,
642
1082
  ]));
643
1083
  }
644
1084
  }
645
1085
 
646
- /* -------------------------------------------------------- delete dialog */
647
-
648
1086
  function closeModal() {
649
1087
  el('modal-root').textContent = '';
650
1088
  }
@@ -660,53 +1098,50 @@ async function openDeleteDialog(file) {
660
1098
  return toast(err.message, { error: true });
661
1099
  }
662
1100
 
663
- const body = node('div', { class: 'modal-body' });
1101
+ const body = node('div', { class: ui.modalBody });
664
1102
 
665
- const removals = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: 'Will be removed' })]);
666
- removals.append(node('div', { class: 'will-item remove', text: `memory/${preview.file} → memory/.trash/` }));
1103
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
1104
+ removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.file} → memory/.trash/` }));
667
1105
  for (const line of [...preview.indexLines, ...preview.continuations]) {
668
- removals.append(node('div', { class: 'will-item remove', text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
1106
+ removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
669
1107
  }
670
1108
  if (!preview.indexLines.length && preview.hasIndex) {
671
- removals.append(node('div', { class: 'will-item', text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
1109
+ removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
672
1110
  }
673
1111
  body.append(removals);
674
1112
 
675
1113
  if (preview.inlineRefs.length) {
676
- const kept = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' })]);
1114
+ const kept = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' })]);
677
1115
  for (const ref of preview.inlineRefs) {
678
- kept.append(node('div', { class: 'will-item keep', text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
1116
+ kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
679
1117
  }
680
1118
  body.append(kept);
681
1119
  }
682
1120
 
683
- // Memories that link here can be deleted in the same operation. Ticking one
684
- // is the common follow-up, so it lives in the dialog rather than forcing a
685
- // second pass through the list.
686
1121
  const cascade = new Set();
687
1122
  if (preview.inboundWikilinks.length) {
688
- const block = node('div', { class: 'will' });
689
- const head = node('div', { class: 'cascade-head' }, [
690
- node('div', { class: 'will-title', style: 'margin:0', text: `${preview.inboundWikilinks.length} memory(s) link here and will break` }),
691
- node('button', { class: 'link-btn', text: 'select all' }),
692
- ]);
693
- block.append(head);
1123
+ const block = node('div', { class: ui.willBlock });
1124
+ const selectAll = node('button', { class: ui.linkButton, text: 'select all' });
1125
+ block.append(node('div', { class: ui.cascadeHead }, [
1126
+ node('div', { class: ui.willTitle, text: `${preview.inboundWikilinks.length} memory(s) link here and will break` }),
1127
+ selectAll,
1128
+ ]));
694
1129
 
695
- const list = node('div', { class: 'cascade' });
1130
+ const list = node('div', { class: ui.cascadeList });
696
1131
  const rows = [];
697
1132
  for (const link of preview.inboundWikilinks) {
698
- const box = node('input', { type: 'checkbox' });
699
- const row = node('label', { class: 'cascade-row' }, [
1133
+ const box = node('input', { type: 'checkbox', class: ui.checkbox });
1134
+ const row = node('label', { class: ui.cascadeRow(false) }, [
700
1135
  box,
701
- node('span', { class: 'cascade-text' }, [
702
- node('span', { class: 'cascade-name', text: link.fromName || link.from }),
703
- node('span', { class: 'cascade-detail', text: link.indexLine
1136
+ node('span', { class: ui.cascadeText }, [
1137
+ node('span', { class: ui.cascadeName, text: link.fromName || link.from }),
1138
+ node('span', { class: ui.cascadeDetail, text: link.indexLine
704
1139
  ? `${link.from} · MEMORY.md line ${link.indexLine.index + 1}`
705
1140
  : `${link.from} · not in the index` }),
706
1141
  ]),
707
1142
  ]);
708
1143
  box.addEventListener('change', () => {
709
- row.classList.toggle('on', box.checked);
1144
+ row.className = ui.cascadeRow(box.checked);
710
1145
  if (box.checked) cascade.add(link.from);
711
1146
  else cascade.delete(link.from);
712
1147
  updateButton();
@@ -715,94 +1150,87 @@ async function openDeleteDialog(file) {
715
1150
  list.append(row);
716
1151
  }
717
1152
  block.append(list);
718
- block.append(node('p', { class: 'muted', style: 'margin:6px 0 0;font-size:12px', text: 'Tick any you also want deleted - they go to the trash together and restore as one step.' }));
1153
+ block.append(node('p', { class: ui.noteTight, text: 'Tick any you also want deleted - they go to the trash together and restore as one step.' }));
719
1154
  body.append(block);
720
1155
 
721
- head.querySelector('.link-btn').onclick = (event) => {
1156
+ selectAll.onclick = (event) => {
722
1157
  event.preventDefault();
723
1158
  const turnOn = rows.some((r) => !r.box.checked);
724
1159
  for (const { box } of rows) {
725
1160
  if (box.checked !== turnOn) { box.checked = turnOn; box.dispatchEvent(new Event('change')); }
726
1161
  }
727
- event.target.textContent = turnOn ? 'select none' : 'select all';
1162
+ selectAll.textContent = turnOn ? 'select none' : 'select all';
728
1163
  };
729
1164
  }
730
1165
 
731
- body.append(node('p', { class: 'muted', style: 'margin:0;font-size:12.5px', text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
1166
+ body.append(node('p', { class: ui.noteTight, text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
1167
+
1168
+ const confirmButton = node('button', {
1169
+ class: ui.button({ tone: 'danger' }),
1170
+ text: 'Delete',
1171
+ onclick: async () => {
1172
+ closeModal();
1173
+ try {
1174
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
1175
+ method: 'POST',
1176
+ body: JSON.stringify({ file, alsoDelete: [...cascade] }),
1177
+ });
1178
+ if (state.selected === file || cascade.has(state.selected)) state.selected = null;
1179
+ await openStore(state.storeId, { keepTab: true });
1180
+ const count = result.record.files.length;
1181
+ toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
1182
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
1183
+ });
1184
+ } catch (err) {
1185
+ toast(err.message, { error: true });
1186
+ }
1187
+ },
1188
+ });
732
1189
 
733
- const modal = node('div', { class: 'modal' }, [
734
- node('header', {}, [
735
- node('h3', { text: `Delete "${preview.name || preview.file}"?` }),
736
- node('p', { class: 'muted', style: 'margin:0;font-size:13px', text: preview.description || '' }),
1190
+ const modal = node('div', { class: ui.modal }, [
1191
+ node('header', { class: ui.modalHead }, [
1192
+ node('h3', { class: ui.modalTitle, text: `Delete "${preview.name || preview.file}"?` }),
1193
+ node('p', { class: ui.note, text: preview.description || '' }),
737
1194
  ]),
738
1195
  body,
739
- node('footer', {}, [
740
- node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
741
- node('button', {
742
- class: 'btn danger',
743
- text: 'Delete',
744
- onclick: async () => {
745
- closeModal();
746
- try {
747
- const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
748
- method: 'POST',
749
- body: JSON.stringify({ file, alsoDelete: [...cascade] }),
750
- });
751
- if (state.selected === file || cascade.has(state.selected)) state.selected = null;
752
- await openStore(state.storeId, { keepTab: true });
753
- const count = result.record.files.length;
754
- toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
755
- action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
756
- });
757
- } catch (err) {
758
- toast(err.message, { error: true });
759
- }
760
- },
761
- }),
1196
+ node('footer', { class: ui.modalFoot }, [
1197
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1198
+ confirmButton,
762
1199
  ]),
763
1200
  ]);
764
1201
 
765
- const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1202
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
766
1203
  el('modal-root').append(backdrop);
767
1204
  updateButton();
768
1205
 
769
1206
  function updateButton() {
770
- const button = modal.querySelector('footer .btn.danger');
771
- if (button) button.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
1207
+ confirmButton.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
772
1208
  }
773
1209
  }
774
1210
 
775
- /**
776
- * Name a store whose transcripts have aged out.
777
- *
778
- * Claude Code sweeps session transcripts on a retention period but keeps memory
779
- * forever, so the evidence that names a store expires while the store does not.
780
- * This is the only thing in the app that writes outside a memory directory, so
781
- * it happens only when asked for, and says so.
782
- */
783
1211
  function openRememberPathDialog() {
784
1212
  const store = state.store;
785
1213
  const input = node('input', {
786
1214
  type: 'text',
787
- class: 'path-input',
1215
+ class: ui.pathInput,
788
1216
  value: store.guess || '',
789
1217
  placeholder: '/Users/you/repos/the-project',
790
1218
  spellcheck: 'false',
791
1219
  });
792
1220
 
793
- const body = node('div', { class: 'modal-body' }, [
794
- node('p', { class: 'muted', style: 'margin-top:0', text: `The folder on disk is "${store.slug}", and its name is a lossy encoding of a path. No session transcript remains to recover the real one from, so tell it once and it will be used from now on.` }),
1221
+ const body = node('div', { class: ui.modalBody }, [
1222
+ node('p', { class: ui.note, text: `The folder on disk is "${store.slug}", and its name is a lossy encoding of a path. No session transcript remains to recover the real one from, so tell it once and it will be used from now on.` }),
795
1223
  input,
796
- node('p', { class: 'muted', style: 'margin-bottom:0;font-size:12.5px', text: 'Saved to ~/.claude-memory-admin/paths.json. That file records slugs and folder paths only, never memory content, and is the one thing this app writes outside a memory/ directory.' }),
1224
+ node('p', { class: ui.noteTight, text: 'Saved to ~/.claude-memory-admin/paths.json. That file records slugs and folder paths only, never memory content, and is the one thing this app writes outside a memory/ directory.' }),
797
1225
  ]);
798
1226
 
799
- const modal = node('div', { class: 'modal' }, [
800
- node('header', {}, [node('h3', { text: 'Remember this project\u2019s path' })]),
1227
+ const modal = node('div', { class: ui.modal }, [
1228
+ node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Remember this project\u2019s path' })]),
801
1229
  body,
802
- node('footer', {}, [
803
- node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
1230
+ node('footer', { class: ui.modalFoot }, [
1231
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
804
1232
  node('button', {
805
- class: 'btn primary',
1233
+ class: ui.button({ tone: 'primary' }),
806
1234
  text: 'Remember',
807
1235
  onclick: async () => {
808
1236
  const value = input.value.trim();
@@ -824,7 +1252,7 @@ function openRememberPathDialog() {
824
1252
  ]),
825
1253
  ]);
826
1254
 
827
- const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1255
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
828
1256
  el('modal-root').append(backdrop);
829
1257
  input.focus();
830
1258
  input.select();
@@ -853,40 +1281,39 @@ async function openStoreDeleteDialog() {
853
1281
  return toast('This store has no memory to delete');
854
1282
  }
855
1283
 
856
- const body = node('div', { class: 'modal-body' });
857
- const removals = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: 'Will be removed' })]);
1284
+ const body = node('div', { class: ui.modalBody });
1285
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
858
1286
  if (preview.hasIndex) {
859
- removals.append(node('div', { class: 'will-item remove', text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
1287
+ removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
860
1288
  }
861
1289
  for (const entry of preview.files) {
862
- removals.append(node('div', { class: 'will-item remove', text: `${entry.file} → memory/.trash/` }));
1290
+ removals.append(node('div', { class: ui.willItem('remove'), text: `${entry.file} → memory/.trash/` }));
863
1291
  }
864
1292
  body.append(removals);
865
1293
 
866
- body.append(node('div', { class: 'will' }, [
867
- node('div', { class: 'will-title', text: 'Will be kept' }),
868
- node('div', { class: 'will-item keep', text: state.store.kind === 'auto'
1294
+ body.append(node('div', { class: ui.willBlock }, [
1295
+ node('div', { class: ui.willTitle, text: 'Will be kept' }),
1296
+ node('div', { class: ui.willItem('keep'), text: state.store.kind === 'auto'
869
1297
  ? 'Session transcripts (*.jsonl) and the project folder itself'
870
1298
  : 'The agent-memory folder itself, and every other agent\u2019s memory' }),
871
1299
  ]));
872
1300
 
873
- body.append(node('p', { class: 'muted', style: 'margin:0;font-size:12.5px', text: 'Everything moves to .trash/ inside the store as one restore point, so it can be put back in a single step from the Trash tab.' }));
1301
+ body.append(node('p', { class: ui.noteTight, text: 'Everything moves to .trash/ inside the store as one restore point, so it can be put back in a single step from the Trash tab.' }));
874
1302
 
875
- // A project-scoped agent store is committed, so this is a working-tree change.
876
1303
  if (state.store.kind === 'agent-project') {
877
- body.append(node('p', { class: 'sub-warn', style: 'margin:8px 0 0', text: 'This store is checked into the repository. Deleting from it changes tracked files, and will show up in git status.' }));
1304
+ body.append(node('p', { class: ui.subWarn, text: 'This store is checked into the repository. Deleting from it changes tracked files, and will show up in git status.' }));
878
1305
  }
879
1306
 
880
- const modal = node('div', { class: 'modal' }, [
881
- node('header', {}, [
882
- node('h3', { text: `Delete all memory for ${state.store.label}?` }),
883
- node('p', { class: 'muted', style: 'margin:0;font-size:13px', text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
1307
+ const modal = node('div', { class: ui.modal }, [
1308
+ node('header', { class: ui.modalHead }, [
1309
+ node('h3', { class: ui.modalTitle, text: `Delete all memory for ${state.store.label}?` }),
1310
+ node('p', { class: ui.note, text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
884
1311
  ]),
885
1312
  body,
886
- node('footer', {}, [
887
- node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
1313
+ node('footer', { class: ui.modalFoot }, [
1314
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
888
1315
  node('button', {
889
- class: 'btn danger',
1316
+ class: ui.button({ tone: 'danger' }),
890
1317
  text: `Delete ${preview.files.length + (preview.hasIndex ? 1 : 0)} files`,
891
1318
  onclick: async () => {
892
1319
  closeModal();
@@ -905,13 +1332,10 @@ async function openStoreDeleteDialog() {
905
1332
  ]),
906
1333
  ]);
907
1334
 
908
- const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1335
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
909
1336
  el('modal-root').append(backdrop);
910
1337
  }
911
1338
 
912
-
913
- /* --------------------------------------------------------------- prune */
914
-
915
1339
  function highlight(text, terms) {
916
1340
  const wrapper = node('span');
917
1341
  if (!terms.length) { wrapper.textContent = text; return wrapper; }
@@ -919,7 +1343,7 @@ function highlight(text, terms) {
919
1343
  let last = 0;
920
1344
  for (const match of text.matchAll(pattern)) {
921
1345
  if (match.index > last) wrapper.append(document.createTextNode(text.slice(last, match.index)));
922
- wrapper.append(node('mark', { text: match[0] }));
1346
+ wrapper.append(node('mark', { class: ui.resultMark, text: match[0] }));
923
1347
  last = match.index + match[0].length;
924
1348
  }
925
1349
  if (last < text.length) wrapper.append(document.createTextNode(text.slice(last)));
@@ -944,7 +1368,7 @@ const PRUNE_SORTS = {
944
1368
  function renderBudget(container) {
945
1369
  const { index } = state.store.stats;
946
1370
  const percent = Math.min(100, index.worstPercent);
947
- const level = index.level === 'over' ? 'way-over' : index.level === 'near' ? 'over' : '';
1371
+ const level = index.level;
948
1372
 
949
1373
  const note = index.level === 'over'
950
1374
  ? 'Over the limit. Everything past the cutoff is dropped when the session loads it, so those entries are effectively invisible to Claude right now.'
@@ -954,42 +1378,43 @@ function renderBudget(container) {
954
1378
 
955
1379
  if (index.cutoff) {
956
1380
  const dropped = index.cutoff.droppedEntries;
957
- const box = node('div', { class: 'cut-summary' });
958
- box.append(node('div', { class: 'cut-summary-head', text: dropped.length
1381
+ const box = node('div', { class: ui.cutSummary });
1382
+ box.append(node('div', { class: ui.cutSummaryHead, text: dropped.length
959
1383
  ? `${dropped.length} ${dropped.length === 1 ? 'memory is' : 'memories are'} past the cutoff and not loaded`
960
1384
  : `${index.cutoff.droppedLines} lines are past the cutoff and not loaded` }));
961
- box.append(node('p', { class: 'muted', text: `MEMORY.md stops loading at line ${index.cutoff.rawLine + 1}, bounded by ${index.cutoff.by}. Claude cannot see anything below it.` }));
1385
+ box.append(node('p', { class: ui.note, text: `MEMORY.md stops loading at line ${index.cutoff.rawLine + 1}, bounded by ${index.cutoff.by}. Claude cannot see anything below it.` }));
962
1386
  for (const entry of dropped.slice(0, 20)) {
963
- box.append(node('div', { class: 'issue' }, [
964
- node('div', { class: 'issue-body' }, [
965
- node('div', { class: 'issue-title', text: entry.title || entry.file }),
966
- node('div', { class: 'issue-detail', text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
1387
+ box.append(node('div', { class: ui.issue(true) }, [
1388
+ node('div', { class: ui.issueBody }, [
1389
+ node('div', { class: ui.issueTitle, text: entry.title || entry.file }),
1390
+ node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
967
1391
  ]),
968
- node('button', { class: 'btn small', text: 'Open', onclick: () => selectMemory(entry.file) }),
1392
+ node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
1393
+ node('button', { class: ui.buttonPrimarySmall, text: 'Move up', onclick: () => openMoveDialog(entry) }),
969
1394
  ]));
970
1395
  }
971
1396
  if (dropped.length > 20) {
972
- box.append(node('p', { class: 'muted', text: `${dropped.length - 20} more not listed.` }));
1397
+ box.append(node('p', { class: ui.note, text: `${dropped.length - 20} more not listed.` }));
973
1398
  }
974
1399
  container.append(box);
975
1400
  }
976
1401
 
977
- container.append(node('div', { class: 'meter' }, [
978
- node('div', { class: 'meter-top' }, [
979
- node('span', { class: 'meter-value', text: `${Math.round(index.worstPercent)}%` }),
980
- node('span', { class: 'meter-unit', text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
1402
+ container.append(node('div', { class: ui.meter }, [
1403
+ node('div', { class: ui.meterTop }, [
1404
+ node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
1405
+ node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
981
1406
  ]),
982
- node('div', { class: 'meter-bar' }, [
983
- node('div', { class: `meter-fill ${level}`, style: `width:${percent.toFixed(1)}%` }),
1407
+ node('div', { class: ui.meterBar }, [
1408
+ node('div', { class: ui.meterFill(level), style: `width:${percent.toFixed(1)}%` }),
984
1409
  ]),
985
- node('p', { class: 'meter-note', text: note }),
986
- node('div', { class: 'meter-facts' }, [
987
- node('span', {}, [node('b', { text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
988
- node('span', {}, [node('b', { text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
989
- node('span', {}, [node('b', { text: String(index.entryCount) }), document.createTextNode(' index entries')]),
990
- node('span', {}, [node('b', { text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
1410
+ node('p', { class: ui.meterNote, text: note }),
1411
+ node('div', { class: ui.meterFacts }, [
1412
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
1413
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
1414
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
1415
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
991
1416
  ]),
992
- node('p', { class: 'meter-note', style: 'margin-top:10px', text: 'Claude Code loads the first 200 lines or 25KB of MEMORY.md, whichever comes first, and drops the rest. Topic files are only read when Claude follows a link, so detail belongs in them and MEMORY.md should stay one line per entry.' }),
1417
+ node('p', { class: ui.meterNote, text: 'Claude Code loads the first 200 lines or 25KB of MEMORY.md, whichever comes first, and drops the rest. Topic files are only read when Claude follows a link, so detail belongs in them and MEMORY.md should stay one line per entry.' }),
993
1418
  ]));
994
1419
  }
995
1420
 
@@ -998,49 +1423,48 @@ function renderPrune(container) {
998
1423
  renderBudget(container);
999
1424
 
1000
1425
  if (!project.memories.length) {
1001
- container.append(node('p', { class: 'muted', text: 'Nothing to prune - this project has no memories.' }));
1426
+ container.append(node('p', { class: ui.note, text: 'Nothing to prune - this project has no memories.' }));
1002
1427
  return;
1003
1428
  }
1004
1429
 
1005
- // --- bulk selection list ---
1006
1430
  const selected = state.pruneSelection;
1007
- const bar = node('div', { class: 'prune-bar' });
1008
- const sort = node('select');
1431
+ const bar = node('div', { class: ui.pruneBar });
1432
+ const sort = node('select', { class: ui.select });
1009
1433
  for (const [key, config] of Object.entries(PRUNE_SORTS)) {
1010
1434
  sort.append(node('option', { value: key, text: config.label, selected: key === state.pruneSort ? '' : null }));
1011
1435
  }
1012
1436
  sort.value = state.pruneSort;
1013
1437
  sort.onchange = () => { state.pruneSort = sort.value; renderTab(); };
1014
1438
 
1015
- const count = node('span', { class: 'muted', style: 'font-size:12.5px' });
1016
- const deleteButton = node('button', { class: 'btn danger small' });
1017
- const clearButton = node('button', { class: 'btn small', text: 'Clear selection' });
1439
+ const count = node('span', { class: ui.note });
1440
+ const deleteButton = node('button', { class: ui.buttonDangerSmall });
1441
+ const clearButton = node('button', { class: ui.buttonSmall, text: 'Clear selection' });
1018
1442
 
1019
- bar.append(node('span', { class: 'section-label', style: 'margin:0', text: 'Prune' }), sort, node('span', { class: 'spacer' }), count, clearButton, deleteButton);
1443
+ bar.append(node('span', { class: ui.sectionLabel, text: 'Prune' }), sort, node('span', { class: ui.pruneSpacer }), count, clearButton, deleteButton);
1020
1444
  container.append(bar);
1021
1445
 
1022
1446
  const list = node('div');
1023
1447
  const ordered = [...project.memories].sort(PRUNE_SORTS[state.pruneSort].compare);
1024
1448
  const rows = [];
1025
1449
  for (const memory of ordered) {
1026
- const box = node('input', { type: 'checkbox' });
1450
+ const box = node('input', { type: 'checkbox', class: ui.checkbox });
1027
1451
  box.checked = selected.has(memory.file);
1028
- const row = node('label', { class: `prune-row${box.checked ? ' on' : ''}` }, [
1452
+ const row = node('label', { class: ui.pruneRow(box.checked) }, [
1029
1453
  box,
1030
- node('span', { class: 'prune-main' }, [
1031
- node('span', { class: 'prune-name', text: memory.name }),
1032
- node('span', { class: 'prune-desc', text: memory.description || memory.file }),
1454
+ node('span', { class: ui.pruneMain }, [
1455
+ node('span', { class: ui.pruneName, text: memory.name }),
1456
+ node('span', { class: ui.pruneDesc, text: memory.description || memory.file }),
1033
1457
  ]),
1034
- node('span', { class: 'prune-facts' }, [
1035
- node('span', { class: (memory.ageDays ?? 0) > 180 ? 'warn' : '', text: formatAge(memory.ageDays) }),
1458
+ node('span', { class: ui.pruneFacts }, [
1459
+ node('span', { class: ui.pruneFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
1036
1460
  node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
1037
- node('span', { class: memory.inbound.length ? '' : 'warn', text: `${memory.inbound.length} in` }),
1038
- node('span', { class: memory.status === 'indexed' ? '' : 'warn', text: memory.status }),
1461
+ node('span', { class: ui.pruneFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
1462
+ node('span', { class: ui.pruneFact(memory.status !== 'indexed'), text: memory.status }),
1039
1463
  ]),
1040
1464
  ]);
1041
1465
  box.addEventListener('change', () => {
1042
1466
  if (box.checked) selected.add(memory.file); else selected.delete(memory.file);
1043
- row.classList.toggle('on', box.checked);
1467
+ row.className = ui.pruneRow(box.checked);
1044
1468
  updateBar();
1045
1469
  });
1046
1470
  rows.push({ box, memory });
@@ -1052,7 +1476,6 @@ function renderPrune(container) {
1052
1476
  count.textContent = selected.size ? `${selected.size} selected` : '';
1053
1477
  deleteButton.textContent = `Delete ${selected.size || ''}`.trim();
1054
1478
  deleteButton.disabled = selected.size === 0;
1055
- deleteButton.style.opacity = selected.size ? '1' : '.45';
1056
1479
  }
1057
1480
  clearButton.onclick = () => {
1058
1481
  selected.clear();
@@ -1061,41 +1484,56 @@ function renderPrune(container) {
1061
1484
  deleteButton.onclick = () => openBulkDeleteDialog([...selected]);
1062
1485
  updateBar();
1063
1486
 
1064
- // --- long hooks ---
1065
1487
  const { longHooks } = project.stats.index;
1066
1488
  if (longHooks.length) {
1067
- container.append(node('div', { class: 'section-label', text: `Long index hooks · ${longHooks.length}` }));
1068
- container.append(node('p', { class: 'muted', style: 'margin:0 0 10px;font-size:12.5px', text: 'The hook is the text after the dash in MEMORY.md. It is loaded every session, so a 400-character hook costs more than the memory it points at. Shortening these is the cheapest win.' }));
1489
+ container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
1490
+ container.append(node('p', { class: ui.noteTight, text: 'The hook is the text after the dash in MEMORY.md. It is loaded every session, so a 400-character hook costs more than the memory it points at. Shortening these is the cheapest win.' }));
1069
1491
  for (const hook of longHooks.slice(0, 12)) {
1070
- container.append(node('div', { class: 'issue' }, [
1071
- node('div', { class: 'issue-body' }, [
1072
- node('div', { class: 'issue-title', text: `${hook.hookLength} chars: ${hook.title}` }),
1073
- node('div', { class: 'issue-detail', text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
1492
+ const row = node('div', { class: ui.issue(false) });
1493
+ const editButton = node('button', { class: ui.buttonPrimarySmall, text: 'Edit hook' });
1494
+ row.append(
1495
+ node('div', { class: ui.issueBody }, [
1496
+ node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
1497
+ node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
1074
1498
  ]),
1075
- node('button', { class: 'btn small', text: 'Open', onclick: () => selectMemory(hook.file) }),
1076
- ]));
1499
+ node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
1500
+ editButton,
1501
+ );
1502
+ editButton.onclick = () => openHookEditor(row, hook.index);
1503
+ container.append(row);
1077
1504
  }
1078
1505
  }
1079
1506
 
1080
- // --- possible overlap ---
1081
1507
  if (project.duplicates.length) {
1082
- container.append(node('div', { class: 'section-label', text: `Possible overlap · ${project.duplicates.length}` }));
1083
- container.append(node('p', { class: 'muted', style: 'margin:0 0 10px;font-size:12.5px', text: 'Ranked by how much rare vocabulary these pairs share. This is a hint, not a verdict - open both and decide whether one covers the other.' }));
1508
+ container.append(node('div', { class: ui.sectionLabel, text: `Possible overlap · ${project.duplicates.length}` }));
1509
+ container.append(node('p', { class: ui.noteTight, text: 'Ranked by how much rare vocabulary these pairs share. This is a hint, not a verdict - open both and decide whether one covers the other.' }));
1084
1510
  for (const pair of project.duplicates) {
1085
- container.append(node('div', { class: 'dupe' }, [
1086
- node('div', { class: 'dupe-head' }, [
1087
- node('span', { class: 'dupe-score', text: `${pair.score}%` }),
1511
+ container.append(node('div', { class: ui.dupe }, [
1512
+ node('div', { class: ui.dupeHead }, [
1513
+ node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
1088
1514
  node('span', { text: `shared: ${pair.shared.join(', ')}` }),
1089
1515
  ]),
1090
- node('div', { class: 'dupe-pair' }, [
1516
+ node('div', { class: ui.dupePair }, [
1091
1517
  ...[pair.a, pair.b].map((side) => node('div', {
1092
- class: 'dupe-side',
1518
+ class: ui.dupeSide,
1093
1519
  onclick: () => selectMemory(side.file),
1094
1520
  }, [
1095
- node('div', { class: 'n', text: side.name }),
1096
- node('div', { class: 'd', text: side.description || side.file }),
1521
+ node('div', { class: ui.dupeSideName, text: side.name }),
1522
+ node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
1097
1523
  ])),
1098
1524
  ]),
1525
+ node('div', { class: ui.dupeActions }, [
1526
+ node('button', {
1527
+ class: ui.buttonSmall,
1528
+ text: `Merge into ${pair.a.name}`,
1529
+ onclick: () => openMergeDialog(pair.a.file, pair.b.file),
1530
+ }),
1531
+ node('button', {
1532
+ class: ui.buttonSmall,
1533
+ text: `Merge into ${pair.b.name}`,
1534
+ onclick: () => openMergeDialog(pair.b.file, pair.a.file),
1535
+ }),
1536
+ ]),
1099
1537
  ]));
1100
1538
  }
1101
1539
  }
@@ -1105,14 +1543,13 @@ async function openBulkDeleteDialog(files) {
1105
1543
  if (!files.length) return;
1106
1544
  const memories = files.map((file) => state.store.memories.find((m) => m.file === file)).filter(Boolean);
1107
1545
 
1108
- const body = node('div', { class: 'modal-body' });
1109
- const removals = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: `${memories.length} memories will be trashed` })]);
1546
+ const body = node('div', { class: ui.modalBody });
1547
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${memories.length} memories will be trashed` })]);
1110
1548
  for (const memory of memories) {
1111
- removals.append(node('div', { class: 'will-item remove', text: `${memory.file}${memory.entry ? ` · MEMORY.md line ${memory.entry.index + 1}` : ' · not in the index'}` }));
1549
+ removals.append(node('div', { class: ui.willItem('remove'), text: `${memory.file}${memory.entry ? ` · MEMORY.md line ${memory.entry.index + 1}` : ' · not in the index'}` }));
1112
1550
  }
1113
1551
  body.append(removals);
1114
1552
 
1115
- // Links from memories that are NOT being deleted are the ones that break.
1116
1553
  const doomed = new Set(files);
1117
1554
  const breaking = [];
1118
1555
  for (const memory of memories) {
@@ -1121,20 +1558,20 @@ async function openBulkDeleteDialog(files) {
1121
1558
  }
1122
1559
  }
1123
1560
  if (breaking.length) {
1124
- const block = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: `${breaking.length} link(s) from memories you are keeping will break` })]);
1125
- for (const link of breaking) block.append(node('div', { class: 'will-item keep', text: `${link.from} → [[${link.target}]]` }));
1561
+ const block = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${breaking.length} link(s) from memories you are keeping will break` })]);
1562
+ for (const link of breaking) block.append(node('div', { class: ui.willItem('keep'), text: `${link.from} → [[${link.target}]]` }));
1126
1563
  body.append(block);
1127
1564
  }
1128
1565
 
1129
- body.append(node('p', { class: 'muted', style: 'margin:0;font-size:12.5px', text: 'All of them go to memory/.trash/ as one restore point.' }));
1566
+ body.append(node('p', { class: ui.noteTight, text: 'All of them go to memory/.trash/ as one restore point.' }));
1130
1567
 
1131
- const modal = node('div', { class: 'modal' }, [
1132
- node('header', {}, [node('h3', { text: `Delete ${memories.length} memories?` })]),
1568
+ const modal = node('div', { class: ui.modal }, [
1569
+ node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Delete ${memories.length} memories?` })]),
1133
1570
  body,
1134
- node('footer', {}, [
1135
- node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
1571
+ node('footer', { class: ui.modalFoot }, [
1572
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1136
1573
  node('button', {
1137
- class: 'btn danger',
1574
+ class: ui.button({ tone: 'danger' }),
1138
1575
  text: `Delete ${memories.length}`,
1139
1576
  onclick: async () => {
1140
1577
  closeModal();
@@ -1156,12 +1593,10 @@ async function openBulkDeleteDialog(files) {
1156
1593
  }),
1157
1594
  ]),
1158
1595
  ]);
1159
- const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1596
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1160
1597
  el('modal-root').append(backdrop);
1161
1598
  }
1162
1599
 
1163
- /* -------------------------------------------------------------- search */
1164
-
1165
1600
  let searchTimer = null;
1166
1601
 
1167
1602
  function scheduleSearch(value) {
@@ -1195,44 +1630,44 @@ function renderSearch() {
1195
1630
  : `No results for "${state.query.trim()}"`;
1196
1631
 
1197
1632
  if (!data.total && !data.stores.length) {
1198
- host.append(node('p', { class: 'muted', text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
1633
+ host.append(node('p', { class: ui.note, text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
1199
1634
  return;
1200
1635
  }
1201
1636
 
1202
1637
  for (const group of data.stores) {
1203
- const block = node('div', { class: 'result-group' });
1204
- block.append(node('div', { class: 'section-label', text: group.sublabel
1638
+ const block = node('div', { class: ui.resultGroup });
1639
+ block.append(node('div', { class: ui.sectionLabel, text: group.sublabel
1205
1640
  ? `${group.label} · ${group.sublabel} · ${group.results.length}`
1206
1641
  : `${group.label} · ${group.results.length}` }));
1207
1642
 
1208
1643
  for (const result of group.results) {
1209
1644
  const button = node('button', {
1210
- class: 'result',
1645
+ class: ui.result,
1211
1646
  onclick: () => openFromSearch(group.id, result.file),
1212
1647
  });
1213
- const top = node('div', { class: 'result-top' });
1214
- const name = node('span', { class: 'result-name' });
1648
+ const top = node('div', { class: ui.resultTop });
1649
+ const name = node('span', { class: ui.resultName });
1215
1650
  name.append(highlight(result.name, data.terms));
1216
- top.append(name, node('span', { class: `badge type-${result.type}`, text: result.type }));
1217
- if (result.status !== 'indexed') top.append(node('span', { class: `badge status-${result.status}`, text: result.status }));
1651
+ top.append(name, node('span', { class: ui.typeBadge(result.type), text: result.type }));
1652
+ if (result.status !== 'indexed') top.append(node('span', { class: ui.badge('warn'), text: result.status }));
1218
1653
  button.append(top);
1219
1654
 
1220
1655
  if (result.snippet) {
1221
- const snippet = node('div', { class: 'result-snippet' });
1656
+ const snippet = node('div', { class: ui.resultSnippet });
1222
1657
  snippet.append(highlight(result.snippet.text, data.terms));
1223
1658
  button.append(snippet);
1224
1659
  }
1225
- button.append(node('div', { class: 'result-where', text: `${result.file} · matched in ${result.fields.join(', ')}` }));
1660
+ button.append(node('div', { class: ui.resultWhere, text: `${result.file} · matched in ${result.fields.join(', ')}` }));
1226
1661
  block.append(button);
1227
1662
  }
1228
1663
 
1229
1664
  for (const hit of group.indexHits) {
1230
1665
  const button = node('button', {
1231
- class: 'result',
1666
+ class: ui.result,
1232
1667
  onclick: () => openFromSearch(group.id, hit.file, 'index'),
1233
1668
  });
1234
- button.append(node('div', { class: 'result-where', text: `MEMORY.md line ${hit.index + 1}` }));
1235
- const line = node('div', { class: 'result-snippet' });
1669
+ button.append(node('div', { class: ui.resultWhere, text: `MEMORY.md line ${hit.index + 1}` }));
1670
+ const line = node('div', { class: ui.resultSnippet });
1236
1671
  line.append(highlight(hit.text.trim(), data.terms));
1237
1672
  button.append(line);
1238
1673
  block.append(button);
@@ -1265,8 +1700,6 @@ function renderView() {
1265
1700
  if (searching) renderSearch();
1266
1701
  }
1267
1702
 
1268
- /* ------------------------------------------------------------ rendering */
1269
-
1270
1703
  function renderTab() {
1271
1704
  const container = el('tab-content');
1272
1705
  container.textContent = '';
@@ -1275,9 +1708,10 @@ function renderTab() {
1275
1708
  else if (state.tab === 'context') renderContext(container);
1276
1709
  else if (state.tab === 'index') renderIndex(container);
1277
1710
  else if (state.tab === 'health') renderHealth(container);
1711
+ else if (state.tab === 'settings') renderSettings(container);
1278
1712
  else if (state.tab === 'trash') renderTrash(container);
1279
1713
  else if (state.tab === 'graph') {
1280
- const wrap = node('div', { id: 'graph-wrap' });
1714
+ const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
1281
1715
  container.append(wrap);
1282
1716
  renderGraph(wrap, state.store.graph, {
1283
1717
  selected: state.selected,
@@ -1301,10 +1735,9 @@ function selectMemory(file) {
1301
1735
  } else {
1302
1736
  renderTab();
1303
1737
  }
1304
- document.querySelector('.mem.active')?.scrollIntoView({ block: 'nearest' });
1738
+ document.querySelector('[data-memory][data-active]')?.scrollIntoView({ block: 'nearest' });
1305
1739
  }
1306
1740
 
1307
- /** Re-read the store list, e.g. after a path is remembered or forgotten. */
1308
1741
  async function reloadStores() {
1309
1742
  const data = await api('/api/stores');
1310
1743
  state.stores = data.stores;
@@ -1312,23 +1745,27 @@ async function reloadStores() {
1312
1745
  return data;
1313
1746
  }
1314
1747
 
1315
- /** The name and provenance line above the tabs, which differs by store kind. */
1316
1748
  function renderStoreHeader() {
1317
1749
  const store = state.store;
1318
1750
  el('project-title').textContent = store.label;
1751
+ el('delete-project').hidden = store.kind === 'global';
1319
1752
 
1320
1753
  const sub = el('project-sub');
1321
1754
  sub.textContent = '';
1322
1755
 
1756
+ if (store.kind === 'global') {
1757
+ sub.append(node('span', { text: store.dir }));
1758
+ sub.append(node('span', { class: ui.subNote, text: 'instructions every session loads, before any project is chosen' }));
1759
+ return;
1760
+ }
1761
+
1323
1762
  if (store.kind !== 'auto') {
1324
1763
  const scope = store.kind === 'agent-user' ? 'user'
1325
1764
  : store.kind === 'agent-project' ? 'project' : 'local';
1326
1765
  sub.append(node('span', { text: store.dir }));
1327
- sub.append(node('span', { class: 'sub-note', text: `subagent memory, ${scope} scope` }));
1328
- // A project-scoped store is committed, so deleting from it edits the repo
1329
- // rather than a private cache. Worth saying before, not after.
1766
+ sub.append(node('span', { class: ui.subNote, text: `subagent memory, ${scope} scope` }));
1330
1767
  if (store.kind === 'agent-project') {
1331
- sub.append(node('span', { class: 'sub-warn', text: 'checked into the repository - changes here show up in git status' }));
1768
+ sub.append(node('span', { class: ui.subWarn, text: 'checked into the repository - changes here show up in git status' }));
1332
1769
  }
1333
1770
  return;
1334
1771
  }
@@ -1337,24 +1774,22 @@ function renderStoreHeader() {
1337
1774
  ? `${store.path} (real path unresolved)`
1338
1775
  : store.path }));
1339
1776
 
1340
- // One store serves every worktree and subdirectory of a repository, which is
1341
- // not obvious from a single path and changes what a memory here applies to.
1342
1777
  const others = (store.workingDirs || []).filter((d) => d !== store.path);
1343
1778
  if (others.length) {
1344
- sub.append(node('span', { class: 'sub-note', text: `also used from ${others.join(', ')}` }));
1779
+ sub.append(node('span', { class: ui.subNote, text: `also used from ${others.join(', ')}` }));
1345
1780
  }
1346
1781
  if (store.resolvedBy === 'unresolved') {
1347
- sub.append(node('button', { class: 'btn small link-btn', text: 'Remember path\u2026', onclick: openRememberPathDialog }));
1782
+ sub.append(node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }));
1348
1783
  } else if (store.resolvedBy === 'remembered') {
1349
- sub.append(node('span', { class: 'sub-note' }, [
1784
+ sub.append(node('span', { class: ui.subNote }, [
1350
1785
  document.createTextNode('path remembered, not recovered from a transcript '),
1351
- node('button', { class: 'btn small link-btn', text: 'Forget', onclick: forgetProjectPath }),
1786
+ node('button', { class: ui.linkButton, text: 'Forget', onclick: forgetProjectPath }),
1352
1787
  ]));
1353
1788
  }
1354
1789
 
1355
1790
  const auto = store.autoMemory;
1356
1791
  if (auto && auto.known && !auto.enabled) {
1357
- sub.append(node('span', { class: 'sub-warn', text: auto.scope === 'env'
1792
+ sub.append(node('span', { class: ui.subWarn, text: auto.scope === 'env'
1358
1793
  ? 'auto memory is off (CLAUDE_CODE_DISABLE_AUTO_MEMORY) - this store will not grow'
1359
1794
  : `auto memory is off (${auto.setBy}) - this store will not grow` }));
1360
1795
  }
@@ -1364,7 +1799,8 @@ async function openStore(id, { keepTab = false } = {}) {
1364
1799
  if (id !== state.storeId) state.pruneSelection.clear();
1365
1800
  state.storeId = id;
1366
1801
  if (!keepTab) {
1367
- state.tab = 'memories';
1802
+ const opening = state.stores.find((store) => store.id === id);
1803
+ state.tab = opening && opening.kind === 'global' ? 'context' : 'memories';
1368
1804
  state.selected = null;
1369
1805
  }
1370
1806
  renderStores();
@@ -1381,7 +1817,6 @@ async function openStore(id, { keepTab = false } = {}) {
1381
1817
 
1382
1818
  renderStoreHeader();
1383
1819
 
1384
- // Refresh the sidebar count in case a delete changed it.
1385
1820
  const listed = state.stores.find((store) => store.id === id);
1386
1821
  if (listed) listed.memoryCount = state.store.memories.length;
1387
1822
  renderStores();
@@ -1392,7 +1827,8 @@ async function openStore(id, { keepTab = false } = {}) {
1392
1827
  }
1393
1828
 
1394
1829
  function applyCollapsed() {
1395
- el('app').classList.toggle('collapsed', state.collapsed);
1830
+ el('app').className = state.collapsed ? ui.shellCollapsed : ui.shell;
1831
+ el('sidebar').className = state.collapsed ? ui.sidebarHidden : ui.sidebar;
1396
1832
  el('expand').hidden = !state.collapsed;
1397
1833
  localStorage.setItem('sidebarCollapsed', state.collapsed ? '1' : '0');
1398
1834
  }
@@ -1400,12 +1836,17 @@ function applyCollapsed() {
1400
1836
  function setCollapsed(value) {
1401
1837
  state.collapsed = value;
1402
1838
  applyCollapsed();
1403
- // The graph sizes itself to the container, so it has to be redrawn once the
1404
- // pane has actually changed width.
1405
1839
  if (state.tab === 'graph' && state.store) requestAnimationFrame(renderTab);
1406
1840
  }
1407
1841
 
1842
+ function applyStyles() {
1843
+ for (const element of document.querySelectorAll('[data-ui]')) {
1844
+ element.className = ui[element.dataset.ui] ?? '';
1845
+ }
1846
+ }
1847
+
1408
1848
  async function init() {
1849
+ applyStyles();
1409
1850
  el('show-all').addEventListener('change', (event) => {
1410
1851
  state.showAll = event.target.checked;
1411
1852
  renderStores();
@@ -1440,7 +1881,7 @@ async function init() {
1440
1881
  el('root-path').title = data.rootFile
1441
1882
  ? `autoMemoryDirectory set in ${data.rootFile}`
1442
1883
  : 'store chosen with --root';
1443
- el('root-path').append(node('span', { class: 'root-source', text: data.rootSource }));
1884
+ el('root-path').append(node('span', { class: ui.rootSource, text: data.rootSource }));
1444
1885
  }
1445
1886
  if (data.rootWarning) toast(data.rootWarning, { error: true });
1446
1887
  const first = state.stores.find((store) => store.memoryCount > 0);