graphtrack 0.1.1 → 0.1.2

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/index.html CHANGED
@@ -4,100 +4,674 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <meta name="description" content="Graphtrack — visualize Claude Code token usage as an interactive graph." />
7
+ <link rel="icon" type="image/svg+xml" href="images/icon.svg" />
7
8
  <title>Graphtrack</title>
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
8
12
  <script src="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.9/standalone/umd/vis-network.min.js"></script>
9
13
  <style>
10
14
  :root {
11
- --canvas:#0a0a0a; --card:#1a1a1a; --yellow:#faff69; --body:#cccccc; --muted:#888888;
12
- }
13
- * { box-sizing: border-box; }
14
- html, body { height:100%; }
15
- body { margin:0; font-family: Inter, system-ui, sans-serif; background:var(--canvas); color:var(--body); display:flex; flex-direction:column; }
16
- header { padding:12px 28px; border-bottom:1px solid #2a2a2a; display:flex; align-items:center; gap:18px; flex: 0 0 auto; flex-wrap:wrap; }
17
- header h1 { font-size:18px; margin:0; color:#fff; letter-spacing:-0.5px; font-weight:700; }
18
- header .dot { color:var(--yellow); margin-right:2px; }
19
- header .sub { font-size:12px; color:var(--muted); }
20
- select {
21
- background:var(--card); color:var(--body); border:1px solid #3a3a3a;
22
- padding:6px 10px; border-radius:6px; font-size:13px; max-width:320px;
23
- }
24
- select:disabled { opacity:0.5; }
25
- .toggle { margin-left:auto; display:flex; gap:8px; }
15
+ --canvas: #08080a;
16
+ --surface: #111114;
17
+ --card: #18181c;
18
+ --card-hover: #1e1e24;
19
+ --border: #25252a;
20
+ --border-light: #2e2e36;
21
+ --yellow: #faff69;
22
+ --yellow-dim: rgba(250, 255, 105, 0.08);
23
+ --yellow-glow: rgba(250, 255, 105, 0.12);
24
+ --text: #e8e8ec;
25
+ --body: #a0a0ac;
26
+ --muted: #65656f;
27
+ --accent-blue: #5b9cf5;
28
+ --accent-green: #4ade80;
29
+ --accent-red: #f87171;
30
+ --radius: 10px;
31
+ --radius-sm: 6px;
32
+ }
33
+ * { box-sizing: border-box; margin: 0; }
34
+ html, body { height: 100%; }
35
+ body {
36
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
37
+ background: var(--canvas);
38
+ color: var(--body);
39
+ display: flex;
40
+ flex-direction: column;
41
+ -webkit-font-smoothing: antialiased;
42
+ }
43
+
44
+ /* ─── NAVBAR ─── */
45
+ header {
46
+ padding: 10px 24px;
47
+ background: var(--surface);
48
+ border-bottom: 1px solid var(--border);
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 10px;
52
+ flex: 0 0 auto;
53
+ flex-wrap: wrap;
54
+ position: relative;
55
+ z-index: 10;
56
+ }
57
+ header::after {
58
+ content: "";
59
+ position: absolute;
60
+ bottom: -1px;
61
+ left: 0;
62
+ right: 0;
63
+ height: 1px;
64
+ background: linear-gradient(90deg, transparent, var(--yellow-dim), transparent);
65
+ }
66
+ .logo {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 8px;
70
+ flex-shrink: 0;
71
+ }
72
+ .logo img {
73
+ width: 26px;
74
+ height: 26px;
75
+ filter: drop-shadow(0 0 6px rgba(250, 255, 105, 0.25));
76
+ transition: filter 0.3s;
77
+ }
78
+ .logo img:hover {
79
+ filter: drop-shadow(0 0 10px rgba(250, 255, 105, 0.45));
80
+ }
81
+ .logo h1 {
82
+ font-size: 17px;
83
+ color: var(--text);
84
+ letter-spacing: -0.4px;
85
+ font-weight: 700;
86
+ }
87
+ .sub {
88
+ font-size: 11.5px;
89
+ color: var(--muted);
90
+ margin-right: 4px;
91
+ white-space: nowrap;
92
+ }
93
+
94
+ /* ─── CUSTOM DROPDOWN ─── */
95
+ .dropdown {
96
+ position: relative;
97
+ }
98
+ .dropdown-trigger {
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 8px;
102
+ background: var(--card);
103
+ color: var(--body);
104
+ border: 1px solid var(--border);
105
+ padding: 6px 12px;
106
+ border-radius: var(--radius-sm);
107
+ font-size: 12.5px;
108
+ font-family: inherit;
109
+ cursor: pointer;
110
+ transition: all 0.2s ease;
111
+ max-width: 340px;
112
+ white-space: nowrap;
113
+ overflow: hidden;
114
+ text-overflow: ellipsis;
115
+ min-width: 200px;
116
+ }
117
+ .dropdown-trigger:hover {
118
+ border-color: var(--border-light);
119
+ background: var(--card-hover);
120
+ }
121
+ .dropdown-trigger:focus {
122
+ outline: none;
123
+ border-color: var(--yellow);
124
+ box-shadow: 0 0 0 2px var(--yellow-dim);
125
+ }
126
+ .dropdown-trigger.disabled {
127
+ opacity: 0.4;
128
+ pointer-events: none;
129
+ }
130
+ .dropdown-trigger .dd-label {
131
+ flex: 1;
132
+ overflow: hidden;
133
+ text-overflow: ellipsis;
134
+ text-align: left;
135
+ }
136
+ .dropdown-trigger .dd-label.placeholder {
137
+ color: var(--muted);
138
+ }
139
+ .dropdown-trigger .dd-chevron {
140
+ flex-shrink: 0;
141
+ width: 14px;
142
+ height: 14px;
143
+ color: var(--muted);
144
+ transition: transform 0.2s ease, color 0.2s;
145
+ }
146
+ .dropdown-trigger:hover .dd-chevron { color: var(--body); }
147
+ .dropdown.open .dropdown-trigger {
148
+ border-color: var(--yellow);
149
+ box-shadow: 0 0 0 2px var(--yellow-dim);
150
+ }
151
+ .dropdown.open .dd-chevron {
152
+ transform: rotate(180deg);
153
+ color: var(--yellow);
154
+ }
155
+ .dropdown-panel {
156
+ position: absolute;
157
+ top: calc(100% + 6px);
158
+ left: 0;
159
+ min-width: 100%;
160
+ width: 360px;
161
+ background: var(--card);
162
+ border: 1px solid var(--border-light);
163
+ border-radius: var(--radius);
164
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(250, 255, 105, 0.04);
165
+ z-index: 100;
166
+ opacity: 0;
167
+ transform: translateY(-6px) scale(0.98);
168
+ pointer-events: none;
169
+ transition: opacity 0.18s ease, transform 0.18s ease;
170
+ overflow: hidden;
171
+ }
172
+ .dropdown.open .dropdown-panel {
173
+ opacity: 1;
174
+ transform: translateY(0) scale(1);
175
+ pointer-events: auto;
176
+ }
177
+ .dd-search {
178
+ padding: 10px 12px;
179
+ border-bottom: 1px solid var(--border);
180
+ }
181
+ .dd-search input {
182
+ width: 100%;
183
+ background: var(--surface);
184
+ border: 1px solid var(--border);
185
+ border-radius: var(--radius-sm);
186
+ padding: 7px 10px 7px 32px;
187
+ font-size: 12.5px;
188
+ font-family: inherit;
189
+ color: var(--text);
190
+ outline: none;
191
+ transition: border-color 0.2s;
192
+ }
193
+ .dd-search input:focus {
194
+ border-color: var(--yellow);
195
+ }
196
+ .dd-search input::placeholder {
197
+ color: var(--muted);
198
+ }
199
+ .dd-search-icon {
200
+ position: absolute;
201
+ left: 22px;
202
+ top: 50%;
203
+ transform: translateY(-50%);
204
+ width: 14px;
205
+ height: 14px;
206
+ color: var(--muted);
207
+ pointer-events: none;
208
+ }
209
+ .dd-search { position: relative; }
210
+ .dd-list {
211
+ max-height: 300px;
212
+ overflow-y: auto;
213
+ overscroll-behavior: contain;
214
+ padding: 4px;
215
+ }
216
+ .dd-group-label {
217
+ padding: 8px 10px 4px;
218
+ font-size: 10px;
219
+ text-transform: uppercase;
220
+ letter-spacing: 1px;
221
+ font-weight: 600;
222
+ color: var(--muted);
223
+ }
224
+ .dd-item {
225
+ display: flex;
226
+ align-items: center;
227
+ gap: 10px;
228
+ padding: 8px 10px;
229
+ border-radius: var(--radius-sm);
230
+ cursor: pointer;
231
+ transition: background 0.12s, color 0.12s;
232
+ font-size: 12.5px;
233
+ color: var(--body);
234
+ }
235
+ .dd-item:hover {
236
+ background: var(--yellow-dim);
237
+ color: var(--text);
238
+ }
239
+ .dd-item.selected {
240
+ background: rgba(250, 255, 105, 0.1);
241
+ color: var(--yellow);
242
+ }
243
+ .dd-item .dd-dot {
244
+ width: 6px;
245
+ height: 6px;
246
+ border-radius: 50%;
247
+ background: var(--border-light);
248
+ flex-shrink: 0;
249
+ transition: background 0.15s;
250
+ }
251
+ .dd-item.selected .dd-dot {
252
+ background: var(--yellow);
253
+ box-shadow: 0 0 6px rgba(250, 255, 105, 0.4);
254
+ }
255
+ .dd-item-info {
256
+ flex: 1;
257
+ min-width: 0;
258
+ display: flex;
259
+ flex-direction: column;
260
+ gap: 1px;
261
+ }
262
+ .dd-item-id {
263
+ font-family: 'JetBrains Mono', monospace;
264
+ font-size: 12px;
265
+ font-weight: 500;
266
+ color: var(--text);
267
+ overflow: hidden;
268
+ text-overflow: ellipsis;
269
+ white-space: nowrap;
270
+ }
271
+ .dd-item.selected .dd-item-id {
272
+ color: var(--yellow);
273
+ }
274
+ .dd-item-proj {
275
+ font-size: 11px;
276
+ color: var(--muted);
277
+ overflow: hidden;
278
+ text-overflow: ellipsis;
279
+ white-space: nowrap;
280
+ }
281
+ .dd-empty {
282
+ padding: 20px 12px;
283
+ text-align: center;
284
+ color: var(--muted);
285
+ font-size: 12.5px;
286
+ }
287
+ .dd-divider {
288
+ height: 1px;
289
+ background: var(--border);
290
+ margin: 4px 8px;
291
+ }
292
+
293
+ .toggle {
294
+ margin-left: auto;
295
+ display: flex;
296
+ gap: 3px;
297
+ background: var(--surface);
298
+ border: 1px solid var(--border);
299
+ border-radius: var(--radius);
300
+ padding: 3px;
301
+ }
26
302
  .toggle button {
27
- background:var(--card); color:var(--body); border:1px solid #3a3a3a;
28
- padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px;
29
- }
30
- .toggle button:hover { border-color:#5a5a5a; }
31
- .toggle button.active { background:var(--yellow); color:#0a0a0a; border-color:var(--yellow); font-weight:600; }
32
- #stats { flex: 0 0 auto; padding:12px 28px; display:flex; gap:28px; flex-wrap:wrap; font-size:13px; border-bottom:1px solid #2a2a2a; }
33
- #stats .stat { display:flex; flex-direction:column; gap:2px; }
34
- #stats .stat .label { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }
35
- #stats .stat .value { color:#fff; font-weight:600; font-size:14px; }
303
+ background: transparent;
304
+ color: var(--muted);
305
+ border: none;
306
+ padding: 6px 14px;
307
+ border-radius: 7px;
308
+ cursor: pointer;
309
+ font-size: 12.5px;
310
+ font-weight: 500;
311
+ font-family: inherit;
312
+ transition: all 0.2s ease;
313
+ position: relative;
314
+ }
315
+ .toggle button:hover {
316
+ color: var(--body);
317
+ background: var(--card);
318
+ }
319
+ .toggle button.active {
320
+ background: var(--yellow);
321
+ color: #0a0a0a;
322
+ font-weight: 600;
323
+ box-shadow: 0 1px 4px rgba(250, 255, 105, 0.2);
324
+ }
325
+
326
+ #btnOverview {
327
+ background: var(--card);
328
+ color: var(--body);
329
+ border: 1px solid var(--border);
330
+ padding: 6px 14px;
331
+ border-radius: var(--radius-sm);
332
+ cursor: pointer;
333
+ font-size: 12.5px;
334
+ font-weight: 500;
335
+ font-family: inherit;
336
+ transition: all 0.2s ease;
337
+ }
338
+ #btnOverview:hover {
339
+ border-color: var(--border-light);
340
+ color: var(--text);
341
+ background: var(--card-hover);
342
+ }
343
+ #btnOverview.active {
344
+ background: var(--yellow);
345
+ color: #0a0a0a;
346
+ border-color: var(--yellow);
347
+ font-weight: 600;
348
+ box-shadow: 0 1px 4px rgba(250, 255, 105, 0.2);
349
+ }
350
+
351
+ /* ─── STATS BAR ─── */
352
+ #stats {
353
+ flex: 0 0 auto;
354
+ padding: 10px 24px;
355
+ display: flex;
356
+ gap: 2px;
357
+ flex-wrap: wrap;
358
+ background: var(--surface);
359
+ border-bottom: 1px solid var(--border);
360
+ }
361
+ #stats .stat {
362
+ display: flex;
363
+ flex-direction: column;
364
+ gap: 1px;
365
+ padding: 6px 16px;
366
+ border-radius: var(--radius-sm);
367
+ transition: background 0.2s;
368
+ }
369
+ #stats .stat:hover {
370
+ background: var(--card);
371
+ }
372
+ #stats .stat:first-child {
373
+ padding-left: 0;
374
+ }
375
+ #stats .stat .label {
376
+ color: var(--muted);
377
+ font-size: 10px;
378
+ text-transform: uppercase;
379
+ letter-spacing: 0.8px;
380
+ font-weight: 600;
381
+ }
382
+ #stats .stat .value {
383
+ color: var(--text);
384
+ font-weight: 600;
385
+ font-size: 14px;
386
+ font-variant-numeric: tabular-nums;
387
+ transition: color 0.2s;
388
+ }
389
+ #stats .stat:hover .value {
390
+ color: #fff;
391
+ }
392
+
393
+ /* ─── GRAPH / MAIN AREA ─── */
36
394
  #graph {
37
- flex: 1 1 auto; min-height:0; width:100%;
395
+ flex: 1 1 auto;
396
+ min-height: 0;
397
+ width: 100%;
38
398
  background-color: var(--canvas);
39
399
  background-image:
40
- linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
41
- linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
42
- background-size: 42px 42px;
400
+ radial-gradient(circle 600px at 50% 50%, rgba(250, 255, 105, 0.015), transparent),
401
+ linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
402
+ linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
403
+ background-size: auto, 48px 48px, 48px 48px;
43
404
  position: relative;
405
+ overflow: hidden;
44
406
  }
45
407
  #graph::after {
46
- content:"";
47
- position: absolute; inset: 0; pointer-events: none;
48
- background: radial-gradient(ellipse at center, rgba(255,250,105,0.04) 0%, transparent 55%);
408
+ content: "";
409
+ position: absolute;
410
+ inset: 0;
411
+ pointer-events: none;
412
+ background: radial-gradient(ellipse at center, rgba(250, 255, 105, 0.03) 0%, transparent 60%);
49
413
  }
50
414
  #graph canvas { position: relative; z-index: 2; }
415
+
416
+ /* empty state */
51
417
  #graph .empty {
52
- position: absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
53
- gap:8px; z-index:2; text-align:center; padding:24px;
54
- }
55
- #graph .empty h2 { color:#fff; margin:0; font-size:20px; }
56
- #graph .empty p { color:var(--muted); margin:0; font-size:13px; max-width:460px; line-height:1.5; }
57
- #graph .empty code { background:var(--card); padding:2px 6px; border-radius:4px; color:var(--yellow); }
58
- #spinner { display:none; position:fixed; inset:0; z-index:1000; align-items:center; justify-content:center; }
59
- #spinner.on { display:flex; }
60
- #spinner span { color:var(--yellow); border:2px solid #3a3a3a; border-top-color:var(--yellow); border-radius:50%; width:34px; height:34px; animation:spin 0.8s linear infinite; }
418
+ position: absolute;
419
+ inset: 0;
420
+ display: flex;
421
+ flex-direction: column;
422
+ align-items: center;
423
+ justify-content: center;
424
+ gap: 12px;
425
+ z-index: 2;
426
+ text-align: center;
427
+ padding: 24px;
428
+ }
429
+ #graph .empty .icon {
430
+ width: 56px;
431
+ height: 56px;
432
+ opacity: 0.15;
433
+ margin-bottom: 4px;
434
+ }
435
+ #graph .empty h2 { color: var(--text); font-size: 18px; font-weight: 600; }
436
+ #graph .empty p { color: var(--muted); font-size: 13px; max-width: 440px; line-height: 1.6; }
437
+ #graph .empty code {
438
+ background: var(--card);
439
+ border: 1px solid var(--border);
440
+ padding: 3px 8px;
441
+ border-radius: var(--radius-sm);
442
+ color: var(--yellow);
443
+ font-family: 'JetBrains Mono', monospace;
444
+ font-size: 12px;
445
+ }
446
+
447
+ /* spinner */
448
+ #spinner {
449
+ display: none;
450
+ position: fixed;
451
+ inset: 0;
452
+ z-index: 1000;
453
+ align-items: center;
454
+ justify-content: center;
455
+ background: rgba(8, 8, 10, 0.5);
456
+ backdrop-filter: blur(4px);
457
+ }
458
+ #spinner.on { display: flex; }
459
+ #spinner span {
460
+ color: var(--yellow);
461
+ border: 2.5px solid var(--border);
462
+ border-top-color: var(--yellow);
463
+ border-radius: 50%;
464
+ width: 30px;
465
+ height: 30px;
466
+ animation: spin 0.7s linear infinite;
467
+ }
61
468
  @keyframes spin { to { transform: rotate(360deg); } }
62
- #btnOverview {
63
- background:var(--card); color:var(--body); border:1px solid #3a3a3a;
64
- padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px;
65
- }
66
- #btnOverview:hover { border-color:#5a5a5a; }
67
- #btnOverview.active { background:var(--yellow); color:#0a0a0a; border-color:var(--yellow); font-weight:600; }
68
- .tables { height:100%; overflow:auto; padding:20px 28px 40px; }
69
- .tbl-sec { margin-bottom:28px; max-width:900px; }
70
- .tables h3 { color:#fff; margin:0 0 10px; font-size:14px; letter-spacing:1px; text-transform:uppercase; }
71
- .tables table { width:100%; border-collapse:collapse; font-size:13px; }
72
- .tables th, .ov-list th { text-align:left; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.5px; font-weight:600; padding:6px 12px; border-bottom:1px solid #2a2a2a; }
73
- .tables td, .ov-list td { padding:7px 12px; border-bottom:1px solid #242424; color:var(--body); }
74
- .tables th.num, .tables td.num, .ov-list th.num, .ov-list td.num { text-align:right; font-variant-numeric:tabular-nums; }
75
- .swatch { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:8px; vertical-align:middle; }
76
- .bar { display:inline-block; height:8px; border-radius:4px; min-width:2px; vertical-align:middle; }
77
- .tables .muted, .ov-list .muted { color:var(--muted); }
78
- .ov-wrap { height:100%; display:flex; }
79
- .ov-graph { flex:1 1 auto; min-width:0; position:relative; }
80
- .ov-list { flex:0 0 420px; overflow:auto; padding:20px 24px; border-left:1px solid #2a2a2a; }
81
- .ov-list h3 { color:#fff; margin:0 0 12px; font-size:14px; letter-spacing:1px; text-transform:uppercase; }
82
- .ov-list .hint { color:var(--muted); font-size:12px; margin:12px 0 0; }
83
- .project-row { cursor:pointer; }
84
- .project-row:hover td { background:#121212; }
85
- .proj-name { color:#fff; font-family:ui-monospace, monospace; font-size:12px; }
469
+
470
+ /* ─── TABLE VIEW ─── */
471
+ .tables {
472
+ height: 100%;
473
+ overflow: auto;
474
+ padding: 24px 28px 40px;
475
+ }
476
+ .tbl-sec {
477
+ margin-bottom: 32px;
478
+ max-width: 920px;
479
+ }
480
+ .tables h3 {
481
+ color: var(--text);
482
+ margin: 0 0 12px;
483
+ font-size: 12px;
484
+ letter-spacing: 1.2px;
485
+ text-transform: uppercase;
486
+ font-weight: 600;
487
+ display: flex;
488
+ align-items: center;
489
+ gap: 8px;
490
+ }
491
+ .tables h3::after {
492
+ content: "";
493
+ flex: 1;
494
+ height: 1px;
495
+ background: var(--border);
496
+ }
497
+ .tables table {
498
+ width: 100%;
499
+ border-collapse: collapse;
500
+ font-size: 13px;
501
+ }
502
+ .tables th {
503
+ text-align: left;
504
+ color: var(--muted);
505
+ font-size: 10.5px;
506
+ text-transform: uppercase;
507
+ letter-spacing: 0.8px;
508
+ font-weight: 600;
509
+ padding: 8px 14px;
510
+ border-bottom: 1px solid var(--border);
511
+ position: sticky;
512
+ top: 0;
513
+ background: var(--canvas);
514
+ z-index: 1;
515
+ }
516
+ .tables td {
517
+ padding: 8px 14px;
518
+ border-bottom: 1px solid var(--border);
519
+ color: var(--body);
520
+ transition: background 0.15s, color 0.15s;
521
+ }
522
+ .tables tbody tr {
523
+ transition: background 0.15s;
524
+ }
525
+ .tables tbody tr:hover {
526
+ background: var(--card);
527
+ }
528
+ .tables tbody tr:hover td {
529
+ color: var(--text);
530
+ }
531
+ .tables th.num, .tables td.num {
532
+ text-align: right;
533
+ font-variant-numeric: tabular-nums;
534
+ }
535
+ .swatch {
536
+ display: inline-block;
537
+ width: 10px;
538
+ height: 10px;
539
+ border-radius: 3px;
540
+ margin-right: 10px;
541
+ vertical-align: middle;
542
+ box-shadow: 0 0 6px rgba(255, 255, 255, 0.05);
543
+ }
544
+ .bar {
545
+ display: inline-block;
546
+ height: 6px;
547
+ border-radius: 3px;
548
+ min-width: 2px;
549
+ vertical-align: middle;
550
+ transition: width 0.4s ease;
551
+ }
552
+ .tables .muted, .ov-list .muted { color: var(--muted); }
553
+
554
+ /* ─── OVERVIEW ─── */
555
+ .ov-wrap { height: 100%; display: flex; }
556
+ .ov-graph { flex: 1 1 auto; min-width: 0; position: relative; }
557
+ .ov-list {
558
+ flex: 0 0 420px;
559
+ overflow: auto;
560
+ padding: 24px;
561
+ border-left: 1px solid var(--border);
562
+ background: var(--surface);
563
+ }
564
+ .ov-list h3 {
565
+ color: var(--text);
566
+ margin: 0 0 16px;
567
+ font-size: 12px;
568
+ letter-spacing: 1.2px;
569
+ text-transform: uppercase;
570
+ font-weight: 600;
571
+ display: flex;
572
+ align-items: center;
573
+ gap: 8px;
574
+ }
575
+ .ov-list h3::after {
576
+ content: "";
577
+ flex: 1;
578
+ height: 1px;
579
+ background: var(--border);
580
+ }
581
+ .ov-list th {
582
+ text-align: left;
583
+ color: var(--muted);
584
+ font-size: 10.5px;
585
+ text-transform: uppercase;
586
+ letter-spacing: 0.8px;
587
+ font-weight: 600;
588
+ padding: 8px 12px;
589
+ border-bottom: 1px solid var(--border);
590
+ position: sticky;
591
+ top: 0;
592
+ background: var(--surface);
593
+ z-index: 1;
594
+ }
595
+ .ov-list td {
596
+ padding: 10px 12px;
597
+ border-bottom: 1px solid var(--border);
598
+ color: var(--body);
599
+ font-size: 13px;
600
+ transition: background 0.15s, color 0.15s;
601
+ }
602
+ .ov-list th.num, .ov-list td.num {
603
+ text-align: right;
604
+ font-variant-numeric: tabular-nums;
605
+ }
606
+ .ov-list .hint {
607
+ color: var(--muted);
608
+ font-size: 11.5px;
609
+ margin: 16px 0 0;
610
+ padding: 0 12px;
611
+ line-height: 1.5;
612
+ }
613
+ .project-row {
614
+ cursor: pointer;
615
+ transition: background 0.15s;
616
+ }
617
+ .project-row:hover td {
618
+ background: var(--card-hover);
619
+ color: var(--text);
620
+ }
621
+ .proj-name {
622
+ color: var(--text);
623
+ font-family: 'JetBrains Mono', monospace;
624
+ font-size: 12px;
625
+ font-weight: 500;
626
+ }
627
+
628
+ /* ─── GLOW CURSOR EFFECT ─── */
86
629
  #glow {
87
- position: fixed; inset: 0; pointer-events: none; z-index: 9999;
88
- background: radial-gradient(circle 200px at var(--gx, -9999px) var(--gy, -9999px),
89
- rgba(255,250,105,0.16), rgba(255,250,105,0.05) 45%, transparent 72%);
630
+ position: fixed;
631
+ inset: 0;
632
+ pointer-events: none;
633
+ z-index: 9999;
634
+ background: radial-gradient(circle 180px at var(--gx, -9999px) var(--gy, -9999px),
635
+ rgba(250, 255, 105, 0.07), rgba(250, 255, 105, 0.02) 50%, transparent 75%);
90
636
  opacity: 0;
637
+ transition: opacity 0.4s ease;
91
638
  }
92
639
  #glow.on { opacity: 1; }
640
+
641
+ /* ─── VIEW TRANSITIONS ─── */
642
+ .fade-in { animation: fadeIn 0.25s ease; }
643
+ @keyframes fadeIn {
644
+ from { opacity: 0; transform: translateY(4px); }
645
+ to { opacity: 1; transform: translateY(0); }
646
+ }
647
+
648
+ /* ─── SCROLLBAR ─── */
649
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
650
+ ::-webkit-scrollbar-track { background: transparent; }
651
+ ::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
652
+ ::-webkit-scrollbar-thumb:hover { background: var(--muted); }
93
653
  </style>
94
654
  </head>
95
655
  <body>
96
656
  <header>
97
- <h1><span class="dot">▪</span>Graphtrack</h1>
98
- <span class="sub">Claude Code token usage, as a graph</span>
657
+ <div class="logo">
658
+ <img src="images/icon.svg" alt="" />
659
+ <h1>Graphtrack</h1>
660
+ </div>
99
661
  <button id="btnOverview" title="All sessions at a glance">Overview</button>
100
- <select id="sessionSelect" disabled title="Select a session"><option>Loading sessions…</option></select>
662
+ <div class="dropdown" id="sessionDropdown">
663
+ <button class="dropdown-trigger disabled" id="sessionTrigger">
664
+ <span class="dd-label placeholder">Loading sessions…</span>
665
+ <svg class="dd-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
666
+ </button>
667
+ <div class="dropdown-panel">
668
+ <div class="dd-search">
669
+ <svg class="dd-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
670
+ <input type="text" id="ddSearch" placeholder="Search sessions…" autocomplete="off" />
671
+ </div>
672
+ <div class="dd-list" id="ddList"></div>
673
+ </div>
674
+ </div>
101
675
  <div class="toggle">
102
676
  <button id="btnAgg" class="active">Aggregate</button>
103
677
  <button id="btnChain">Chronological</button>
@@ -135,11 +709,20 @@
135
709
  }
136
710
  function truncate(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + "…" : s; }
137
711
 
138
- const sessionSelect = $("sessionSelect");
712
+ /* ─── Custom dropdown state ─── */
713
+ let sessions = [];
714
+ let selectedSession = null;
715
+ let activeFilter = null; // project slug or null
716
+ let ddOpen = false;
717
+
718
+ const dropdown = $("sessionDropdown");
719
+ const ddTrigger = $("sessionTrigger");
720
+ const ddList = $("ddList");
721
+ const ddSearch = $("ddSearch");
139
722
  const spinner = $("spinner");
140
723
  const container = $("graph");
141
- let current = null; // { session, turns, aggregate, chronological, toolAggregates }
142
- let overview = null; // { totals, projects, toolAggregates, graph }
724
+ let current = null;
725
+ let overview = null;
143
726
  let view = "overview";
144
727
  let network = null;
145
728
 
@@ -154,9 +737,21 @@
154
737
 
155
738
  const options = {
156
739
  physics: { enabled: true, solver: "forceAtlas2Based", stabilization: { iterations: 200 } },
157
- interaction: { hover: true, tooltipDelay: 200 },
158
- nodes: { shape: "dot", font: { color: "#cccccc", size: 13 }, widthConstraint: { maximum: 140 } },
159
- edges: { color: { color: "#3a3a3a", highlight: "#faff69" }, smooth: { enabled: true, type: "dynamic" } },
740
+ interaction: { hover: true, tooltipDelay: 200, zoomView: true, dragView: true },
741
+ nodes: {
742
+ shape: "dot",
743
+ font: { color: "#a0a0ac", size: 12, face: "Inter, system-ui, sans-serif" },
744
+ widthConstraint: { maximum: 140 },
745
+ borderWidth: 2,
746
+ borderWidthSelected: 3,
747
+ shadow: false,
748
+ },
749
+ edges: {
750
+ color: { color: "#25252a", highlight: "#faff69", hover: "#4a4a52" },
751
+ smooth: { enabled: true, type: "dynamic" },
752
+ width: 1.2,
753
+ shadow: false,
754
+ },
160
755
  };
161
756
 
162
757
  function showSpinner(on) { spinner.classList.toggle("on", on); }
@@ -166,7 +761,8 @@
166
761
  function unmountGraph() { if (network) { network.destroy(); network = null; } }
167
762
  function mountGraph(data, el) {
168
763
  unmountGraph();
169
- network = new vis.Network(el || container, { nodes: new vis.DataSet(data.nodes), edges: new vis.DataSet(data.edges) }, options);
764
+ const target = el || container;
765
+ network = new vis.Network(target, { nodes: new vis.DataSet(data.nodes), edges: new vis.DataSet(data.edges) }, options);
170
766
  }
171
767
 
172
768
  function fillStats(s) {
@@ -184,11 +780,13 @@
184
780
 
185
781
  function showEmpty(title, body) {
186
782
  unmountGraph();
187
- container.innerHTML = '<div class="empty">' + (title ? "<h2></h2><p></p>" : "") + '</div>';
783
+ container.innerHTML = '<div class="empty">' +
784
+ '<img src="images/icon.svg" class="icon" alt="" />' +
785
+ (title ? "<h2></h2><p></p>" : "") + '</div>';
188
786
  if (title) { container.querySelector("h2").textContent = title; container.querySelector("p").textContent = body; }
189
787
  }
190
788
 
191
- // ---- session views (aggregate / chronological / table) ----
789
+ // ─── session views ───
192
790
 
193
791
  function render() {
194
792
  if (!current) { showEmpty("No session selected", "Pick a session from the dropdown, or open Overview for all sessions."); return; }
@@ -216,7 +814,7 @@
216
814
  <td class="num">${fmt(t.totalTokens)}</td>
217
815
  </tr>`).join("");
218
816
  container.innerHTML = `
219
- <div class="tables">
817
+ <div class="tables fade-in">
220
818
  <section class="tbl-sec">
221
819
  <h3>Tool share</h3>
222
820
  <table>
@@ -240,6 +838,7 @@
240
838
  const r = await fetch("/api/session/" + encodeURIComponent(slug) + "/" + encodeURIComponent(id));
241
839
  if (!r.ok) throw new Error("no data");
242
840
  current = await r.json();
841
+ lastTokens = current.session.totalTokens;
243
842
  fillStats(current.session);
244
843
  view = "aggregate";
245
844
  setViewActive("btnAgg");
@@ -251,7 +850,7 @@
251
850
  }
252
851
  }
253
852
 
254
- // ---- overview ----
853
+ // ─── overview ───
255
854
 
256
855
  function overviewStats(o) {
257
856
  return {
@@ -269,20 +868,105 @@
269
868
  }
270
869
 
271
870
  function filterSessions(project) {
272
- for (const opt of sessionSelect.options) opt.hidden = opt.dataset.slug !== project;
273
- const firstVisible = [...sessionSelect.options].find((o) => !o.hidden);
274
- if (firstVisible) { sessionSelect.value = firstVisible.value; loadSession(firstVisible.dataset.slug, firstVisible.value); }
871
+ activeFilter = project;
872
+ renderDdList();
873
+ // Auto-select first matching session
874
+ const filtered = getFilteredSessions();
875
+ if (filtered.length > 0) {
876
+ selectSession(filtered[0]);
877
+ }
878
+ }
879
+
880
+ function getFilteredSessions() {
881
+ let list = sessions.filter((s) => s.project !== "subagents");
882
+ if (activeFilter) list = list.filter((s) => s.project === activeFilter);
883
+ const q = ddSearch.value.trim().toLowerCase();
884
+ if (q) list = list.filter((s) => s.id.toLowerCase().includes(q) || s.project.toLowerCase().includes(q));
885
+ return list;
275
886
  }
276
887
 
888
+ function selectSession(s) {
889
+ selectedSession = s;
890
+ const label = ddTrigger.querySelector(".dd-label");
891
+ label.classList.remove("placeholder");
892
+ label.textContent = s.project + " · " + s.id;
893
+ closeDropdown();
894
+ loadSession(s.project, s.id);
895
+ }
896
+
897
+ function openDropdown() {
898
+ ddOpen = true;
899
+ dropdown.classList.add("open");
900
+ ddSearch.value = "";
901
+ renderDdList();
902
+ requestAnimationFrame(() => ddSearch.focus());
903
+ }
904
+
905
+ function closeDropdown() {
906
+ ddOpen = false;
907
+ dropdown.classList.remove("open");
908
+ }
909
+
910
+ function toggleDropdown() {
911
+ ddOpen ? closeDropdown() : openDropdown();
912
+ }
913
+
914
+ function renderDdList() {
915
+ const filtered = getFilteredSessions();
916
+ if (filtered.length === 0) {
917
+ ddList.innerHTML = '<div class="dd-empty">No sessions found</div>';
918
+ return;
919
+ }
920
+ // Group by project
921
+ const groups = new Map();
922
+ for (const s of filtered) {
923
+ if (!groups.has(s.project)) groups.set(s.project, []);
924
+ groups.get(s.project).push(s);
925
+ }
926
+ let html = "";
927
+ for (const [proj, items] of groups) {
928
+ if (groups.size > 1) {
929
+ html += `<div class="dd-group-label">${escapeHtml(truncate(proj, 36))}</div>`;
930
+ }
931
+ for (const s of items) {
932
+ const sel = selectedSession && selectedSession.id === s.id ? " selected" : "";
933
+ html += `<div class="dd-item${sel}" data-id="${escapeHtml(s.id)}" data-project="${escapeHtml(s.project)}">
934
+ <span class="dd-dot"></span>
935
+ <div class="dd-item-info">
936
+ <span class="dd-item-id">${escapeHtml(s.id)}</span>
937
+ <span class="dd-item-proj">${escapeHtml(s.project)}</span>
938
+ </div>
939
+ </div>`;
940
+ }
941
+ }
942
+ ddList.innerHTML = html;
943
+ ddList.querySelectorAll(".dd-item").forEach((el) =>
944
+ el.addEventListener("click", (e) => {
945
+ e.stopPropagation();
946
+ selectSession({ id: el.dataset.id, project: el.dataset.project });
947
+ })
948
+ );
949
+ }
950
+
951
+ // Close on outside click
952
+ document.addEventListener("click", (e) => {
953
+ if (ddOpen && !dropdown.contains(e.target)) closeDropdown();
954
+ });
955
+ // Close on Escape
956
+ document.addEventListener("keydown", (e) => {
957
+ if (e.key === "Escape" && ddOpen) closeDropdown();
958
+ });
959
+
277
960
  function renderOverview() {
278
961
  unmountGraph();
279
962
  const o = overview;
280
963
  if (!o) { showEmpty("No overview data", "The server didn't return an overview."); return; }
281
964
  fillStats(overviewStats(o));
965
+ o.projects = o.projects.filter((p) => p.project !== "subagents");
282
966
  const projMax = Math.max(1, ...o.projects.map((p) => p.totalTokens));
283
967
  const total = o.totals.totalTokens || 1;
284
- const projRows = o.projects.map((p) => `
285
- <tr class="project-row" data-project="${escapeHtml(p.project)}">
968
+ const projRows = o.projects.map((p, i) => `
969
+ <tr class="project-row" data-project="${escapeHtml(p.project)}" style="animation-delay:${i * 30}ms">
286
970
  <td class="proj-name">${escapeHtml(p.project)}</td>
287
971
  <td class="num">${p.sessions}</td>
288
972
  <td class="num">${fmt(p.totalTokens)}</td>
@@ -290,7 +974,7 @@
290
974
  <td><div class="bar" style="width:${Math.max(2, Math.round(p.totalTokens / projMax * 100))}%;background:var(--yellow)"></div></td>
291
975
  </tr>`).join("");
292
976
  container.innerHTML = `
293
- <div class="ov-wrap">
977
+ <div class="ov-wrap fade-in">
294
978
  <div class="ov-graph" id="ovGraph"></div>
295
979
  <div class="ov-list">
296
980
  <h3>Projects · ${o.totals.sessions} sessions</h3>
@@ -298,7 +982,7 @@
298
982
  <thead><tr><th>Project</th><th class="num">Sess</th><th class="num">Tokens</th><th class="num">%</th><th style="width:30%"></th></tr></thead>
299
983
  <tbody>${projRows || '<tr><td colspan="5" class="muted">No sessions found.</td></tr>'}</tbody>
300
984
  </table>
301
- <p class="hint">Click a project to load its sessions above.</p>
985
+ <p class="hint">Click a project to filter sessions in the dropdown above.</p>
302
986
  </div>
303
987
  </div>`;
304
988
  container.querySelectorAll(".project-row").forEach((tr) =>
@@ -322,7 +1006,7 @@
322
1006
  }
323
1007
  }
324
1008
 
325
- // ---- boot + controls ----
1009
+ // ─── boot ───
326
1010
 
327
1011
  async function loadSessions() {
328
1012
  try {
@@ -333,15 +1017,12 @@
333
1017
  "Graphtrack reads session logs from " + data.logsDir + ". Run Claude Code a few times, then restart with: graphtrack --dir <path> if your logs live elsewhere.");
334
1018
  return;
335
1019
  }
336
- sessionSelect.disabled = false;
337
- sessionSelect.innerHTML = "";
338
- data.sessions.forEach((s) => {
339
- const opt = document.createElement("option");
340
- opt.value = s.id;
341
- opt.dataset.slug = s.project;
342
- opt.textContent = s.project + " · " + s.id;
343
- sessionSelect.appendChild(opt);
344
- });
1020
+ sessions = data.sessions;
1021
+ ddTrigger.classList.remove("disabled");
1022
+ const label = ddTrigger.querySelector(".dd-label");
1023
+ label.classList.remove("placeholder");
1024
+ label.textContent = sessions.length + " sessions";
1025
+ ddTrigger.removeAttribute("disabled");
345
1026
  loadOverview();
346
1027
  } catch (e) {
347
1028
  showEmpty("Can't reach the Graphtrack server",
@@ -349,10 +1030,12 @@
349
1030
  }
350
1031
  }
351
1032
 
352
- sessionSelect.addEventListener("change", () => {
353
- const opt = sessionSelect.selectedOptions[0];
354
- if (opt) loadSession(opt.dataset.slug, opt.value);
1033
+ ddTrigger.addEventListener("click", (e) => {
1034
+ e.stopPropagation();
1035
+ toggleDropdown();
355
1036
  });
1037
+ ddSearch.addEventListener("input", renderDdList);
1038
+ ddSearch.addEventListener("click", (e) => e.stopPropagation());
356
1039
  $("btnOverview").addEventListener("click", () => {
357
1040
  view = "overview";
358
1041
  setViewActive("btnOverview");
@@ -362,7 +1045,41 @@
362
1045
  $("btnChain").addEventListener("click", () => { view = "chronological"; setViewActive("btnChain"); render(); });
363
1046
  $("btnTable").addEventListener("click", () => { view = "table"; setViewActive("btnTable"); if (!current) render(); else renderTable(); });
364
1047
 
1048
+ // ─── auto-refresh every 10s ───
1049
+ let lastTokens = null;
1050
+ async function refreshData() {
1051
+ try {
1052
+ // Refresh session list silently
1053
+ const r = await fetch("/api/sessions");
1054
+ const data = await r.json();
1055
+ if (data.sessions) sessions = data.sessions;
1056
+
1057
+ // Refresh current view — only re-render if data actually changed
1058
+ if (view === "overview") {
1059
+ const ov = await fetch("/api/overview");
1060
+ const newOverview = await ov.json();
1061
+ if (newOverview.totals.totalTokens !== (overview?.totals?.totalTokens)) {
1062
+ overview = newOverview;
1063
+ renderOverview();
1064
+ }
1065
+ } else if (current && selectedSession) {
1066
+ const sr = await fetch("/api/session/" + encodeURIComponent(selectedSession.project) + "/" + encodeURIComponent(selectedSession.id));
1067
+ if (sr.ok) {
1068
+ const newData = await sr.json();
1069
+ const newTotal = newData.session.totalTokens;
1070
+ if (newTotal !== lastTokens) {
1071
+ lastTokens = newTotal;
1072
+ current = newData;
1073
+ fillStats(current.session);
1074
+ render();
1075
+ }
1076
+ }
1077
+ }
1078
+ } catch { /* server may be restarting — skip this tick */ }
1079
+ }
1080
+ setInterval(refreshData, 10000);
1081
+
365
1082
  loadSessions();
366
1083
  </script>
367
1084
  </body>
368
- </html>
1085
+ </html>