claude-memory-admin 1.0.1 → 1.1.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,60 +59,26 @@ 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) {
119
83
  if (store.kind === 'auto') return store.path;
120
84
  const scope = store.kind === 'agent-user' ? 'user'
@@ -126,19 +90,19 @@ function storeButton(store) {
126
90
  const health = store.memoryCount === 0 ? 'none' : 'ok';
127
91
  const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
128
92
  return node('button', {
129
- class: `project${store.id === state.storeId ? ' active' : ''}${store.hasMemoryDir ? '' : ' dim'}`,
93
+ class: ui.storeItem({ active: store.id === state.storeId, empty: !store.hasMemoryDir }),
130
94
  onclick: () => openStore(store.id),
131
95
  title: store.kind === 'auto' && store.resolvedBy === 'unresolved'
132
96
  ? 'Real path could not be resolved - showing the raw folder name'
133
97
  : store.dir,
134
98
  }, [
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) : '-' }),
99
+ node('span', { class: ui.storeRow }, [
100
+ node('span', { class: ui.dot(health) }),
101
+ node('span', { class: ui.storeName, text: store.label }),
102
+ off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
103
+ node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
140
104
  ]),
141
- node('span', { class: 'project-path', text: storeSubtitle(store) }),
105
+ node('span', { class: ui.storePath, text: storeSubtitle(store) }),
142
106
  ]);
143
107
  }
144
108
 
@@ -148,13 +112,10 @@ function renderStores() {
148
112
 
149
113
  const visible = state.stores.filter((s) => state.showAll || s.hasMemoryDir);
150
114
  if (!visible.length) {
151
- list.append(node('p', { class: 'muted', text: 'No memory stores found.', style: 'padding:12px' }));
115
+ list.append(node('p', { class: ui.note, text: 'No memory stores found.' }));
152
116
  return;
153
117
  }
154
118
 
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
119
  const groups = [
159
120
  ['Projects', visible.filter((s) => s.kind === 'auto')],
160
121
  ['Subagents', visible.filter((s) => s.kind !== 'auto')],
@@ -162,16 +123,13 @@ function renderStores() {
162
123
 
163
124
  for (const [title, stores] of groups) {
164
125
  if (!stores.length) continue;
165
- // With nothing to tell apart, a single heading is just noise.
166
126
  if (groups.every(([, group]) => group.length) ) {
167
- list.append(node('div', { class: 'sidebar-group', text: title }));
127
+ list.append(node('div', { class: ui.sidebarGroup, text: title }));
168
128
  }
169
129
  for (const store of stores) list.append(storeButton(store));
170
130
  }
171
131
  }
172
132
 
173
- /* ------------------------------------------------------------------ tabs */
174
-
175
133
  const TABS = [
176
134
  { id: 'memories', label: 'Memories' },
177
135
  { id: 'index', label: 'MEMORY.md' },
@@ -198,43 +156,42 @@ function renderTabs() {
198
156
  if (tab.id === 'prune' && state.store.stats.index.overTarget) badge = '!';
199
157
 
200
158
  container.append(node('button', {
201
- class: `tab${state.tab === tab.id ? ' active' : ''}`,
159
+ class: ui.tab(state.tab === tab.id),
202
160
  onclick: () => { state.tab = tab.id; renderTabs(); renderTab(); },
203
161
  }, [
204
162
  document.createTextNode(tab.label),
205
- badge ? node('span', { class: `badge${tab.id === 'health' || tab.id === 'prune' ? ' warn' : ''}`, text: badge }) : null,
163
+ badge ? node('span', { class: ui.tabBadge(tab.id === 'health' || tab.id === 'prune'), text: badge }) : null,
206
164
  ]));
207
165
  }
208
166
  }
209
167
 
210
- /* -------------------------------------------------------------- memories */
211
-
212
168
  function memoryButton(memory) {
169
+ const active = state.selected === memory.file;
213
170
  return node('button', {
214
- class: `mem${state.selected === memory.file ? ' active' : ''}`,
171
+ class: ui.memoryItem(active),
172
+ 'data-memory': memory.file,
173
+ 'data-active': active ? '' : null,
215
174
  onclick: () => selectMemory(memory.file),
216
175
  }, [
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 }),
176
+ node('span', { class: ui.memoryTop }, [
177
+ node('span', { class: ui.memoryName, text: memory.name }),
178
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
220
179
  memory.status !== 'indexed'
221
- ? node('span', { class: `badge status-${memory.status}`, text: memory.status })
180
+ ? node('span', { class: ui.badge('warn'), text: memory.status })
222
181
  : null,
223
182
  ]),
224
- memory.description ? node('span', { class: 'mem-desc', text: memory.description }) : null,
183
+ memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
225
184
  ]);
226
185
  }
227
186
 
228
187
  function renderMemories(container) {
229
188
  const project = state.store;
230
189
  if (!project.memories.length) {
231
- container.append(node('p', { class: 'muted', text: 'This project has no memory files yet.' }));
190
+ container.append(node('p', { class: ui.note, text: 'This project has no memory files yet.' }));
232
191
  return;
233
192
  }
234
193
 
235
194
  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
195
  const groups = new Map();
239
196
  for (const memory of project.memories) {
240
197
  const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
@@ -242,12 +199,12 @@ function renderMemories(container) {
242
199
  groups.get(key).push(memory);
243
200
  }
244
201
  for (const [section, memories] of groups) {
245
- left.append(node('div', { class: 'section-label', text: `${section} · ${memories.length}` }));
202
+ left.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
246
203
  for (const memory of memories) left.append(memoryButton(memory));
247
204
  }
248
205
 
249
- const right = node('div', { id: 'detail' });
250
- container.append(node('div', { class: 'split' }, [left, right]));
206
+ const right = node('div', { id: 'detail', class: ui.detailPane });
207
+ container.append(node('div', { class: ui.split }, [left, right]));
251
208
  renderDetail();
252
209
  }
253
210
 
@@ -258,30 +215,28 @@ function renderDetail() {
258
215
 
259
216
  const memory = state.store.memories.find((m) => m.file === state.selected);
260
217
  if (!memory) {
261
- host.append(node('p', { class: 'muted', text: 'Select a memory to read it.' }));
218
+ host.append(node('p', { class: ui.note, text: 'Select a memory to read it.' }));
262
219
  return;
263
220
  }
264
221
 
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 }),
222
+ const card = node('div', { class: ui.card });
223
+ card.append(node('div', { class: ui.detailHead }, [
224
+ node('h3', { class: ui.detailTitle, text: memory.name }),
225
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
269
226
  node('button', {
270
- class: 'btn danger small',
227
+ class: ui.buttonDangerSmall,
271
228
  text: 'Delete',
272
229
  onclick: () => openDeleteDialog(memory.file),
273
230
  }),
274
231
  ]));
275
232
 
276
- if (memory.description) card.append(node('p', { class: 'detail-desc', text: memory.description }));
233
+ if (memory.description) card.append(node('p', { class: ui.detailDesc, text: memory.description }));
277
234
 
278
- const meta = node('dl', { class: 'meta' });
235
+ const meta = node('dl', { class: ui.metaList });
279
236
  const rows = [
280
237
  ['file', memory.file],
281
238
  ['status', memory.status],
282
239
  ['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
240
  ? `${memory.modified.replace('T', ' ').replace(/\..*$/, '')}${memory.modifiedFrom === 'mtime' ? ' (file mtime)' : ''}`
286
241
  : '-'],
287
242
  ['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
@@ -291,23 +246,23 @@ function renderDetail() {
291
246
  if (key !== 'type' && key !== 'modified') rows.push([key, value]);
292
247
  }
293
248
  for (const [key, value] of rows) {
294
- meta.append(node('dt', { text: key }), node('dd', { text: String(value) }));
249
+ meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
295
250
  }
296
251
  card.append(meta);
297
252
 
298
- const body = node('div', { class: 'body' });
253
+ const body = node('div', { class: ui.prose });
299
254
  card.append(body);
300
255
  renderBody(body, memory, state.store);
301
256
 
302
257
  const outbound = memory.outboundResolved || [];
303
258
  if (outbound.length || memory.inbound.length) {
304
- const links = node('div', { style: 'margin-top:20px;border-top:1px solid var(--border);padding-top:14px' });
259
+ const links = node('div', { class: ui.linkSection });
305
260
  if (outbound.length) {
306
- links.append(node('div', { class: 'section-label', text: 'Links out' }));
307
- const chips = node('div', { class: 'links' });
261
+ links.append(node('div', { class: ui.sectionLabel, text: 'Links out' }));
262
+ const chips = node('div', { class: ui.linkRow });
308
263
  for (const link of outbound) {
309
264
  chips.append(node('button', {
310
- class: `chip${link.file ? '' : ' dead'}`,
265
+ class: ui.chip(Boolean(link.file)),
311
266
  text: link.file ? link.target : `${link.target} (missing)`,
312
267
  onclick: link.file ? () => selectMemory(link.file) : null,
313
268
  }));
@@ -315,12 +270,12 @@ function renderDetail() {
315
270
  links.append(chips);
316
271
  }
317
272
  if (memory.inbound.length) {
318
- links.append(node('div', { class: 'section-label', text: 'Linked from' }));
319
- const chips = node('div', { class: 'links' });
273
+ links.append(node('div', { class: ui.sectionLabel, text: 'Linked from' }));
274
+ const chips = node('div', { class: ui.linkRow });
320
275
  for (const link of memory.inbound) {
321
276
  const source = state.store.memories.find((m) => m.file === link.from);
322
277
  chips.append(node('button', {
323
- class: 'chip',
278
+ class: ui.chip(true),
324
279
  text: source ? source.name : link.from,
325
280
  onclick: () => selectMemory(link.from),
326
281
  }));
@@ -333,45 +288,96 @@ function renderDetail() {
333
288
  host.append(card);
334
289
  }
335
290
 
336
- /* ----------------------------------------------------------- MEMORY.md */
291
+ const INDEX_VIEWS = [
292
+ { id: 'rendered', label: 'Rendered' },
293
+ { id: 'source', label: 'Source' },
294
+ ];
295
+
296
+ function setIndexView(view) {
297
+ state.indexView = view;
298
+ localStorage.setItem('memoryIndexView', view);
299
+ renderTab();
300
+ }
301
+
302
+ function indexViewPicker() {
303
+ return node('div', { class: ui.segmentGroup, role: 'tablist' }, INDEX_VIEWS.map((view) => node('button', {
304
+ class: ui.segment(state.indexView === view.id),
305
+ text: view.label,
306
+ role: 'tab',
307
+ 'aria-selected': String(state.indexView === view.id),
308
+ onclick: () => setIndexView(view.id),
309
+ })));
310
+ }
311
+
312
+ function cutMarker(cutoff) {
313
+ return node('div', { class: ui.cutLine }, [
314
+ node('span', { class: ui.cutLabel, text: `not loaded past here — ${cutoff.droppedLines} lines dropped on ${cutoff.by}` }),
315
+ ]);
316
+ }
337
317
 
338
318
  function renderIndex(container) {
339
319
  const project = state.store;
340
320
  if (!project.hasIndex) {
341
- container.append(node('p', { class: 'muted', text: 'This project has no MEMORY.md.' }));
321
+ container.append(node('p', { class: ui.note, text: 'This project has no MEMORY.md.' }));
342
322
  return;
343
323
  }
344
324
 
325
+ const card = node('div', { class: ui.card });
326
+ card.append(node('div', { class: ui.cardHeadRow }, [
327
+ node('p', { class: ui.note, text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }),
328
+ indexViewPicker(),
329
+ ]));
330
+
331
+ if (state.indexView === 'source') renderIndexSource(card, project);
332
+ else renderIndexRendered(card, project);
333
+
334
+ container.append(card);
335
+ }
336
+
337
+ function renderIndexRendered(card, project) {
338
+ const { cutoff } = project.stats.index;
339
+ const files = new Set(project.memories.map((memory) => memory.file));
340
+ const byName = memoryLookup(project);
341
+ const options = {
342
+ resolveWikilink: (target) => openTarget(byName.get(target)),
343
+ resolveHref: (href) => openTarget(files.has(href) ? href : null),
344
+ };
345
+
346
+ const lines = project.index.raw.split('\n');
347
+ const loaded = node('div', { class: ui.prose });
348
+ renderMarkdown(loaded, cutoff ? lines.slice(0, cutoff.rawLine).join('\n') : project.index.raw, options);
349
+ card.append(loaded);
350
+
351
+ if (!cutoff) return;
352
+ card.append(cutMarker(cutoff));
353
+ const dropped = node('div', { class: ui.proseDim });
354
+ renderMarkdown(dropped, lines.slice(cutoff.rawLine).join('\n'), options);
355
+ card.append(dropped);
356
+ }
357
+
358
+ function renderIndexSource(card, project) {
345
359
  const missing = new Set(project.health.danglingIndex.map((d) => d.index));
346
360
  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
361
 
350
362
  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);
363
+ if (cutoff && line.index === cutoff.rawLine) card.append(cutMarker(cutoff));
364
+
365
+ const dangling = missing.has(line.index);
366
+ const dropped = Boolean(cutoff) && line.index >= cutoff.rawLine;
367
+ const clickable = line.kind === 'index' && !dangling;
368
+ const content = node('span', {
369
+ class: ui.indexLineText({ kind: line.kind, clickable, dropped }),
370
+ text: line.text || ' ',
371
+ });
372
+ if (clickable) content.onclick = () => selectMemory(line.file);
373
+
374
+ card.append(node('div', { class: ui.indexLine({ dangling, dropped }) }, [
375
+ node('span', { class: ui.indexLineNumber, text: String(line.index + 1) }),
376
+ content,
377
+ ]));
368
378
  }
369
- container.append(card);
370
379
  }
371
380
 
372
-
373
- /* ------------------------------------------------------------- context */
374
-
375
381
  const SCOPE_ORDER = ['managed', 'user', 'project', 'local'];
376
382
 
377
383
  const CONTEXT_PROBLEMS = {
@@ -386,107 +392,103 @@ const CONTEXT_PROBLEMS = {
386
392
  };
387
393
 
388
394
  async function renderContext(container) {
389
- container.append(node('p', { class: 'muted', text: 'Reading instruction files…' }));
395
+ container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
390
396
  let data;
391
397
  try {
392
398
  data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
393
399
  } catch (err) {
394
400
  container.textContent = '';
395
- return container.append(node('p', { class: 'muted', text: err.message }));
401
+ return container.append(node('p', { class: ui.note, text: err.message }));
396
402
  }
397
- // The tab may have been switched while the request was in flight.
398
403
  if (state.tab !== 'context') return;
399
404
  container.textContent = '';
400
405
 
401
406
  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.' }));
407
+ 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
408
  }
404
409
 
405
410
  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' }),
411
+ container.append(node('div', { class: ui.meter }, [
412
+ node('div', { class: ui.meterTop }, [
413
+ node('span', { class: ui.meterValue, text: `~${totals.alwaysTokens.toLocaleString()}` }),
414
+ node('span', { class: ui.meterUnit, text: 'estimated tokens of instructions, every session' }),
410
415
  ]),
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')]),
416
+ node('div', { class: ui.meterFacts }, [
417
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.files) }), document.createTextNode(' files resolved')]),
418
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.alwaysLines) }), document.createTextNode(' lines always loaded')]),
419
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(totals.alwaysBytes / 1024).toFixed(1)} KB` }), document.createTextNode(' always loaded')]),
420
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.conditionalFiles) }), document.createTextNode(' path-scoped rules, loaded only on a match')]),
416
421
  ]),
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.' }),
422
+ 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
423
  ]));
419
424
 
420
425
  if (data.problems.length) {
421
- container.append(node('div', { class: 'section-label', text: `Problems · ${data.problems.length}` }));
426
+ container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
422
427
  for (const problem of data.problems) {
423
428
  const describe = CONTEXT_PROBLEMS[problem.kind];
424
429
  const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
425
430
  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 }),
431
+ container.append(node('div', { class: ui.issue(bad) }, [
432
+ node('div', { class: ui.issueBody }, [
433
+ node('div', { class: ui.issueTitle, text: title }),
434
+ node('div', { class: ui.issueDetail, text: detail }),
430
435
  ]),
431
436
  ]));
432
437
  }
433
438
  }
434
439
 
435
- container.append(node('div', { class: 'section-label', text: `Loaded in this order · ${data.files.length}` }));
436
- const card = node('div', { class: 'card' });
440
+ container.append(node('div', { class: ui.sectionLabel, text: `Loaded in this order · ${data.files.length}` }));
441
+ const card = node('div', { class: ui.card });
437
442
  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 }),
443
+ const tags = [node('span', { class: ui.scopeBadge(file.scope), text: file.scope })];
444
+ if (file.kind === 'import') tags.push(node('span', { class: ui.badge(), text: `import · depth ${file.depth}` }));
445
+ if (file.kind === 'rule') tags.push(node('span', { class: ui.badge(), text: 'rule' }));
446
+ if (file.kind === 'managed-settings') tags.push(node('span', { class: ui.badge(), text: 'claudeMd setting' }));
447
+ if (file.conditional) tags.push(node('span', { class: ui.badge('warn'), text: 'only on a path match' }));
448
+
449
+ card.append(node('div', { class: ui.contextRow }, [
450
+ node('div', { class: ui.contextMain }, [
451
+ node('div', { class: ui.contextTags }, tags),
452
+ node('div', { class: ui.contextFile, text: file.file }),
448
453
  ]),
449
- node('div', { class: 'ctx-size', text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
454
+ node('div', { class: ui.contextSize, text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
450
455
  ]));
451
456
  }
452
457
  container.append(card);
453
458
 
454
459
  if (data.excluded.length) {
455
- container.append(node('div', { class: 'section-label', text: `Excluded by claudeMdExcludes · ${data.excluded.length}` }));
460
+ container.append(node('div', { class: ui.sectionLabel, text: `Excluded by claudeMdExcludes · ${data.excluded.length}` }));
456
461
  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 })]),
462
+ container.append(node('div', { class: ui.issue(false) }, [
463
+ node('div', { class: ui.issueBody }, [node('div', { class: ui.issueDetail, text: file })]),
459
464
  ]));
460
465
  }
461
466
  }
462
467
 
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.' }));
468
+ 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
469
  }
465
470
 
466
- /* -------------------------------------------------------------- health */
467
-
468
471
  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 }),
472
+ return node('div', { class: ui.issue(bad) }, [
473
+ node('div', { class: ui.issueBody }, [
474
+ node('div', { class: ui.issueTitle, text: title }),
475
+ node('div', { class: ui.issueDetail, text: detail }),
473
476
  ]),
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,
477
+ secondary ? node('button', { class: ui.buttonSmall, text: secondary.label, onclick: secondary.run }) : null,
478
+ action ? node('button', { class: ui.buttonSmall, text: action.label, onclick: action.run }) : null,
476
479
  ]);
477
480
  }
478
481
 
479
482
  function renderHealth(container) {
480
483
  const { health, memories } = state.store;
481
484
  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.' }),
485
+ container.append(node('div', { class: ui.card }, [
486
+ node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
487
+ node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
485
488
  ]));
486
489
  return;
487
490
  }
488
491
 
489
- // Rendered straight from health.issues so the tab and the badge always agree.
490
492
  for (const item of health.issues) {
491
493
  container.append(renderIssue(item, memories));
492
494
  }
@@ -618,12 +620,10 @@ async function restoreFromTrash(id) {
618
620
  }
619
621
  }
620
622
 
621
- /* --------------------------------------------------------------- trash */
622
-
623
623
  function renderTrash(container) {
624
624
  const trash = state.store.trash;
625
625
  if (!trash.length) {
626
- container.append(node('p', { class: 'muted', text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
626
+ container.append(node('p', { class: ui.note, text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
627
627
  return;
628
628
  }
629
629
  for (const record of trash) {
@@ -631,20 +631,18 @@ function renderTrash(container) {
631
631
  const detail = record.kind === 'wikilink'
632
632
  ? `unlinked in ${record.sourceFile} · ${when}`
633
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` }),
634
+ container.append(node('div', { class: ui.issue(!record.present) }, [
635
+ node('div', { class: ui.issueBody }, [
636
+ node('div', { class: ui.issueTitle, text: record.label || record.id }),
637
+ node('div', { class: ui.issueDetail, text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
638
638
  ]),
639
639
  record.present
640
- ? node('button', { class: 'btn small primary', text: 'Restore', onclick: () => restoreFromTrash(record.id) })
640
+ ? node('button', { class: ui.buttonPrimarySmall, text: 'Restore', onclick: () => restoreFromTrash(record.id) })
641
641
  : null,
642
642
  ]));
643
643
  }
644
644
  }
645
645
 
646
- /* -------------------------------------------------------- delete dialog */
647
-
648
646
  function closeModal() {
649
647
  el('modal-root').textContent = '';
650
648
  }
@@ -660,53 +658,50 @@ async function openDeleteDialog(file) {
660
658
  return toast(err.message, { error: true });
661
659
  }
662
660
 
663
- const body = node('div', { class: 'modal-body' });
661
+ const body = node('div', { class: ui.modalBody });
664
662
 
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/` }));
663
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
664
+ removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.file} → memory/.trash/` }));
667
665
  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}` }));
666
+ removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
669
667
  }
670
668
  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.' }));
669
+ removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
672
670
  }
673
671
  body.append(removals);
674
672
 
675
673
  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' })]);
674
+ 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
675
  for (const ref of preview.inlineRefs) {
678
- kept.append(node('div', { class: 'will-item keep', text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
676
+ kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
679
677
  }
680
678
  body.append(kept);
681
679
  }
682
680
 
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
681
  const cascade = new Set();
687
682
  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);
683
+ const block = node('div', { class: ui.willBlock });
684
+ const selectAll = node('button', { class: ui.linkButton, text: 'select all' });
685
+ block.append(node('div', { class: ui.cascadeHead }, [
686
+ node('div', { class: ui.willTitle, text: `${preview.inboundWikilinks.length} memory(s) link here and will break` }),
687
+ selectAll,
688
+ ]));
694
689
 
695
- const list = node('div', { class: 'cascade' });
690
+ const list = node('div', { class: ui.cascadeList });
696
691
  const rows = [];
697
692
  for (const link of preview.inboundWikilinks) {
698
- const box = node('input', { type: 'checkbox' });
699
- const row = node('label', { class: 'cascade-row' }, [
693
+ const box = node('input', { type: 'checkbox', class: ui.checkbox });
694
+ const row = node('label', { class: ui.cascadeRow(false) }, [
700
695
  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
696
+ node('span', { class: ui.cascadeText }, [
697
+ node('span', { class: ui.cascadeName, text: link.fromName || link.from }),
698
+ node('span', { class: ui.cascadeDetail, text: link.indexLine
704
699
  ? `${link.from} · MEMORY.md line ${link.indexLine.index + 1}`
705
700
  : `${link.from} · not in the index` }),
706
701
  ]),
707
702
  ]);
708
703
  box.addEventListener('change', () => {
709
- row.classList.toggle('on', box.checked);
704
+ row.className = ui.cascadeRow(box.checked);
710
705
  if (box.checked) cascade.add(link.from);
711
706
  else cascade.delete(link.from);
712
707
  updateButton();
@@ -715,94 +710,87 @@ async function openDeleteDialog(file) {
715
710
  list.append(row);
716
711
  }
717
712
  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.' }));
713
+ 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
714
  body.append(block);
720
715
 
721
- head.querySelector('.link-btn').onclick = (event) => {
716
+ selectAll.onclick = (event) => {
722
717
  event.preventDefault();
723
718
  const turnOn = rows.some((r) => !r.box.checked);
724
719
  for (const { box } of rows) {
725
720
  if (box.checked !== turnOn) { box.checked = turnOn; box.dispatchEvent(new Event('change')); }
726
721
  }
727
- event.target.textContent = turnOn ? 'select none' : 'select all';
722
+ selectAll.textContent = turnOn ? 'select none' : 'select all';
728
723
  };
729
724
  }
730
725
 
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.' }));
726
+ body.append(node('p', { class: ui.noteTight, text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
727
+
728
+ const confirmButton = node('button', {
729
+ class: ui.button({ tone: 'danger' }),
730
+ text: 'Delete',
731
+ onclick: async () => {
732
+ closeModal();
733
+ try {
734
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
735
+ method: 'POST',
736
+ body: JSON.stringify({ file, alsoDelete: [...cascade] }),
737
+ });
738
+ if (state.selected === file || cascade.has(state.selected)) state.selected = null;
739
+ await openStore(state.storeId, { keepTab: true });
740
+ const count = result.record.files.length;
741
+ toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
742
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
743
+ });
744
+ } catch (err) {
745
+ toast(err.message, { error: true });
746
+ }
747
+ },
748
+ });
732
749
 
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 || '' }),
750
+ const modal = node('div', { class: ui.modal }, [
751
+ node('header', { class: ui.modalHead }, [
752
+ node('h3', { class: ui.modalTitle, text: `Delete "${preview.name || preview.file}"?` }),
753
+ node('p', { class: ui.note, text: preview.description || '' }),
737
754
  ]),
738
755
  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
- }),
756
+ node('footer', { class: ui.modalFoot }, [
757
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
758
+ confirmButton,
762
759
  ]),
763
760
  ]);
764
761
 
765
- const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
762
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
766
763
  el('modal-root').append(backdrop);
767
764
  updateButton();
768
765
 
769
766
  function updateButton() {
770
- const button = modal.querySelector('footer .btn.danger');
771
- if (button) button.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
767
+ confirmButton.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
772
768
  }
773
769
  }
774
770
 
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
771
  function openRememberPathDialog() {
784
772
  const store = state.store;
785
773
  const input = node('input', {
786
774
  type: 'text',
787
- class: 'path-input',
775
+ class: ui.pathInput,
788
776
  value: store.guess || '',
789
777
  placeholder: '/Users/you/repos/the-project',
790
778
  spellcheck: 'false',
791
779
  });
792
780
 
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.` }),
781
+ const body = node('div', { class: ui.modalBody }, [
782
+ 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
783
  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.' }),
784
+ 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
785
  ]);
798
786
 
799
- const modal = node('div', { class: 'modal' }, [
800
- node('header', {}, [node('h3', { text: 'Remember this project\u2019s path' })]),
787
+ const modal = node('div', { class: ui.modal }, [
788
+ node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Remember this project\u2019s path' })]),
801
789
  body,
802
- node('footer', {}, [
803
- node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
790
+ node('footer', { class: ui.modalFoot }, [
791
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
804
792
  node('button', {
805
- class: 'btn primary',
793
+ class: ui.button({ tone: 'primary' }),
806
794
  text: 'Remember',
807
795
  onclick: async () => {
808
796
  const value = input.value.trim();
@@ -824,7 +812,7 @@ function openRememberPathDialog() {
824
812
  ]),
825
813
  ]);
826
814
 
827
- const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
815
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
828
816
  el('modal-root').append(backdrop);
829
817
  input.focus();
830
818
  input.select();
@@ -853,40 +841,39 @@ async function openStoreDeleteDialog() {
853
841
  return toast('This store has no memory to delete');
854
842
  }
855
843
 
856
- const body = node('div', { class: 'modal-body' });
857
- const removals = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: 'Will be removed' })]);
844
+ const body = node('div', { class: ui.modalBody });
845
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
858
846
  if (preview.hasIndex) {
859
- removals.append(node('div', { class: 'will-item remove', text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
847
+ removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
860
848
  }
861
849
  for (const entry of preview.files) {
862
- removals.append(node('div', { class: 'will-item remove', text: `${entry.file} → memory/.trash/` }));
850
+ removals.append(node('div', { class: ui.willItem('remove'), text: `${entry.file} → memory/.trash/` }));
863
851
  }
864
852
  body.append(removals);
865
853
 
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'
854
+ body.append(node('div', { class: ui.willBlock }, [
855
+ node('div', { class: ui.willTitle, text: 'Will be kept' }),
856
+ node('div', { class: ui.willItem('keep'), text: state.store.kind === 'auto'
869
857
  ? 'Session transcripts (*.jsonl) and the project folder itself'
870
858
  : 'The agent-memory folder itself, and every other agent\u2019s memory' }),
871
859
  ]));
872
860
 
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.' }));
861
+ 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
862
 
875
- // A project-scoped agent store is committed, so this is a working-tree change.
876
863
  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.' }));
864
+ 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
865
  }
879
866
 
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' : ''}` }),
867
+ const modal = node('div', { class: ui.modal }, [
868
+ node('header', { class: ui.modalHead }, [
869
+ node('h3', { class: ui.modalTitle, text: `Delete all memory for ${state.store.label}?` }),
870
+ node('p', { class: ui.note, text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
884
871
  ]),
885
872
  body,
886
- node('footer', {}, [
887
- node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
873
+ node('footer', { class: ui.modalFoot }, [
874
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
888
875
  node('button', {
889
- class: 'btn danger',
876
+ class: ui.button({ tone: 'danger' }),
890
877
  text: `Delete ${preview.files.length + (preview.hasIndex ? 1 : 0)} files`,
891
878
  onclick: async () => {
892
879
  closeModal();
@@ -905,13 +892,10 @@ async function openStoreDeleteDialog() {
905
892
  ]),
906
893
  ]);
907
894
 
908
- const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
895
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
909
896
  el('modal-root').append(backdrop);
910
897
  }
911
898
 
912
-
913
- /* --------------------------------------------------------------- prune */
914
-
915
899
  function highlight(text, terms) {
916
900
  const wrapper = node('span');
917
901
  if (!terms.length) { wrapper.textContent = text; return wrapper; }
@@ -919,7 +903,7 @@ function highlight(text, terms) {
919
903
  let last = 0;
920
904
  for (const match of text.matchAll(pattern)) {
921
905
  if (match.index > last) wrapper.append(document.createTextNode(text.slice(last, match.index)));
922
- wrapper.append(node('mark', { text: match[0] }));
906
+ wrapper.append(node('mark', { class: ui.resultMark, text: match[0] }));
923
907
  last = match.index + match[0].length;
924
908
  }
925
909
  if (last < text.length) wrapper.append(document.createTextNode(text.slice(last)));
@@ -944,7 +928,7 @@ const PRUNE_SORTS = {
944
928
  function renderBudget(container) {
945
929
  const { index } = state.store.stats;
946
930
  const percent = Math.min(100, index.worstPercent);
947
- const level = index.level === 'over' ? 'way-over' : index.level === 'near' ? 'over' : '';
931
+ const level = index.level;
948
932
 
949
933
  const note = index.level === 'over'
950
934
  ? '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 +938,42 @@ function renderBudget(container) {
954
938
 
955
939
  if (index.cutoff) {
956
940
  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
941
+ const box = node('div', { class: ui.cutSummary });
942
+ box.append(node('div', { class: ui.cutSummaryHead, text: dropped.length
959
943
  ? `${dropped.length} ${dropped.length === 1 ? 'memory is' : 'memories are'} past the cutoff and not loaded`
960
944
  : `${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.` }));
945
+ 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
946
  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}` }),
947
+ box.append(node('div', { class: ui.issue(true) }, [
948
+ node('div', { class: ui.issueBody }, [
949
+ node('div', { class: ui.issueTitle, text: entry.title || entry.file }),
950
+ node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
967
951
  ]),
968
- node('button', { class: 'btn small', text: 'Open', onclick: () => selectMemory(entry.file) }),
952
+ node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
969
953
  ]));
970
954
  }
971
955
  if (dropped.length > 20) {
972
- box.append(node('p', { class: 'muted', text: `${dropped.length - 20} more not listed.` }));
956
+ box.append(node('p', { class: ui.note, text: `${dropped.length - 20} more not listed.` }));
973
957
  }
974
958
  container.append(box);
975
959
  }
976
960
 
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}` }),
961
+ container.append(node('div', { class: ui.meter }, [
962
+ node('div', { class: ui.meterTop }, [
963
+ node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
964
+ node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
981
965
  ]),
982
- node('div', { class: 'meter-bar' }, [
983
- node('div', { class: `meter-fill ${level}`, style: `width:${percent.toFixed(1)}%` }),
966
+ node('div', { class: ui.meterBar }, [
967
+ node('div', { class: ui.meterFill(level), style: `width:${percent.toFixed(1)}%` }),
984
968
  ]),
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')]),
969
+ node('p', { class: ui.meterNote, text: note }),
970
+ node('div', { class: ui.meterFacts }, [
971
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
972
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
973
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
974
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
991
975
  ]),
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.' }),
976
+ 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
977
  ]));
994
978
  }
995
979
 
@@ -998,49 +982,48 @@ function renderPrune(container) {
998
982
  renderBudget(container);
999
983
 
1000
984
  if (!project.memories.length) {
1001
- container.append(node('p', { class: 'muted', text: 'Nothing to prune - this project has no memories.' }));
985
+ container.append(node('p', { class: ui.note, text: 'Nothing to prune - this project has no memories.' }));
1002
986
  return;
1003
987
  }
1004
988
 
1005
- // --- bulk selection list ---
1006
989
  const selected = state.pruneSelection;
1007
- const bar = node('div', { class: 'prune-bar' });
1008
- const sort = node('select');
990
+ const bar = node('div', { class: ui.pruneBar });
991
+ const sort = node('select', { class: ui.select });
1009
992
  for (const [key, config] of Object.entries(PRUNE_SORTS)) {
1010
993
  sort.append(node('option', { value: key, text: config.label, selected: key === state.pruneSort ? '' : null }));
1011
994
  }
1012
995
  sort.value = state.pruneSort;
1013
996
  sort.onchange = () => { state.pruneSort = sort.value; renderTab(); };
1014
997
 
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' });
998
+ const count = node('span', { class: ui.note });
999
+ const deleteButton = node('button', { class: ui.buttonDangerSmall });
1000
+ const clearButton = node('button', { class: ui.buttonSmall, text: 'Clear selection' });
1018
1001
 
1019
- bar.append(node('span', { class: 'section-label', style: 'margin:0', text: 'Prune' }), sort, node('span', { class: 'spacer' }), count, clearButton, deleteButton);
1002
+ bar.append(node('span', { class: ui.sectionLabel, text: 'Prune' }), sort, node('span', { class: ui.pruneSpacer }), count, clearButton, deleteButton);
1020
1003
  container.append(bar);
1021
1004
 
1022
1005
  const list = node('div');
1023
1006
  const ordered = [...project.memories].sort(PRUNE_SORTS[state.pruneSort].compare);
1024
1007
  const rows = [];
1025
1008
  for (const memory of ordered) {
1026
- const box = node('input', { type: 'checkbox' });
1009
+ const box = node('input', { type: 'checkbox', class: ui.checkbox });
1027
1010
  box.checked = selected.has(memory.file);
1028
- const row = node('label', { class: `prune-row${box.checked ? ' on' : ''}` }, [
1011
+ const row = node('label', { class: ui.pruneRow(box.checked) }, [
1029
1012
  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 }),
1013
+ node('span', { class: ui.pruneMain }, [
1014
+ node('span', { class: ui.pruneName, text: memory.name }),
1015
+ node('span', { class: ui.pruneDesc, text: memory.description || memory.file }),
1033
1016
  ]),
1034
- node('span', { class: 'prune-facts' }, [
1035
- node('span', { class: (memory.ageDays ?? 0) > 180 ? 'warn' : '', text: formatAge(memory.ageDays) }),
1017
+ node('span', { class: ui.pruneFacts }, [
1018
+ node('span', { class: ui.pruneFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
1036
1019
  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 }),
1020
+ node('span', { class: ui.pruneFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
1021
+ node('span', { class: ui.pruneFact(memory.status !== 'indexed'), text: memory.status }),
1039
1022
  ]),
1040
1023
  ]);
1041
1024
  box.addEventListener('change', () => {
1042
1025
  if (box.checked) selected.add(memory.file); else selected.delete(memory.file);
1043
- row.classList.toggle('on', box.checked);
1026
+ row.className = ui.pruneRow(box.checked);
1044
1027
  updateBar();
1045
1028
  });
1046
1029
  rows.push({ box, memory });
@@ -1052,7 +1035,6 @@ function renderPrune(container) {
1052
1035
  count.textContent = selected.size ? `${selected.size} selected` : '';
1053
1036
  deleteButton.textContent = `Delete ${selected.size || ''}`.trim();
1054
1037
  deleteButton.disabled = selected.size === 0;
1055
- deleteButton.style.opacity = selected.size ? '1' : '.45';
1056
1038
  }
1057
1039
  clearButton.onclick = () => {
1058
1040
  selected.clear();
@@ -1061,39 +1043,37 @@ function renderPrune(container) {
1061
1043
  deleteButton.onclick = () => openBulkDeleteDialog([...selected]);
1062
1044
  updateBar();
1063
1045
 
1064
- // --- long hooks ---
1065
1046
  const { longHooks } = project.stats.index;
1066
1047
  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.' }));
1048
+ container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
1049
+ 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
1050
  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}` }),
1051
+ container.append(node('div', { class: ui.issue(false) }, [
1052
+ node('div', { class: ui.issueBody }, [
1053
+ node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
1054
+ node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
1074
1055
  ]),
1075
- node('button', { class: 'btn small', text: 'Open', onclick: () => selectMemory(hook.file) }),
1056
+ node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
1076
1057
  ]));
1077
1058
  }
1078
1059
  }
1079
1060
 
1080
- // --- possible overlap ---
1081
1061
  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.' }));
1062
+ container.append(node('div', { class: ui.sectionLabel, text: `Possible overlap · ${project.duplicates.length}` }));
1063
+ 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
1064
  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}%` }),
1065
+ container.append(node('div', { class: ui.dupe }, [
1066
+ node('div', { class: ui.dupeHead }, [
1067
+ node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
1088
1068
  node('span', { text: `shared: ${pair.shared.join(', ')}` }),
1089
1069
  ]),
1090
- node('div', { class: 'dupe-pair' }, [
1070
+ node('div', { class: ui.dupePair }, [
1091
1071
  ...[pair.a, pair.b].map((side) => node('div', {
1092
- class: 'dupe-side',
1072
+ class: ui.dupeSide,
1093
1073
  onclick: () => selectMemory(side.file),
1094
1074
  }, [
1095
- node('div', { class: 'n', text: side.name }),
1096
- node('div', { class: 'd', text: side.description || side.file }),
1075
+ node('div', { class: ui.dupeSideName, text: side.name }),
1076
+ node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
1097
1077
  ])),
1098
1078
  ]),
1099
1079
  ]));
@@ -1105,14 +1085,13 @@ async function openBulkDeleteDialog(files) {
1105
1085
  if (!files.length) return;
1106
1086
  const memories = files.map((file) => state.store.memories.find((m) => m.file === file)).filter(Boolean);
1107
1087
 
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` })]);
1088
+ const body = node('div', { class: ui.modalBody });
1089
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${memories.length} memories will be trashed` })]);
1110
1090
  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'}` }));
1091
+ 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
1092
  }
1113
1093
  body.append(removals);
1114
1094
 
1115
- // Links from memories that are NOT being deleted are the ones that break.
1116
1095
  const doomed = new Set(files);
1117
1096
  const breaking = [];
1118
1097
  for (const memory of memories) {
@@ -1121,20 +1100,20 @@ async function openBulkDeleteDialog(files) {
1121
1100
  }
1122
1101
  }
1123
1102
  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}]]` }));
1103
+ const block = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${breaking.length} link(s) from memories you are keeping will break` })]);
1104
+ for (const link of breaking) block.append(node('div', { class: ui.willItem('keep'), text: `${link.from} → [[${link.target}]]` }));
1126
1105
  body.append(block);
1127
1106
  }
1128
1107
 
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.' }));
1108
+ body.append(node('p', { class: ui.noteTight, text: 'All of them go to memory/.trash/ as one restore point.' }));
1130
1109
 
1131
- const modal = node('div', { class: 'modal' }, [
1132
- node('header', {}, [node('h3', { text: `Delete ${memories.length} memories?` })]),
1110
+ const modal = node('div', { class: ui.modal }, [
1111
+ node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Delete ${memories.length} memories?` })]),
1133
1112
  body,
1134
- node('footer', {}, [
1135
- node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
1113
+ node('footer', { class: ui.modalFoot }, [
1114
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1136
1115
  node('button', {
1137
- class: 'btn danger',
1116
+ class: ui.button({ tone: 'danger' }),
1138
1117
  text: `Delete ${memories.length}`,
1139
1118
  onclick: async () => {
1140
1119
  closeModal();
@@ -1156,12 +1135,10 @@ async function openBulkDeleteDialog(files) {
1156
1135
  }),
1157
1136
  ]),
1158
1137
  ]);
1159
- const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1138
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1160
1139
  el('modal-root').append(backdrop);
1161
1140
  }
1162
1141
 
1163
- /* -------------------------------------------------------------- search */
1164
-
1165
1142
  let searchTimer = null;
1166
1143
 
1167
1144
  function scheduleSearch(value) {
@@ -1195,44 +1172,44 @@ function renderSearch() {
1195
1172
  : `No results for "${state.query.trim()}"`;
1196
1173
 
1197
1174
  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.' }));
1175
+ host.append(node('p', { class: ui.note, text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
1199
1176
  return;
1200
1177
  }
1201
1178
 
1202
1179
  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
1180
+ const block = node('div', { class: ui.resultGroup });
1181
+ block.append(node('div', { class: ui.sectionLabel, text: group.sublabel
1205
1182
  ? `${group.label} · ${group.sublabel} · ${group.results.length}`
1206
1183
  : `${group.label} · ${group.results.length}` }));
1207
1184
 
1208
1185
  for (const result of group.results) {
1209
1186
  const button = node('button', {
1210
- class: 'result',
1187
+ class: ui.result,
1211
1188
  onclick: () => openFromSearch(group.id, result.file),
1212
1189
  });
1213
- const top = node('div', { class: 'result-top' });
1214
- const name = node('span', { class: 'result-name' });
1190
+ const top = node('div', { class: ui.resultTop });
1191
+ const name = node('span', { class: ui.resultName });
1215
1192
  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 }));
1193
+ top.append(name, node('span', { class: ui.typeBadge(result.type), text: result.type }));
1194
+ if (result.status !== 'indexed') top.append(node('span', { class: ui.badge('warn'), text: result.status }));
1218
1195
  button.append(top);
1219
1196
 
1220
1197
  if (result.snippet) {
1221
- const snippet = node('div', { class: 'result-snippet' });
1198
+ const snippet = node('div', { class: ui.resultSnippet });
1222
1199
  snippet.append(highlight(result.snippet.text, data.terms));
1223
1200
  button.append(snippet);
1224
1201
  }
1225
- button.append(node('div', { class: 'result-where', text: `${result.file} · matched in ${result.fields.join(', ')}` }));
1202
+ button.append(node('div', { class: ui.resultWhere, text: `${result.file} · matched in ${result.fields.join(', ')}` }));
1226
1203
  block.append(button);
1227
1204
  }
1228
1205
 
1229
1206
  for (const hit of group.indexHits) {
1230
1207
  const button = node('button', {
1231
- class: 'result',
1208
+ class: ui.result,
1232
1209
  onclick: () => openFromSearch(group.id, hit.file, 'index'),
1233
1210
  });
1234
- button.append(node('div', { class: 'result-where', text: `MEMORY.md line ${hit.index + 1}` }));
1235
- const line = node('div', { class: 'result-snippet' });
1211
+ button.append(node('div', { class: ui.resultWhere, text: `MEMORY.md line ${hit.index + 1}` }));
1212
+ const line = node('div', { class: ui.resultSnippet });
1236
1213
  line.append(highlight(hit.text.trim(), data.terms));
1237
1214
  button.append(line);
1238
1215
  block.append(button);
@@ -1265,8 +1242,6 @@ function renderView() {
1265
1242
  if (searching) renderSearch();
1266
1243
  }
1267
1244
 
1268
- /* ------------------------------------------------------------ rendering */
1269
-
1270
1245
  function renderTab() {
1271
1246
  const container = el('tab-content');
1272
1247
  container.textContent = '';
@@ -1277,7 +1252,7 @@ function renderTab() {
1277
1252
  else if (state.tab === 'health') renderHealth(container);
1278
1253
  else if (state.tab === 'trash') renderTrash(container);
1279
1254
  else if (state.tab === 'graph') {
1280
- const wrap = node('div', { id: 'graph-wrap' });
1255
+ const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
1281
1256
  container.append(wrap);
1282
1257
  renderGraph(wrap, state.store.graph, {
1283
1258
  selected: state.selected,
@@ -1301,10 +1276,9 @@ function selectMemory(file) {
1301
1276
  } else {
1302
1277
  renderTab();
1303
1278
  }
1304
- document.querySelector('.mem.active')?.scrollIntoView({ block: 'nearest' });
1279
+ document.querySelector('[data-memory][data-active]')?.scrollIntoView({ block: 'nearest' });
1305
1280
  }
1306
1281
 
1307
- /** Re-read the store list, e.g. after a path is remembered or forgotten. */
1308
1282
  async function reloadStores() {
1309
1283
  const data = await api('/api/stores');
1310
1284
  state.stores = data.stores;
@@ -1312,7 +1286,6 @@ async function reloadStores() {
1312
1286
  return data;
1313
1287
  }
1314
1288
 
1315
- /** The name and provenance line above the tabs, which differs by store kind. */
1316
1289
  function renderStoreHeader() {
1317
1290
  const store = state.store;
1318
1291
  el('project-title').textContent = store.label;
@@ -1324,11 +1297,9 @@ function renderStoreHeader() {
1324
1297
  const scope = store.kind === 'agent-user' ? 'user'
1325
1298
  : store.kind === 'agent-project' ? 'project' : 'local';
1326
1299
  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.
1300
+ sub.append(node('span', { class: ui.subNote, text: `subagent memory, ${scope} scope` }));
1330
1301
  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' }));
1302
+ sub.append(node('span', { class: ui.subWarn, text: 'checked into the repository - changes here show up in git status' }));
1332
1303
  }
1333
1304
  return;
1334
1305
  }
@@ -1337,24 +1308,22 @@ function renderStoreHeader() {
1337
1308
  ? `${store.path} (real path unresolved)`
1338
1309
  : store.path }));
1339
1310
 
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
1311
  const others = (store.workingDirs || []).filter((d) => d !== store.path);
1343
1312
  if (others.length) {
1344
- sub.append(node('span', { class: 'sub-note', text: `also used from ${others.join(', ')}` }));
1313
+ sub.append(node('span', { class: ui.subNote, text: `also used from ${others.join(', ')}` }));
1345
1314
  }
1346
1315
  if (store.resolvedBy === 'unresolved') {
1347
- sub.append(node('button', { class: 'btn small link-btn', text: 'Remember path\u2026', onclick: openRememberPathDialog }));
1316
+ sub.append(node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }));
1348
1317
  } else if (store.resolvedBy === 'remembered') {
1349
- sub.append(node('span', { class: 'sub-note' }, [
1318
+ sub.append(node('span', { class: ui.subNote }, [
1350
1319
  document.createTextNode('path remembered, not recovered from a transcript '),
1351
- node('button', { class: 'btn small link-btn', text: 'Forget', onclick: forgetProjectPath }),
1320
+ node('button', { class: ui.linkButton, text: 'Forget', onclick: forgetProjectPath }),
1352
1321
  ]));
1353
1322
  }
1354
1323
 
1355
1324
  const auto = store.autoMemory;
1356
1325
  if (auto && auto.known && !auto.enabled) {
1357
- sub.append(node('span', { class: 'sub-warn', text: auto.scope === 'env'
1326
+ sub.append(node('span', { class: ui.subWarn, text: auto.scope === 'env'
1358
1327
  ? 'auto memory is off (CLAUDE_CODE_DISABLE_AUTO_MEMORY) - this store will not grow'
1359
1328
  : `auto memory is off (${auto.setBy}) - this store will not grow` }));
1360
1329
  }
@@ -1381,7 +1350,6 @@ async function openStore(id, { keepTab = false } = {}) {
1381
1350
 
1382
1351
  renderStoreHeader();
1383
1352
 
1384
- // Refresh the sidebar count in case a delete changed it.
1385
1353
  const listed = state.stores.find((store) => store.id === id);
1386
1354
  if (listed) listed.memoryCount = state.store.memories.length;
1387
1355
  renderStores();
@@ -1392,7 +1360,8 @@ async function openStore(id, { keepTab = false } = {}) {
1392
1360
  }
1393
1361
 
1394
1362
  function applyCollapsed() {
1395
- el('app').classList.toggle('collapsed', state.collapsed);
1363
+ el('app').className = state.collapsed ? ui.shellCollapsed : ui.shell;
1364
+ el('sidebar').className = state.collapsed ? ui.sidebarHidden : ui.sidebar;
1396
1365
  el('expand').hidden = !state.collapsed;
1397
1366
  localStorage.setItem('sidebarCollapsed', state.collapsed ? '1' : '0');
1398
1367
  }
@@ -1400,12 +1369,17 @@ function applyCollapsed() {
1400
1369
  function setCollapsed(value) {
1401
1370
  state.collapsed = value;
1402
1371
  applyCollapsed();
1403
- // The graph sizes itself to the container, so it has to be redrawn once the
1404
- // pane has actually changed width.
1405
1372
  if (state.tab === 'graph' && state.store) requestAnimationFrame(renderTab);
1406
1373
  }
1407
1374
 
1375
+ function applyStyles() {
1376
+ for (const element of document.querySelectorAll('[data-ui]')) {
1377
+ element.className = ui[element.dataset.ui] ?? '';
1378
+ }
1379
+ }
1380
+
1408
1381
  async function init() {
1382
+ applyStyles();
1409
1383
  el('show-all').addEventListener('change', (event) => {
1410
1384
  state.showAll = event.target.checked;
1411
1385
  renderStores();
@@ -1440,7 +1414,7 @@ async function init() {
1440
1414
  el('root-path').title = data.rootFile
1441
1415
  ? `autoMemoryDirectory set in ${data.rootFile}`
1442
1416
  : 'store chosen with --root';
1443
- el('root-path').append(node('span', { class: 'root-source', text: data.rootSource }));
1417
+ el('root-path').append(node('span', { class: ui.rootSource, text: data.rootSource }));
1444
1418
  }
1445
1419
  if (data.rootWarning) toast(data.rootWarning, { error: true });
1446
1420
  const first = state.stores.find((store) => store.memoryCount > 0);