somtum 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,480 +1,886 @@
1
1
  <!doctype html>
2
- <html lang="en" class="dark">
2
+ <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Somtum</title>
7
- <script src="https://cdn.tailwindcss.com"></script>
8
- <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
6
+ <title>Somtum — Memory</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link
10
- href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
10
+ href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap"
11
11
  rel="stylesheet"
12
12
  />
13
- <script>
14
- tailwind.config = {
15
- darkMode: 'class',
16
- theme: {
17
- extend: {
18
- colors: {
19
- bg: '#07080b',
20
- surface: '#0d0f14',
21
- surface2: '#131720',
22
- border: '#1a1f2c',
23
- 'border-s': '#252d3e',
24
- papaya: '#4ade80',
25
- chili: '#f87171',
26
- carrot: '#fbbf24',
27
- teal: '#22d3ee',
28
- cobalt: '#60a5fa',
29
- violet: '#a78bfa',
30
- },
31
- fontFamily: {
32
- display: ['Syne', 'sans-serif'],
33
- mono: ['JetBrains Mono', 'monospace'],
34
- },
35
- },
36
- },
37
- };
38
- </script>
13
+ <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
14
+
39
15
  <style>
40
16
  *,
41
17
  *::before,
42
18
  *::after {
43
19
  box-sizing: border-box;
20
+ margin: 0;
21
+ padding: 0;
22
+ }
23
+
24
+ :root {
25
+ --bg: #0b0d12;
26
+ --surface: #111318;
27
+ --surface2: #181c24;
28
+ --surface3: #1d2230;
29
+ --border: #1f2535;
30
+ --border2: #2a3245;
31
+ --text: #dde1ea;
32
+ --muted: #4d5870;
33
+ --muted2: #6b7a96;
34
+ --accent: #4f8dff;
35
+ --accent-g: #27c27d;
36
+ --accent-a: #f0a638;
37
+ --accent-r: #ef5858;
38
+ --accent-v: #9b72f5;
39
+ --accent-c: #22d0c8;
40
+ --radius: 8px;
41
+ --sidebar: 300px;
42
+ --header: 52px;
44
43
  }
44
+
45
45
  html,
46
46
  body {
47
47
  height: 100%;
48
48
  overflow: hidden;
49
- margin: 0;
50
- }
51
- body {
52
- background: #07080b;
53
- color: #c5c9d4;
54
- font-family: 'JetBrains Mono', monospace;
55
- font-size: 12.5px;
49
+ background: var(--bg);
50
+ color: var(--text);
51
+ font-family: 'Bricolage Grotesque', sans-serif;
52
+ font-size: 13px;
53
+ line-height: 1.5;
56
54
  -webkit-font-smoothing: antialiased;
57
55
  }
58
56
 
59
- /* ── Scrollbars ────────────────────────────────────────────── */
57
+ /* ── Scrollbars ─────────────────────────────────────────────── */
60
58
  ::-webkit-scrollbar {
61
- width: 3px;
62
- height: 3px;
59
+ width: 4px;
60
+ height: 4px;
63
61
  }
64
62
  ::-webkit-scrollbar-track {
65
63
  background: transparent;
66
64
  }
67
65
  ::-webkit-scrollbar-thumb {
68
- background: #252d3e;
69
- border-radius: 2px;
66
+ background: var(--border2);
67
+ border-radius: 4px;
70
68
  }
71
69
  ::-webkit-scrollbar-thumb:hover {
72
- background: #333d52;
70
+ background: var(--muted);
73
71
  }
74
72
 
75
- /* ── Stat chips ────────────────────────────────────────────── */
76
- .stat-chip {
73
+ /* ── Mono text ──────────────────────────────────────────────── */
74
+ .mono {
75
+ font-family: 'JetBrains Mono', monospace;
76
+ }
77
+
78
+ /* ── Header ─────────────────────────────────────────────────── */
79
+ #header {
80
+ height: var(--header);
81
+ border-bottom: 1px solid var(--border);
82
+ padding: 0 20px;
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 14px;
86
+ flex-shrink: 0;
87
+ background: var(--surface);
88
+ }
89
+ #logo {
90
+ font-size: 19px;
91
+ font-weight: 800;
92
+ color: var(--accent);
93
+ letter-spacing: -0.04em;
94
+ line-height: 1;
77
95
  display: flex;
78
- flex-direction: column;
79
96
  align-items: center;
80
- padding: 5px 12px;
81
- background: #0d0f14;
82
- border: 1px solid #1a1f2c;
97
+ gap: 7px;
98
+ }
99
+ #logo-mark {
100
+ width: 26px;
101
+ height: 26px;
102
+ border-radius: 7px;
103
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-v) 100%);
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ flex-shrink: 0;
108
+ }
109
+ #logo-mark svg {
110
+ width: 14px;
111
+ height: 14px;
112
+ color: #fff;
113
+ }
114
+ #header-sep {
115
+ width: 1px;
116
+ height: 20px;
117
+ background: var(--border);
118
+ }
119
+ #project-label {
120
+ font-size: 11px;
121
+ color: var(--muted);
122
+ font-family: 'JetBrains Mono', monospace;
123
+ white-space: nowrap;
124
+ overflow: hidden;
125
+ text-overflow: ellipsis;
126
+ max-width: 240px;
127
+ }
128
+ #stat-bar {
129
+ display: flex;
130
+ gap: 6px;
131
+ margin-left: auto;
132
+ }
133
+ .stat-pill {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 8px;
137
+ padding: 5px 11px;
83
138
  border-radius: 6px;
84
- min-width: 76px;
139
+ background: var(--surface2);
140
+ border: 1px solid var(--border);
85
141
  cursor: default;
86
142
  }
87
- .stat-chip .lbl {
88
- font-size: 8.5px;
143
+ .stat-pill .s-lbl {
144
+ font-size: 10px;
145
+ font-weight: 600;
89
146
  text-transform: uppercase;
90
- letter-spacing: 0.1em;
91
- color: #333d52;
92
- font-family: 'Syne', sans-serif;
93
- font-weight: 800;
147
+ letter-spacing: 0.08em;
148
+ color: var(--muted);
94
149
  }
95
- .stat-chip .val {
96
- font-size: 15px;
97
- font-weight: 700;
98
- font-family: 'Syne', sans-serif;
99
- line-height: 1.2;
100
- margin-top: 1px;
150
+ .stat-pill .s-val {
151
+ font-family: 'JetBrains Mono', monospace;
152
+ font-size: 13px;
153
+ font-weight: 600;
101
154
  }
102
155
 
103
- /* ── Tab buttons ───────────────────────────────────────────── */
104
- .tab-btn {
105
- padding: 9px 16px;
106
- font-family: 'Syne', sans-serif;
107
- font-size: 11px;
108
- font-weight: 800;
109
- letter-spacing: 0.06em;
110
- text-transform: uppercase;
111
- color: #333d52;
112
- border: none;
113
- border-bottom: 2px solid transparent;
114
- background: none;
115
- cursor: pointer;
156
+ /* ── Layout ─────────────────────────────────────────────────── */
157
+ #layout {
158
+ display: flex;
159
+ height: calc(100vh - var(--header));
160
+ overflow: hidden;
161
+ }
162
+
163
+ /* ── Sidebar ─────────────────────────────────────────────────── */
164
+ #sidebar {
165
+ width: var(--sidebar);
166
+ flex-shrink: 0;
167
+ border-right: 1px solid var(--border);
168
+ display: flex;
169
+ flex-direction: column;
170
+ overflow: hidden;
171
+ background: var(--surface);
172
+ }
173
+
174
+ /* Search */
175
+ #search-wrap {
176
+ padding: 10px;
177
+ border-bottom: 1px solid var(--border);
178
+ display: flex;
179
+ flex-direction: column;
180
+ gap: 8px;
181
+ }
182
+ .search-box {
183
+ position: relative;
184
+ }
185
+ .search-box svg {
186
+ position: absolute;
187
+ left: 9px;
188
+ top: 50%;
189
+ transform: translateY(-50%);
190
+ width: 14px;
191
+ height: 14px;
192
+ color: var(--muted);
193
+ pointer-events: none;
194
+ }
195
+ #search-input {
196
+ width: 100%;
197
+ background: var(--bg);
198
+ border: 1px solid var(--border);
199
+ border-radius: var(--radius);
200
+ padding: 7px 9px 7px 30px;
201
+ font-size: 12.5px;
202
+ font-family: 'JetBrains Mono', monospace;
203
+ color: var(--text);
204
+ outline: none;
116
205
  transition:
117
- color 0.15s,
118
- border-color 0.15s;
206
+ border-color 0.15s,
207
+ box-shadow 0.15s;
119
208
  }
120
- .tab-btn:hover {
121
- color: #7a8299;
209
+ #search-input::placeholder {
210
+ color: var(--muted);
122
211
  }
123
- .tab-btn.active {
124
- color: #4ade80;
125
- border-bottom-color: #4ade80;
212
+ #search-input:focus {
213
+ border-color: var(--accent);
214
+ box-shadow: 0 0 0 2px rgba(79, 141, 255, 0.12);
215
+ }
216
+ #search-meta {
217
+ display: flex;
218
+ align-items: center;
219
+ justify-content: space-between;
220
+ }
221
+ .strat-row {
222
+ display: flex;
223
+ gap: 4px;
126
224
  }
127
-
128
- /* ── Strategy buttons ──────────────────────────────────────── */
129
225
  .strat-btn {
130
- padding: 2px 8px;
131
- font-size: 9.5px;
132
- font-family: 'Syne', sans-serif;
133
- font-weight: 800;
134
- letter-spacing: 0.04em;
135
- border: 1px solid #1a1f2c;
226
+ padding: 3px 8px;
227
+ font-size: 10px;
228
+ font-weight: 600;
229
+ border: 1px solid var(--border);
136
230
  border-radius: 4px;
137
231
  cursor: pointer;
138
- background: #0d0f14;
139
- color: #333d52;
232
+ background: var(--surface2);
233
+ color: var(--muted2);
140
234
  transition: all 0.12s;
235
+ font-family: 'Bricolage Grotesque', sans-serif;
141
236
  }
142
237
  .strat-btn:hover {
143
- border-color: #252d3e;
144
- color: #7a8299;
238
+ color: var(--text);
239
+ border-color: var(--border2);
145
240
  }
146
241
  .strat-btn.active {
147
- background: rgba(34, 211, 238, 0.08);
148
- border-color: rgba(34, 211, 238, 0.4);
149
- color: #22d3ee;
242
+ background: rgba(79, 141, 255, 0.1);
243
+ border-color: rgba(79, 141, 255, 0.4);
244
+ color: var(--accent);
245
+ }
246
+ #search-spinner {
247
+ display: none;
248
+ }
249
+ #search-spinner svg {
250
+ animation: spin 0.7s linear infinite;
251
+ display: block;
150
252
  }
151
253
 
152
- /* ── Kind filter chips ─────────────────────────────────────── */
153
- .kind-chip {
154
- padding: 2px 7px;
155
- font-size: 9.5px;
156
- font-family: 'Syne', sans-serif;
157
- font-weight: 800;
254
+ /* Kind filters */
255
+ #kind-filters {
256
+ padding: 7px 10px;
257
+ border-bottom: 1px solid var(--border);
258
+ display: flex;
259
+ flex-wrap: wrap;
260
+ gap: 4px;
261
+ flex-shrink: 0;
262
+ }
263
+ .kind-tag {
264
+ padding: 2px 8px;
265
+ font-size: 10px;
266
+ font-weight: 600;
267
+ border-radius: 4px;
158
268
  border: 1px solid transparent;
159
- border-radius: 3px;
160
269
  cursor: pointer;
161
270
  transition: all 0.12s;
162
- background: #131720;
163
- color: #333d52;
271
+ color: var(--muted2);
272
+ background: var(--surface2);
273
+ }
274
+ .kind-tag:hover {
275
+ color: var(--text);
276
+ border-color: var(--border2);
164
277
  }
165
- .kind-chip:hover {
166
- color: #7a8299;
167
- border-color: #252d3e;
278
+ .kind-tag.active {
279
+ color: #fff;
168
280
  }
169
281
 
170
- /* ── Memory cards ──────────────────────────────────────────── */
282
+ /* List header */
283
+ #list-header {
284
+ padding: 6px 10px 5px;
285
+ display: flex;
286
+ align-items: center;
287
+ justify-content: space-between;
288
+ border-bottom: 1px solid var(--border);
289
+ }
290
+ #list-header .h-label {
291
+ font-size: 10px;
292
+ font-weight: 700;
293
+ text-transform: uppercase;
294
+ letter-spacing: 0.08em;
295
+ color: var(--muted);
296
+ }
297
+ #list-count {
298
+ font-size: 10px;
299
+ font-family: 'JetBrains Mono', monospace;
300
+ color: var(--muted);
301
+ }
302
+
303
+ /* Memory list */
304
+ #memory-list {
305
+ flex: 1;
306
+ overflow-y: auto;
307
+ padding: 6px;
308
+ display: flex;
309
+ flex-direction: column;
310
+ gap: 3px;
311
+ }
312
+
313
+ /* Memory card */
171
314
  .mem-card {
172
315
  padding: 9px 10px;
173
- background: #0d0f14;
174
- border: 1px solid #1a1f2c;
175
316
  border-radius: 6px;
317
+ background: var(--surface2);
318
+ border: 1px solid var(--border);
176
319
  cursor: pointer;
177
320
  transition:
178
321
  border-color 0.12s,
179
322
  background 0.12s;
180
323
  position: relative;
324
+ overflow: hidden;
325
+ }
326
+ .mem-card::before {
327
+ content: '';
328
+ position: absolute;
329
+ left: 0;
330
+ top: 0;
331
+ bottom: 0;
332
+ width: 3px;
333
+ border-radius: 3px 0 0 3px;
181
334
  }
182
335
  .mem-card:hover {
183
- border-color: #252d3e;
184
- background: #101318;
336
+ border-color: var(--border2);
337
+ background: var(--surface3);
185
338
  }
186
339
  .mem-card.selected {
187
- border-color: #4ade80 !important;
188
- background: rgba(74, 222, 128, 0.035) !important;
340
+ border-color: var(--accent);
341
+ }
342
+ .mem-card.selected::before {
343
+ background: var(--accent);
344
+ }
345
+
346
+ .mc-head {
347
+ display: flex;
348
+ align-items: flex-start;
349
+ gap: 7px;
350
+ margin-bottom: 3px;
351
+ }
352
+ .mc-title {
353
+ font-size: 12.5px;
354
+ font-weight: 600;
355
+ color: var(--text);
356
+ line-height: 1.35;
357
+ flex: 1;
358
+ min-width: 0;
359
+ }
360
+ .mc-kind {
361
+ flex-shrink: 0;
362
+ font-size: 9px;
363
+ font-weight: 700;
364
+ text-transform: uppercase;
365
+ letter-spacing: 0.08em;
366
+ padding: 2px 6px;
367
+ border-radius: 3px;
368
+ }
369
+ .mc-body {
370
+ font-size: 11px;
371
+ color: var(--muted2);
372
+ line-height: 1.4;
373
+ overflow: hidden;
374
+ display: -webkit-box;
375
+ -webkit-line-clamp: 2;
376
+ -webkit-box-orient: vertical;
377
+ }
378
+ .mc-footer {
379
+ display: flex;
380
+ align-items: center;
381
+ gap: 5px;
382
+ margin-top: 5px;
383
+ }
384
+ .mc-time {
385
+ font-size: 10px;
386
+ color: var(--muted);
387
+ font-family: 'JetBrains Mono', monospace;
388
+ }
389
+ .mc-tags {
390
+ display: flex;
391
+ gap: 3px;
392
+ flex-wrap: wrap;
393
+ flex: 1;
394
+ }
395
+ .mc-tag {
396
+ font-size: 9px;
397
+ font-weight: 600;
398
+ padding: 1px 5px;
399
+ background: var(--surface3);
400
+ border: 1px solid var(--border2);
401
+ border-radius: 3px;
402
+ color: var(--muted2);
189
403
  }
190
- .forget-btn {
404
+ .mc-forget {
191
405
  display: none;
406
+ width: 20px;
407
+ height: 20px;
408
+ align-items: center;
409
+ justify-content: center;
410
+ border-radius: 4px;
411
+ cursor: pointer;
412
+ background: rgba(239, 88, 88, 0.08);
413
+ border: 1px solid rgba(239, 88, 88, 0.2);
414
+ color: var(--accent-r);
415
+ font-size: 10px;
416
+ transition: background 0.12s;
417
+ flex-shrink: 0;
192
418
  position: absolute;
193
419
  top: 7px;
194
420
  right: 7px;
195
- width: 18px;
196
- height: 18px;
421
+ }
422
+ .mem-card:hover .mc-forget {
423
+ display: flex;
424
+ }
425
+ .mc-forget:hover {
426
+ background: rgba(239, 88, 88, 0.18);
427
+ }
428
+
429
+ /* ── Main panel ──────────────────────────────────────────────── */
430
+ #main {
431
+ flex: 1;
432
+ display: flex;
433
+ flex-direction: column;
434
+ overflow: hidden;
435
+ min-width: 0;
436
+ }
437
+
438
+ /* Tab bar */
439
+ #tab-bar {
440
+ height: 42px;
441
+ border-bottom: 1px solid var(--border);
442
+ padding: 0 8px;
443
+ display: flex;
197
444
  align-items: center;
198
- justify-content: center;
199
- border-radius: 3px;
200
- background: rgba(248, 113, 113, 0.08);
201
- border: 1px solid rgba(248, 113, 113, 0.2);
202
- color: #f87171;
203
- font-size: 9px;
445
+ flex-shrink: 0;
446
+ background: var(--surface);
447
+ gap: 2px;
448
+ }
449
+ .tab-btn {
450
+ padding: 6px 14px;
451
+ font-size: 11.5px;
452
+ font-weight: 600;
453
+ color: var(--muted2);
454
+ border: none;
455
+ border-radius: 5px;
456
+ background: none;
204
457
  cursor: pointer;
205
- transition: background 0.12s;
206
- font-family: 'Syne', sans-serif;
207
- font-weight: 800;
458
+ transition:
459
+ color 0.12s,
460
+ background 0.12s;
461
+ }
462
+ .tab-btn:hover {
463
+ color: var(--text);
464
+ background: var(--surface2);
465
+ }
466
+ .tab-btn.active {
467
+ color: var(--accent);
468
+ background: rgba(79, 141, 255, 0.1);
469
+ }
470
+ #tab-hint {
471
+ margin-left: auto;
472
+ font-size: 10px;
473
+ color: var(--muted);
474
+ padding-right: 10px;
475
+ font-family: 'JetBrains Mono', monospace;
476
+ }
477
+
478
+ /* Tab panes */
479
+ #tab-graph {
480
+ flex: 1;
481
+ display: flex;
482
+ flex-direction: column;
483
+ overflow: hidden;
484
+ }
485
+ #tab-analytics {
486
+ display: none;
487
+ flex: 1;
488
+ overflow-y: auto;
489
+ padding: 14px;
490
+ }
491
+
492
+ /* Graph */
493
+ #graph-container {
494
+ flex: 1;
495
+ margin: 10px;
496
+ border: 1px solid var(--border);
497
+ border-radius: var(--radius);
498
+ background: #08090f;
499
+ overflow: hidden;
500
+ }
501
+
502
+ /* Detail panel */
503
+ #detail-panel {
504
+ display: none;
505
+ border-top: 1px solid var(--border);
506
+ background: var(--surface);
507
+ overflow-y: auto;
508
+ flex-shrink: 0;
509
+ max-height: 38vh;
510
+ }
511
+ #detail-inner {
512
+ padding: 16px 20px;
208
513
  }
209
- .mem-card:hover .forget-btn {
514
+ .detail-head {
210
515
  display: flex;
516
+ align-items: flex-start;
517
+ gap: 10px;
518
+ margin-bottom: 12px;
211
519
  }
212
- .forget-btn:hover {
213
- background: rgba(248, 113, 113, 0.16) !important;
520
+ .detail-title {
521
+ font-size: 15px;
522
+ font-weight: 700;
523
+ color: var(--text);
524
+ flex: 1;
525
+ line-height: 1.3;
526
+ }
527
+ .detail-meta {
528
+ display: flex;
529
+ gap: 16px;
530
+ margin-bottom: 12px;
531
+ font-size: 11px;
532
+ color: var(--muted);
533
+ font-family: 'JetBrains Mono', monospace;
534
+ }
535
+ .detail-body {
536
+ font-size: 12.5px;
537
+ color: var(--muted2);
538
+ line-height: 1.65;
539
+ white-space: pre-wrap;
540
+ word-break: break-word;
541
+ margin-bottom: 12px;
542
+ }
543
+ .detail-chips {
544
+ display: flex;
545
+ flex-wrap: wrap;
546
+ gap: 5px;
547
+ }
548
+ .detail-chip {
549
+ font-size: 10px;
550
+ font-weight: 500;
551
+ padding: 2px 8px;
552
+ border-radius: 4px;
553
+ background: var(--surface2);
554
+ border: 1px solid var(--border2);
555
+ color: var(--muted2);
556
+ }
557
+ .detail-chip.file {
558
+ color: var(--accent-c);
559
+ border-color: rgba(34, 208, 200, 0.3);
560
+ background: rgba(34, 208, 200, 0.07);
561
+ }
562
+ .detail-close {
563
+ width: 24px;
564
+ height: 24px;
565
+ border-radius: 5px;
566
+ flex-shrink: 0;
567
+ display: flex;
568
+ align-items: center;
569
+ justify-content: center;
570
+ background: var(--surface2);
571
+ border: 1px solid var(--border);
572
+ cursor: pointer;
573
+ color: var(--muted2);
574
+ transition: color 0.12s;
575
+ }
576
+ .detail-close:hover {
577
+ color: var(--text);
214
578
  }
215
579
 
216
- /* ── Analytics cards ───────────────────────────────────────── */
580
+ /* Analytics */
581
+ .a-grid {
582
+ display: grid;
583
+ grid-template-columns: 1fr 1fr;
584
+ gap: 10px;
585
+ }
586
+ @media (max-width: 900px) {
587
+ .a-grid {
588
+ grid-template-columns: 1fr;
589
+ }
590
+ }
217
591
  .a-card {
218
- background: #0d0f14;
219
- border: 1px solid #1a1f2c;
220
- border-radius: 8px;
592
+ background: var(--surface);
593
+ border: 1px solid var(--border);
594
+ border-radius: var(--radius);
221
595
  padding: 16px;
222
596
  }
223
597
  .a-title {
224
- font-size: 9px;
225
- font-family: 'Syne', sans-serif;
226
- font-weight: 800;
598
+ font-size: 10px;
599
+ font-weight: 700;
227
600
  text-transform: uppercase;
228
601
  letter-spacing: 0.1em;
229
- color: #333d52;
602
+ color: var(--muted);
230
603
  margin-bottom: 14px;
231
604
  }
232
-
233
- /* ── Progress bars ─────────────────────────────────────────── */
605
+ .bar-row {
606
+ display: flex;
607
+ align-items: center;
608
+ gap: 8px;
609
+ margin-bottom: 8px;
610
+ }
611
+ .bar-label {
612
+ font-size: 11.5px;
613
+ font-weight: 600;
614
+ width: 100px;
615
+ flex-shrink: 0;
616
+ }
234
617
  .bar-track {
235
- height: 3px;
236
- background: #1a1f2c;
237
- border-radius: 2px;
238
618
  flex: 1;
619
+ height: 4px;
620
+ background: var(--surface3);
621
+ border-radius: 2px;
239
622
  overflow: hidden;
240
623
  }
241
624
  .bar-fill {
242
625
  height: 100%;
243
626
  border-radius: 2px;
244
627
  width: 0;
245
- transition: width 0.7s cubic-bezier(0.16, 1, 0.3, 1);
628
+ transition: width 0.65s cubic-bezier(0.16, 1, 0.3, 1);
246
629
  }
247
-
248
- /* ── Graph ─────────────────────────────────────────────────── */
249
- #graph-wrap {
630
+ .bar-count {
631
+ font-size: 11px;
632
+ font-family: 'JetBrains Mono', monospace;
633
+ color: var(--muted2);
634
+ width: 36px;
635
+ text-align: right;
636
+ flex-shrink: 0;
637
+ }
638
+ .cache-stat {
639
+ display: flex;
640
+ justify-content: space-between;
641
+ align-items: center;
642
+ padding: 7px 0;
643
+ border-bottom: 1px solid var(--border);
644
+ }
645
+ .cache-stat:last-child {
646
+ border-bottom: none;
647
+ }
648
+ .cache-stat-lbl {
649
+ font-size: 12px;
650
+ color: var(--muted2);
651
+ }
652
+ .cache-stat-val {
653
+ font-family: 'JetBrains Mono', monospace;
654
+ font-size: 13px;
655
+ font-weight: 600;
656
+ }
657
+ .file-row {
658
+ display: flex;
659
+ align-items: center;
660
+ gap: 8px;
661
+ padding: 6px 0;
662
+ border-bottom: 1px solid var(--border);
663
+ }
664
+ .file-row:last-child {
665
+ border-bottom: none;
666
+ }
667
+ .file-name {
668
+ font-size: 11px;
669
+ font-family: 'JetBrains Mono', monospace;
670
+ color: var(--text);
250
671
  flex: 1;
251
- min-height: 0;
252
- margin: 10px;
253
- border: 1px solid #1a1f2c;
254
- border-radius: 8px;
255
- background: #09090d;
256
672
  overflow: hidden;
673
+ text-overflow: ellipsis;
674
+ white-space: nowrap;
675
+ min-width: 0;
257
676
  }
258
-
259
- /* ── Detail drawer ─────────────────────────────────────────── */
260
- #detail-drawer {
261
- border-top: 1px solid #1a1f2c;
262
- background: #0b0d11;
263
- overflow-y: auto;
677
+ .file-count {
678
+ font-size: 11px;
679
+ font-family: 'JetBrains Mono', monospace;
680
+ color: var(--muted2);
264
681
  flex-shrink: 0;
265
- max-height: 40vh;
266
682
  }
267
683
 
268
- /* ── Animations ────────────────────────────────────────────── */
269
- @keyframes fadeUp {
270
- from {
271
- opacity: 0;
272
- transform: translateY(5px);
273
- }
274
- to {
275
- opacity: 1;
276
- transform: translateY(0);
277
- }
684
+ /* Empty state */
685
+ .empty {
686
+ display: flex;
687
+ flex-direction: column;
688
+ align-items: center;
689
+ justify-content: center;
690
+ gap: 10px;
691
+ padding: 48px 24px;
692
+ color: var(--muted);
693
+ text-align: center;
694
+ }
695
+ .empty-icon {
696
+ opacity: 0.25;
697
+ margin-bottom: 2px;
698
+ }
699
+ .empty-title {
700
+ font-size: 13px;
701
+ font-weight: 600;
702
+ color: var(--muted2);
278
703
  }
279
- .fade-up {
280
- animation: fadeUp 0.18s ease forwards;
704
+ .empty-sub {
705
+ font-size: 11.5px;
706
+ color: var(--muted);
707
+ max-width: 220px;
708
+ line-height: 1.5;
281
709
  }
282
710
 
711
+ /* Skeleton */
712
+ .skel {
713
+ background: var(--surface2);
714
+ border-radius: 5px;
715
+ animation: shimmer 1.5s ease-in-out infinite;
716
+ }
717
+
718
+ /* Animations */
719
+ @keyframes spin {
720
+ to {
721
+ transform: rotate(360deg);
722
+ }
723
+ }
283
724
  @keyframes shimmer {
284
725
  0%,
285
726
  100% {
286
- opacity: 0.35;
727
+ opacity: 0.4;
287
728
  }
288
729
  50% {
289
- opacity: 0.65;
730
+ opacity: 0.7;
290
731
  }
291
732
  }
292
- .skel {
293
- background: #131720;
294
- border-radius: 5px;
295
- animation: shimmer 1.4s ease-in-out infinite;
733
+ @keyframes fadeIn {
734
+ from {
735
+ opacity: 0;
736
+ transform: translateY(4px);
737
+ }
738
+ to {
739
+ opacity: 1;
740
+ transform: translateY(0);
741
+ }
742
+ }
743
+ .fade-in {
744
+ animation: fadeIn 0.16s ease forwards;
296
745
  }
297
746
 
298
- /* ── Empty states ──────────────────────────────────────────── */
299
- .empty {
300
- display: flex;
301
- flex-direction: column;
302
- align-items: center;
303
- justify-content: center;
304
- padding: 40px 20px;
305
- color: #333d52;
306
- font-family: 'Syne', sans-serif;
307
- font-weight: 700;
308
- font-size: 11px;
309
- text-align: center;
310
- gap: 8px;
747
+ /* Kind colour map */
748
+ .kind-decision {
749
+ background: rgba(79, 141, 255, 0.12);
750
+ color: #4f8dff;
311
751
  }
312
- .empty svg {
313
- opacity: 0.25;
752
+ .kind-learning {
753
+ background: rgba(155, 114, 245, 0.12);
754
+ color: #9b72f5;
314
755
  }
315
-
316
- /* ── File rows ─────────────────────────────────────────────── */
317
- .file-row {
318
- display: flex;
319
- align-items: center;
320
- gap: 8px;
321
- padding: 5px 0;
322
- border-bottom: 1px solid #13161e;
756
+ .kind-bugfix {
757
+ background: rgba(239, 88, 88, 0.12);
758
+ color: #ef5858;
323
759
  }
324
- .file-row:last-child {
325
- border-bottom: none;
760
+ .kind-command {
761
+ background: rgba(240, 166, 56, 0.12);
762
+ color: #f0a638;
763
+ }
764
+ .kind-file_summary {
765
+ background: rgba(34, 208, 200, 0.1);
766
+ color: #22d0c8;
767
+ }
768
+ .kind-other {
769
+ background: rgba(107, 122, 150, 0.12);
770
+ color: #6b7a96;
771
+ }
772
+ .kf-decision {
773
+ border-color: rgba(79, 141, 255, 0.3);
774
+ background: rgba(79, 141, 255, 0.08);
775
+ color: #4f8dff;
776
+ }
777
+ .kf-learning {
778
+ border-color: rgba(155, 114, 245, 0.3);
779
+ background: rgba(155, 114, 245, 0.08);
780
+ color: #9b72f5;
781
+ }
782
+ .kf-bugfix {
783
+ border-color: rgba(239, 88, 88, 0.3);
784
+ background: rgba(239, 88, 88, 0.08);
785
+ color: #ef5858;
786
+ }
787
+ .kf-command {
788
+ border-color: rgba(240, 166, 56, 0.3);
789
+ background: rgba(240, 166, 56, 0.08);
790
+ color: #f0a638;
791
+ }
792
+ .kf-file_summary {
793
+ border-color: rgba(34, 208, 200, 0.3);
794
+ background: rgba(34, 208, 200, 0.07);
795
+ color: #22d0c8;
796
+ }
797
+ .kf-other {
798
+ border-color: rgba(107, 122, 150, 0.3);
799
+ background: rgba(107, 122, 150, 0.08);
800
+ color: #6b7a96;
801
+ }
802
+ .kf-all {
803
+ border-color: rgba(79, 141, 255, 0.3);
804
+ background: rgba(79, 141, 255, 0.08);
805
+ color: #4f8dff;
326
806
  }
327
807
  </style>
328
808
  </head>
329
809
  <body>
330
- <!-- ═══════════════════════ HEADER ═══════════════════════════ -->
331
- <header
332
- style="
333
- height: 49px;
334
- border-bottom: 1px solid #1a1f2c;
335
- padding: 0 18px;
336
- display: flex;
337
- align-items: center;
338
- justify-content: space-between;
339
- flex-shrink: 0;
340
- "
341
- >
342
- <div style="display: flex; align-items: center; gap: 10px">
343
- <span
344
- style="
345
- font-family: Syne;
346
- font-size: 18px;
347
- font-weight: 800;
348
- color: #4ade80;
349
- letter-spacing: -0.02em;
350
- "
351
- >somtum</span
352
- >
353
- <span style="width: 1px; height: 16px; background: #1a1f2c"></span>
354
- <span
355
- id="project-id"
356
- style="
357
- font-size: 10px;
358
- color: #2e3547;
359
- max-width: 280px;
360
- overflow: hidden;
361
- text-overflow: ellipsis;
362
- white-space: nowrap;
363
- "
364
- ></span>
810
+ <!-- ═══ HEADER ══════════════════════════════════════════════════════ -->
811
+ <header id="header">
812
+ <div id="logo">
813
+ <div id="logo-mark">
814
+ <svg
815
+ viewBox="0 0 24 24"
816
+ fill="none"
817
+ stroke="currentColor"
818
+ stroke-width="2.2"
819
+ stroke-linecap="round"
820
+ stroke-linejoin="round"
821
+ >
822
+ <path
823
+ d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.46L7 19M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.46l.04-.54"
824
+ />
825
+ </svg>
826
+ </div>
827
+ somtum
365
828
  </div>
366
- <div id="stat-chips" style="display: flex; gap: 6px">
367
- <div class="stat-chip">
368
- <span class="lbl">memories</span
369
- ><span id="s-mem" class="val" style="color: #4ade80">—</span>
829
+ <div id="header-sep"></div>
830
+ <span id="project-label">—</span>
831
+ <div id="stat-bar">
832
+ <div class="stat-pill">
833
+ <span class="s-lbl">Memories</span>
834
+ <span id="s-mem" class="s-val" style="color: var(--accent-g)">—</span>
370
835
  </div>
371
- <div class="stat-chip">
372
- <span class="lbl">saved</span
373
- ><span id="s-saved" class="val" style="color: #fbbf24">—</span>
836
+ <div class="stat-pill">
837
+ <span class="s-lbl">Saved</span>
838
+ <span id="s-saved" class="s-val" style="color: var(--accent-a)">—</span>
374
839
  </div>
375
- <div class="stat-chip">
376
- <span class="lbl">spent</span
377
- ><span id="s-spent" class="val" style="color: #c5c9d4">—</span>
840
+ <div class="stat-pill">
841
+ <span class="s-lbl">Spent</span>
842
+ <span id="s-spent" class="s-val" style="color: var(--muted2)">—</span>
378
843
  </div>
379
- <div class="stat-chip">
380
- <span class="lbl">roi</span><span id="s-roi" class="val" style="color: #22d3ee">—</span>
844
+ <div class="stat-pill">
845
+ <span class="s-lbl">ROI</span>
846
+ <span id="s-roi" class="s-val" style="color: var(--accent)">—</span>
381
847
  </div>
382
- <div class="stat-chip">
383
- <span class="lbl">cache hit</span
384
- ><span id="s-cache" class="val" style="color: #a78bfa">—</span>
848
+ <div class="stat-pill">
849
+ <span class="s-lbl">Cache</span>
850
+ <span id="s-cache" class="s-val" style="color: var(--accent-v)">—</span>
385
851
  </div>
386
852
  </div>
387
853
  </header>
388
854
 
389
- <!-- ═══════════════════════ BODY ════════════════════════════= -->
390
- <div style="display: flex; height: calc(100vh - 49px); overflow: hidden">
391
- <!-- ─── LEFT SIDEBAR: Memory browser ────────────────────── -->
392
- <aside
393
- style="
394
- width: 296px;
395
- flex-shrink: 0;
396
- border-right: 1px solid #1a1f2c;
397
- display: flex;
398
- flex-direction: column;
399
- overflow: hidden;
400
- "
401
- >
402
- <!-- Search + strategy -->
403
- <div
404
- style="
405
- padding: 10px;
406
- border-bottom: 1px solid #1a1f2c;
407
- display: flex;
408
- flex-direction: column;
409
- gap: 7px;
410
- "
411
- >
412
- <div style="position: relative">
413
- <svg
414
- style="
415
- position: absolute;
416
- left: 8px;
417
- top: 6.5px;
418
- width: 13px;
419
- height: 13px;
420
- color: #2e3547;
421
- pointer-events: none;
422
- "
423
- fill="none"
424
- stroke="currentColor"
425
- viewBox="0 0 24 24"
426
- >
427
- <path
428
- stroke-linecap="round"
429
- stroke-linejoin="round"
430
- stroke-width="2"
431
- d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0"
432
- />
855
+ <!-- ═══ LAYOUT ══════════════════════════════════════════════════════ -->
856
+ <div id="layout">
857
+ <!-- ─── SIDEBAR ───────────────────────────────────────────────── -->
858
+ <aside id="sidebar">
859
+ <!-- Search -->
860
+ <div id="search-wrap">
861
+ <div class="search-box">
862
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
863
+ <circle cx="11" cy="11" r="7" />
864
+ <path d="M21 21l-5-5" stroke-linecap="round" />
433
865
  </svg>
434
866
  <input
435
867
  id="search-input"
436
868
  type="text"
437
- placeholder="search memories…"
869
+ placeholder="Search memories…"
438
870
  autocomplete="off"
439
- style="
440
- width: 100%;
441
- background: #0a0c10;
442
- border: 1px solid #1a1f2c;
443
- border-radius: 5px;
444
- padding: 6px 8px 6px 26px;
445
- font-size: 12px;
446
- font-family: 'JetBrains Mono', monospace;
447
- color: #c5c9d4;
448
- outline: none;
449
- transition: border-color 0.15s;
450
- "
451
- onfocus="this.style.borderColor = '#22d3ee'"
452
- onblur="this.style.borderColor = '#1a1f2c'"
871
+ spellcheck="false"
453
872
  />
454
873
  </div>
455
- <div style="display: flex; align-items: center; gap: 6px">
456
- <span
457
- style="
458
- font-size: 8.5px;
459
- color: #2e3547;
460
- font-family: Syne;
461
- font-weight: 800;
462
- text-transform: uppercase;
463
- letter-spacing: 0.1em;
464
- white-space: nowrap;
465
- "
466
- >via</span
467
- >
468
- <div id="strategy-btns" style="display: flex; gap: 4px"></div>
469
- <div id="search-spinner" style="display: none; margin-left: 4px">
874
+ <div id="search-meta">
875
+ <div class="strat-row" id="strategy-btns"></div>
876
+ <div id="search-spinner">
470
877
  <svg
471
- width="11"
472
- height="11"
878
+ width="13"
879
+ height="13"
473
880
  viewBox="0 0 24 24"
474
881
  fill="none"
475
- stroke="#22d3ee"
882
+ stroke="var(--accent)"
476
883
  stroke-width="2.5"
477
- style="animation: spin 0.8s linear infinite"
478
884
  >
479
885
  <path
480
886
  d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"
@@ -484,106 +890,44 @@
484
890
  </div>
485
891
  </div>
486
892
 
487
- <!-- Kind filter -->
488
- <div
489
- id="kind-filter"
490
- style="
491
- padding: 8px 10px;
492
- border-bottom: 1px solid #1a1f2c;
493
- display: flex;
494
- flex-wrap: wrap;
495
- gap: 4px;
496
- flex-shrink: 0;
497
- "
498
- ></div>
893
+ <!-- Kind filters -->
894
+ <div id="kind-filters"></div>
499
895
 
500
896
  <!-- List header -->
501
- <div
502
- style="
503
- padding: 6px 10px;
504
- display: flex;
505
- align-items: center;
506
- justify-content: space-between;
507
- border-bottom: 1px solid #13161e;
508
- "
509
- >
510
- <span
511
- style="
512
- font-size: 9px;
513
- font-family: Syne;
514
- font-weight: 800;
515
- text-transform: uppercase;
516
- letter-spacing: 0.1em;
517
- color: #2e3547;
518
- "
519
- >memories</span
520
- >
521
- <span
522
- id="list-count"
523
- style="font-size: 9px; font-family: 'JetBrains Mono'; color: #2e3547"
524
- ></span>
897
+ <div id="list-header">
898
+ <span class="h-label">Memories</span>
899
+ <span id="list-count"></span>
525
900
  </div>
526
901
 
527
902
  <!-- Memory list -->
528
- <div
529
- id="memory-list"
530
- style="
531
- flex: 1;
532
- overflow-y: auto;
533
- padding: 6px;
534
- display: flex;
535
- flex-direction: column;
536
- gap: 4px;
537
- "
538
- ></div>
903
+ <div id="memory-list">
904
+ <div class="skel" style="height: 68px; border-radius: 6px"></div>
905
+ <div class="skel" style="height: 56px; border-radius: 6px; margin-top: 3px"></div>
906
+ <div class="skel" style="height: 72px; border-radius: 6px; margin-top: 3px"></div>
907
+ </div>
539
908
  </aside>
540
909
 
541
- <!-- ─── RIGHT PANEL ───────────────────────────────────────── -->
542
- <main style="flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0">
910
+ <!-- ─── MAIN ──────────────────────────────────────────────────── -->
911
+ <main id="main">
543
912
  <!-- Tab bar -->
544
- <div
545
- style="
546
- border-bottom: 1px solid #1a1f2c;
547
- padding: 0 6px;
548
- display: flex;
549
- align-items: center;
550
- flex-shrink: 0;
551
- background: #07080b;
552
- "
553
- >
913
+ <div id="tab-bar">
554
914
  <button class="tab-btn active" data-tab="graph">Graph</button>
555
915
  <button class="tab-btn" data-tab="analytics">Analytics</button>
556
- <div style="flex: 1"></div>
557
- <span
558
- id="tab-info"
559
- style="
560
- font-size: 9px;
561
- font-family: Syne;
562
- font-weight: 700;
563
- color: #2e3547;
564
- padding-right: 14px;
565
- "
566
- ></span>
916
+ <span id="tab-hint"></span>
567
917
  </div>
568
918
 
569
- <!-- Tab content -->
919
+ <!-- Panes wrapper -->
570
920
  <div
571
921
  style="flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0"
572
922
  >
573
923
  <!-- GRAPH TAB -->
574
- <div
575
- id="tab-graph"
576
- style="flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0"
577
- >
578
- <div id="graph-wrap"></div>
924
+ <div id="tab-graph">
925
+ <div id="graph-container"></div>
579
926
  </div>
580
927
 
581
- <!-- ANALYTICS TAB (hidden) -->
582
- <div id="tab-analytics" style="display: none; flex: 1; overflow-y: auto; padding: 12px">
583
- <div
584
- id="analytics-grid"
585
- style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px"
586
- >
928
+ <!-- ANALYTICS TAB -->
929
+ <div id="tab-analytics">
930
+ <div class="a-grid" id="analytics-grid">
587
931
  <div class="skel" style="height: 180px"></div>
588
932
  <div class="skel" style="height: 180px"></div>
589
933
  <div class="skel" style="height: 200px"></div>
@@ -592,122 +936,20 @@
592
936
  </div>
593
937
  </div>
594
938
 
595
- <!-- Detail drawer (hidden by default) -->
596
- <div id="detail-drawer" style="display: none">
597
- <div style="padding: 14px 18px">
598
- <div
599
- style="
600
- display: flex;
601
- justify-content: space-between;
602
- align-items: flex-start;
603
- margin-bottom: 10px;
604
- "
605
- >
606
- <div>
607
- <span
608
- id="d-kind"
609
- style="
610
- font-family: Syne;
611
- font-size: 9px;
612
- font-weight: 800;
613
- text-transform: uppercase;
614
- letter-spacing: 0.08em;
615
- padding: 2px 6px;
616
- border-radius: 3px;
617
- display: inline-block;
618
- margin-bottom: 5px;
619
- "
620
- ></span>
621
- <h3
622
- id="d-title"
623
- style="
624
- font-family: Syne;
625
- font-size: 15px;
626
- font-weight: 700;
627
- color: #dde2ee;
628
- line-height: 1.3;
629
- margin: 0;
630
- "
631
- ></h3>
632
- <div style="display: flex; gap: 14px; margin-top: 5px">
633
- <span id="d-date" style="font-size: 10px; color: #2e3547"></span>
634
- <span id="d-tokens" style="font-size: 10px; color: #333d52"></span>
635
- </div>
636
- </div>
637
- <button
638
- onclick="closeDetail()"
639
- style="
640
- color: #2e3547;
641
- background: none;
642
- border: none;
643
- cursor: pointer;
644
- padding: 3px;
645
- border-radius: 4px;
646
- line-height: 1;
647
- flex-shrink: 0;
648
- transition: color 0.12s;
649
- "
650
- onmouseover="this.style.color = '#7a8299'"
651
- onmouseout="this.style.color = '#2e3547'"
652
- >
653
- <svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24">
654
- <path
655
- stroke-linecap="round"
656
- stroke-linejoin="round"
657
- stroke-width="2.5"
658
- d="M6 18L18 6M6 6l12 12"
659
- />
660
- </svg>
661
- </button>
662
- </div>
663
- <div
664
- id="d-body"
665
- style="
666
- color: #8a91a8;
667
- font-size: 12px;
668
- line-height: 1.75;
669
- white-space: pre-wrap;
670
- margin-bottom: 12px;
671
- max-height: 180px;
672
- overflow-y: auto;
673
- "
674
- ></div>
675
- <div
676
- id="d-footer"
677
- style="
678
- display: flex;
679
- flex-wrap: wrap;
680
- gap: 5px;
681
- padding-top: 10px;
682
- border-top: 1px solid #13161e;
683
- "
684
- ></div>
685
- </div>
939
+ <!-- Detail panel -->
940
+ <div id="detail-panel">
941
+ <div id="detail-inner"></div>
686
942
  </div>
687
943
  </main>
688
944
  </div>
689
945
 
690
- <!-- spin keyframes (for search spinner) -->
691
- <style>
692
- @keyframes spin {
693
- from {
694
- transform: rotate(0deg);
695
- }
696
- to {
697
- transform: rotate(360deg);
698
- }
699
- }
700
- </style>
701
-
702
946
  <script>
703
- // ──────────────────────────────────────────────────────────────────────────────
704
- // State
705
- // ──────────────────────────────────────────────────────────────────────────────
947
+ // ─── State ───────────────────────────────────────────────────────────────────
706
948
  const S = {
707
- data: null, // /api/data response
708
- fullStats: null, // /api/stats/full response
949
+ data: null,
950
+ fullStats: null,
709
951
  activeTab: 'graph',
710
- activeKind: null, // kind filter
952
+ activeKind: 'all',
711
953
  selectedId: null,
712
954
  searchQuery: '',
713
955
  searchResults: null,
@@ -717,574 +959,644 @@
717
959
  analyticsRendered: false,
718
960
  };
719
961
 
720
- // ──────────────────────────────────────────────────────────────────────────────
721
- // Kind → color
722
- // ──────────────────────────────────────────────────────────────────────────────
723
- const KC = {
724
- decision: '#4ade80',
725
- bugfix: '#f87171',
726
- learning: '#fbbf24',
727
- command: '#60a5fa',
728
- file_summary: '#a78bfa',
729
- other: '#52576a',
962
+ // ─── Kind colours (for vis-network nodes) ────────────────────────────────────
963
+ const KIND_COLOR = {
964
+ decision: { bg: '#4f8dff', border: '#2d6edd' },
965
+ learning: { bg: '#9b72f5', border: '#7a50d4' },
966
+ bugfix: { bg: '#ef5858', border: '#d43737' },
967
+ command: { bg: '#f0a638', border: '#d48820' },
968
+ file_summary: { bg: '#22d0c8', border: '#16a8a1' },
969
+ other: { bg: '#4d5870', border: '#3a4560' },
730
970
  };
731
- function kc(kind) {
732
- return KC[kind] ?? '#52576a';
733
- }
734
971
 
735
- // ──────────────────────────────────────────────────────────────────────────────
736
- // Helpers
737
- // ──────────────────────────────────────────────────────────────────────────────
738
- function fmtN(n) {
739
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
740
- if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
741
- return n.toLocaleString();
972
+ // ─── Utilities ────────────────────────────────────────────────────────────────
973
+ function fmtK(n) {
974
+ if (n === undefined || n === null) return '—';
975
+ if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1) + 'M';
976
+ if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1) + 'k';
977
+ return String(n);
742
978
  }
743
979
 
744
980
  function relTime(ts) {
745
- const d = Date.now() - ts;
746
- const s = d / 1000,
747
- m = s / 60,
748
- h = m / 60,
749
- day = h / 24;
750
- if (s < 60) return 'just now';
751
- if (m < 60) return Math.floor(m) + 'm ago';
752
- if (h < 24) return Math.floor(h) + 'h ago';
753
- if (day < 30) return Math.floor(day) + 'd ago';
754
- return new Date(ts).toLocaleDateString();
981
+ if (!ts) return '';
982
+ const d = Date.now() - new Date(ts).getTime();
983
+ const min = Math.floor(d / 60000);
984
+ if (min < 1) return 'just now';
985
+ if (min < 60) return `${min}m ago`;
986
+ const hr = Math.floor(min / 60);
987
+ if (hr < 24) return `${hr}h ago`;
988
+ return `${Math.floor(hr / 24)}d ago`;
755
989
  }
756
990
 
757
- function trunc(s, n) {
758
- return s.length > n ? s.slice(0, n - 1) + '…' : s;
991
+ function kindClass(k) {
992
+ return 'kind-' + (k || 'other').replace(/[^a-z_]/g, '');
759
993
  }
760
994
 
761
- function esc(s) {
762
- return String(s)
763
- .replace(/&/g, '&amp;')
764
- .replace(/</g, '&lt;')
765
- .replace(/>/g, '&gt;')
766
- .replace(/"/g, '&quot;');
995
+ function h(tag, attrs, ...children) {
996
+ const el = document.createElement(tag);
997
+ for (const [k, v] of Object.entries(attrs || {})) {
998
+ if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
999
+ else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
1000
+ else el.setAttribute(k, v);
1001
+ }
1002
+ for (const c of children.flat()) {
1003
+ if (c == null) continue;
1004
+ el.append(typeof c === 'string' ? c : c);
1005
+ }
1006
+ return el;
767
1007
  }
768
1008
 
769
- // ──────────────────────────────────────────────────────────────────────────────
770
- // Boot
771
- // ──────────────────────────────────────────────────────────────────────────────
772
- async function init() {
773
- buildStrategyButtons();
774
- buildKindFilter([]);
775
-
1009
+ // ─── Boot ─────────────────────────────────────────────────────────────────────
1010
+ async function boot() {
1011
+ buildStrategyBtns();
1012
+ buildKindFilters([]);
1013
+ bindTabs();
776
1014
  try {
777
- const r = await fetch('/api/data');
778
- S.data = await r.json();
779
- renderHeaderStats(S.data.stats);
780
- buildKindFilter(Object.keys(KC));
781
- renderList(visibleMemories());
782
- renderGraph(S.data.graph);
783
- updateListCount();
1015
+ const res = await fetch('/api/data');
1016
+ S.data = await res.json();
1017
+ applyData();
784
1018
  } catch (e) {
785
- console.error('init failed', e);
1019
+ document.getElementById('memory-list').innerHTML =
1020
+ '<div class="empty"><div class="empty-title">Could not load data</div>' +
1021
+ '<div class="empty-sub">' +
1022
+ e.message +
1023
+ '</div></div>';
786
1024
  }
787
-
788
- // Load cache hit for header without blocking
789
- loadFullStats().catch(() => {});
790
1025
  }
791
1026
 
792
- // ──────────────────────────────────────────────────────────────────────────────
793
- // Header
794
- // ──────────────────────────────────────────────────────────────────────────────
795
- function renderHeaderStats(stats) {
796
- document.getElementById('project-id').textContent = stats.projectId;
797
- document.getElementById('s-mem').textContent = fmtN(stats.memories);
798
- document.getElementById('s-saved').textContent = fmtN(stats.tokensSaved);
799
- document.getElementById('s-spent').textContent = fmtN(stats.tokensSpent);
800
- const roi =
801
- stats.tokensSpent > 0 ? '×' + (stats.tokensSaved / stats.tokensSpent).toFixed(1) : '—';
1027
+ function applyData() {
1028
+ const { stats, memories, graph } = S.data;
1029
+ document.getElementById('project-label').textContent = stats.projectId;
1030
+ const saved = stats.tokensSaved ?? 0;
1031
+ const spent = stats.tokensSpent ?? 0;
1032
+ const roi = spent > 0 ? (saved / spent).toFixed(1) + 'x' : '—';
1033
+ document.getElementById('s-mem').textContent = fmtK(stats.memories);
1034
+ document.getElementById('s-saved').textContent = fmtK(saved);
1035
+ document.getElementById('s-spent').textContent = fmtK(spent);
802
1036
  document.getElementById('s-roi').textContent = roi;
1037
+ // cache from full stats, placeholder for now
1038
+ document.getElementById('s-cache').textContent = '—';
1039
+
1040
+ buildKindFilters(memories);
1041
+ renderList(memories);
1042
+ buildGraph(graph);
1043
+ // load full stats in background for cache pill
1044
+ loadFullStats();
803
1045
  }
804
1046
 
805
- async function loadFullStats() {
806
- if (S.fullStats) return S.fullStats;
807
- const r = await fetch('/api/stats/full');
808
- S.fullStats = await r.json();
809
- const hr = S.fullStats.cache.hit_rate;
810
- document.getElementById('s-cache').textContent =
811
- hr !== null ? Math.round(hr * 100) + '%' : '—';
812
- return S.fullStats;
813
- }
814
-
815
- // ──────────────────────────────────────────────────────────────────────────────
816
- // Strategy buttons
817
- // ──────────────────────────────────────────────────────────────────────────────
818
- function buildStrategyButtons() {
819
- const el = document.getElementById('strategy-btns');
820
- el.innerHTML = '';
1047
+ // ─── Strategy buttons ─────────────────────────────────────────────────────────
1048
+ function buildStrategyBtns() {
1049
+ const wrap = document.getElementById('strategy-btns');
821
1050
  ['bm25', 'hybrid', 'embeddings'].forEach((s) => {
822
- const b = document.createElement('button');
823
- b.className = 'strat-btn' + (s === S.strategy ? ' active' : '');
824
- b.textContent = s;
825
- b.onclick = () => {
826
- S.strategy = s;
827
- buildStrategyButtons();
828
- if (S.searchQuery) doSearch();
829
- };
830
- el.appendChild(b);
1051
+ const btn = h(
1052
+ 'button',
1053
+ {
1054
+ class: 'strat-btn' + (s === S.strategy ? ' active' : ''),
1055
+ onclick: () => {
1056
+ S.strategy = s;
1057
+ wrap.querySelectorAll('.strat-btn').forEach((b) => b.classList.remove('active'));
1058
+ btn.classList.add('active');
1059
+ if (S.searchQuery) runSearch(S.searchQuery);
1060
+ },
1061
+ },
1062
+ s,
1063
+ );
1064
+ wrap.append(btn);
831
1065
  });
832
1066
  }
833
1067
 
834
- // ──────────────────────────────────────────────────────────────────────────────
835
- // Kind filter
836
- // ──────────────────────────────────────────────────────────────────────────────
837
- function buildKindFilter(kinds) {
838
- const el = document.getElementById('kind-filter');
839
- el.innerHTML = '';
840
- const chips = [
841
- { kind: null, label: 'all' },
842
- ...kinds.map((k) => ({ kind: k, label: k.replace('_', ' ') })),
843
- ];
844
- chips.forEach(({ kind, label }) => {
845
- const b = document.createElement('button');
846
- b.className = 'kind-chip';
847
- b.textContent = label;
848
- const active = S.activeKind === kind;
849
- if (active) {
850
- const c = kind ? kc(kind) : '#4ade80';
851
- b.style.cssText = `background:${c}14;border-color:${c}50;color:${c};`;
852
- }
853
- b.onclick = () => {
854
- S.activeKind = kind;
855
- buildKindFilter(kinds);
856
- renderList(visibleMemories());
857
- updateListCount();
858
- };
859
- el.appendChild(b);
1068
+ // ─── Kind filter chips ────────────────────────────────────────────────────────
1069
+ const KIND_ORDER = [
1070
+ 'all',
1071
+ 'decision',
1072
+ 'learning',
1073
+ 'bugfix',
1074
+ 'command',
1075
+ 'file_summary',
1076
+ 'other',
1077
+ ];
1078
+
1079
+ function buildKindFilters(memories) {
1080
+ const wrap = document.getElementById('kind-filters');
1081
+ wrap.innerHTML = '';
1082
+ const counts = {};
1083
+ memories.forEach((m) => {
1084
+ counts[m.kind] = (counts[m.kind] || 0) + 1;
860
1085
  });
861
- }
862
1086
 
863
- // ──────────────────────────────────────────────────────────────────────────────
864
- // Visible memory list
865
- // ──────────────────────────────────────────────────────────────────────────────
866
- function visibleMemories() {
867
- let mems =
868
- S.searchResults !== null
869
- ? S.searchResults.map((r) => r.observation ?? r)
870
- : (S.data?.memories ?? []);
871
- if (S.activeKind) mems = mems.filter((m) => m.kind === S.activeKind);
872
- return mems;
873
- }
874
-
875
- function updateListCount() {
876
- const vis = visibleMemories().length;
877
- const total = S.data?.stats?.memories ?? 0;
878
- const el = document.getElementById('list-count');
879
- el.textContent = S.searchQuery || S.activeKind ? `${vis} / ${total}` : `${total}`;
880
- }
881
-
882
- // ──────────────────────────────────────────────────────────────────────────────
883
- // Memory list
884
- // ──────────────────────────────────────────────────────────────────────────────
885
- function renderList(mems) {
886
- const el = document.getElementById('memory-list');
887
- if (!mems.length) {
888
- el.innerHTML = `<div class="empty">
889
- <svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24">
890
- <circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
891
- </svg>
892
- <span>${S.searchQuery ? 'no results' : 'no memories'}</span>
893
- </div>`;
894
- return;
895
- }
1087
+ const kinds = [
1088
+ 'all',
1089
+ ...Object.keys(counts).sort((a, b) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b)),
1090
+ ];
896
1091
 
897
- el.innerHTML = '';
898
- mems.forEach((m) => {
899
- const card = document.createElement('div');
900
- card.className = 'mem-card fade-up' + (S.selectedId === m.id ? ' selected' : '');
901
- card.dataset.id = m.id;
902
-
903
- const c = kc(m.kind);
904
- const tags = (m.tags ?? [])
905
- .slice(0, 3)
906
- .map(
907
- (t) =>
908
- `<span style="font-size:9px;background:#131720;border:1px solid #1a1f2c;color:#3d4757;padding:1px 5px;border-radius:3px;">${esc(t)}</span>`,
909
- )
910
- .join('');
911
- const score =
912
- m.score != null
913
- ? `<span style="font-size:9px;color:#2e3547;font-family:JetBrains Mono;">↑${m.score.toFixed(2)}</span>`
914
- : '';
915
-
916
- card.innerHTML = `
917
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:5px;padding-right:20px;">
918
- <span style="font-size:9px;font-family:Syne;font-weight:800;text-transform:uppercase;letter-spacing:0.06em;padding:1px 5px;border-radius:3px;background:${c}12;color:${c};border:1px solid ${c}28;">${esc(m.kind.replace('_', ' '))}</span>
919
- <div style="display:flex;align-items:center;gap:6px;">${score}<span style="font-size:9px;color:#2e3547;">${relTime(m.created_at)}</span></div>
920
- </div>
921
- <div style="font-size:12px;font-weight:600;color:#b8bfd4;line-height:1.35;margin-bottom:${tags ? '6px' : '0'};font-family:Syne;padding-right:4px;">${esc(trunc(m.title, 65))}</div>
922
- ${tags ? `<div style="display:flex;flex-wrap:wrap;gap:3px;">${tags}</div>` : ''}
923
- <button class="forget-btn" onclick="event.stopPropagation();forgetMemory('${esc(m.id)}')">✕</button>
924
- `;
925
- card.onclick = () => selectMemory(m.id);
926
- el.appendChild(card);
1092
+ kinds.forEach((k) => {
1093
+ const isAll = k === 'all';
1094
+ const label = isAll ? 'All' : k.replace('_', ' ');
1095
+ const count = isAll ? memories.length : counts[k] || 0;
1096
+ const btn = h(
1097
+ 'div',
1098
+ {
1099
+ class: 'kind-tag' + (S.activeKind === k ? ' active kf-' + k : ''),
1100
+ onclick: () => {
1101
+ S.activeKind = k;
1102
+ wrap.querySelectorAll('.kind-tag').forEach((b) => {
1103
+ b.className = 'kind-tag';
1104
+ });
1105
+ btn.className = 'kind-tag active kf-' + k;
1106
+ renderList(S.searchResults ?? S.data?.memories ?? []);
1107
+ },
1108
+ },
1109
+ label + (count > 0 ? ' ' + count : ''),
1110
+ );
1111
+ wrap.append(btn);
927
1112
  });
928
1113
  }
929
1114
 
930
- // ──────────────────────────────────────────────────────────────────────────────
931
- // Search
932
- // ──────────────────────────────────────────────────────────────────────────────
933
- document.getElementById('search-input').addEventListener('input', (e) => {
934
- S.searchQuery = e.target.value.trim();
935
- clearTimeout(S.debounce);
936
- if (!S.searchQuery) {
937
- S.searchResults = null;
938
- renderList(visibleMemories());
939
- updateListCount();
940
- document.getElementById('tab-info').textContent = '';
1115
+ // ─── Memory list ──────────────────────────────────────────────────────────────
1116
+ function renderList(memories) {
1117
+ const list = document.getElementById('memory-list');
1118
+ const filtered =
1119
+ S.activeKind === 'all' ? memories : memories.filter((m) => m.kind === S.activeKind);
1120
+
1121
+ document.getElementById('list-count').textContent =
1122
+ filtered.length !== memories.length
1123
+ ? filtered.length + ' / ' + memories.length
1124
+ : memories.length.toString();
1125
+
1126
+ if (filtered.length === 0) {
1127
+ list.innerHTML = '';
1128
+ list.append(
1129
+ h(
1130
+ 'div',
1131
+ { class: 'empty' },
1132
+ h('div', { class: 'empty-icon' }, svgIcon('search')),
1133
+ h('div', { class: 'empty-title' }, S.searchQuery ? 'No results' : 'No memories yet'),
1134
+ h(
1135
+ 'div',
1136
+ { class: 'empty-sub' },
1137
+ S.searchQuery
1138
+ ? 'Try a different query or strategy.'
1139
+ : 'End a Claude session — the hook will extract observations automatically.',
1140
+ ),
1141
+ ),
1142
+ );
941
1143
  return;
942
1144
  }
943
- S.debounce = setTimeout(doSearch, 320);
944
- });
945
1145
 
946
- async function doSearch() {
947
- if (!S.searchQuery) return;
948
- const spinner = document.getElementById('search-spinner');
949
- spinner.style.display = 'block';
950
- try {
951
- const url = `/api/search?q=${encodeURIComponent(S.searchQuery)}&strategy=${S.strategy}&k=20`;
952
- const r = await fetch(url);
953
- const d = await r.json();
954
- S.searchResults = d.results ?? [];
955
- // Attach score to each result's observation for display
956
- S.searchResults = S.searchResults.map((r) => ({
957
- ...r.observation,
958
- score: r.score,
959
- id: r.id,
960
- }));
961
- renderList(visibleMemories());
962
- updateListCount();
963
- document.getElementById('tab-info').textContent =
964
- `${S.searchResults.length} hits via ${d.strategy}`;
965
- } catch (e) {
966
- console.error('search error', e);
967
- } finally {
968
- spinner.style.display = 'none';
969
- }
1146
+ list.innerHTML = '';
1147
+ filtered.forEach((m) => list.append(memCard(m)));
970
1148
  }
971
1149
 
972
- // ──────────────────────────────────────────────────────────────────────────────
973
- // Graph
974
- // ──────────────────────────────────────────────────────────────────────────────
975
- function renderGraph(graph) {
976
- const wrap = document.getElementById('graph-wrap');
977
- if (!graph?.nodes?.length) {
978
- wrap.innerHTML = `<div class="empty" style="height:100%;">
979
- <svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="opacity:0.2;">
980
- <circle cx="5" cy="12" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="12" r="2"/>
981
- <line x1="7" y1="12" x2="10" y2="12"/><line x1="12" y1="7" x2="12" y2="10"/>
982
- <line x1="12" y1="14" x2="12" y2="17"/><line x1="14" y1="12" x2="17" y2="12"/>
983
- </svg>
984
- <span>no connections yet</span>
985
- </div>`;
986
- return;
1150
+ function memCard(m) {
1151
+ const card = h('div', {
1152
+ class: 'mem-card fade-in' + (m.id === S.selectedId ? ' selected' : ''),
1153
+ onclick: () => selectMemory(m),
1154
+ });
1155
+
1156
+ // Left accent color
1157
+ const kc = KIND_COLOR[m.kind] || KIND_COLOR.other;
1158
+ card.style.setProperty('--card-accent', kc.bg);
1159
+ if (m.id === S.selectedId) {
1160
+ card.style.borderLeftColor = kc.bg;
987
1161
  }
1162
+ card.style.cssText += `--before-bg: ${kc.bg};`;
988
1163
 
989
- const nodes = new vis.DataSet(
990
- graph.nodes.map((n) => ({
991
- ...n,
992
- font: { color: '#636b85', face: 'JetBrains Mono', size: 10.5 },
993
- shape: 'dot',
994
- size: 9,
995
- borderWidth: 1.5,
996
- borderWidthSelected: 3,
997
- color: {
998
- background: kc(n.kind),
999
- border: '#1a1f2c',
1000
- highlight: { background: kc(n.kind), border: '#dde2ee' },
1001
- hover: { background: kc(n.kind), border: '#252d3e' },
1164
+ const forget = h(
1165
+ 'div',
1166
+ {
1167
+ class: 'mc-forget',
1168
+ title: 'Forget this memory',
1169
+ onclick: async (e) => {
1170
+ e.stopPropagation();
1171
+ if (!confirm('Remove this memory?')) return;
1172
+ await fetch('/api/memories/' + m.id, { method: 'DELETE' });
1173
+ S.data.memories = S.data.memories.filter((x) => x.id !== m.id);
1174
+ if (S.selectedId === m.id) {
1175
+ S.selectedId = null;
1176
+ hideDetail();
1177
+ }
1178
+ buildKindFilters(S.data.memories);
1179
+ renderList(S.searchResults ?? S.data.memories);
1180
+ if (S.network) {
1181
+ try {
1182
+ S.network.getBody().data.nodes.remove(m.id);
1183
+ } catch {}
1184
+ }
1185
+ document.getElementById('s-mem').textContent = fmtK(S.data.memories.length);
1002
1186
  },
1003
- })),
1187
+ },
1188
+ '×',
1004
1189
  );
1005
1190
 
1006
- const edges = new vis.DataSet(
1007
- graph.edges.map((e) => ({
1008
- ...e,
1009
- color: { color: '#1a1f2c', opacity: 0.7, highlight: '#4ade80' },
1010
- width: 1,
1011
- smooth: { type: 'curvedCW', roundness: 0.08 },
1012
- })),
1191
+ const head = h(
1192
+ 'div',
1193
+ { class: 'mc-head' },
1194
+ h('span', { class: 'mc-title' }, m.title),
1195
+ h(
1196
+ 'span',
1197
+ { class: 'mc-kind ' + kindClass(m.kind) },
1198
+ (m.kind || 'other').replace('_', ' '),
1199
+ ),
1013
1200
  );
1014
1201
 
1015
- S.network = new vis.Network(
1016
- wrap,
1017
- { nodes, edges },
1018
- {
1019
- physics: {
1020
- forceAtlas2Based: {
1021
- gravitationalConstant: -42,
1022
- centralGravity: 0.012,
1023
- springLength: 130,
1024
- springConstant: 0.055,
1025
- },
1026
- maxVelocity: 50,
1027
- solver: 'forceAtlas2Based',
1028
- timestep: 0.35,
1029
- stabilization: { iterations: 130 },
1030
- },
1031
- interaction: { hover: true, tooltipDelay: 120 },
1032
- },
1202
+ const preview = m.body ? h('div', { class: 'mc-body' }, m.body) : null;
1203
+
1204
+ const tags = (m.tags || []).slice(0, 3).map((t) => h('span', { class: 'mc-tag' }, t));
1205
+ const footer = h(
1206
+ 'div',
1207
+ { class: 'mc-footer' },
1208
+ h('span', { class: 'mc-time' }, relTime(m.created_at)),
1209
+ h('div', { class: 'mc-tags' }, ...tags),
1033
1210
  );
1034
1211
 
1035
- S.network.on('click', (params) => {
1036
- if (params.nodes.length) selectMemory(params.nodes[0]);
1037
- });
1212
+ card.append(forget, head);
1213
+ if (preview) card.append(preview);
1214
+ card.append(footer);
1215
+ return card;
1038
1216
  }
1039
1217
 
1040
- // ──────────────────────────────────────────────────────────────────────────────
1041
- // Select memory (syncs list ↔ graph ↔ detail)
1042
- // ──────────────────────────────────────────────────────────────────────────────
1043
- function selectMemory(id) {
1044
- S.selectedId = id;
1045
- document
1046
- .querySelectorAll('.mem-card')
1047
- .forEach((c) => c.classList.toggle('selected', c.dataset.id === id));
1048
- const card = document.querySelector(`.mem-card[data-id="${id}"]`);
1049
- if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1218
+ // ─── Detail panel ─────────────────────────────────────────────────────────────
1219
+ function selectMemory(m) {
1220
+ S.selectedId = m.id;
1221
+ document.querySelectorAll('.mem-card').forEach((c) => c.classList.remove('selected'));
1222
+ const cards = document.querySelectorAll('.mem-card');
1223
+ // find by checking onclick would match but instead we just re-render is expensive
1224
+ // so find by matching title as a quick approach
1225
+ renderList(S.searchResults ?? S.data?.memories ?? []);
1226
+ showDetail(m);
1050
1227
  if (S.network) {
1051
1228
  try {
1052
- S.network.selectNodes([id], true);
1053
- S.network.focus(id, {
1054
- animation: { duration: 350, easingFunction: 'easeInOutQuad' },
1055
- scale: 1.2,
1229
+ S.network.selectNodes([m.id]);
1230
+ S.network.focus(m.id, {
1231
+ animation: { duration: 400, easingFunction: 'easeInOutQuad' },
1056
1232
  });
1057
1233
  } catch {}
1058
1234
  }
1059
- const obs = findMemory(id);
1060
- if (obs) showDetail(obs);
1061
1235
  }
1062
1236
 
1063
- function findMemory(id) {
1064
- return (
1065
- S.data?.memories?.find((m) => m.id === id) ??
1066
- S.searchResults?.find((m) => m.id === id) ??
1067
- null
1068
- );
1069
- }
1237
+ function showDetail(m) {
1238
+ const panel = document.getElementById('detail-panel');
1239
+ const inner = document.getElementById('detail-inner');
1240
+ panel.style.display = 'block';
1070
1241
 
1071
- // ──────────────────────────────────────────────────────────────────────────────
1072
- // Detail drawer
1073
- // ──────────────────────────────────────────────────────────────────────────────
1074
- function showDetail(obs) {
1075
- const drawer = document.getElementById('detail-drawer');
1076
- drawer.style.display = 'block';
1077
- const c = kc(obs.kind);
1078
- const kindEl = document.getElementById('d-kind');
1079
- kindEl.textContent = obs.kind.replace('_', ' ');
1080
- kindEl.style.cssText = `font-family:Syne;font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;padding:2px 6px;border-radius:3px;display:inline-block;margin-bottom:5px;background:${c}12;color:${c};border:1px solid ${c}28;`;
1081
- document.getElementById('d-title').textContent = obs.title;
1082
- document.getElementById('d-date').textContent = new Date(obs.created_at).toLocaleString();
1083
- const ts = obs.tokens_saved ?? 0;
1084
- document.getElementById('d-tokens').textContent = ts ? fmtN(ts) + ' tokens saved' : '';
1085
- document.getElementById('d-body').textContent = obs.body;
1086
-
1087
- const footer = document.getElementById('d-footer');
1088
- footer.innerHTML = '';
1089
- (obs.files ?? []).forEach((f) => {
1090
- const s = document.createElement('span');
1091
- s.style.cssText =
1092
- 'font-size:10px;font-family:JetBrains Mono;background:#131720;border:1px solid #1a1f2c;color:#4a5168;padding:2px 7px;border-radius:3px;';
1093
- s.textContent = f;
1094
- footer.appendChild(s);
1095
- });
1096
- (obs.tags ?? []).forEach((t) => {
1097
- const s = document.createElement('span');
1098
- s.style.cssText =
1099
- 'font-size:9.5px;font-family:Syne;font-weight:700;background:#0a0c10;border:1px solid #1a1f2c;color:#2e3547;padding:2px 7px;border-radius:3px;';
1100
- s.textContent = '#' + t;
1101
- footer.appendChild(s);
1242
+ const kc = KIND_COLOR[m.kind] || KIND_COLOR.other;
1243
+ const saved = m.tokens_saved ? fmtK(m.tokens_saved) + ' tk saved' : '';
1244
+ const spent = m.tokens_spent ? fmtK(m.tokens_spent) + ' tk spent' : '';
1245
+
1246
+ const fileChips = (m.files || []).map((f) => {
1247
+ const short = f.split('/').slice(-2).join('/');
1248
+ return h('span', { class: 'detail-chip file', title: f }, short);
1102
1249
  });
1103
- drawer.scrollTop = 0;
1250
+ const tagChips = (m.tags || []).map((t) => h('span', { class: 'detail-chip' }, t));
1251
+
1252
+ inner.innerHTML = '';
1253
+ inner.append(
1254
+ h(
1255
+ 'div',
1256
+ { class: 'detail-head' },
1257
+ h('span', { class: 'detail-title' }, m.title),
1258
+ h(
1259
+ 'span',
1260
+ { class: 'mc-kind ' + kindClass(m.kind) },
1261
+ (m.kind || 'other').replace('_', ' '),
1262
+ ),
1263
+ h(
1264
+ 'div',
1265
+ {
1266
+ class: 'detail-close',
1267
+ title: 'Close',
1268
+ onclick: () => {
1269
+ S.selectedId = null;
1270
+ hideDetail();
1271
+ renderList(S.searchResults ?? S.data?.memories ?? []);
1272
+ },
1273
+ },
1274
+ h(
1275
+ 'svg',
1276
+ {
1277
+ width: '10',
1278
+ height: '10',
1279
+ viewBox: '0 0 10 10',
1280
+ fill: 'none',
1281
+ stroke: 'currentColor',
1282
+ 'stroke-width': '1.6',
1283
+ 'stroke-linecap': 'round',
1284
+ },
1285
+ h('path', { d: 'M1 1l8 8M9 1L1 9' }),
1286
+ ),
1287
+ ),
1288
+ ),
1289
+ h(
1290
+ 'div',
1291
+ { class: 'detail-meta' },
1292
+ h('span', {}, relTime(m.created_at)),
1293
+ saved ? h('span', { style: 'color:var(--accent-g)' }, saved) : null,
1294
+ spent ? h('span', {}, spent) : null,
1295
+ ),
1296
+ h('div', { class: 'detail-body' }, m.body || ''),
1297
+ fileChips.length || tagChips.length
1298
+ ? h('div', { class: 'detail-chips' }, ...fileChips, ...tagChips)
1299
+ : null,
1300
+ );
1104
1301
  }
1105
1302
 
1106
- function closeDetail() {
1107
- document.getElementById('detail-drawer').style.display = 'none';
1108
- S.selectedId = null;
1109
- document.querySelectorAll('.mem-card').forEach((c) => c.classList.remove('selected'));
1110
- if (S.network)
1303
+ function hideDetail() {
1304
+ document.getElementById('detail-panel').style.display = 'none';
1305
+ if (S.network) {
1111
1306
  try {
1112
1307
  S.network.unselectAll();
1113
1308
  } catch {}
1309
+ }
1114
1310
  }
1115
1311
 
1116
- // ──────────────────────────────────────────────────────────────────────────────
1117
- // Forget
1118
- // ──────────────────────────────────────────────────────────────────────────────
1119
- async function forgetMemory(id) {
1312
+ // ─── Search ────────────────────────────────────────────────────────────────────
1313
+ document.getElementById('search-input').addEventListener('input', (e) => {
1314
+ const q = e.target.value.trim();
1315
+ S.searchQuery = q;
1316
+ clearTimeout(S.debounce);
1317
+ if (!q) {
1318
+ S.searchResults = null;
1319
+ renderList(S.data?.memories ?? []);
1320
+ return;
1321
+ }
1322
+ S.debounce = setTimeout(() => runSearch(q), 320);
1323
+ });
1324
+
1325
+ async function runSearch(q) {
1326
+ const spin = document.getElementById('search-spinner');
1327
+ spin.style.display = 'block';
1120
1328
  try {
1121
- const r = await fetch(`/api/memories/${id}`, { method: 'DELETE' });
1122
- const d = await r.json();
1123
- if (!d.ok) return;
1124
- if (S.data) {
1125
- S.data.memories = S.data.memories.filter((m) => m.id !== id);
1126
- S.data.stats.memories = S.data.memories.length;
1127
- document.getElementById('s-mem').textContent = fmtN(S.data.stats.memories);
1128
- }
1129
- if (S.searchResults) S.searchResults = S.searchResults.filter((m) => m.id !== id);
1130
- if (S.selectedId === id) closeDetail();
1131
- renderList(visibleMemories());
1132
- updateListCount();
1133
- if (S.network)
1134
- try {
1135
- S.network.body.data.nodes.remove(id);
1136
- } catch {}
1137
- } catch (e) {
1138
- console.error('forget failed', e);
1329
+ const res = await fetch(
1330
+ `/api/search?q=${encodeURIComponent(q)}&strategy=${S.strategy}&k=20`,
1331
+ );
1332
+ const json = await res.json();
1333
+ S.searchResults = json.results;
1334
+ document.getElementById('tab-hint').textContent =
1335
+ `${json.strategy} · ${json.results.length} results`;
1336
+ renderList(json.results);
1337
+ } catch {
1338
+ S.searchResults = null;
1339
+ renderList(S.data?.memories ?? []);
1340
+ } finally {
1341
+ spin.style.display = 'none';
1139
1342
  }
1140
1343
  }
1141
1344
 
1142
- // ──────────────────────────────────────────────────────────────────────────────
1143
- // Tabs
1144
- // ──────────────────────────────────────────────────────────────────────────────
1145
- document.querySelectorAll('.tab-btn').forEach((btn) => {
1146
- btn.addEventListener('click', () => {
1147
- const tab = btn.dataset.tab;
1148
- S.activeTab = tab;
1149
- document
1150
- .querySelectorAll('.tab-btn')
1151
- .forEach((b) => b.classList.toggle('active', b.dataset.tab === tab));
1152
- document.getElementById('tab-graph').style.display = tab === 'graph' ? 'flex' : 'none';
1153
- document.getElementById('tab-analytics').style.display =
1154
- tab === 'analytics' ? 'block' : 'none';
1155
- if (tab === 'analytics' && !S.analyticsRendered) openAnalytics();
1156
- if (tab !== 'graph') document.getElementById('tab-info').textContent = '';
1345
+ // ─── Tabs ─────────────────────────────────────────────────────────────────────
1346
+ function bindTabs() {
1347
+ document.querySelectorAll('.tab-btn[data-tab]').forEach((btn) => {
1348
+ btn.addEventListener('click', () => {
1349
+ const t = btn.dataset.tab;
1350
+ S.activeTab = t;
1351
+ document
1352
+ .querySelectorAll('.tab-btn[data-tab]')
1353
+ .forEach((b) => b.classList.remove('active'));
1354
+ btn.classList.add('active');
1355
+ document.getElementById('tab-graph').style.display = t === 'graph' ? 'flex' : 'none';
1356
+ document.getElementById('tab-analytics').style.display =
1357
+ t === 'analytics' ? 'flex' : 'none';
1358
+ if (t === 'analytics' && !S.analyticsRendered && S.fullStats) {
1359
+ renderAnalytics(S.fullStats);
1360
+ }
1361
+ });
1157
1362
  });
1158
- });
1363
+ }
1159
1364
 
1160
- // ──────────────────────────────────────────────────────────────────────────────
1161
- // Analytics
1162
- // ──────────────────────────────────────────────────────────────────────────────
1163
- async function openAnalytics() {
1164
- try {
1165
- const stats = await loadFullStats();
1166
- renderAnalytics(stats);
1167
- S.analyticsRendered = true;
1168
- } catch (e) {
1169
- document.getElementById('analytics-grid').innerHTML =
1170
- '<div class="empty" style="grid-column:span 2;">failed to load analytics</div>';
1365
+ // ─── Knowledge graph ──────────────────────────────────────────────────────────
1366
+ function buildGraph({ nodes, edges }) {
1367
+ const container = document.getElementById('graph-container');
1368
+ if (!nodes?.length) {
1369
+ container.innerHTML =
1370
+ '<div class="empty" style="height:100%">' +
1371
+ svgIcon('graph') +
1372
+ '<div class="empty-title">No graph yet</div>' +
1373
+ '<div class="empty-sub">Memories with shared files or tags will appear here.</div></div>';
1374
+ return;
1171
1375
  }
1172
- }
1173
1376
 
1174
- function renderAnalytics({ kindBreakdown, topFiles, retrieval, cache }) {
1175
- const grid = document.getElementById('analytics-grid');
1377
+ const vNodes = new vis.DataSet(
1378
+ nodes.map((n) => {
1379
+ const kc = KIND_COLOR[n.kind] || KIND_COLOR.other;
1380
+ return {
1381
+ id: n.id,
1382
+ label: n.label,
1383
+ title: n.title,
1384
+ color: {
1385
+ background: kc.bg,
1386
+ border: kc.border,
1387
+ highlight: { background: kc.bg, border: '#fff' },
1388
+ },
1389
+ font: { color: '#dde1ea', size: 11, face: 'Bricolage Grotesque' },
1390
+ shape: 'dot',
1391
+ size: 10,
1392
+ borderWidth: 1.5,
1393
+ };
1394
+ }),
1395
+ );
1176
1396
 
1177
- // ── Kind breakdown ───────────────────────────────────────────
1178
- const total = Object.values(kindBreakdown).reduce((a, b) => a + b, 0);
1179
- const maxKind = Math.max(...Object.values(kindBreakdown), 1);
1180
- const kindRows = Object.entries(kindBreakdown)
1181
- .filter(([, n]) => n > 0)
1182
- .sort(([, a], [, b]) => b - a)
1183
- .map(([kind, n]) => {
1184
- const c = kc(kind);
1185
- return `
1186
- <div style="display:flex;align-items:center;gap:8px;padding:4px 0;">
1187
- <span style="font-size:9.5px;font-family:Syne;font-weight:800;width:76px;color:${c};flex-shrink:0;letter-spacing:0.02em;">${kind.replace('_', ' ')}</span>
1188
- <div class="bar-track"><div class="bar-fill" data-w="${Math.round((n / maxKind) * 100)}" style="background:${c};"></div></div>
1189
- <span style="font-size:11px;font-family:JetBrains Mono;color:#3d4757;width:24px;text-align:right;">${n}</span>
1190
- </div>`;
1191
- })
1192
- .join('');
1193
-
1194
- // ── Cache stats ──────────────────────────────────────────────
1195
- const hr = cache.hit_rate;
1196
- const hrPct = hr !== null ? Math.round(hr * 100) : null;
1197
- const cColor =
1198
- hrPct !== null
1199
- ? hrPct >= 60
1200
- ? '#4ade80'
1201
- : hrPct >= 30
1202
- ? '#fbbf24'
1203
- : '#f87171'
1204
- : '#52576a';
1205
- const cacheBar =
1206
- hrPct !== null
1207
- ? `<div style="margin-top:12px;"><div style="display:flex;justify-content:space-between;margin-bottom:4px;"><span style="font-size:9px;font-family:Syne;font-weight:800;color:#2e3547;text-transform:uppercase;letter-spacing:0.08em;">hit rate</span><span style="font-size:10px;font-family:JetBrains Mono;color:${cColor};">${hrPct}%</span></div><div class="bar-track" style="height:5px;"><div class="bar-fill" data-w="${hrPct}" style="background:${cColor};height:5px;"></div></div></div>`
1208
- : '';
1209
-
1210
- // ── Retrieval usage ──────────────────────────────────────────
1211
- const maxRetr = Math.max(...retrieval.map((r) => r.call_count), 1);
1212
- const retriRows = retrieval.length
1213
- ? retrieval
1214
- .map(
1215
- (r) => `
1216
- <div style="display:flex;align-items:center;gap:8px;padding:4px 0;">
1217
- <span style="font-size:9.5px;font-family:Syne;font-weight:800;color:#22d3ee;width:70px;flex-shrink:0;">${r.strategy}</span>
1218
- <div class="bar-track"><div class="bar-fill" data-w="${Math.round((r.call_count / maxRetr) * 100)}" style="background:#22d3ee88;"></div></div>
1219
- <span style="font-size:11px;font-family:JetBrains Mono;color:#3d4757;width:24px;text-align:right;">${r.call_count}</span>
1220
- </div>`,
1221
- )
1222
- .join('')
1223
- : '<div style="font-size:10px;font-family:Syne;font-weight:700;color:#2e3547;">no retrieval calls recorded</div>';
1224
-
1225
- // ── Top files ────────────────────────────────────────────────
1226
- const fileRows = topFiles.length
1227
- ? topFiles
1228
- .slice(0, 14)
1229
- .map(
1230
- (f) => `
1231
- <div class="file-row">
1232
- <span style="font-size:10px;font-family:JetBrains Mono;color:#7a8299;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(f.file)}">${esc(f.file)}</span>
1233
- <span style="font-size:11px;font-family:JetBrains Mono;color:#3d4757;flex-shrink:0;padding-left:8px;">${f.count}</span>
1234
- </div>`,
1235
- )
1236
- .join('')
1237
- : '<div style="font-size:10px;font-family:Syne;font-weight:700;color:#2e3547;">no file references</div>';
1238
-
1239
- grid.innerHTML = `
1240
- <div class="a-card">
1241
- <div class="a-title">memories by kind ${total ? `<span style="color:#1e2330;font-weight:600;">· ${total}</span>` : ''}</div>
1242
- ${total ? kindRows : '<div style="font-size:10px;font-family:Syne;font-weight:700;color:#2e3547;">no memories yet</div>'}
1243
- </div>
1397
+ const vEdges = new vis.DataSet(
1398
+ edges.map((e, i) => ({
1399
+ id: i,
1400
+ from: e.from,
1401
+ to: e.to,
1402
+ color: { color: '#1f2535', highlight: '#4f8dff', opacity: 0.8 },
1403
+ width: 1,
1404
+ smooth: { type: 'dynamic' },
1405
+ })),
1406
+ );
1244
1407
 
1245
- <div class="a-card">
1246
- <div class="a-title">prompt cache</div>
1247
- <div style="display:flex;gap:16px;align-items:flex-start;">
1248
- <div>
1249
- <div style="font-family:Syne;font-size:32px;font-weight:800;color:${cColor};line-height:1;">${hrPct !== null ? hrPct + '%' : '—'}</div>
1250
- <div style="font-size:8.5px;font-family:Syne;font-weight:800;color:#2e3547;text-transform:uppercase;letter-spacing:0.1em;margin-top:3px;">hit rate</div>
1251
- </div>
1252
- <div style="border-left:1px solid #1a1f2c;padding-left:16px;display:flex;flex-direction:column;gap:5px;margin-top:3px;">
1253
- <div style="font-family:JetBrains Mono;font-size:12px;color:#b8bfd4;">${cache.hit_count.toLocaleString()} <span style="color:#4ade80;font-size:9.5px;">hits</span></div>
1254
- <div style="font-family:JetBrains Mono;font-size:12px;color:#b8bfd4;">${cache.miss_count.toLocaleString()} <span style="color:#f87171;font-size:9.5px;">misses</span></div>
1255
- <div style="font-family:JetBrains Mono;font-size:12px;color:#b8bfd4;">${cache.entry_count.toLocaleString()} <span style="color:#52576a;font-size:9.5px;">entries</span></div>
1256
- </div>
1257
- </div>
1258
- ${cacheBar}
1259
- </div>
1408
+ S.network = new vis.Network(
1409
+ container,
1410
+ { nodes: vNodes, edges: vEdges },
1411
+ {
1412
+ physics: {
1413
+ stabilization: { iterations: 100 },
1414
+ barnesHut: { gravitationalConstant: -2000, springLength: 120 },
1415
+ },
1416
+ interaction: { hover: true, tooltipDelay: 200 },
1417
+ nodes: { borderWidthSelected: 2 },
1418
+ },
1419
+ );
1260
1420
 
1261
- <div class="a-card">
1262
- <div class="a-title">retrieval usage</div>
1263
- ${retriRows}
1264
- </div>
1421
+ S.network.on('click', ({ nodes: selected }) => {
1422
+ if (!selected.length) return;
1423
+ const id = selected[0];
1424
+ const mem = S.data?.memories?.find((m) => m.id === id);
1425
+ if (mem) {
1426
+ S.selectedId = id;
1427
+ renderList(S.searchResults ?? S.data?.memories ?? []);
1428
+ showDetail(mem);
1429
+ }
1430
+ });
1265
1431
 
1266
- <div class="a-card">
1267
- <div class="a-title">top files <span style="color:#1e2330;font-weight:600;">· by reference count</span></div>
1268
- ${fileRows}
1269
- </div>
1270
- `;
1432
+ document.getElementById('tab-hint').textContent =
1433
+ `${nodes.length} nodes · ${edges.length} edges`;
1434
+ }
1271
1435
 
1272
- // Animate bars on next frame
1273
- requestAnimationFrame(() => {
1274
- document.querySelectorAll('.bar-fill[data-w]').forEach((el) => {
1275
- const w = el.dataset.w;
1276
- el.style.width = '0';
1436
+ // ─── Full stats ────────────────────────────────────────────────────────────────
1437
+ async function loadFullStats() {
1438
+ try {
1439
+ const res = await fetch('/api/stats/full');
1440
+ S.fullStats = await res.json();
1441
+ // Update cache pill
1442
+ const hit = S.fullStats.cache?.hit_rate;
1443
+ document.getElementById('s-cache').textContent =
1444
+ hit != null ? (hit * 100).toFixed(0) + '%' : '—';
1445
+ if (S.activeTab === 'analytics') renderAnalytics(S.fullStats);
1446
+ } catch {}
1447
+ }
1448
+
1449
+ function renderAnalytics(fs) {
1450
+ S.analyticsRendered = true;
1451
+ const grid = document.getElementById('analytics-grid');
1452
+ grid.innerHTML = '';
1453
+
1454
+ // 1. Kind breakdown
1455
+ const kb = fs.kindBreakdown || {};
1456
+ const kbMax = Math.max(...Object.values(kb).map(Number), 1);
1457
+ const kbCard = card('Memory kinds');
1458
+ Object.entries(kb)
1459
+ .sort((a, b) => b[1] - a[1])
1460
+ .forEach(([kind, count]) => {
1461
+ const kc = KIND_COLOR[kind] || KIND_COLOR.other;
1462
+ const row = h(
1463
+ 'div',
1464
+ { class: 'bar-row' },
1465
+ h(
1466
+ 'span',
1467
+ { class: 'bar-label', style: 'font-size:11.5px;font-weight:600' },
1468
+ kind.replace('_', ' '),
1469
+ ),
1470
+ h(
1471
+ 'div',
1472
+ { class: 'bar-track' },
1473
+ h('div', { class: 'bar-fill', style: `background:${kc.bg}` }),
1474
+ ),
1475
+ h('span', { class: 'bar-count' }, String(count)),
1476
+ );
1477
+ kbCard.append(row);
1277
1478
  requestAnimationFrame(() => {
1278
- el.style.width = w + '%';
1479
+ row.querySelector('.bar-fill').style.width = (count / kbMax) * 100 + '%';
1279
1480
  });
1280
1481
  });
1482
+ if (!Object.keys(kb).length)
1483
+ kbCard.append(
1484
+ h('div', { class: 'empty' }, h('div', { class: 'empty-sub' }, 'No data yet')),
1485
+ );
1486
+ grid.append(wrap(kbCard));
1487
+
1488
+ // 2. Cache performance
1489
+ const cc = fs.cache || {};
1490
+ const cacheCard = card('Cache performance');
1491
+ [
1492
+ [
1493
+ 'Hit rate',
1494
+ cc.hit_rate != null ? (cc.hit_rate * 100).toFixed(1) + '%' : '—',
1495
+ 'var(--accent-g)',
1496
+ ],
1497
+ ['Hits', cc.hit_count ?? 0, 'var(--accent-g)'],
1498
+ ['Misses', cc.miss_count ?? 0, 'var(--muted2)'],
1499
+ ['Entries', cc.entry_count ?? 0, 'var(--accent)'],
1500
+ ].forEach(([lbl, val, color]) => {
1501
+ cacheCard.append(
1502
+ h(
1503
+ 'div',
1504
+ { class: 'cache-stat' },
1505
+ h('span', { class: 'cache-stat-lbl' }, lbl),
1506
+ h('span', { class: 'cache-stat-val', style: `color:${color}` }, String(val)),
1507
+ ),
1508
+ );
1509
+ });
1510
+ grid.append(wrap(cacheCard));
1511
+
1512
+ // 3. Retrieval usage
1513
+ const ru = fs.retrieval || [];
1514
+ const ruMax = Math.max(...ru.map((r) => r.call_count), 1);
1515
+ const ruCard = card('Retrieval strategy');
1516
+ ru.sort((a, b) => b.call_count - a.call_count).forEach((r) => {
1517
+ const row = h(
1518
+ 'div',
1519
+ { class: 'bar-row' },
1520
+ h('span', { class: 'bar-label' }, r.strategy),
1521
+ h(
1522
+ 'div',
1523
+ { class: 'bar-track' },
1524
+ h('div', { class: 'bar-fill', style: 'background:var(--accent-v)' }),
1525
+ ),
1526
+ h('span', { class: 'bar-count' }, String(r.call_count)),
1527
+ );
1528
+ ruCard.append(row);
1529
+ requestAnimationFrame(() => {
1530
+ row.querySelector('.bar-fill').style.width = (r.call_count / ruMax) * 100 + '%';
1531
+ });
1532
+ });
1533
+ if (!ru.length)
1534
+ ruCard.append(
1535
+ h('div', { class: 'empty' }, h('div', { class: 'empty-sub' }, 'No retrieval data yet')),
1536
+ );
1537
+ grid.append(wrap(ruCard));
1538
+
1539
+ // 4. Top files
1540
+ const tf = fs.topFiles || [];
1541
+ const tfCard = card('Top files referenced');
1542
+ tf.slice(0, 12).forEach((f) => {
1543
+ tfCard.append(
1544
+ h(
1545
+ 'div',
1546
+ { class: 'file-row' },
1547
+ h(
1548
+ 'span',
1549
+ { class: 'file-name', title: f.file_path },
1550
+ f.file_path.split('/').slice(-2).join('/'),
1551
+ ),
1552
+ h('span', { class: 'file-count' }, String(f.reference_count) + 'x'),
1553
+ ),
1554
+ );
1281
1555
  });
1556
+ if (!tf.length)
1557
+ tfCard.append(
1558
+ h(
1559
+ 'div',
1560
+ { class: 'empty' },
1561
+ h('div', { class: 'empty-sub' }, 'No file references yet'),
1562
+ ),
1563
+ );
1564
+ grid.append(wrap(tfCard));
1565
+ }
1566
+
1567
+ function card(title) {
1568
+ const el = h('div', { class: 'a-card' });
1569
+ el.append(h('div', { class: 'a-title' }, title));
1570
+ return el;
1571
+ }
1572
+ function wrap(el) {
1573
+ const w = h('div', {});
1574
+ w.append(el);
1575
+ return w;
1576
+ }
1577
+
1578
+ // ─── SVG icons ────────────────────────────────────────────────────────────────
1579
+ function svgIcon(name) {
1580
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1581
+ el.setAttribute('width', '32');
1582
+ el.setAttribute('height', '32');
1583
+ el.setAttribute('viewBox', '0 0 24 24');
1584
+ el.setAttribute('fill', 'none');
1585
+ el.setAttribute('stroke', 'currentColor');
1586
+ el.setAttribute('stroke-width', '1.5');
1587
+ el.setAttribute('stroke-linecap', 'round');
1588
+ el.classList.add('empty-icon');
1589
+ const paths = {
1590
+ search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-5-5"/>',
1591
+ graph:
1592
+ '<circle cx="5" cy="12" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="19" cy="19" r="2"/><path d="M7 11.5L17 6M7 12.5L17 18"/>',
1593
+ };
1594
+ el.innerHTML = paths[name] || '';
1595
+ return el;
1282
1596
  }
1283
1597
 
1284
- // ──────────────────────────────────────────────────────────────────────────────
1285
- // Go
1286
- // ──────────────────────────────────────────────────────────────────────────────
1287
- init();
1598
+ // ─── Init ─────────────────────────────────────────────────────────────────────
1599
+ boot();
1288
1600
  </script>
1289
1601
  </body>
1290
1602
  </html>