burner-cc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/render.js ADDED
@@ -0,0 +1,36 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, cpSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { spawn } from 'node:child_process';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const webDir = path.join(__dirname, '..', 'web');
9
+ const outDir = path.join(os.homedir(), '.burner-cc-cache');
10
+
11
+ export function renderReceipts(sessions) {
12
+ mkdirSync(outDir, { recursive: true });
13
+
14
+ const template = readFileSync(path.join(webDir, 'receipt.html'), 'utf-8');
15
+ const injected = template.replace(
16
+ '/*__BURNER_DATA__*/[]',
17
+ JSON.stringify(sessions)
18
+ );
19
+
20
+ const outHtml = path.join(outDir, 'receipt.html');
21
+ writeFileSync(outHtml, injected, 'utf-8');
22
+ copyFileSync(path.join(webDir, 'receipt.css'), path.join(outDir, 'receipt.css'));
23
+ copyFileSync(path.join(webDir, 'receipt.js'), path.join(outDir, 'receipt.js'));
24
+ copyFileSync(path.join(webDir, 'logo-mark.png'), path.join(outDir, 'logo-mark.png'));
25
+ copyFileSync(path.join(webDir, 'logo-with-text.png'), path.join(outDir, 'logo-with-text.png'));
26
+ cpSync(path.join(webDir, 'vendor'), path.join(outDir, 'vendor'), { recursive: true });
27
+
28
+ return outHtml;
29
+ }
30
+
31
+ export function openInBrowser(filePath) {
32
+ const platform = process.platform;
33
+ const cmd = platform === 'win32' ? 'cmd' : platform === 'darwin' ? 'open' : 'xdg-open';
34
+ const args = platform === 'win32' ? ['/c', 'start', '""', filePath] : [filePath];
35
+ spawn(cmd, args, { detached: true, stdio: 'ignore', shell: false }).unref();
36
+ }
Binary file
Binary file
@@ -0,0 +1,298 @@
1
+ :root[data-theme="paper"] {
2
+ --bg: #f4f1e8;
3
+ --ink: #2b2b26;
4
+ --dim: #8a8478;
5
+ --rule: #b8b2a2;
6
+ --accent: #2b2b26;
7
+ --page-bg: #e7e2d3;
8
+ }
9
+
10
+ :root[data-theme="dark"] {
11
+ --bg: #14161a;
12
+ --ink: #e6e6e6;
13
+ --dim: #767b85;
14
+ --rule: #3a3f47;
15
+ --accent: #ffffff;
16
+ --page-bg: #0a0b0d;
17
+ }
18
+
19
+ :root[data-theme="thermal"] {
20
+ --bg: #fdf6e3;
21
+ --ink: #4a3f2a;
22
+ --dim: #a89a78;
23
+ --rule: #d8c9a0;
24
+ --accent: #7a5c00;
25
+ --page-bg: #efe6c8;
26
+ }
27
+
28
+ :root[data-theme="matrix"] {
29
+ --bg: #041005;
30
+ --ink: #45f77e;
31
+ --dim: #1f7a3f;
32
+ --rule: #1f7a3f;
33
+ --accent: #7dffab;
34
+ --page-bg: #000700;
35
+ }
36
+
37
+ * { box-sizing: border-box; }
38
+
39
+ body {
40
+ margin: 0;
41
+ font-family: 'IBM Plex Mono', 'Courier New', monospace;
42
+ background: var(--page-bg);
43
+ color: var(--ink);
44
+ padding: 28px 16px 60px;
45
+ transition: background 0.15s, color 0.15s;
46
+ }
47
+
48
+ .page-brand {
49
+ position: fixed;
50
+ top: 20px;
51
+ left: 24px;
52
+ height: 56px;
53
+ width: auto;
54
+ z-index: 6;
55
+ }
56
+
57
+ .toolbar {
58
+ max-width: 520px;
59
+ margin: 0 auto 16px;
60
+ display: flex;
61
+ justify-content: space-between;
62
+ gap: 12px;
63
+ flex-wrap: wrap;
64
+ }
65
+
66
+ .toolbar-group {
67
+ display: flex;
68
+ gap: 8px;
69
+ flex-wrap: wrap;
70
+ }
71
+
72
+ .toolbar button {
73
+ font-family: inherit;
74
+ font-size: 12px;
75
+ letter-spacing: 0.05em;
76
+ border: 1px solid var(--rule);
77
+ background: transparent;
78
+ color: var(--ink);
79
+ padding: 6px 12px;
80
+ cursor: pointer;
81
+ border-radius: 3px;
82
+ text-transform: uppercase;
83
+ }
84
+
85
+ .toolbar button.active {
86
+ background: var(--ink);
87
+ color: var(--page-bg);
88
+ }
89
+
90
+ .carousel {
91
+ position: relative;
92
+ max-width: 520px;
93
+ margin: 0 auto;
94
+ }
95
+
96
+ .carousel-track {
97
+ display: flex;
98
+ align-items: flex-start;
99
+ overflow-x: auto;
100
+ scroll-snap-type: x mandatory;
101
+ gap: 24px;
102
+ padding-bottom: 8px;
103
+ scrollbar-width: thin;
104
+ }
105
+
106
+ .carousel-track > .slide {
107
+ flex: 0 0 100%;
108
+ scroll-snap-align: center;
109
+ display: flex;
110
+ justify-content: center;
111
+ }
112
+
113
+ .nav-arrow {
114
+ position: fixed;
115
+ top: 50%;
116
+ transform: translateY(-50%);
117
+ z-index: 5;
118
+ background: var(--ink);
119
+ color: var(--page-bg);
120
+ border: none;
121
+ width: 44px;
122
+ height: 44px;
123
+ border-radius: 50%;
124
+ cursor: pointer;
125
+ font-size: 18px;
126
+ opacity: 0.7;
127
+ box-shadow: 0 2px 10px rgba(0,0,0,0.3);
128
+ }
129
+
130
+ .nav-arrow.prev { left: 32px; }
131
+ .nav-arrow.next { right: 32px; }
132
+ .nav-arrow:hover { opacity: 1; }
133
+
134
+ .slide-dots {
135
+ display: flex;
136
+ justify-content: center;
137
+ gap: 6px;
138
+ margin: 10px 0 0;
139
+ }
140
+
141
+ .slide-dots span {
142
+ width: 6px;
143
+ height: 6px;
144
+ border-radius: 50%;
145
+ background: var(--rule);
146
+ }
147
+
148
+ .slide-dots span.active { background: var(--ink); }
149
+
150
+ .receipt {
151
+ width: 100%;
152
+ max-width: 460px;
153
+ background: var(--bg);
154
+ color: var(--ink);
155
+ padding: 28px 26px 30px;
156
+ box-shadow: 0 6px 18px rgba(0,0,0,0.25);
157
+ }
158
+
159
+ .receipt h1 {
160
+ text-align: center;
161
+ font-size: 22px;
162
+ letter-spacing: 0.15em;
163
+ margin: 0 0 4px;
164
+ }
165
+
166
+ .receipt .subtitle {
167
+ text-align: center;
168
+ font-size: 12px;
169
+ letter-spacing: 0.1em;
170
+ font-weight: bold;
171
+ margin: 0 0 4px;
172
+ }
173
+
174
+ .receipt .tagline {
175
+ text-align: center;
176
+ font-size: 11px;
177
+ color: var(--dim);
178
+ margin: 0 0 14px;
179
+ font-style: italic;
180
+ }
181
+
182
+ .rule {
183
+ border: none;
184
+ border-top: 1px dashed var(--rule);
185
+ margin: 12px 0;
186
+ }
187
+
188
+ .meta-row {
189
+ display: flex;
190
+ justify-content: space-between;
191
+ font-size: 11px;
192
+ line-height: 1.6;
193
+ }
194
+
195
+ .meta-row .left, .meta-row .right { text-align: left; }
196
+ .meta-row .right { text-align: right; }
197
+
198
+ .line {
199
+ display: flex;
200
+ justify-content: space-between;
201
+ align-items: baseline;
202
+ font-size: 12.5px;
203
+ line-height: 1.7;
204
+ gap: 8px;
205
+ }
206
+
207
+ .line .label { flex: 1; }
208
+ .line .label.indent { padding-left: 14px; color: var(--dim); font-size: 11px; }
209
+ .line .tok { color: var(--dim); font-size: 11px; min-width: 72px; text-align: right; }
210
+ .line .amt { font-weight: bold; min-width: 60px; text-align: right; }
211
+
212
+ .line.bold .label, .line.bold .amt { font-weight: bold; }
213
+ .line.total .amt { font-size: 15px; }
214
+
215
+ .model-heading {
216
+ font-weight: bold;
217
+ font-size: 12px;
218
+ letter-spacing: 0.05em;
219
+ margin-top: 10px;
220
+ }
221
+
222
+ .footer {
223
+ text-align: center;
224
+ font-size: 11px;
225
+ color: var(--dim);
226
+ margin-top: 4px;
227
+ }
228
+
229
+ .footer .tip {
230
+ font-weight: bold;
231
+ color: var(--ink);
232
+ margin-top: 6px;
233
+ }
234
+
235
+ .receipt-logo {
236
+ display: block;
237
+ height: 64px;
238
+ width: auto;
239
+ margin: 14px auto 4px;
240
+ }
241
+
242
+ .footer .brand {
243
+ margin-top: 2px;
244
+ font-size: 10px;
245
+ letter-spacing: 0.15em;
246
+ color: var(--dim);
247
+ }
248
+
249
+ .unpriced-flag {
250
+ color: #c0392b;
251
+ font-weight: bold;
252
+ }
253
+
254
+ .barcode {
255
+ display: flex;
256
+ justify-content: center;
257
+ align-items: flex-end;
258
+ gap: 2px;
259
+ margin: 18px 0 4px;
260
+ cursor: help;
261
+ }
262
+
263
+ .barcode span {
264
+ background: var(--ink);
265
+ }
266
+
267
+ .barcode-label {
268
+ text-align: center;
269
+ font-size: 10px;
270
+ color: var(--dim);
271
+ letter-spacing: 0.1em;
272
+ }
273
+
274
+ .disclaimer {
275
+ max-width: 460px;
276
+ margin: 14px auto 0;
277
+ font-size: 10px;
278
+ color: var(--dim);
279
+ text-align: center;
280
+ }
281
+
282
+ .toast {
283
+ position: fixed;
284
+ bottom: 24px;
285
+ left: 50%;
286
+ transform: translateX(-50%);
287
+ background: var(--ink);
288
+ color: var(--page-bg);
289
+ padding: 8px 16px;
290
+ border-radius: 4px;
291
+ font-size: 12px;
292
+ letter-spacing: 0.05em;
293
+ opacity: 0;
294
+ transition: opacity 0.2s;
295
+ pointer-events: none;
296
+ }
297
+
298
+ .toast.show { opacity: 1; }
@@ -0,0 +1,31 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="paper">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Burner — Claude Code Session Receipt</title>
6
+ <link rel="icon" type="image/png" href="logo-mark.png" />
7
+ <link rel="stylesheet" href="receipt.css" />
8
+ </head>
9
+ <body>
10
+ <img class="page-brand" src="logo-with-text.png" alt="Burner" />
11
+
12
+ <div class="toolbar">
13
+ <div class="toolbar-group">
14
+ <button data-theme="paper">Paper</button>
15
+ <button data-theme="thermal">Thermal</button>
16
+ <button data-theme="matrix">Matrix</button>
17
+ <button data-theme="dark">Dark</button>
18
+ </div>
19
+ <div class="toolbar-group">
20
+ <button id="copyBtn">Copy</button>
21
+ <button id="downloadBtn">Download</button>
22
+ </div>
23
+ </div>
24
+
25
+ <div id="root"></div>
26
+
27
+ <script>window.__BURNER_DATA__ = /*__BURNER_DATA__*/[];</script>
28
+ <script src="vendor/html2canvas.min.js"></script>
29
+ <script src="receipt.js"></script>
30
+ </body>
31
+ </html>
package/web/receipt.js ADDED
@@ -0,0 +1,315 @@
1
+ function fmtUsd(n) {
2
+ if (n === null || n === undefined) return '???';
3
+ return '$' + n.toFixed(3);
4
+ }
5
+
6
+ function fmtTok(n) {
7
+ return n.toLocaleString('en-US') + ' tok';
8
+ }
9
+
10
+ function shortDate(iso) {
11
+ if (!iso) return 'unknown';
12
+ const d = new Date(iso);
13
+ return d.toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' }).toUpperCase();
14
+ }
15
+
16
+ const TAGLINES = [
17
+ 'context in, cash out',
18
+ 'tokens are not free, apparently',
19
+ 'thoughts, itemized',
20
+ 'you paid for every "let me check"',
21
+ 'burned so you don’t have to guess',
22
+ ];
23
+
24
+ function taglineFor(seed) {
25
+ return TAGLINES[seed % TAGLINES.length];
26
+ }
27
+
28
+ // The barcode isn't decorative noise: it's a real (if silly) binary
29
+ // encoding of the session's total cost in cents, so two receipts only
30
+ // look alike if they cost the same. Widest bar = most significant bit.
31
+ function barcode(totalCost, seed) {
32
+ const cents = Math.max(0, Math.round(totalCost * 100));
33
+ const bits = cents.toString(2).padStart(16, '0').slice(-16);
34
+ const bars = [];
35
+ for (const bit of bits) {
36
+ const w = bit === '1' ? 4 : 1;
37
+ bars.push(`<span style="width:${w}px;height:32px"></span>`);
38
+ bars.push(`<span style="width:1px;height:32px;background:transparent"></span>`);
39
+ }
40
+ // A few seeded filler bars purely so short/cheap sessions don't render
41
+ // a suspiciously tiny barcode.
42
+ let x = seed || 1;
43
+ for (let i = 0; i < 10; i++) {
44
+ x = (x * 9301 + 49297) % 233280;
45
+ bars.push(`<span style="width:${1 + (x % 2)}px;height:${18 + (x % 14)}px"></span>`);
46
+ }
47
+ return { html: bars.join(''), title: `encodes total cost: ${cents}¢ as binary (${bits})` };
48
+ }
49
+
50
+ function hashSeed(str) {
51
+ let h = 0;
52
+ for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
53
+ return h;
54
+ }
55
+
56
+ function modelBlock(m) {
57
+ const rows = [];
58
+ rows.push(`<div class="model-heading">${m.displayName}${m.fidelity !== 'measured' ? ` <span class="unpriced-flag" title="${m.fidelity}">*</span>` : ''}</div>`);
59
+
60
+ const t = m.tokens;
61
+ const c = m.cost;
62
+ const line = (label, tok, amt, indent) =>
63
+ `<div class="line"><span class="label${indent ? ' indent' : ''}">${label}</span><span class="tok">${fmtTok(tok)}</span><span class="amt">${fmtUsd(amt)}</span></div>`;
64
+
65
+ if (t.input) rows.push(line('FRESH INPUT (uncached)', t.input, c ? c.input : null, true));
66
+ if (t.cacheWrite5m || t.cacheWrite1h) rows.push(line('CACHE WRITE (new context)', t.cacheWrite5m + t.cacheWrite1h, c ? c.cacheWrite : null, true));
67
+ if (t.cacheRead) rows.push(line('CACHE READ (reused)', t.cacheRead, c ? c.cacheRead : null, true));
68
+ if (t.output) rows.push(line('OUTPUT TOKENS', t.output, c ? c.output : null, true));
69
+
70
+ return rows.join('');
71
+ }
72
+
73
+ function receiptHtml(session, index) {
74
+ const totalTok = Object.values(session.totals.tokens).reduce((a, b) => a + b, 0);
75
+ const seed = hashSeed(session.sessionId);
76
+
77
+ const modelLines = session.models.map(modelBlock).join('');
78
+ const subagentSection = (session.subagentGroups ?? []).length
79
+ ? `<hr class="rule" />` + session.subagentGroups.map((g) =>
80
+ `<div class="model-heading">SUBAGENT: ${g.agentType.toUpperCase()} (${g.runCount} run${g.runCount > 1 ? 's' : ''})</div>` +
81
+ g.models.map(modelBlock).join('')
82
+ ).join('')
83
+ : '';
84
+
85
+ const activitySection = activityHtml(session.activity);
86
+
87
+ const authCode = session.sessionId.slice(0, 4).toUpperCase() + '-' + (seed % 9999);
88
+ const bc = barcode(session.totals.cost.total, seed);
89
+ const notSubscription = session.billingMode !== 'subscription-likely';
90
+ const totalLabel = notSubscription ? 'TOTAL' : 'API-EQUIVALENT VALUE';
91
+ const paidWith = notSubscription ? 'TOKEN BUDGET' : 'FLAT-FEE PLAN (est. value shown, not what you paid)';
92
+
93
+ return `
94
+ <div class="receipt" data-session="${session.sessionId}">
95
+ <h1>CLAUDE CODE</h1>
96
+ <div class="subtitle">AGENTIC SESSION RECEIPT</div>
97
+ <div class="tagline">-- ${taglineFor(seed)} --</div>
98
+ <hr class="rule" />
99
+ <div class="meta-row">
100
+ <div class="left">
101
+ SESSION: ${session.title ?? session.sessionId.slice(0, 8)}<br/>
102
+ DATE: ${shortDate(session.startedAt)}<br/>
103
+ PROJECT: ${session.project}
104
+ </div>
105
+ <div class="right">
106
+ TURNS: ~${session.turnCount}<br/>
107
+ MODELS: ${session.models.length}<br/>
108
+ &nbsp;
109
+ </div>
110
+ </div>
111
+ <hr class="rule" />
112
+ ${modelLines}
113
+ ${subagentSection}
114
+ <hr class="rule" />
115
+ <div class="line bold"><span class="label">SUBTOTAL</span><span class="tok">${fmtTok(totalTok)}</span><span class="amt"></span></div>
116
+ <div class="line total bold"><span class="label">${totalLabel}</span><span class="tok"></span><span class="amt">${fmtUsd(session.totals.cost.total)}</span></div>
117
+ ${activitySection}
118
+ <hr class="rule" />
119
+ <div class="footer">
120
+ PAID WITH: ${paidWith}<br/>
121
+ AUTH CODE: ${authCode}
122
+ ${session.hasUnpriced ? '<br/><span class="unpriced-flag">* one model unpriced — total is a floor</span>' : ''}
123
+ </div>
124
+ <hr class="rule" />
125
+ <div class="footer">
126
+ * THANK YOU FOR YOUR CONTEXT *<br/>
127
+ ITEMS NON-REFUNDABLE ONCE SENT
128
+ <div class="tip">SUGGESTED TIP: ${tipFor(session)}</div>
129
+ </div>
130
+ <div class="barcode" title="${bc.title}">${bc.html}</div>
131
+ <div class="barcode-label">CC-SESSION-${session.sessionId.slice(0, 8).toUpperCase()}</div>
132
+ <img class="receipt-logo" src="logo-mark.png" alt="Burner" />
133
+ <div class="footer brand">~ B U R N E R ~</div>
134
+ </div>
135
+ `;
136
+ }
137
+
138
+ function activityHtml(activity) {
139
+ if (!activity) return '';
140
+ const mcpEntries = Object.entries(activity.mcp ?? {});
141
+ const skillEntries = Object.entries(activity.skills ?? {});
142
+ const webEntries = Object.entries(activity.web ?? {});
143
+ if (!mcpEntries.length && !skillEntries.length && !webEntries.length) return '';
144
+
145
+ const row = (label, count) =>
146
+ `<div class="line"><span class="label indent">${label}</span><span class="tok">${count}×</span><span class="amt"></span></div>`;
147
+
148
+ return `
149
+ <hr class="rule" />
150
+ <div class="model-heading">ACTIVITY LOG <span class="unpriced-flag" title="call counts only — not split into a dollar cost, since a turn's token cost can't be attributed to one tool call inside it">*</span></div>
151
+ ${mcpEntries.map(([server, n]) => row(`MCP: ${server}`, n)).join('')}
152
+ ${skillEntries.map(([skill, n]) => row(`SKILL: ${skill}`, n)).join('')}
153
+ ${webEntries.map(([tool, n]) => row(tool.toUpperCase(), n)).join('')}
154
+ `;
155
+ }
156
+
157
+ function tipFor(session) {
158
+ const t = session.totals.tokens;
159
+ const cacheEff = t.cacheRead / (t.cacheRead + t.input + t.cacheWrite5m + t.cacheWrite1h || 1);
160
+ if (cacheEff < 0.3 && (t.cacheWrite5m + t.cacheWrite1h) > 20000) {
161
+ return 'LOW CACHE REUSE — AVOID LONG GAPS BETWEEN TURNS';
162
+ }
163
+ if (session.subagentCount > 3) {
164
+ return 'HEAVY SUBAGENT USE — CHECK IF TASKS CAN BE MERGED';
165
+ }
166
+ if (t.output > t.input) {
167
+ return 'OUTPUT-HEAVY SESSION — CONSIDER SHORTER RESPONSES';
168
+ }
169
+ return 'DISABLE UNUSED MCP SERVERS BEFORE CHECKOUT';
170
+ }
171
+
172
+ function textReceipt(session) {
173
+ const lines = [];
174
+ lines.push('CLAUDE CODE - AGENTIC SESSION RECEIPT');
175
+ lines.push(`SESSION: ${session.title ?? session.sessionId.slice(0, 8)} | ${shortDate(session.startedAt)} | ${session.project}`);
176
+ lines.push('-'.repeat(40));
177
+ const modelLine = (m) => {
178
+ lines.push(m.displayName + ':');
179
+ const t = m.tokens, c = m.cost;
180
+ if (t.input) lines.push(` fresh input ${fmtTok(t.input)} ${fmtUsd(c?.input)}`);
181
+ if (t.cacheWrite5m || t.cacheWrite1h) lines.push(` cache write ${fmtTok(t.cacheWrite5m + t.cacheWrite1h)} ${fmtUsd(c?.cacheWrite)}`);
182
+ if (t.cacheRead) lines.push(` cache read ${fmtTok(t.cacheRead)} ${fmtUsd(c?.cacheRead)}`);
183
+ if (t.output) lines.push(` output ${fmtTok(t.output)} ${fmtUsd(c?.output)}`);
184
+ };
185
+ session.models.forEach(modelLine);
186
+ for (const g of session.subagentGroups ?? []) {
187
+ lines.push(`SUBAGENT: ${g.agentType} (${g.runCount} runs)`);
188
+ g.models.forEach(modelLine);
189
+ }
190
+ lines.push('-'.repeat(40));
191
+ const totalLabel = session.billingMode === 'subscription-likely' ? 'API-EQUIVALENT VALUE' : 'TOTAL';
192
+ lines.push(`${totalLabel}: ${fmtUsd(session.totals.cost.total)}`);
193
+ const mcpEntries = Object.entries(session.activity?.mcp ?? {});
194
+ const skillEntries = Object.entries(session.activity?.skills ?? {});
195
+ const webEntries = Object.entries(session.activity?.web ?? {});
196
+ if (mcpEntries.length || skillEntries.length || webEntries.length) {
197
+ lines.push('ACTIVITY (not itemized in cost above):');
198
+ mcpEntries.forEach(([s, n]) => lines.push(` mcp:${s} ${n}x`));
199
+ skillEntries.forEach(([s, n]) => lines.push(` skill:${s} ${n}x`));
200
+ webEntries.forEach(([s, n]) => lines.push(` ${s} ${n}x`));
201
+ }
202
+ lines.push('~ burner ~');
203
+ return lines.join('\n');
204
+ }
205
+
206
+ let currentData = [];
207
+
208
+ function showToast(msg) {
209
+ const toast = document.getElementById('toast');
210
+ toast.textContent = msg;
211
+ toast.classList.add('show');
212
+ setTimeout(() => toast.classList.remove('show'), 1600);
213
+ }
214
+
215
+ function currentSlideIndex(track) {
216
+ const slideWidth = track.children[0]?.offsetWidth ?? 1;
217
+ return Math.round(track.scrollLeft / (slideWidth + 24));
218
+ }
219
+
220
+ function render() {
221
+ currentData = window.__BURNER_DATA__ || [];
222
+ const root = document.getElementById('root');
223
+ if (!currentData.length) {
224
+ root.innerHTML = '<div class="disclaimer">No session data found.</div>';
225
+ return;
226
+ }
227
+
228
+ const slides = currentData.map((s, i) => `<div class="slide">${receiptHtml(s, i)}</div>`).join('');
229
+ const arrows = currentData.length > 1
230
+ ? `<button class="nav-arrow prev" aria-label="previous">‹</button><button class="nav-arrow next" aria-label="next">›</button>`
231
+ : '';
232
+ const dots = currentData.length > 1
233
+ ? `<div class="slide-dots">${currentData.map((_, i) => `<span data-i="${i}" class="${i === 0 ? 'active' : ''}"></span>`).join('')}</div>`
234
+ : '';
235
+
236
+ const onSubscription = currentData[0]?.billingMode === 'subscription-likely';
237
+ const disclaimer = onSubscription
238
+ ? 'Estimates, not official billing. Looks like a Pro/Max/Team plan, not pay-per-token — shown as API-equivalent value, not an actual charge.'
239
+ : 'Estimates, not official billing.';
240
+
241
+ root.innerHTML =
242
+ `<div class="carousel">${arrows}<div class="carousel-track" id="track">${slides}</div></div>${dots}` +
243
+ `<div class="disclaimer">${disclaimer}</div>` +
244
+ `<div class="toast" id="toast"></div>`;
245
+
246
+ const track = document.getElementById('track');
247
+ const dotEls = () => [...document.querySelectorAll('.slide-dots span')];
248
+
249
+ const goPrev = () => track.scrollBy({ left: -(track.children[0].offsetWidth + 24), behavior: 'smooth' });
250
+ const goNext = () => track.scrollBy({ left: track.children[0].offsetWidth + 24, behavior: 'smooth' });
251
+
252
+ document.querySelector('.nav-arrow.prev')?.addEventListener('click', goPrev);
253
+ document.querySelector('.nav-arrow.next')?.addEventListener('click', goNext);
254
+ track?.addEventListener('scroll', () => {
255
+ const i = currentSlideIndex(track);
256
+ dotEls().forEach((d) => d.classList.toggle('active', Number(d.dataset.i) === i));
257
+ }, { passive: true });
258
+
259
+ if (track && currentData.length > 1) {
260
+ document.addEventListener('keydown', (e) => {
261
+ if (e.key === 'ArrowLeft') goPrev();
262
+ else if (e.key === 'ArrowRight') goNext();
263
+ });
264
+ }
265
+ }
266
+
267
+ function activeReceiptNode() {
268
+ const track = document.getElementById('track');
269
+ if (!track) return document.querySelector('.receipt');
270
+ const i = currentSlideIndex(track);
271
+ return track.children[i]?.querySelector('.receipt') ?? document.querySelector('.receipt');
272
+ }
273
+
274
+ function activeSession() {
275
+ const node = activeReceiptNode();
276
+ const id = node?.dataset.session;
277
+ return currentData.find((s) => s.sessionId === id) ?? currentData[0];
278
+ }
279
+
280
+ function setTheme(theme) {
281
+ document.documentElement.setAttribute('data-theme', theme);
282
+ localStorage.setItem('burner-theme', theme);
283
+ document.querySelectorAll('.toolbar-group button[data-theme]').forEach((b) => {
284
+ b.classList.toggle('active', b.dataset.theme === theme);
285
+ });
286
+ }
287
+
288
+ document.addEventListener('DOMContentLoaded', () => {
289
+ const saved = localStorage.getItem('burner-theme') || 'paper';
290
+ setTheme(saved);
291
+ document.querySelectorAll('.toolbar-group button[data-theme]').forEach((b) => {
292
+ b.addEventListener('click', () => setTheme(b.dataset.theme));
293
+ });
294
+ render();
295
+
296
+ document.getElementById('copyBtn').addEventListener('click', async () => {
297
+ const session = activeSession();
298
+ if (!session) return;
299
+ await navigator.clipboard.writeText(textReceipt(session));
300
+ showToast('Copied to clipboard');
301
+ });
302
+
303
+ document.getElementById('downloadBtn').addEventListener('click', () => {
304
+ const node = activeReceiptNode();
305
+ if (!node || !window.html2canvas) return;
306
+ html2canvas(node, { backgroundColor: null, scale: 2 }).then((canvas) => {
307
+ const link = document.createElement('a');
308
+ const session = activeSession();
309
+ link.download = `burner-receipt-${session?.sessionId?.slice(0, 8) ?? 'session'}.png`;
310
+ link.href = canvas.toDataURL('image/png');
311
+ link.click();
312
+ showToast('Downloaded');
313
+ });
314
+ });
315
+ });