claude-rpc 0.7.2 → 0.7.3

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,1257 +1,33 @@
1
- // All browser-side assets for the local web dashboard, packaged as JS
2
- // string constants so the bundler picks them up cleanly. Single export
3
- // is `buildHtml({ port })`.
1
+ // Composes the local web dashboard's HTML from three on-disk assets:
2
+ // assets/dashboard.html — scaffold with {{STYLES}} {{SCRIPT}} {{PORT}}
3
+ // assets/dashboard.css — stylesheet
4
+ // assets/dashboard.client.js — vanilla browser runtime (one IIFE)
4
5
  //
5
- // ┌─ TABLE OF CONTENTS ─────────────────────────────────────┐
6
- // │ §1 CSS — stylesheet (light + dark) │ ~line 26
7
- // │ §2 LANG_PALETTE — language swatch hex │ ~line 400
8
- // │ §3 buildHtml — HTML scaffold + interpolation │ ~line 420
9
- // │ §4 HTML_SCRIPT_BODY — client runtime (SSE, charts, │ ~line 700
10
- // │ range selector, drilldowns, │
11
- // │ theme toggle, keyboard) │
12
- // └─────────────────────────────────────────────────────────┘
6
+ // These are real files you edit with full CSS/HTML/JS tooling — the reason
7
+ // this module shrank from a 1,200-line string monolith. loadAsset() reads
8
+ // them from disk in dev/npm and from the SEA blob in the packaged exe
9
+ // (see src/server/assets.js + sea-config.json).
13
10
  //
14
- // Each section is just a string constant no runtime dependencies
15
- // between them. Edit one without re-reading the others.
11
+ // Single export: buildHtml({ port }). The server composes it once at
12
+ // startup and reuses the string for every request.
16
13
 
17
- // ─────────────────────────────────────────────────────────────────────
18
- // §1 CSS
19
- // ─────────────────────────────────────────────────────────────────────
14
+ import { loadAsset } from './assets.js';
20
15
 
21
- const CSS = `
22
- :root {
23
- --paper: #f4ede0;
24
- --paper-dark: #ebe2d2;
25
- --ink: #1a1611;
26
- --ink-soft: #2d2520;
27
- --ink-mute: #5c5147;
28
- --ink-faint: #8a7c6d;
29
-
30
- --rust: #c2491e;
31
- --tape: #f2d76e;
32
- --grass: #4a9462;
33
-
34
- --font-sans: 'Inter', system-ui, sans-serif;
35
- --font-mono: 'JetBrains Mono', monospace;
36
- --font-disp: 'Space Grotesk', sans-serif;
37
-
38
- --shadow: 3px 3px 0 var(--ink);
39
- --shadow-hover: 5px 5px 0 var(--ink);
40
- --radius: 0;
41
-
42
- /* Fallbacks for existing variables used by page.js */
43
- --bg: var(--paper);
44
- --text: var(--ink);
45
- --text-2: var(--ink-soft);
46
- --text-3: var(--ink-mute);
47
- --text-4: var(--ink-faint);
48
- --border: var(--ink);
49
- --surface: #fff;
50
- --surface-hover: var(--paper);
51
- --green: var(--grass);
52
- --red: #c54a3a;
53
- --purple: #5865f2; /* using blurple */
54
- --amber: var(--tape);
55
- }
56
-
57
- * { box-sizing: border-box; margin: 0; padding: 0; }
58
- ::selection { background: rgba(26,22,17,0.16); }
59
- html, body { background: var(--paper); color: var(--ink); }
60
- body {
61
- font-family: var(--font-sans);
62
- font-size: 14px; line-height: 1.5;
63
- min-height: 100vh;
64
- background-image: radial-gradient(circle at 1px 1px, rgba(26,22,17,0.07) 1px, transparent 0);
65
- background-size: 20px 20px;
66
- }
67
- .num { font-variant-numeric: tabular-nums; }
68
- a { color: inherit; text-decoration: none; }
69
- button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
70
- h1, h2, h3 { font-family: var(--font-disp); }
71
-
72
- .page { max-width: 1000px; margin: 0 auto; padding: 40px 40px 100px; }
73
-
74
- /* ── Top bar ─────────────────────────────────────────── */
75
- .topbar {
76
- display: flex; justify-content: space-between; align-items: center;
77
- padding-bottom: 24px;
78
- border-bottom: 2px dashed var(--ink-mute);
79
- margin-bottom: 40px;
80
- }
81
- .brand {
82
- display: flex; align-items: center; gap: 12px;
83
- font-family: var(--font-disp);
84
- font-weight: 700; font-size: 24px;
85
- }
86
- .brand .mark {
87
- width: 32px; height: 32px;
88
- background: var(--ink); color: var(--paper);
89
- font-family: var(--font-mono); font-size: 14px;
90
- display: grid; place-items: center;
91
- border: 2px solid var(--ink);
92
- box-shadow: 2px 2px 0 var(--rust);
93
- transform: rotate(-3deg);
94
- }
95
- .brand .sep { display: none; }
96
- .brand .meta { display: none; }
97
- .top-right { margin-left: auto; display: flex; align-items: center; gap: 16px; }
98
-
99
- .range-pills {
100
- display: inline-flex; gap: 4px;
101
- }
102
- .range-pills button {
103
- font-family: var(--font-mono);
104
- font-size: 12px; padding: 6px 12px; font-weight: 700;
105
- border: 2px solid transparent; text-transform: uppercase;
106
- transition: transform 0.12s;
107
- }
108
- .range-pills button:hover { transform: translateY(-2px); }
109
- .range-pills button.active {
110
- background: var(--tape);
111
- border-color: var(--ink);
112
- box-shadow: 2px 2px 0 var(--ink);
113
- transform: rotate(2deg);
114
- }
115
-
116
- .status {
117
- display: inline-flex; align-items: center; gap: 8px;
118
- font-family: var(--font-mono);
119
- font-size: 12px; font-weight: 700; text-transform: uppercase;
120
- background: var(--grass); color: var(--paper);
121
- padding: 6px 12px; border: 2px solid var(--ink); box-shadow: 2px 2px 0 var(--ink);
122
- transform: rotate(-2deg);
123
- }
124
- .status .dot { display: none; }
125
- .theme-btn { display: none; } /* Hide theme toggle, we only use paper now */
126
- .model { font-family: var(--font-mono); font-size: 12px; font-weight: 700; background: #fff; padding: 4px 8px; border: 2px solid var(--ink); box-shadow: 2px 2px 0 var(--ink); }
127
-
128
- /* ── Live rail ───────────────────────────────────────── */
129
- .live-rail {
130
- display: grid; grid-template-columns: 80px 1fr auto; gap: 24px; align-items: center;
131
- background: #fff; padding: 24px;
132
- border: 2px solid var(--ink); box-shadow: var(--shadow);
133
- margin-bottom: 48px; position: relative;
134
- transform: rotate(-0.5deg); transition: transform 0.2s, box-shadow 0.2s;
135
- }
136
- .live-rail:hover { transform: rotate(0deg) translate(-2px, -2px); box-shadow: var(--shadow-hover); }
137
- .live-rail::before {
138
- content: "LIVE PREVIEW"; position: absolute; top: -12px; right: 24px;
139
- background: var(--tape); font-family: var(--font-mono); font-weight: 700; font-size: 11px;
140
- padding: 4px 10px; border: 2px solid var(--ink); box-shadow: 2px 2px 0 var(--ink); transform: rotate(3deg);
141
- }
142
- .live-rail .avatar {
143
- width: 80px; height: 80px; background: var(--paper-dark);
144
- border: 2px solid var(--ink); box-shadow: 2px 2px 0 var(--ink) inset; overflow: hidden;
145
- }
146
- .live-rail .avatar img { width: 100%; height: 100%; object-fit: cover; }
147
- .live-rail .frame-app { display: none; }
148
- .live-rail .frame-details { font-family: var(--font-disp); font-size: 22px; font-weight: 700; margin-bottom: 4px; }
149
- .live-rail .frame-state { font-family: var(--font-mono); font-size: 13px; color: var(--ink-mute); }
150
- .live-rail .right { text-align: right; border-left: 2px dashed var(--ink); padding-left: 24px; }
151
- .live-rail .right .frame-num { font-family: var(--font-mono); font-size: 11px; font-weight: 700; color: var(--ink-mute); margin-bottom: 8px; text-transform: uppercase; }
152
- .live-rail .right .elapsed { font-family: var(--font-mono); font-size: 24px; font-weight: 700; }
153
-
154
- /* ── Hero ────────────────────────────────────────────── */
155
- .hero {
156
- display: grid; grid-template-columns: 1fr 1.4fr; gap: 56px;
157
- align-items: end; margin-bottom: 48px;
158
- }
159
- .hero .eyebrow {
160
- font-family: var(--font-mono); font-size: 12px; font-weight: 700; color: var(--ink-mute);
161
- text-transform: uppercase; margin-bottom: 12px;
162
- border-bottom: 2px solid var(--rust); display: inline-block; padding-bottom: 4px;
163
- }
164
- .hero .figure { font-family: var(--font-disp); font-size: 86px; font-weight: 700; line-height: 0.92; letter-spacing: -0.05em; }
165
- .hero .unit { font-family: var(--font-mono); font-size: 24px; color: var(--ink-mute); margin-left: 10px; font-weight: 700; }
166
- .hero .caption { margin-top: 20px; font-family: var(--font-mono); font-size: 13px; color: var(--ink-soft); max-width: 380px; }
167
-
168
- .chart-block .chart-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; font-family: var(--font-mono); font-weight: 700; }
169
- .chart-block .chart-title { font-size: 12px; color: var(--ink-mute); text-transform: uppercase; }
170
- .chart-block .chart-side { font-size: 12px; color: var(--ink-mute); }
171
- .chart-block .chart-side strong { color: var(--ink); }
172
- .chart-wrap { position: relative; height: 130px; background: #fff; border: 2px solid var(--ink); box-shadow: var(--shadow); padding: 12px; }
173
- svg.chart { width: 100%; height: 100%; overflow: visible; }
174
- svg.chart .grid { stroke: var(--ink-faint); stroke-width: 1; stroke-dasharray: 2 2; }
175
- svg.chart .area { fill: url(#whiteGrad); }
176
- svg.chart .line { fill: none; stroke: var(--rust); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
177
- svg.chart .dot { fill: var(--rust); stroke: #fff; stroke-width: 2px; }
178
- svg.chart .ax { fill: var(--ink-mute); font-size: 10px; font-family: var(--font-mono); font-weight: 700; }
179
-
180
- /* ── Stat cards ──────────────────────────────────────── */
181
- .stat-row {
182
- display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px;
183
- margin-bottom: 48px;
184
- }
185
- .stat-card {
186
- background: #fff; border: 2px solid var(--ink); padding: 24px;
187
- box-shadow: var(--shadow); position: relative;
188
- transition: transform 0.2s, box-shadow 0.2s;
189
- }
190
- .stat-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-hover); }
191
- .stat-card::before {
192
- content: ''; position: absolute; top: -8px; left: 50%; transform: translateX(-50%);
193
- width: 40px; height: 16px; background: rgba(0,0,0,0.05); /* tape piece effect */
194
- }
195
- .stat-card .label { font-family: var(--font-mono); font-size: 11px; font-weight: 700; color: var(--ink-mute); text-transform: uppercase; border-bottom: 2px solid var(--rust); display: inline-block; padding-bottom: 4px; }
196
- .stat-card .value { margin-top: 16px; display: flex; align-items: baseline; gap: 6px; font-family: var(--font-disp); font-size: 36px; font-weight: 700; line-height: 1; }
197
- .stat-card .value .unit { font-family: var(--font-mono); font-size: 16px; color: var(--ink-mute); font-weight: 700; }
198
- .stat-card .meta { margin-top: 12px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-mute); font-weight: 500; }
199
- .delta { font-weight: 700; }
200
- .delta.up { color: var(--grass); }
201
- .delta.down { color: var(--rust); }
202
- .delta.flat { color: var(--ink-mute); }
203
-
204
- /* ── Split: cost + languages ─────────────────────── */
205
- .split-row { display: grid; grid-template-columns: 1.2fr 1fr; gap: 24px; margin-bottom: 48px; }
206
- .card { background: #fff; border: 2px solid var(--ink); padding: 24px; box-shadow: var(--shadow); position: relative; }
207
- .card-h { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20px; font-family: var(--font-mono); font-weight: 700; }
208
- .card-h h3 { font-size: 14px; text-transform: uppercase; border-bottom: 2px solid var(--rust); display: inline-block; padding-bottom: 4px; }
209
- .card-h .meta { font-size: 11px; color: var(--ink-mute); }
210
-
211
- .cost-grid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 24px; align-items: center; }
212
- .cost-figure { font-family: var(--font-disp); font-size: 48px; font-weight: 700; line-height: 1; }
213
- .cost-sub { font-family: var(--font-mono); color: var(--ink-mute); font-size: 12px; margin-top: 8px; font-weight: 700; }
214
- .cost-bars { display: grid; gap: 12px; font-family: var(--font-mono); font-size: 12px; font-weight: 700; }
215
- .cost-bar { display: grid; grid-template-columns: 60px 1fr auto; gap: 8px; align-items: center; }
216
- .cost-bar .name { color: var(--ink-soft); }
217
- .cost-bar .track { height: 8px; background: var(--paper-dark); border: 1.5px solid var(--ink); overflow: hidden; }
218
- .cost-bar .fill { height: 100%; background: var(--purple); border-right: 1.5px solid var(--ink); }
219
- .cost-bar .val { color: var(--ink); }
220
-
221
- .lang-stack { display: flex; height: 16px; background: var(--paper-dark); border: 2px solid var(--ink); margin-bottom: 20px; box-shadow: 2px 2px 0 var(--ink); }
222
- .lang-stack > span { display: block; border-right: 1.5px solid var(--ink); }
223
- .lang-stack > span:last-child { border-right: none; }
224
- .lang-list { display: grid; gap: 8px; font-family: var(--font-mono); font-size: 12px; font-weight: 700; }
225
- .lang-list .row { display: grid; grid-template-columns: 14px 1fr auto; gap: 10px; align-items: center; }
226
- .lang-list .swatch { width: 14px; height: 14px; border: 2px solid var(--ink); }
227
- .lang-list .val { color: var(--ink-mute); }
228
-
229
- /* ── Code churn ──────────────────────────────────── */
230
- .churn-row { display: grid; grid-template-columns: 1fr 200px; gap: 32px; align-items: center; }
231
- .churn-spark svg { width: 100%; height: 60px; display: block; overflow: visible; }
232
- .churn-spark .add { fill: var(--grass); stroke: var(--ink); stroke-width: 1.5; }
233
- .churn-spark .rem { fill: var(--rust); stroke: var(--ink); stroke-width: 1.5; }
234
- .churn-numbers { display: grid; gap: 8px; font-family: var(--font-mono); font-size: 13px; font-weight: 700; }
235
- .churn-numbers .row { display: flex; justify-content: space-between; padding-bottom: 4px; border-bottom: 1.5px dashed var(--ink-faint); }
236
- .churn-numbers .row:last-child { border-bottom: none; }
237
- .churn-numbers .label { color: var(--ink-mute); }
238
- .churn-numbers .added { color: var(--grass); }
239
- .churn-numbers .removed { color: var(--rust); }
240
- .churn-numbers .net { color: var(--ink); }
241
-
242
- /* ── Section heading ─────────────────────────────────── */
243
- .section-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 20px; font-family: var(--font-mono); font-weight: 700; }
244
- .section-head h2 { font-size: 16px; text-transform: uppercase; background: var(--tape); display: inline-block; padding: 4px 12px; border: 2px solid var(--ink); box-shadow: 2px 2px 0 var(--ink); transform: rotate(-1deg); }
245
- .section-head .right { font-size: 12px; color: var(--ink-mute); }
246
- section { margin-bottom: 48px; }
247
-
248
- /* ── Leaderboards ────────────────────────────────────── */
249
- .lb-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
250
- .lb { background: #fff; border: 2px solid var(--ink); box-shadow: var(--shadow); position: relative; }
251
- .lb-h { display: flex; justify-content: space-between; align-items: baseline; padding: 16px 20px 12px; font-family: var(--font-mono); font-weight: 700; border-bottom: 2px solid var(--ink); background: var(--paper-dark); }
252
- .lb-h .t { font-size: 13px; text-transform: uppercase; }
253
- .lb-h .s { font-size: 11px; color: var(--ink-mute); }
254
- .lb table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-weight: 600; font-size: 12px; }
255
- .lb td { padding: 10px 20px; border-bottom: 1.5px dashed var(--ink-faint); }
256
- .lb tr:last-child td { border-bottom: none; }
257
- .lb tr:hover td { background: var(--paper); }
258
- .lb td.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 1px; color: var(--ink); }
259
- .lb td.val { color: var(--ink-soft); text-align: right; white-space: nowrap; }
260
- .lb td.name .ico { width: 12px; height: 12px; border: 1.5px solid var(--ink); margin-right: 8px; vertical-align: -2px; }
261
-
262
- /* ── Discord card ────────────────────────────────────── */
263
- .discord { background: #fff; border: 2px solid var(--ink); padding: 32px; box-shadow: var(--shadow); position: relative; }
264
- .discord-h { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 24px; font-family: var(--font-mono); font-weight: 700; }
265
- .discord-h .t { font-size: 14px; text-transform: uppercase; border-bottom: 2px solid var(--rust); padding-bottom: 4px; }
266
- .live-frame { padding: 24px 0; border-top: 2px solid var(--ink); border-bottom: 2px dashed var(--ink); }
267
- .live-frame .label-tag { font-family: var(--font-mono); font-size: 11px; font-weight: 700; background: var(--grass); color: #fff; padding: 4px 8px; border: 1.5px solid var(--ink); box-shadow: 2px 2px 0 var(--ink); display: inline-block; margin-bottom: 12px; transform: rotate(-2deg); }
268
- .live-frame .label-tag::before { display: none; }
269
- .live-frame .details { font-family: var(--font-disp); font-size: 24px; font-weight: 700; margin-bottom: 4px; }
270
- .live-frame .state { font-family: var(--font-mono); font-size: 13px; color: var(--ink-soft); font-weight: 600; }
271
- .rotation-list { list-style: none; margin-top: 24px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 24px; font-family: var(--font-mono); font-size: 12px; font-weight: 700; }
272
- .rotation-list li { display: flex; align-items: center; gap: 10px; color: var(--ink-soft); }
273
- .rotation-list li .pip { width: 8px; height: 8px; background: var(--paper-dark); border: 1.5px solid var(--ink); flex-shrink: 0; }
274
- .rotation-list li.live .pip { background: var(--grass); }
275
- .rotation-list li.current { color: var(--ink); }
276
- .rotation-list li.current .pip { background: var(--ink); box-shadow: 2px 2px 0 var(--ink); }
277
-
278
- /* ── Achievements, Insights, Modals ─────────────────── */
279
- .achievements { display: grid; grid-template-columns: repeat(6, 1fr); gap: 16px; margin-bottom: 48px; }
280
- .achievement { background: #fff; border: 2px dashed var(--ink-faint); padding: 16px; text-align: center; opacity: 0.5; transition: all 0.2s; }
281
- .achievement.unlocked { opacity: 1; border: 2px solid var(--ink); box-shadow: var(--shadow); transform: rotate(1deg); }
282
- .achievement .ico { font-size: 24px; margin-bottom: 8px; display: block; }
283
- .achievement .t { font-family: var(--font-mono); font-size: 12px; font-weight: 700; text-transform: uppercase; margin-bottom: 4px; }
284
- .achievement .s { font-size: 11px; color: var(--ink-mute); }
285
-
286
- .insights { background: #fff; border: 2px solid var(--ink); padding: 24px; margin-bottom: 48px; box-shadow: var(--shadow); display: grid; gap: 12px; }
287
- .insights .insight { display: flex; align-items: baseline; gap: 12px; font-family: var(--font-mono); font-size: 13px; font-weight: 600; }
288
- .insights .insight::before { content: '→'; color: var(--rust); font-weight: 700; }
289
-
290
- .scrim { position: fixed; inset: 0; background: rgba(26,22,17,0.4); backdrop-filter: blur(2px); display: none; z-index: 50; }
291
- .scrim.open { display: block; }
292
- .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 480px; max-width: 100%; background: var(--paper); border-left: 2px solid var(--ink); transform: translateX(100%); transition: transform 0.2s ease; z-index: 60; padding: 40px; overflow-y: auto; box-shadow: -10px 0 30px rgba(0,0,0,0.1); }
293
- .drawer.open { transform: translateX(0); }
294
- .drawer h3 { font-family: var(--font-disp); font-size: 28px; margin-bottom: 8px; }
295
- .drawer .sub { font-family: var(--font-mono); color: var(--ink-mute); font-weight: 700; margin-bottom: 32px; }
296
-
297
- .modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.96); background: var(--paper); border: 2px solid var(--ink); padding: 40px; box-shadow: 10px 10px 0 var(--ink); z-index: 60; opacity: 0; pointer-events: none; transition: all 0.2s; }
298
- .modal.open { opacity: 1; transform: translate(-50%, -50%) scale(1); pointer-events: auto; }
299
- .modal h4 { font-family: var(--font-disp); font-size: 24px; margin-bottom: 8px; }
300
-
301
- footer { margin-top: 60px; padding-top: 32px; border-top: 2px dashed var(--ink); display: flex; justify-content: space-between; align-items: center; font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: var(--ink-mute); }
302
- footer .pulse-dot { background: var(--grass); border: 1.5px solid var(--ink); }
303
-
304
- /* ── Heatmap ─────────────────────────────────────────── */
305
- .heatmap-card { padding: 20px 22px; }
306
- .heatmap { display: grid; grid-template-columns: 20px 1fr; gap: 6px; }
307
- .heatmap .day-labels { display: grid; grid-template-rows: repeat(7, 12px); gap: 3px; font-size: 9px; color: var(--text-3); padding-top: 14px; }
308
- .heatmap .grid {
309
- display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 12px); gap: 3px;
310
- font-size: 0;
311
- }
312
- .heatmap .cell { width: 12px; height: 12px; border-radius: 2px; background: rgba(0,0,0,0.06); cursor: pointer; transition: transform 0.1s; }
313
- .heatmap .cell:hover { transform: scale(1.4); outline: 1px solid var(--text); }
314
-
315
- /* ── Help overlay ────────────────────────────────────── */
316
- .help { position: fixed; inset: 0; background: rgba(26,22,17,0.7); display: none; z-index: 70; align-items: center; justify-content: center; }
317
- .help.open { display: flex; }
318
- .help-card { background: var(--paper); border: 2px solid var(--ink); box-shadow: var(--shadow); padding: 28px 32px; max-width: 420px; width: 90%; }
319
- .help-card h4 { font-family: var(--font-disp); font-size: 20px; margin-bottom: 16px; }
320
- .help-card .kbd { display: inline-block; padding: 2px 6px; border: 1.5px solid var(--ink); background: #fff; font-size: 11px; font-family: var(--font-mono); margin-right: 8px; font-weight: 700; box-shadow: 1.5px 1.5px 0 var(--ink); }
321
- .help-card .row { display: flex; padding: 8px 0; border-top: 1.5px dashed var(--ink-faint); font-family: var(--font-mono); font-size: 12px; font-weight: 700; color: var(--ink-soft); }
322
- .help-card .row:first-of-type { border-top: 0; }
323
- .help-card .keys { width: 110px; }
324
-
325
- /* ── Responsive ──────────────────────────────────────── */
326
- @media (max-width: 1100px) {
327
- .stat-row { grid-template-columns: repeat(2, 1fr); }
328
- .split-row { grid-template-columns: 1fr; }
329
- .achievements { grid-template-columns: repeat(3, 1fr); }
330
- }
331
- @media (max-width: 760px) {
332
- .hero { grid-template-columns: 1fr; gap: 28px; }
333
- .lb-grid { grid-template-columns: 1fr; }
334
- .rotation-list { grid-template-columns: 1fr; }
335
- .drawer { width: 100%; }
336
- }
337
- `;
338
-
339
- // ─────────────────────────────────────────────────────────────────────
340
- // §2 LANG_PALETTE — language → swatch hex
341
- // ─────────────────────────────────────────────────────────────────────
342
- //
343
- // Stable across renders. Embedded as a JS object literal inside the
344
- // client script template; the client parses it once at startup.
345
-
346
- const LANG_PALETTE = `{
347
- 'JavaScript': '#f7df1e', 'TypeScript': '#3178c6', 'Python': '#3776ab', 'Rust': '#dea584',
348
- 'Go': '#00add8', 'Ruby': '#cc342d', 'Java': '#b07219', 'Kotlin': '#a97bff',
349
- 'C': '#555', 'C++': '#f34b7d', 'C#': '#178600', 'PHP': '#4f5b93',
350
- 'Swift': '#ffac45', 'HTML': '#e34c26', 'CSS': '#563d7c', 'SCSS': '#c6538c',
351
- 'Markdown': '#888', 'JSON': '#888', 'Shell': '#89e051', 'YAML': '#cb171e',
352
- 'Vue': '#41b883', 'Svelte': '#ff3e00', 'Notebook': '#da5b0b', 'SQL': '#dad8d8',
353
- 'GraphQL': '#e10098', 'Dockerfile': '#384d54', 'Make': '#427819', 'CMake': '#da3434',
354
- 'Lua': '#000080', 'Dart': '#00b4ab', 'Elm': '#60b5cc', 'Elixir': '#6e4a7e',
355
- 'Erlang': '#a90533', 'Haskell': '#5d4f85', 'OCaml': '#3be133', 'Clojure': '#db5855',
356
- 'ClojureScript': '#db5855', 'R': '#198ce7', 'Julia': '#a270ba', 'Zig': '#ec915c',
357
- 'PowerShell': '#012456', 'Batch': '#c1f12e', 'TOML': '#9c4221', 'INI': '#888',
358
- 'XML': '#0060ac', 'Protobuf': '#888', 'LaTeX': '#3D6117', 'Text': '#888',
359
- 'reStructuredText': '#888', 'Lockfile': '#444', 'Gradle': '#02303a',
360
- 'Crystal': '#000100', 'Nim': '#ffc200', 'V': '#4f87c4', 'Objective-C': '#438eff',
361
- 'Objective-C++': '#6866fb', 'Sass': '#a53b70', 'Less': '#1d365d', 'Vue': '#41b883',
362
- 'Scala': '#c22d40', 'Groovy': '#4298b8', 'Interface Builder': '#888', 'Env': '#888',
363
- 'Config': '#888', 'Git': '#f1502f',
364
- }`;
365
-
366
- // ─────────────────────────────────────────────────────────────────────
367
- // §3 buildHtml — HTML scaffold + interpolation
368
- // ─────────────────────────────────────────────────────────────────────
369
- //
370
- // Returns the full HTML string. The only dynamic value is the port,
371
- // which is fixed at server startup, so this is composed once and reused
372
- // for every request (see src/server/index.js).
16
+ // Read once at module load. The assets are static for the life of the
17
+ // process; only `port` varies, and that's injected per buildHtml call.
18
+ const TEMPLATE = loadAsset('dashboard.html');
19
+ const STYLES = loadAsset('dashboard.css');
20
+ const SCRIPT = loadAsset('dashboard.client.js');
373
21
 
374
22
  function buildHtml({ port }) {
375
- const PORT = port;
376
- return String.raw`<!doctype html>
377
- <html lang="en">
378
- <head>
379
- <meta charset="utf-8" />
380
- <meta name="viewport" content="width=device-width,initial-scale=1" />
381
- <title>Claude</title>
382
- <link rel="preconnect" href="https://fonts.googleapis.com">
383
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
384
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
385
- <style>${CSS}</style>
386
- </head>
387
- <body>
388
- <main class="page">
389
-
390
- <!-- ── Top bar ─────────────────────────────────────── -->
391
- <header class="topbar">
392
- <div class="brand">
393
- <span class="mark">◆</span>
394
- <span>Claude</span>
395
- <span class="sep">·</span>
396
- <span class="meta" id="meta">—</span>
397
- </div>
398
- <div class="top-right">
399
- <div class="range-pills" id="range-pills">
400
- <button data-range="7d">7d</button>
401
- <button data-range="30d">30d</button>
402
- <button data-range="90d" class="active">90d</button>
403
- <button data-range="1y">1y</button>
404
- <button data-range="all">All</button>
405
- </div>
406
- <button class="theme-btn" id="theme-btn" title="Toggle theme">◐</button>
407
- <span class="model" id="model">—</span>
408
- <span class="status"><span class="dot" id="dot"></span><span id="statustext">—</span></span>
409
- </div>
410
- </header>
411
-
412
- <!-- ── Live rail ───────────────────────────────────── -->
413
- <section class="live-rail" id="live-rail">
414
- <div class="avatar" id="live-avatar"></div>
415
- <div>
416
- <div class="frame-app">Claude Code</div>
417
- <div class="frame-details" id="frame-details">—</div>
418
- <div class="frame-state" id="frame-state">—</div>
419
- </div>
420
- <div class="right">
421
- <div class="frame-num" id="frame-num">—</div>
422
- <div class="elapsed" id="elapsed">—</div>
423
- </div>
424
- </section>
425
-
426
- <!-- ── Insights ────────────────────────────────────── -->
427
- <section class="insights" id="insights"><div class="insight">Loading…</div></section>
428
-
429
- <!-- ── Hero ────────────────────────────────────────── -->
430
- <section class="hero">
431
- <div>
432
- <div class="eyebrow">Active time</div>
433
- <div><span class="figure" id="hero-num">—</span><span class="unit" id="hero-unit">hours</span></div>
434
- <div class="caption" id="hero-caption">—</div>
435
- </div>
436
- <div class="chart-block">
437
- <div class="chart-head">
438
- <span class="chart-title" id="chart-title">Last 90 days</span>
439
- <span class="chart-side"><strong id="chart-total">—</strong> <span style="color: var(--text-4); margin: 0 6px;">·</span> peak <strong id="chart-peak">—</strong></span>
440
- </div>
441
- <div class="chart-wrap">
442
- <svg id="chart" class="chart" viewBox="0 0 800 130" preserveAspectRatio="none">
443
- <defs>
444
- <linearGradient id="whiteGrad" x1="0" y1="0" x2="0" y2="1">
445
- <stop offset="0%" stop-color="var(--text)" stop-opacity="0.14"/>
446
- <stop offset="100%" stop-color="var(--text)" stop-opacity="0"/>
447
- </linearGradient>
448
- </defs>
449
- </svg>
450
- </div>
451
- </div>
452
- </section>
453
-
454
- <!-- ── Stat row ────────────────────────────────────── -->
455
- <section class="stat-row">
456
- <div class="stat-card">
457
- <div class="label">Today</div>
458
- <div class="value"><span id="today-num">—</span><span class="unit" id="today-unit">hrs</span></div>
459
- <div class="meta"><span class="delta" id="today-delta">—</span> <span id="today-sub" class="num">—</span></div>
460
- </div>
461
- <div class="stat-card">
462
- <div class="label">This range</div>
463
- <div class="value"><span id="range-num">—</span><span class="unit" id="range-unit">hrs</span></div>
464
- <div class="meta"><span class="delta" id="range-delta">—</span> <span id="range-sub" class="num">—</span></div>
465
- </div>
466
- <div class="stat-card">
467
- <div class="label">Streak</div>
468
- <div class="value"><span id="streak-num">—</span><span class="unit">days</span></div>
469
- <div class="meta"><span id="streak-sub">—</span></div>
470
- </div>
471
- <div class="stat-card">
472
- <div class="label">Cost · range</div>
473
- <div class="value"><span id="cost-num">—</span></div>
474
- <div class="meta"><span id="cost-sub">—</span></div>
475
- </div>
476
- </section>
477
-
478
- <!-- ── Achievements ────────────────────────────────── -->
479
- <section class="achievements" id="achievements"></section>
480
-
481
- <!-- ── Heatmap ─────────────────────────────────────── -->
482
- <section>
483
- <div class="section-head">
484
- <h2>Activity</h2>
485
- <div class="right" id="heatmap-meta">click a day for details</div>
486
- </div>
487
- <div class="card heatmap-card">
488
- <div class="heatmap">
489
- <div class="day-labels"><span></span><span>M</span><span></span><span>W</span><span></span><span>F</span><span></span></div>
490
- <div class="grid" id="heatmap-grid"></div>
491
- </div>
492
- </div>
493
- </section>
494
-
495
- <!-- ── Split: cost + languages ─────────────────────── -->
496
- <section class="split-row">
497
- <div class="card">
498
- <div class="card-h"><h3>Cost</h3><div class="meta" id="cost-card-meta">approximate · range</div></div>
499
- <div class="cost-grid">
500
- <div>
501
- <div class="cost-figure" id="cost-figure">—</div>
502
- <div class="cost-sub" id="cost-figure-sub">—</div>
503
- </div>
504
- <div class="cost-bars" id="cost-bars"></div>
505
- </div>
506
- </div>
507
- <div class="card">
508
- <div class="card-h"><h3>Languages</h3><div class="meta" id="lang-meta">by edits</div></div>
509
- <div class="lang-stack" id="lang-stack"></div>
510
- <div class="lang-list" id="lang-list"></div>
511
- </div>
512
- </section>
513
-
514
- <!-- ── Code churn ──────────────────────────────────── -->
515
- <section class="card" style="margin-bottom: 28px;">
516
- <div class="card-h"><h3>Code churn</h3><div class="meta" id="churn-meta">lines added / removed · range</div></div>
517
- <div class="churn-row">
518
- <div class="churn-spark">
519
- <svg id="churn-svg" viewBox="0 0 800 60" preserveAspectRatio="none"></svg>
520
- </div>
521
- <div class="churn-numbers">
522
- <div class="row"><span class="label">Added</span><span class="added" id="churn-added">—</span></div>
523
- <div class="row"><span class="label">Removed</span><span class="removed" id="churn-removed">—</span></div>
524
- <div class="row"><span class="label">Net</span><span class="net" id="churn-net">—</span></div>
525
- </div>
526
- </div>
527
- </section>
528
-
529
- <!-- ── Tokens ──────────────────────────────────────── -->
530
- <section>
531
- <div class="section-head">
532
- <h2>Tokens</h2>
533
- <div class="right"><span id="tok-cache-pct">—</span> from cache</div>
534
- </div>
535
- <div class="stat-row" style="margin-bottom: 0; grid-template-columns: repeat(3, 1fr);">
536
- <div class="stat-card"><div class="label">Grand total</div><div class="value"><span id="tok-grand">—</span></div><div class="meta"><span class="num" id="tok-grand-sub">in + out + cache</span></div></div>
537
- <div class="stat-card"><div class="label">Output</div><div class="value"><span id="tok-out">—</span></div><div class="meta"><span class="num" id="tok-in-sub">input —</span></div></div>
538
- <div class="stat-card"><div class="label">Cache</div><div class="value"><span id="tok-cache">—</span></div><div class="meta"><span class="num" id="tok-cache-sub">read — · write —</span></div></div>
539
- </div>
540
- </section>
541
-
542
- <!-- ── Leaderboards ────────────────────────────────── -->
543
- <section>
544
- <div class="section-head">
545
- <h2>Projects · tools · files</h2>
546
- <div class="right" id="lb-meta">across <span id="lb-sessions">—</span> sessions</div>
547
- </div>
548
- <div class="lb-grid">
549
- <div class="lb">
550
- <div class="lb-h"><span class="t">Projects</span><span class="s">by hours</span></div>
551
- <table id="projects-tbl"></table>
552
- </div>
553
- <div class="lb">
554
- <div class="lb-h"><span class="t">Tools</span><span class="s">by calls</span></div>
555
- <table id="tools-tbl"></table>
556
- </div>
557
- <div class="lb">
558
- <div class="lb-h"><span class="t">Files</span><span class="s">by edits</span></div>
559
- <table id="files-tbl"></table>
560
- </div>
561
- </div>
562
- </section>
563
-
564
- <!-- ── More leaderboards: bash + domains + subagents ─ -->
565
- <section>
566
- <div class="section-head">
567
- <h2>Shell · web · subagents</h2>
568
- <div class="right"><span id="mcp-label">—</span></div>
569
- </div>
570
- <div class="lb-grid">
571
- <div class="lb">
572
- <div class="lb-h"><span class="t">Bash commands</span><span class="s">by invocations</span></div>
573
- <table id="bash-tbl"></table>
574
- </div>
575
- <div class="lb">
576
- <div class="lb-h"><span class="t">WebFetch domains</span><span class="s">by hits</span></div>
577
- <table id="domains-tbl"></table>
578
- </div>
579
- <div class="lb">
580
- <div class="lb-h"><span class="t">Subagents</span><span class="s">by invocations</span></div>
581
- <table id="subagents-tbl"></table>
582
- </div>
583
- </div>
584
- </section>
585
-
586
- <!-- ── Discord card ────────────────────────────────── -->
587
- <section>
588
- <div class="section-head">
589
- <h2>Discord presence</h2>
590
- <div class="right"><span id="frames-live">—</span> live · <span id="frames-total">—</span> total</div>
591
- </div>
592
- <div class="discord">
593
- <div class="discord-h"><span class="t">Now showing</span><span class="s" id="frame-no">—</span></div>
594
- <div class="live-frame">
595
- <div class="label-tag">On air</div>
596
- <div class="details" id="frame-details-2">—</div>
597
- <div class="state" id="frame-state-2">—</div>
598
- </div>
599
- <ul class="rotation-list" id="rotation-list"></ul>
600
- </div>
601
- </section>
602
-
603
- <footer>
604
- <span class="pulse"><span class="pulse-dot"></span><span id="conn-state">live</span></span>
605
- <span>
606
- <a href="/api/badge.svg?metric=hours&range=7d" target="_blank">badges</a>
607
- ·
608
- <span>127.0.0.1:${PORT}</span>
609
- ·
610
- <span style="color: var(--text-4);">?</span> for help
611
- </span>
612
- </footer>
613
- </main>
614
-
615
- <!-- Drawer (project drilldown) -->
616
- <div class="scrim" id="scrim"></div>
617
- <div class="drawer" id="drawer">
618
- <button class="close" id="drawer-close">×</button>
619
- <h3 id="drawer-title">—</h3>
620
- <div class="sub" id="drawer-sub">—</div>
621
- <div class="grid" id="drawer-body"></div>
622
- </div>
623
-
624
- <!-- Modal (day detail) -->
625
- <div class="modal" id="modal">
626
- <button class="close" id="modal-close">×</button>
627
- <h4 id="modal-title">—</h4>
628
- <div class="sub" id="modal-sub">—</div>
629
- <div id="modal-body"></div>
630
- </div>
631
-
632
- <!-- Keyboard help -->
633
- <div class="help" id="help">
634
- <div class="help-card">
635
- <h4>Keyboard shortcuts</h4>
636
- <div class="row"><span class="keys"><span class="kbd">1</span><span class="kbd">5</span></span><span>switch range</span></div>
637
- <div class="row"><span class="keys"><span class="kbd">t</span></span><span>toggle theme</span></div>
638
- <div class="row"><span class="keys"><span class="kbd">esc</span></span><span>close drawer / modal</span></div>
639
- <div class="row"><span class="keys"><span class="kbd">?</span></span><span>this help</span></div>
640
- </div>
641
- </div>
642
-
643
- <script>
644
- ${HTML_SCRIPT_PLACEHOLDER()}
645
- </script>
646
- </body>
647
- </html>`;
648
- }
649
-
650
- // ─────────────────────────────────────────────────────────────────────
651
- // §4 HTML_SCRIPT_BODY — client runtime
652
- // ─────────────────────────────────────────────────────────────────────
653
- //
654
- // Returned as a string and inlined into the HTML at the bottom of the
655
- // document. Vanilla browser JS — no bundler, no framework, no npm. The
656
- // IIFE keeps all locals out of window.
657
- //
658
- // Sub-structure within the IIFE:
659
- // • $ — DOM accessor
660
- // • LANGS — parsed language palette
661
- // • Utilities — fmtH / fmtN / fmtCost / escape / etc.
662
- // • State buckets — liveData / aggData / allFrames / rotationTimer
663
- // • Range pills — active selector + delta vs previous range
664
- // • Charts — area chart, sparkline, heatmap, lang stack
665
- // • Drilldowns — project drawer + day-detail modal
666
- // • SSE wiring — EventSource('/events') → refresh handlers
667
- // • Keyboard — 1..5 range, t theme, esc close, ? help
668
-
669
- function HTML_SCRIPT_PLACEHOLDER() {
670
- return `(() => {
671
- const $ = (id) => document.getElementById(id);
672
- const LANGS = ${LANG_PALETTE};
673
-
674
- let range = '90d';
675
- let liveData = null;
676
- let aggData = null;
677
- let allFrames = [];
678
- let currentLiveIdx = 0;
679
- let rotationTimer = null;
680
-
681
- // ── Utilities ───────────────────────────────────────────
682
- const fmtH = (ms) => {
683
- if (!ms) return '0h';
684
- const h = ms / 3_600_000;
685
- if (h < 1) return Math.round(h * 60) + 'm';
686
- if (h < 10) return h.toFixed(1) + 'h';
687
- return Math.round(h) + 'h';
688
- };
689
- const fmtN = (n) => {
690
- if (!n) return '0';
691
- if (n < 1000) return String(n);
692
- if (n < 1e6) return (n / 1e3).toFixed(1) + 'k';
693
- if (n < 1e9) return (n / 1e6).toFixed(2) + 'M';
694
- return (n / 1e9).toFixed(2) + 'B';
695
- };
696
- const fmtCost = (usd) => {
697
- if (!usd) return '$0';
698
- if (usd < 0.01) return '$' + usd.toFixed(4);
699
- if (usd < 100) return '$' + usd.toFixed(2);
700
- if (usd < 1000) return '$' + Math.round(usd);
701
- if (usd < 10000) return '$' + (usd / 1000).toFixed(2) + 'k';
702
- return '$' + (usd / 1000).toFixed(1) + 'k';
703
- };
704
- const dayKey = (ts) => {
705
- const d = new Date(ts);
706
- return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
707
- };
708
- const splitTime = (s) => {
709
- if (!s) return ['—', ''];
710
- const m = String(s).match(/^([\\d.]+)([a-z]*)$/i);
711
- return m ? [m[1], m[2]] : [s, ''];
712
- };
713
- const setDelta = (node, ms, suffix) => {
714
- if (ms === 0) { node.className = 'delta flat'; node.textContent = '—'; return; }
715
- const sign = ms > 0 ? 'up' : 'down';
716
- const arrow = ms > 0 ? '↑' : '↓';
717
- node.className = 'delta ' + sign;
718
- node.textContent = arrow + ' ' + fmtH(Math.abs(ms)) + (suffix ? ' ' + suffix : '');
719
- };
720
- const elapsedStr = (start) => {
721
- if (!start) return '—';
722
- const s = Math.floor((Date.now() - start) / 1000);
723
- const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
724
- if (h) return h + 'h ' + m + 'm';
725
- return m + 'm ' + (s % 60) + 's';
726
- };
727
-
728
- // ── Theme ───────────────────────────────────────────────
729
- function applyTheme() {
730
- const saved = localStorage.getItem('theme') || 'dark';
731
- document.documentElement.classList.toggle('light', saved === 'light');
732
- }
733
- $('theme-btn').addEventListener('click', () => {
734
- const cur = localStorage.getItem('theme') || 'dark';
735
- localStorage.setItem('theme', cur === 'dark' ? 'light' : 'dark');
736
- applyTheme();
737
- });
738
- applyTheme();
739
-
740
- // ── Range pills ─────────────────────────────────────────
741
- document.querySelectorAll('#range-pills button').forEach((b) => {
742
- b.addEventListener('click', () => {
743
- range = b.dataset.range;
744
- for (const x of document.querySelectorAll('#range-pills button')) x.classList.toggle('active', x === b);
745
- $('chart-title').textContent = range === 'all' ? 'All time' : 'Last ' + range;
746
- fetchAggregate();
747
- });
748
- });
749
-
750
- // ── Chart ───────────────────────────────────────────────
751
- function renderChart(byDay, days) {
752
- const svg = $('chart');
753
- [...svg.querySelectorAll('.dyn')].forEach((n) => n.remove());
754
- const ns = 'http://www.w3.org/2000/svg';
755
- const VIEW_W = 800, VIEW_H = 130, PAD_T = 6, PAD_B = 16;
756
- const today = new Date(); today.setHours(0, 0, 0, 0);
757
- const series = [];
758
- for (let i = days - 1; i >= 0; i--) {
759
- const d = new Date(today); d.setDate(d.getDate() - i);
760
- const ms = (byDay[dayKey(d.getTime())] || {}).activeMs || 0;
761
- series.push({ d, ms });
762
- }
763
- const max = Math.max(...series.map((p) => p.ms), 1);
764
- const h = VIEW_H - PAD_T - PAD_B;
765
- const xAt = (i) => series.length > 1 ? (i / (series.length - 1)) * VIEW_W : VIEW_W / 2;
766
- const yAt = (ms) => PAD_T + h - (ms / max) * h;
767
- for (let r = 1; r <= 3; r++) {
768
- const y = PAD_T + (h / 3) * r;
769
- const ln = document.createElementNS(ns, 'line');
770
- ln.setAttribute('x1', 0); ln.setAttribute('x2', VIEW_W);
771
- ln.setAttribute('y1', y); ln.setAttribute('y2', y);
772
- ln.setAttribute('class', 'grid dyn');
773
- svg.appendChild(ln);
774
- }
775
- let path = '';
776
- series.forEach((p, i) => {
777
- const x = xAt(i), y = yAt(p.ms);
778
- path += (i === 0 ? 'M' : ' L') + x.toFixed(1) + ',' + y.toFixed(1);
779
- });
780
- const area = document.createElementNS(ns, 'path');
781
- area.setAttribute('d', path + ' L' + xAt(series.length - 1).toFixed(1) + ',' + (PAD_T + h) + ' L0,' + (PAD_T + h) + ' Z');
782
- area.setAttribute('class', 'area dyn');
783
- svg.appendChild(area);
784
- const line = document.createElementNS(ns, 'path');
785
- line.setAttribute('d', path);
786
- line.setAttribute('class', 'line dyn');
787
- svg.appendChild(line);
788
- const last = series[series.length - 1];
789
- if (last.ms > 0) {
790
- const dot = document.createElementNS(ns, 'circle');
791
- dot.setAttribute('cx', xAt(series.length - 1));
792
- dot.setAttribute('cy', yAt(last.ms));
793
- dot.setAttribute('r', 3);
794
- dot.setAttribute('class', 'dot dyn');
795
- svg.appendChild(dot);
796
- }
797
- const totalMs = series.reduce((s, p) => s + p.ms, 0);
798
- const peakDay = series.reduce((m, p) => p.ms > m.ms ? p : m, { ms: 0, d: null });
799
- $('chart-total').textContent = fmtH(totalMs) + ' total';
800
- $('chart-peak').textContent = peakDay.ms > 0 ? fmtH(peakDay.ms) + ' on ' + peakDay.d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—';
801
- }
802
-
803
- // ── Heatmap ─────────────────────────────────────────────
804
- function renderHeatmap(byDay) {
805
- const grid = $('heatmap-grid');
806
- grid.innerHTML = '';
807
- const today = new Date(); today.setHours(0, 0, 0, 0);
808
- let start = new Date(today); start.setDate(start.getDate() - 90);
809
- while (start.getDay() !== 0) start.setDate(start.getDate() - 1);
810
- let max = 0;
811
- for (let k in byDay) max = Math.max(max, byDay[k].activeMs || 0);
812
- const cur = new Date(start);
813
- while (cur <= today) {
814
- const k = dayKey(cur.getTime());
815
- const ms = (byDay[k] || {}).activeMs || 0;
816
- const cell = document.createElement('div');
817
- cell.className = 'cell';
818
- if (ms > 0) {
819
- const lvl = Math.min(1, ms / max);
820
- cell.style.background = 'rgba(74, 222, 128, ' + (0.18 + lvl * 0.72).toFixed(2) + ')';
821
- }
822
- cell.title = k + ' · ' + fmtH(ms);
823
- cell.addEventListener('click', () => openDay(k));
824
- grid.appendChild(cell);
825
- cur.setDate(cur.getDate() + 1);
826
- }
827
- }
828
-
829
- // ── Churn sparkline ─────────────────────────────────────
830
- function renderChurn(byDay, days) {
831
- const svg = $('churn-svg');
832
- svg.innerHTML = '';
833
- const ns = 'http://www.w3.org/2000/svg';
834
- const W = 800, H = 60;
835
- const today = new Date(); today.setHours(0, 0, 0, 0);
836
- const series = [];
837
- for (let i = days - 1; i >= 0; i--) {
838
- const d = new Date(today); d.setDate(d.getDate() - i);
839
- const day = byDay[dayKey(d.getTime())] || {};
840
- series.push({ add: day.linesAdded || 0, rem: day.linesRemoved || 0 });
841
- }
842
- const maxAdd = Math.max(1, ...series.map((s) => s.add));
843
- const maxRem = Math.max(1, ...series.map((s) => s.rem));
844
- const maxBoth = Math.max(maxAdd, maxRem);
845
- const half = H / 2;
846
- const bw = W / series.length;
847
- series.forEach((s, i) => {
848
- const ah = (s.add / maxBoth) * (half - 2);
849
- const rh = (s.rem / maxBoth) * (half - 2);
850
- const a = document.createElementNS(ns, 'rect');
851
- a.setAttribute('x', (i * bw + 0.5).toFixed(1));
852
- a.setAttribute('y', (half - ah).toFixed(1));
853
- a.setAttribute('width', (bw - 1).toFixed(1));
854
- a.setAttribute('height', ah.toFixed(1));
855
- a.setAttribute('class', 'add');
856
- svg.appendChild(a);
857
- const r = document.createElementNS(ns, 'rect');
858
- r.setAttribute('x', (i * bw + 0.5).toFixed(1));
859
- r.setAttribute('y', half.toFixed(1));
860
- r.setAttribute('width', (bw - 1).toFixed(1));
861
- r.setAttribute('height', rh.toFixed(1));
862
- r.setAttribute('class', 'rem');
863
- svg.appendChild(r);
864
- });
865
- }
866
-
867
- // ── Tables ──────────────────────────────────────────────
868
- function renderTable(target, rows, opts = {}) {
869
- const tbl = $(target);
870
- tbl.innerHTML = '';
871
- if (!rows.length) {
872
- const tr = document.createElement('tr');
873
- tr.innerHTML = '<td class="name" style="color: var(--text-3);">—</td><td class="val">—</td>';
874
- tbl.appendChild(tr);
875
- return;
876
- }
877
- rows.forEach((r) => {
878
- const tr = document.createElement('tr');
879
- if (r.onClick) tr.classList.add('clickable');
880
- const ico = r.color ? '<span class="ico" style="background:' + r.color + '"></span>' : '';
881
- const nameHtml = opts.mono
882
- ? '<code style="font-family: JetBrains Mono, monospace; font-size: 12px;">' + ico + r.name + '</code>'
883
- : ico + r.name;
884
- tr.innerHTML = '<td class="name">' + nameHtml + '</td>' +
885
- '<td class="val">' + r.val + (r.unit ? '<span class="u">' + r.unit + '</span>' : '') + '</td>';
886
- if (r.onClick) tr.addEventListener('click', r.onClick);
887
- tbl.appendChild(tr);
888
- });
889
- }
890
-
891
- // ── Achievements ────────────────────────────────────────
892
- function renderAchievements(a) {
893
- const list = [
894
- { t: 'First session', ok: (a.sessions || 0) >= 1, s: '1', ico: '◉' },
895
- { t: 'Week streak', ok: (a.longestStreak || 0) >= 7, s: '7 days', ico: '◆' },
896
- { t: 'Month streak', ok: (a.longestStreak || 0) >= 30, s: '30 days', ico: '◇' },
897
- { t: '1k prompts', ok: (a.userMessages || 0) >= 1000, s: '1k', ico: '◈' },
898
- { t: '10k lines', ok: (a.linesAdded || 0) >= 10000, s: '10k', ico: '◍' },
899
- { t: '100 sessions', ok: (a.sessions || 0) >= 100, s: '100', ico: '◎' },
900
- ];
901
- const root = $('achievements');
902
- root.innerHTML = '';
903
- for (const it of list) {
904
- const el = document.createElement('div');
905
- el.className = 'achievement' + (it.ok ? ' unlocked' : '');
906
- el.innerHTML = '<span class="ico">' + it.ico + '</span><div class="t">' + it.t + '</div><div class="s">' + it.s + '</div>';
907
- root.appendChild(el);
908
- }
909
- }
910
-
911
- // ── Cost panel ──────────────────────────────────────────
912
- function renderCost(a) {
913
- $('cost-figure').textContent = fmtCost(a.estimatedCost || 0);
914
- const hours = (a.activeMs || 0) / 3_600_000;
915
- const perHour = hours > 0.05 ? a.estimatedCost / hours : 0;
916
- $('cost-figure-sub').textContent = (perHour ? fmtCost(perHour) + ' / hour' : 'across the range');
917
- const byModel = a.costByModel || {};
918
- const entries = Object.entries(byModel).sort((x, y) => y[1] - x[1]).slice(0, 6);
919
- const total = entries.reduce((s, [, v]) => s + v, 0) || 1;
920
- const bars = $('cost-bars');
921
- bars.innerHTML = '';
922
- for (const [model, cost] of entries) {
923
- const w = Math.max(2, (cost / total) * 100);
924
- const row = document.createElement('div');
925
- row.className = 'cost-bar';
926
- row.innerHTML = '<span class="name">' + model + '</span>' +
927
- '<span class="track"><span class="fill" style="width:' + w.toFixed(0) + '%"></span></span>' +
928
- '<span class="val">' + fmtCost(cost) + '</span>';
929
- bars.appendChild(row);
930
- }
931
- if (!entries.length) bars.innerHTML = '<div style="color: var(--text-3); font-size: 12px;">No data in range</div>';
932
- }
933
-
934
- // ── Languages panel ─────────────────────────────────────
935
- function renderLanguages(langs) {
936
- const entries = Object.entries(langs || {}).sort((x, y) => y[1].edits - x[1].edits).slice(0, 5);
937
- const total = entries.reduce((s, [, v]) => s + v.edits, 0) || 1;
938
- const stack = $('lang-stack');
939
- stack.innerHTML = '';
940
- for (const [name, v] of entries) {
941
- const span = document.createElement('span');
942
- span.style.background = LANGS[name] || '#888';
943
- span.style.width = ((v.edits / total) * 100).toFixed(2) + '%';
944
- span.title = name + ' · ' + v.edits;
945
- stack.appendChild(span);
946
- }
947
- const list = $('lang-list');
948
- list.innerHTML = '';
949
- for (const [name, v] of entries) {
950
- const row = document.createElement('div');
951
- row.className = 'row';
952
- row.innerHTML = '<span class="swatch" style="background:' + (LANGS[name] || '#888') + '"></span>' +
953
- '<span class="name">' + name + '</span>' +
954
- '<span class="val">' + fmtN(v.edits) + ' edits · ' + fmtN(v.files) + ' files</span>';
955
- list.appendChild(row);
956
- }
957
- if (!entries.length) list.innerHTML = '<div style="color: var(--text-3); font-size: 12px;">No language data yet</div>';
958
- }
959
-
960
- // ── Discord rotation ────────────────────────────────────
961
- function renderRotation() {
962
- const live = allFrames.filter((f) => f.passes);
963
- if (live.length) {
964
- currentLiveIdx = currentLiveIdx % live.length;
965
- const f = live[currentLiveIdx];
966
- const liveOrder = allFrames.map((af, i) => af.passes ? i : -1).filter((i) => i >= 0);
967
- const allIdx = liveOrder[currentLiveIdx];
968
- // Mirror to both the top live rail and the bottom Discord card.
969
- $('frame-details').textContent = f.details || '—';
970
- $('frame-state').textContent = f.state || '—';
971
- $('frame-details-2').textContent = f.details || '—';
972
- $('frame-state-2').textContent = f.state || '—';
973
- $('frame-num').textContent = 'Frame ' + (allIdx + 1) + '/' + allFrames.length;
974
- $('frame-no').textContent = 'Frame ' + (allIdx + 1) + ' of ' + allFrames.length;
975
- }
976
- $('frames-live').textContent = live.length;
977
- $('frames-total').textContent = allFrames.length;
978
- const ul = $('rotation-list');
979
- ul.innerHTML = '';
980
- const liveOrder = allFrames.map((af, i) => af.passes ? i : -1).filter((i) => i >= 0);
981
- const onAir = liveOrder[currentLiveIdx];
982
- allFrames.forEach((f, i) => {
983
- const li = document.createElement('li');
984
- const isCurrent = i === onAir;
985
- li.className = isCurrent ? 'current' : f.passes ? 'live' : 'skip';
986
- const summary = f.passes ? ((f.details || '—') + (f.state ? ' · ' + f.state : '')) : (f.details || '—');
987
- li.innerHTML = '<span class="pip"></span><span class="frame-text">' + summary + '</span>';
988
- ul.appendChild(li);
989
- });
990
- }
991
-
992
- // ── Drawer (project) ────────────────────────────────────
993
- async function openProject(name) {
994
- location.hash = '#projects/' + encodeURIComponent(name);
995
- const p = (aggData?.projects || {})[name];
996
- if (!p) return;
997
- $('drawer-title').textContent = name;
998
- $('drawer-sub').textContent = p.sessions + ' sessions · ' + fmtH(p.activeMs) + ' active';
999
- $('drawer-body').innerHTML = [
1000
- ['Active time', fmtH(p.activeMs)],
1001
- ['Prompts', fmtN(p.userMessages)],
1002
- ['Tool calls', fmtN(p.toolCalls)],
1003
- ['Lines added', fmtN(p.linesAdded || 0)],
1004
- ['Lines removed', fmtN(p.linesRemoved || 0)],
1005
- ['Estimated cost', fmtCost(p.cost || 0)],
1006
- ['Tokens in', fmtN(p.inputTokens)],
1007
- ['Tokens out', fmtN(p.outputTokens)],
1008
- ].map(([k, v]) => '<div class="kv"><span class="k">' + k + '</span><span class="v">' + v + '</span></div>').join('');
1009
- $('scrim').classList.add('open');
1010
- $('drawer').classList.add('open');
1011
- }
1012
- function closeDrawer() {
1013
- $('scrim').classList.remove('open');
1014
- $('drawer').classList.remove('open');
1015
- if (location.hash.startsWith('#projects/')) location.hash = '';
1016
- }
1017
- $('scrim').addEventListener('click', closeDrawer);
1018
- $('drawer-close').addEventListener('click', closeDrawer);
1019
-
1020
- // ── Modal (day) ─────────────────────────────────────────
1021
- async function openDay(k) {
1022
- location.hash = '#days/' + k;
1023
- const day = (aggData?.byDay || {})[k];
1024
- if (!day) {
1025
- $('modal-title').textContent = k;
1026
- $('modal-sub').textContent = 'No activity';
1027
- $('modal-body').innerHTML = '';
1028
- } else {
1029
- $('modal-title').textContent = new Date(k + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
1030
- $('modal-sub').textContent = fmtH(day.activeMs) + ' active · ' + (day.sessions || 0) + ' sessions';
1031
- $('modal-body').innerHTML = [
1032
- ['Prompts', fmtN(day.userMessages)],
1033
- ['Tool calls', fmtN(day.toolCalls)],
1034
- ['Lines added', fmtN(day.linesAdded || 0)],
1035
- ['Lines removed', fmtN(day.linesRemoved || 0)],
1036
- ['Cost', fmtCost(day.cost || 0)],
1037
- ['Tokens', fmtN((day.inputTokens || 0) + (day.outputTokens || 0) + (day.cacheReadTokens || 0) + (day.cacheWriteTokens || 0))],
1038
- ['Notifications', day.notifications || 0],
1039
- ].map(([k, v]) => '<div class="kv" style="display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--border);font-size:13px;"><span style="color:var(--text-3);">' + k + '</span><span style="font-weight:500;">' + v + '</span></div>').join('');
1040
- }
1041
- $('modal').classList.add('open');
1042
- $('scrim').classList.add('open');
1043
- }
1044
- function closeModal() {
1045
- $('modal').classList.remove('open');
1046
- $('scrim').classList.remove('open');
1047
- if (location.hash.startsWith('#days/')) location.hash = '';
1048
- }
1049
- $('modal-close').addEventListener('click', closeModal);
1050
- $('scrim').addEventListener('click', closeModal);
1051
-
1052
- // ── Help ────────────────────────────────────────────────
1053
- $('help').addEventListener('click', () => $('help').classList.remove('open'));
1054
- document.addEventListener('keydown', (e) => {
1055
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
1056
- if (e.key === '?') { e.preventDefault(); $('help').classList.toggle('open'); }
1057
- if (e.key === 'Escape') { closeDrawer(); closeModal(); $('help').classList.remove('open'); }
1058
- if (e.key === 't') {
1059
- const cur = localStorage.getItem('theme') || 'dark';
1060
- localStorage.setItem('theme', cur === 'dark' ? 'light' : 'dark'); applyTheme();
1061
- }
1062
- if (e.key >= '1' && e.key <= '5') {
1063
- const pills = ['7d', '30d', '90d', '1y', 'all'];
1064
- const target = document.querySelector('[data-range="' + pills[parseInt(e.key, 10) - 1] + '"]');
1065
- if (target) target.click();
1066
- }
1067
- });
1068
-
1069
- // ── State refresh ───────────────────────────────────────
1070
- async function fetchAggregate() {
1071
- try {
1072
- const r = await fetch('/api/aggregate?range=' + range, { cache: 'no-store' });
1073
- aggData = await r.json();
1074
- drawAggregate();
1075
- } catch (e) { console.error(e); }
1076
- }
1077
-
1078
- async function fetchInsights() {
1079
- try {
1080
- const r = await fetch('/api/insights');
1081
- const j = await r.json();
1082
- const root = $('insights');
1083
- root.innerHTML = '';
1084
- for (const line of (j.insights || [])) {
1085
- const el = document.createElement('div');
1086
- el.className = 'insight';
1087
- el.textContent = line;
1088
- root.appendChild(el);
1089
- }
1090
- if (!(j.insights || []).length) root.innerHTML = '<div class="insight">Keep working — insights appear once you have a few days of activity.</div>';
1091
- } catch (e) { console.error(e); }
1092
- }
1093
-
1094
- function drawAggregate() {
1095
- if (!aggData) return;
1096
- const days = range === '7d' ? 7 : range === '30d' ? 30 : range === '1y' ? 365 : range === 'all' ? 365 : 90;
1097
- renderChart(aggData.byDay || {}, days);
1098
- renderHeatmap(aggData.byDay || {});
1099
- renderChurn(aggData.byDay || {}, Math.min(days, 90));
1100
- renderCost(aggData);
1101
- renderLanguages(aggData.languages);
1102
- renderAchievements(aggData);
1103
-
1104
- // Range stat card
1105
- const [rn, ru] = splitTime(fmtH(aggData.activeMs || 0));
1106
- $('range-num').textContent = rn;
1107
- $('range-unit').textContent = ru === 'h' ? 'hrs' : ru;
1108
- $('range-sub').textContent = fmtN(aggData.userMessages || 0) + ' prompts · ' + fmtN(aggData.grandTokens || 0) + ' tok';
1109
-
1110
- // Range delta vs prior identical window
1111
- // (approximation: today's value minus same-day-of-week last range)
1112
- setDelta($('range-delta'), 0, 'range');
1113
-
1114
- // Cost card
1115
- $('cost-num').textContent = fmtCost(aggData.estimatedCost || 0);
1116
- $('cost-sub').textContent = fmtN(aggData.grandTokens || 0) + ' tokens';
1117
-
1118
- // Lifetime tokens card
1119
- const grand = (aggData.inputTokens || 0) + (aggData.outputTokens || 0) + (aggData.cacheReadTokens || 0) + (aggData.cacheWriteTokens || 0);
1120
- $('tok-grand').textContent = fmtN(grand);
1121
- $('tok-out').textContent = fmtN(aggData.outputTokens || 0);
1122
- const cache = (aggData.cacheReadTokens || 0) + (aggData.cacheWriteTokens || 0);
1123
- $('tok-cache').textContent = fmtN(cache);
1124
- $('tok-in-sub').textContent = 'input ' + fmtN(aggData.inputTokens || 0);
1125
- $('tok-cache-sub').textContent = 'read ' + fmtN(aggData.cacheReadTokens || 0) + ' · write ' + fmtN(aggData.cacheWriteTokens || 0);
1126
- $('tok-cache-pct').textContent = grand ? Math.round((cache / grand) * 100) + '%' : '0%';
1127
-
1128
- // Code churn numbers
1129
- $('churn-added').textContent = '+' + fmtN(aggData.linesAdded || 0);
1130
- $('churn-removed').textContent = '−' + fmtN(aggData.linesRemoved || 0);
1131
- const net = (aggData.linesAdded || 0) - (aggData.linesRemoved || 0);
1132
- $('churn-net').textContent = (net >= 0 ? '+' : '−') + fmtN(Math.abs(net));
1133
-
1134
- // Leaderboards
1135
- const projs = Object.entries(aggData.projects || {}).sort((x, y) => y[1].activeMs - x[1].activeMs).slice(0, 8);
1136
- renderTable('projects-tbl', projs.map(([name, p]) => {
1137
- const h = p.activeMs / 3_600_000;
1138
- const val = h < 1 ? Math.round(h * 60) : h < 10 ? h.toFixed(1) : Math.round(h);
1139
- return { name, val: String(val), unit: h < 1 ? 'm' : 'h', onClick: () => openProject(name) };
1140
- }));
1141
- const tools = Object.entries(aggData.toolBreakdown || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
1142
- renderTable('tools-tbl', tools.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })), { mono: true });
1143
- const files = (aggData.topEditedFiles || []).slice(0, 8);
1144
- renderTable('files-tbl', files.map((f) => ({ name: f.file || (f.path || '').split('/').pop(), val: fmtN(f.count), unit: '' })), { mono: true });
1145
-
1146
- // Bash / domains / subagents
1147
- const bash = Object.entries(aggData.bashCommands || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
1148
- renderTable('bash-tbl', bash.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })), { mono: true });
1149
- const domains = Object.entries(aggData.webDomains || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
1150
- renderTable('domains-tbl', domains.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })), { mono: true });
1151
- const sa = Object.entries(aggData.subagents || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
1152
- renderTable('subagents-tbl', sa.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })));
1153
-
1154
- const tot = (aggData.mcpToolCalls || 0) + (aggData.builtinToolCalls || 0);
1155
- $('mcp-label').textContent = tot ? Math.round(((aggData.mcpToolCalls || 0) / tot) * 100) + '% MCP · ' + Math.round(((aggData.builtinToolCalls || 0) / tot) * 100) + '% built-in' : '—';
1156
-
1157
- $('lb-sessions').textContent = fmtN(aggData.sessions || 0);
1158
- }
1159
-
1160
- function drawState() {
1161
- if (!liveData) return;
1162
- const a = liveData.aggregate;
1163
- const v = liveData.vars;
1164
- const s = liveData.state;
1165
-
1166
- // Top bar
1167
- const now = new Date();
1168
- $('meta').textContent = 'No. ' + (v.daysSinceFirst || '—') + ' · ' + now.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
1169
- $('model').textContent = v.modelPretty;
1170
- $('statustext').textContent = v.statusVerbose;
1171
- $('dot').className = 'dot ' + (s.status === 'working' || s.status === 'thinking' ? '' : s.status === 'idle' ? 'idle' : 'stale');
1172
-
1173
- // Live avatar
1174
- const cfgAvatar = (s.status && liveData.config?.statusAssets?.[s.status]) || '';
1175
- $('live-avatar').innerHTML = cfgAvatar
1176
- ? '<img src="' + cfgAvatar.replace(/"/g, '&quot;') + '" alt="" />'
1177
- : '';
1178
- $('elapsed').textContent = elapsedStr(s.sessionStart);
1179
-
1180
- // Hero
1181
- const [hn, hu] = splitTime(v.allHours);
1182
- $('hero-num').textContent = hn;
1183
- $('hero-unit').textContent = hu === 'h' ? 'hours' : hu === 'm' ? 'minutes' : hu;
1184
- $('hero-caption').innerHTML =
1185
- 'on Claude Code · day <strong>' + (v.daysSinceFirst || 1) + '</strong> · ' +
1186
- '<strong>' + (a.sessions || 0).toLocaleString() + '</strong> sessions · ' +
1187
- '<strong>' + (a.userMessages || 0).toLocaleString() + '</strong> prompts.';
1188
-
1189
- // Today
1190
- const [tn, tu] = splitTime(v.todayHours);
1191
- $('today-num').textContent = tn;
1192
- $('today-unit').textContent = tu === 'h' ? 'hrs' : tu;
1193
- $('today-sub').textContent = (v.todayPrompts || 0) + ' prompts · ' + (v.todayTokensFmt || '0');
1194
-
1195
- const todayMs = ((a.byDay || {})[dayKey(Date.now())] || {}).activeMs || 0;
1196
- const yest = new Date(); yest.setHours(0,0,0,0); yest.setDate(yest.getDate() - 1);
1197
- const yMs = ((a.byDay || {})[dayKey(yest.getTime())] || {}).activeMs || 0;
1198
- setDelta($('today-delta'), todayMs - yMs, 'vs yest.');
1199
-
1200
- // Streak
1201
- $('streak-num').textContent = v.streak;
1202
- $('streak-sub').textContent = 'Longest ' + v.longestStreak + ' · best ' + (v.bestDayHours || '—');
1203
-
1204
- // Discord
1205
- allFrames = liveData.frames || [];
1206
- renderRotation();
1207
- }
1208
-
1209
- // ── SSE ────────────────────────────────────────────────
1210
- function startSse() {
1211
- try {
1212
- const ev = new EventSource('/events');
1213
- ev.onmessage = async (e) => {
1214
- try {
1215
- const d = JSON.parse(e.data);
1216
- if (d.type === 'state') await refreshState();
1217
- if (d.type === 'aggregate') {
1218
- await refreshState();
1219
- await fetchAggregate();
1220
- await fetchInsights();
1221
- }
1222
- } catch { /* malformed SSE frame — wait for the next one */ }
1223
- };
1224
- ev.onerror = () => { $('conn-state').textContent = 'reconnecting'; setTimeout(() => { $('conn-state').textContent = 'live'; }, 4000); };
1225
- } catch { /* EventSource constructor failed (very old browser) — dashboard falls back to one-shot fetches */ }
1226
- }
1227
-
1228
- async function refreshState() {
1229
- try {
1230
- const r = await fetch('/api/state', { cache: 'no-store' });
1231
- liveData = await r.json();
1232
- drawState();
1233
- } catch (e) { console.error(e); }
1234
- }
1235
-
1236
- // Elapsed tick — light, just updates the number.
1237
- setInterval(() => {
1238
- if (liveData?.state?.sessionStart) $('elapsed').textContent = elapsedStr(liveData.state.sessionStart);
1239
- }, 1000);
1240
-
1241
- // Rotation cycle
1242
- rotationTimer = setInterval(() => { currentLiveIdx++; renderRotation(); }, 4000);
1243
-
1244
- // Initial load.
1245
- (async () => {
1246
- await refreshState();
1247
- await fetchAggregate();
1248
- await fetchInsights();
1249
- startSse();
1250
- // Restore deep link.
1251
- if (location.hash.startsWith('#projects/')) openProject(decodeURIComponent(location.hash.slice(10)));
1252
- else if (location.hash.startsWith('#days/')) openDay(location.hash.slice(6));
1253
- })();
1254
- })();`;
23
+ // Replacer FUNCTIONS, not strings: the client JS is full of `$(id)` DOM
24
+ // accessors and the CSS could carry `$`, both of which String.prototype
25
+ // .replace treats as special replacement patterns when passed as a
26
+ // string. A function replacer inserts the value verbatim.
27
+ return TEMPLATE
28
+ .replace('{{STYLES}}', () => STYLES)
29
+ .replace('{{SCRIPT}}', () => SCRIPT)
30
+ .replaceAll('{{PORT}}', String(port));
1255
31
  }
1256
32
 
1257
33
  export { buildHtml };