cchubber 0.5.8 → 0.6.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.
@@ -0,0 +1,439 @@
1
+ import { FRONTIER_MODELS } from '../data/frontier-models.js';
2
+ import {
3
+ FINE_PRINT, PAID_FINE_PRINT, PAID_ACTUAL_TEXT, PAID_ACTUAL_POST, PAID_ACTUAL_CAPTION, SHARE_CMD, usd0, int, dayLabel, periodLabel, totalTokens, tokensLabel,
4
+ readLine, cacheLine, paidFacts, multipleFacts, timesLabel, pricesLine, introNote, filledNotes, overrideRows, postText, postHead, driftPost,
5
+ } from './vs-text.js';
6
+
7
+ function esc(s) {
8
+ if (s === 0) return '0';
9
+ if (!s) return '';
10
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
11
+ }
12
+
13
+ // Bold the figures in a sentence (after escaping each piece), so the eye lands on the numbers.
14
+ function emph(text) {
15
+ return String(text).split(/(\$[\d,]+|\d[\d,]*%(?: to \d+%)?|\d[\d,]*)(?=[.\s]|$)/)
16
+ .map((part, i) => (i % 2 ? `<b>${esc(part)}</b>` : esc(part))).join('');
17
+ }
18
+
19
+ const REF_ID = '__ran';
20
+ const REF_LABEL = 'What you ran';
21
+ const REF_MAKER = 'Claude list price';
22
+
23
+ // ---------------------------------------------------------------------------------------------------------------
24
+ // STYLING LIVES HERE AND ONLY HERE. Every selector starts with #vs and every class is vs-prefixed, so this block can
25
+ // neither be restyled by the report around it nor restyle it. The palette is the report's own (#1b1c1d surface,
26
+ // #c0c1ff primary, #ffb690 secondary). A visual redesign should only need to touch this constant; the card colours
27
+ // are read from the same variables. Fonts are inherited from the report; nothing is fetched.
28
+ // ---------------------------------------------------------------------------------------------------------------
29
+ const FINAL_RULES = `#vs .vs-row{transition:none; transform:translateY(calc(var(--fpos) * var(--vs-row)))}
30
+ #vs .vs-fill{width:var(--fw)}
31
+ #vs .vs-now{display:none}
32
+ #vs .vs-final{display:inline}`;
33
+
34
+ const CSS = `
35
+ #vs{
36
+ --vs-surface:#1b1c1d; --vs-deep:#0d0e0f; --vs-ink:#e3e2e3; --vs-dim:#c7c4d7; --vs-faint:#908fa0;
37
+ --vs-line:rgba(70,69,84,.25); --vs-accent:#c0c1ff; --vs-warm:#ffb690; --vs-on:#7fe0b0; --vs-bar:#8083ff; --vs-bar2:#c0c1ff;
38
+ --vs-row:56px;
39
+ --vs-sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
40
+ --vs-mono:"JetBrains Mono",ui-monospace,"SF Mono",Menlo,Consolas,monospace;
41
+ background:var(--vs-surface); border:1px solid rgba(70,69,84,.15); border-radius:12px; padding:32px; scroll-margin-top:16px;
42
+ }
43
+ #vs .vs-head{display:flex; justify-content:space-between; align-items:baseline; gap:12px; flex-wrap:wrap}
44
+ #vs .vs-title{margin:0; font-size:20px; line-height:1.2; font-weight:700; color:var(--vs-ink)}
45
+ #vs .vs-range{font-family:var(--vs-mono); font-size:11px; color:var(--vs-faint)}
46
+ #vs .vs-lead{margin:14px 0 0; font-size:clamp(22px,3.4vw,38px); line-height:1.1; font-weight:800; letter-spacing:-.02em; color:var(--vs-ink)}
47
+ #vs .vs-sub{margin:8px 0 0; font-size:14px; color:var(--vs-faint)}
48
+ #vs .vs-clock{display:flex; justify-content:space-between; align-items:baseline; gap:12px; margin:26px 0 8px; font-size:10px; color:var(--vs-faint); letter-spacing:.08em; text-transform:uppercase}
49
+ #vs .vs-clock b{font-family:var(--vs-mono); color:var(--vs-dim); font-weight:600; letter-spacing:0; text-transform:none; font-size:12px}
50
+
51
+ #vs .vs-race{list-style:none; margin:0; padding:0; position:relative; height:calc(var(--vs-n) * var(--vs-row)); border-top:1px solid var(--vs-line)}
52
+ #vs .vs-row{position:absolute; left:0; right:0; top:0; height:var(--vs-row); padding:0 4px; display:grid; align-items:center; column-gap:16px;
53
+ grid-template-columns:minmax(150px,230px) 1fr 120px; grid-template-areas:"name track amt";
54
+ transform:translateY(calc(var(--pos) * var(--vs-row))); transition:transform .75s cubic-bezier(.22,1,.32,1); border-bottom:1px solid var(--vs-line)}
55
+ #vs .vs-instant .vs-row{transition:none}
56
+ #vs .vs-name{grid-area:name; min-width:0; display:flex; flex-direction:column; gap:1px}
57
+ #vs .vs-label{font-size:14px; font-weight:700; color:var(--vs-ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
58
+ #vs .vs-meta{font-size:11px; color:var(--vs-faint); white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
59
+ #vs .vs-tag{color:var(--vs-warm)}
60
+ #vs .vs-track{grid-area:track; height:8px; border-radius:8px; background:rgba(255,255,255,.05); overflow:hidden}
61
+ #vs .vs-fill{display:block; height:100%; width:0; border-radius:8px; background:linear-gradient(90deg,var(--vs-bar),var(--vs-bar2))}
62
+ #vs .vs-final{display:none}
63
+ #vs .vs-amt{grid-area:amt; text-align:right; font-family:var(--vs-mono); font-weight:700; font-size:18px; color:var(--vs-ink); font-variant-numeric:tabular-nums}
64
+ #vs .vs-ref .vs-label{color:var(--vs-warm)}
65
+ #vs .vs-ref .vs-fill{background:var(--vs-warm)}
66
+
67
+ #vs .vs-facts{margin:28px 0 0; display:flex; flex-direction:column; gap:10px}
68
+ #vs .vs-fact{margin:0; font-size:clamp(16px,2vw,21px); line-height:1.3; font-weight:600; color:var(--vs-faint)}
69
+ #vs .vs-fact b{color:var(--vs-ink); font-weight:800}
70
+ #vs .vs-fact.vs-drift b{color:var(--vs-on)}
71
+
72
+ #vs .vs-paid{margin:2px 0 0; display:flex; flex-direction:column; gap:6px; max-width:360px}
73
+ #vs .vs-plabel{font-size:13px; font-weight:700; color:var(--vs-dim)}
74
+ #vs .vs-pbox{display:flex; align-items:stretch; min-height:44px; background:var(--vs-deep); border:1px solid rgba(144,143,160,.45); border-radius:8px; overflow:hidden}
75
+ #vs .vs-pbox:focus-within{outline:2px solid var(--vs-accent); outline-offset:2px; border-color:var(--vs-accent)}
76
+ #vs .vs-cur{display:flex; align-items:center; padding:0 8px 0 14px; font-family:var(--vs-mono); font-size:16px; color:var(--vs-faint)}
77
+ #vs .vs-pin{flex:1 1 auto; min-width:0; width:100%; appearance:textfield; -moz-appearance:textfield; background:transparent; border:0; outline:0; color:var(--vs-ink);
78
+ font-family:var(--vs-mono); font-size:16px; padding:10px 14px 10px 0}
79
+ #vs .vs-pin::-webkit-outer-spin-button, #vs .vs-pin::-webkit-inner-spin-button{-webkit-appearance:none; margin:0}
80
+ #vs .vs-pin::placeholder{color:var(--vs-faint); opacity:.7}
81
+ #vs .vs-phelp{margin:0; font-size:11px; line-height:1.5; color:var(--vs-faint)}
82
+
83
+ #vs .vs-actions{display:flex; gap:10px; flex-wrap:wrap; margin:26px 0 0}
84
+ #vs .vs-btn{appearance:none; font:inherit; font-size:13px; font-weight:700; cursor:pointer; text-decoration:none; color:var(--vs-ink); background:rgba(255,255,255,.05);
85
+ border:1px solid rgba(70,69,84,.3); border-radius:8px; padding:10px 16px; min-height:40px; display:inline-flex; align-items:center}
86
+ #vs .vs-btn:hover{background:#292a2b}
87
+ #vs .vs-btn.vs-primary{background:var(--vs-accent); color:#1000a9; border-color:transparent}
88
+ #vs .vs-btn.vs-primary:hover{background:#d3d4ff}
89
+ #vs .vs-btn:focus-visible{outline:2px solid var(--vs-accent); outline-offset:2px}
90
+ #vs .vs-toast{min-height:18px; font-size:12px; color:var(--vs-on); margin:8px 2px 0}
91
+
92
+ #vs .vs-fine{margin-top:18px; padding-top:16px; border-top:1px solid var(--vs-line); font-size:11px; line-height:1.6; color:var(--vs-faint)}
93
+ #vs .vs-fine p{margin:0 0 6px}
94
+ #vs .vs-fine a{color:var(--vs-dim)}
95
+ #vs .vs-fine .vs-first{color:var(--vs-dim)}
96
+
97
+ @media (max-width:640px){
98
+ #vs{--vs-row:72px; padding:20px 16px}
99
+ #vs .vs-row{grid-template-columns:1fr auto; grid-template-areas:"name amt" "track track"; row-gap:6px; align-content:center}
100
+ #vs .vs-amt{font-size:17px}
101
+ #vs .vs-btn{flex:1 1 100%; justify-content:center}
102
+ #vs .vs-paid{max-width:none}
103
+ }
104
+ /* The markup is the START of the race, so the first paint is the start. The finished values are kept in the page and
105
+ shown by these rules for anyone who does not get the animation: reduced motion, or no script at all. */
106
+ @media (prefers-reduced-motion: reduce), (scripting: none){
107
+ ${FINAL_RULES}
108
+ }
109
+ `;
110
+
111
+ // ---------------------------------------------------------------------------------------------------------------
112
+ // The script. Plain ES5, no template literals and no ${ so it can sit in a String.raw block. It is wrapped in an IIFE,
113
+ // reads its numbers from the #vs-data block, and touches nothing outside #vs except window.cchubberVs.
114
+ // ---------------------------------------------------------------------------------------------------------------
115
+ const SCRIPT = String.raw`
116
+ (function(){
117
+ var VS = JSON.parse(document.getElementById('vs-data').textContent);
118
+ var DURATION = 6000; // the race lasts about six seconds
119
+ var box = document.getElementById('vs');
120
+ var reduce = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
121
+ var shot = /shot/.test(location.hash); // #shot jumps to the finished state, for a clean screenshot
122
+ var list = document.getElementById('vs-race');
123
+ // Each counter and the clock hold two spans: .vs-now (what the script drives; $0 in the served HTML) and .vs-final.
124
+ var clock = document.querySelector('#vs-clock .vs-now');
125
+ var clockLabel = document.querySelector('#vs-clock-label .vs-now');
126
+ var rows = {};
127
+ var maxFinal = 0;
128
+ VS.models.forEach(function(m){
129
+ var li = list.querySelector('[data-id="' + m.id + '"]');
130
+ rows[m.id] = { li: li, amt: li.querySelector('.vs-amt .vs-now'), fill: li.querySelector('.vs-fill'), pts: [0].concat(m.series), total: m.total };
131
+ if (m.total > maxFinal) maxFinal = m.total;
132
+ });
133
+ var N = VS.dateLabels.length;
134
+
135
+ function money(v){ return '$' + Math.round(v).toLocaleString('en-US'); }
136
+ function place(order){ order.forEach(function(id, i){ rows[id].li.style.setProperty('--pos', i); }); }
137
+
138
+ // One frame of the race. The curve is each model's running total by day, with a leading zero so every counter
139
+ // starts at $0; each logged day takes an equal slice of the time.
140
+ function frame(p){
141
+ var x = p * N;
142
+ var i = Math.min(N - 1, Math.floor(x));
143
+ var f = Math.min(1, x - i);
144
+ VS.models.forEach(function(m){
145
+ var r = rows[m.id];
146
+ var v = p >= 1 ? r.total : r.pts[i] + (r.pts[i + 1] - r.pts[i]) * f;
147
+ r.amt.textContent = money(v);
148
+ r.fill.style.width = (maxFinal ? v / maxFinal * 100 : 0).toFixed(2) + '%';
149
+ });
150
+ clock.textContent = VS.dateLabels[Math.min(N - 1, Math.max(0, Math.ceil(x) - 1))];
151
+ }
152
+
153
+ function settle(){
154
+ frame(1); place(VS.sortedOrder);
155
+ clock.textContent = VS.dateLabels[N - 1]; clockLabel.textContent = 'Your real days, replayed';
156
+ }
157
+
158
+ function run(){
159
+ list.classList.add('vs-instant'); place(VS.startOrder); frame(0); clockLabel.textContent = 'Replaying your real days';
160
+ void list.offsetWidth; list.classList.remove('vs-instant');
161
+ var t0 = null;
162
+ function tick(ts){
163
+ if (t0 === null) t0 = ts;
164
+ var p = Math.min(1, (ts - t0) / DURATION);
165
+ frame(p);
166
+ if (p < 1) requestAnimationFrame(tick); else setTimeout(settle, 250);
167
+ }
168
+ requestAnimationFrame(tick);
169
+ }
170
+
171
+ // The served HTML is already the first frame (everyone at $0, in the listed order), so finished numbers never flash.
172
+ // The race starts once a fair share of the block is on screen: 15% of it, or half the viewport for a tall block on a
173
+ // short screen. If that is already true on load it starts at once.
174
+ var started = false;
175
+ function go(){ if (started) return; started = true; window.removeEventListener('scroll', check); window.removeEventListener('resize', check); run(); }
176
+ function enough(seen, height, vh){ return seen > 0 && height > 0 && (seen / height >= 0.15 || seen >= vh * 0.5); }
177
+ // The visible height is clientHeight: on a phone, a wide table further down makes innerHeight (the layout viewport)
178
+ // taller than the screen, and the block would count as seen while it is still below the fold.
179
+ function inView(){
180
+ var r = list.getBoundingClientRect(), vh = document.documentElement.clientHeight || window.innerHeight;
181
+ return enough(Math.min(r.bottom, vh) - Math.max(r.top, 0), r.height, vh);
182
+ }
183
+ var ticking = false;
184
+ function check(){ if (ticking || started) return; ticking = true; requestAnimationFrame(function(){ ticking = false; if (inView()) go(); }); }
185
+ if (reduce || shot || N < 1) {
186
+ settle();
187
+ } else {
188
+ list.classList.add('vs-instant'); place(VS.startOrder); frame(0); void list.offsetWidth; list.classList.remove('vs-instant');
189
+ clockLabel.textContent = 'Replaying your real days';
190
+ // Armed on load, not at parse time: before the page's styles are in, the block looks near the top of the screen and
191
+ // the race would start where nobody is looking. From then on it starts on the first scroll that shows enough of it.
192
+ var arm = function(){
193
+ if (started) return;
194
+ if (inView()) { go(); return; }
195
+ window.addEventListener('scroll', check, { passive: true }); window.addEventListener('resize', check);
196
+ };
197
+ if (document.readyState === 'complete') arm(); else window.addEventListener('load', arm);
198
+ }
199
+ if (location.hash === '#vs') { window.addEventListener('load', function(){ setTimeout(function(){ box.scrollIntoView(); }, 60); }); }
200
+
201
+ // ---- the card: 1200 x 675, drawn on a canvas from the same numbers ----
202
+ function css(n){ return getComputedStyle(box).getPropertyValue(n).trim(); }
203
+ function rr(g, x, y, w, h, r){ g.beginPath(); g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r); g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath(); }
204
+ function wrap(g, text, x, y, maxW, lh){
205
+ var words = text.split(' '), line = '', yy = y;
206
+ words.forEach(function(w){
207
+ var t = line ? line + ' ' + w : w;
208
+ if (g.measureText(t).width > maxW && line) { g.fillText(line, x, yy); line = w; yy += lh; } else { line = t; }
209
+ });
210
+ if (line) { g.fillText(line, x, yy); yy += lh; }
211
+ return yy;
212
+ }
213
+ function drawCard(cv){
214
+ cv.width = 1200; cv.height = 675;
215
+ var g = cv.getContext('2d'), C = VS.card;
216
+ var sans = css('--vs-sans') || 'sans-serif', mono = css('--vs-mono') || 'monospace';
217
+ var ink = css('--vs-ink'), dim = css('--vs-dim'), faint = css('--vs-faint'), accent = css('--vs-accent'), on = css('--vs-on'), warm = css('--vs-warm'), bar = css('--vs-bar'), bar2 = css('--vs-bar2'), line = css('--vs-line');
218
+ g.fillStyle = '#121315'; g.fillRect(0, 0, 1200, 675);
219
+ var gr = g.createRadialGradient(600, -60, 30, 600, -60, 800);
220
+ gr.addColorStop(0, 'rgba(192,193,255,0.16)'); gr.addColorStop(1, 'rgba(192,193,255,0)');
221
+ g.fillStyle = gr; g.fillRect(0, 0, 1200, 675);
222
+ g.strokeStyle = line; g.lineWidth = 2; rr(g, 14, 14, 1172, 647, 28); g.stroke();
223
+ g.textBaseline = 'alphabetic'; g.textAlign = 'left';
224
+
225
+ g.fillStyle = accent; g.font = '800 15px ' + sans; if ('letterSpacing' in g) g.letterSpacing = '4px';
226
+ g.fillText('CC HUBBER', 56, 62); if ('letterSpacing' in g) g.letterSpacing = '0px';
227
+ g.textAlign = 'right'; g.fillStyle = faint; g.font = '15px ' + mono; g.fillText(C.period, 1144, 62);
228
+ g.textAlign = 'left';
229
+ g.fillStyle = ink; g.font = '800 44px ' + sans; g.fillText(C.title, 56, 122);
230
+ g.fillStyle = dim; g.font = '22px ' + sans; g.fillText(C.sub, 56, 158);
231
+
232
+ // the sorted bills
233
+ var top = 190, rowH = 54, nameX = 56, barX = 300, barW = 300, amtX = 744;
234
+ var max = 0; C.bills.forEach(function(b){ if (b.total > max) max = b.total; });
235
+ C.bills.forEach(function(b, i){
236
+ var y = top + i * rowH;
237
+ g.fillStyle = ink; g.font = '700 23px ' + sans; g.textAlign = 'left'; g.fillText(b.label, nameX, y + 28);
238
+ if (b.note) { g.fillStyle = warm; g.font = '14px ' + sans; g.fillText(b.note, nameX, y + 46); }
239
+ g.fillStyle = 'rgba(255,255,255,0.06)'; rr(g, barX, y + 14, barW, 14, 7); g.fill();
240
+ var w = Math.max(10, barW * b.total / max);
241
+ var lg = g.createLinearGradient(barX, 0, barX + w, 0); lg.addColorStop(0, bar); lg.addColorStop(1, bar2);
242
+ g.fillStyle = lg; rr(g, barX, y + 14, w, 14, 7); g.fill();
243
+ g.textAlign = 'right'; g.fillStyle = ink; g.font = '800 25px ' + mono; g.fillText(b.amount, amtX, y + 30);
244
+ });
245
+
246
+ // the right-hand panel: read per write, what was paid, drift
247
+ g.textAlign = 'left';
248
+ g.fillStyle = 'rgba(255,255,255,0.035)'; rr(g, 800, 150, 344, 456, 22); g.fill();
249
+ g.strokeStyle = line; g.lineWidth = 1.5; rr(g, 800, 150, 344, 456, 22); g.stroke();
250
+ var y = 268;
251
+ g.fillStyle = accent; g.font = '800 104px ' + mono; g.fillText(C.read, 828, y);
252
+ g.fillStyle = dim; g.font = '21px ' + sans; y = wrap(g, C.readCaption, 828, y + 34, 290, 27);
253
+ if (C.paid) {
254
+ y += 18; g.fillStyle = ink; g.font = '800 52px ' + mono; g.fillText(C.paid.amount, 828, y + 30);
255
+ g.fillStyle = dim; g.font = '19px ' + sans; y = wrap(g, C.paid.caption, 828, y + 58, 290, 25);
256
+ }
257
+ if (C.drift) {
258
+ y += 18; g.fillStyle = on; g.font = '800 52px ' + mono; g.fillText(C.drift.pct, 828, y + 30);
259
+ g.fillStyle = dim; g.font = '19px ' + sans; y = wrap(g, C.drift.caption, 828, y + 58, 290, 25);
260
+ }
261
+
262
+ g.fillStyle = faint; g.font = '14px ' + sans; g.textAlign = 'left'; g.fillText(C.fine, 56, 644);
263
+ g.textAlign = 'right'; g.fillStyle = accent; g.font = '700 16px ' + mono; g.fillText(C.cmd, 1144, 644);
264
+ g.textAlign = 'left';
265
+ }
266
+
267
+ var toastEl = document.getElementById('vs-toast'), toastT = null;
268
+ function toast(msg){ toastEl.textContent = msg; clearTimeout(toastT); toastT = setTimeout(function(){ toastEl.textContent = ''; }, 2600); }
269
+
270
+ function download(){
271
+ var cv = document.createElement('canvas'); drawCard(cv);
272
+ cv.toBlob(function(blob){
273
+ if (!blob) { toast('Could not draw the card.'); return; }
274
+ var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'cchubber-vs.png';
275
+ document.body.appendChild(a); a.click(); document.body.removeChild(a);
276
+ setTimeout(function(){ URL.revokeObjectURL(a.href); }, 4000);
277
+ toast('Card saved.');
278
+ }, 'image/png');
279
+ }
280
+ function copy(){
281
+ function fallback(){
282
+ var t = document.createElement('textarea'); t.value = VS.text; t.style.position = 'fixed'; t.style.opacity = '0';
283
+ document.body.appendChild(t); t.select();
284
+ var ok = false; try { ok = document.execCommand('copy'); } catch (e) {}
285
+ document.body.removeChild(t); toast(ok ? 'Copied.' : 'Could not copy. Use Post on X instead.');
286
+ }
287
+ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(VS.text).then(function(){ toast('Copied.'); }, fallback); } else { fallback(); }
288
+ }
289
+ // ---- what you actually paid: typed here, used here, never stored or sent ----
290
+ var paidIn = document.getElementById('vs-paid-in');
291
+ var paidEl = document.getElementById('vs-paid-line'), multEl = document.getElementById('vs-mult-line'), pxEl = document.getElementById('vs-px');
292
+ var W = VS.words;
293
+ var est = { paid: paidEl.innerHTML, paidHidden: paidEl.hidden, mult: multEl.innerHTML, multHidden: multEl.hidden, card: VS.card.paid, text: VS.text };
294
+ var api = { drawCard: drawCard, text: VS.text };
295
+ function times(r){ return r >= 10 ? String(Math.round(r)) : r.toFixed(1).replace(/\.0$/, ''); }
296
+ function fill(tpl, key, val){ return tpl.replace(key, function(){ return val; }); } // a function, so the $ in $1,127 is literal
297
+ function assemble(head, optional){
298
+ var body = head;
299
+ optional.forEach(function(l){ if (!l) return; var next = body + ' ' + l; if ((next + '\n\n' + VS.cmd).length <= 280) body = next; });
300
+ if ((body + '\n\n' + VS.cmd).length > 280) body = head;
301
+ return body + '\n\n' + VS.cmd;
302
+ }
303
+ function applyPaid(){
304
+ var raw = paidIn.value, v = raw === '' ? NaN : Number(raw);
305
+ if (isFinite(v) && v > 0 && v < 1e9) {
306
+ var amt = money(v);
307
+ paidEl.innerHTML = fill(W.paidHtml, '{amt}', amt); paidEl.hidden = false;
308
+ if (VS.ran > 0) { multEl.innerHTML = fill(W.multHtml, '{x}', times(VS.ran / v)); multEl.hidden = false; } else { multEl.hidden = true; }
309
+ VS.card.paid = { amount: amt, caption: W.actualCaption };
310
+ VS.text = assemble(VS.post.head, [VS.post.read, fill(W.actualPost, '{amt}', amt), VS.post.drift]);
311
+ } else {
312
+ paidEl.innerHTML = est.paid; paidEl.hidden = est.paidHidden;
313
+ multEl.innerHTML = est.mult; multEl.hidden = est.multHidden;
314
+ VS.card.paid = est.card; VS.text = est.text;
315
+ }
316
+ pxEl.href = 'https://x.com/intent/post?text=' + encodeURIComponent(VS.text);
317
+ api.text = VS.text;
318
+ }
319
+ paidIn.addEventListener('input', applyPaid);
320
+ document.getElementById('vs-dl').addEventListener('click', download);
321
+ document.getElementById('vs-cp').addEventListener('click', copy);
322
+ document.getElementById('vs-replay').addEventListener('click', function(){ if (reduce) { settle(); } else { run(); } });
323
+ window.cchubberVs = api; // lets a test or a screenshot script draw the card directly
324
+ })();
325
+ `;
326
+
327
+ /**
328
+ * "Your usage on other models": the race, the facts and the card buttons, as one block (style + markup + data + script)
329
+ * for the main report. Offline: no fetches, no fonts, no external scripts.
330
+ * `ctx`: { plan, months, driftPct (null unless a stated plan computed it) }.
331
+ */
332
+ export function renderVsBlock(rp, { plan = null, months = 1, driftPct = null } = {}) {
333
+ const paid = paidFacts(plan, months);
334
+ const text = postText(rp, { plan, months, driftPct });
335
+ const mult = paid ? multipleFacts(rp.actualListCost, paid.paid, true) : null;
336
+ // First occurrence only: the figure that is the answer (the list price later in the sentence stays plain). A function
337
+ // replacer, because a string replacement would read the $ in "$800" as a pattern.
338
+ const boldFirst = (str, figure) => esc(str).replace(esc(figure), (m) => `<b>${m}</b>`);
339
+ const driftLine = driftPct != null ? `${driftPct}% of your last week went to what you said you'd do.` : null;
340
+
341
+ // Race rows: every model plus what the user actually ran. They start in the listed order and settle sorted.
342
+ const all = [
343
+ ...rp.models.map(m => ({ id: m.id, label: m.label, maker: m.maker, total: m.total, note: introNote(m), series: rp.dailyCum.perModel[m.id] })),
344
+ { id: REF_ID, label: REF_LABEL, maker: REF_MAKER, total: rp.actualListCost, note: '', series: rp.dailyCum.actual, ref: true },
345
+ ];
346
+ const listIdx = (id) => { const i = FRONTIER_MODELS.findIndex(f => f.id === id); return i === -1 ? 999 : i; };
347
+ const startOrder = [REF_ID, ...[...rp.models].sort((a, b) => listIdx(a.id) - listIdx(b.id)).map(m => m.id)];
348
+ const sortedOrder = [...all].sort((a, b) => b.total - a.total).map(m => m.id);
349
+ const max = Math.max(...all.map(x => x.total), 1);
350
+
351
+ // The markup is the START of the race (listed order, $0, empty bars), so the first paint is the start and the finished
352
+ // numbers never flash. The finish sits beside it in --fpos, --fw and .vs-final, for no-JS and reduced motion (FINAL_RULES).
353
+ const rowsHtml = all.map(m => `
354
+ <li class="vs-row${m.ref ? ' vs-ref' : ''}" data-id="${esc(m.id)}" style="--pos:${startOrder.indexOf(m.id)};--fpos:${sortedOrder.indexOf(m.id)};--fw:${(m.total / max * 100).toFixed(2)}%">
355
+ <div class="vs-name"><span class="vs-label">${esc(m.label)}</span><span class="vs-meta">${esc(m.maker)}${m.note ? ` &middot; <span class="vs-tag">${esc(m.note)}</span>` : ''}</span></div>
356
+ <div class="vs-track" aria-hidden="true"><i class="vs-fill"></i></div>
357
+ <div class="vs-amt"><span class="vs-now">$0</span><span class="vs-final">${esc(usd0(m.total))}</span></div>
358
+ </li>`).join('');
359
+
360
+ const dateLabels = rp.dailyCum.dates.map(dayLabel);
361
+ const data = {
362
+ models: all.map(m => ({ id: m.id, total: m.total, series: m.series })),
363
+ startOrder, sortedOrder, dateLabels, text,
364
+ card: {
365
+ title: 'My Claude Code tokens, repriced',
366
+ sub: `${tokensLabel(totalTokens(rp))} tokens, each model's list price`,
367
+ period: periodLabel(rp),
368
+ bills: rp.models.map(m => ({ label: m.label, total: m.total, amount: usd0(m.total), note: introNote(m) })),
369
+ read: int(rp.readPerWrite),
370
+ readCaption: 'tokens read for every token Claude wrote',
371
+ paid: paid ? { amount: usd0(paid.paid), caption: paid.caption } : null,
372
+ drift: driftPct != null ? { pct: `${driftPct}%`, caption: `of my last week on what I said I'd do` } : null,
373
+ fine: "Same tokens, each model's list price, cache included. Compares prices, not outcomes.",
374
+ cmd: SHARE_CMD,
375
+ },
376
+ dailyCum: { dates: rp.dailyCum.dates },
377
+ // What the page script needs to swap the estimate for a typed amount; the wording stays in vs-text.js.
378
+ ran: rp.actualListCost,
379
+ cmd: SHARE_CMD,
380
+ post: { head: postHead(rp), read: readLine(rp), drift: driftPost(driftPct) },
381
+ words: {
382
+ paidHtml: boldFirst(PAID_ACTUAL_TEXT, '{amt}'),
383
+ multHtml: boldFirst(multipleFacts(1, 1, false, '{x}').text, '{x}'),
384
+ actualCaption: PAID_ACTUAL_CAPTION,
385
+ actualPost: PAID_ACTUAL_POST,
386
+ },
387
+ };
388
+ const json = JSON.stringify(data).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
389
+
390
+ const srcNotes = overrideRows(rp).map(m => `<p>${esc(m.label)}: ${esc(m.note || 'price')} from <a href="${esc(m.sourceUrl)}" rel="noopener" target="_blank">${esc(m.sourceName || m.sourceUrl)}</a>, checked ${esc(dayLabel(m.checked))}${m.after ? `; ${esc(usd0(m.after.total))} at the price after the introductory period` : ''}.${m.assumption ? ' ' + esc(m.assumption) : ''}</p>`);
391
+ const fillNotes = filledNotes(rp).map(n => `<p>${esc(n)}</p>`);
392
+
393
+ return `
394
+ <!-- YOUR USAGE ON OTHER MODELS (reprice): race, facts, card -->
395
+ <style>${CSS}</style>
396
+ <section id="vs">
397
+ <div class="vs-head"><h3 class="vs-title">Your usage on other models</h3><span class="vs-range">${esc(periodLabel(rp))}</span></div>
398
+ <p class="vs-lead">Same tokens, every frontier model.</p>
399
+ <p class="vs-sub">${esc(tokensLabel(totalTokens(rp)))} tokens from your Claude Code logs, repriced at each model&rsquo;s list price.</p>
400
+
401
+ <div class="vs-clock"><span id="vs-clock-label"><span class="vs-now">Replaying your real days</span><span class="vs-final">Your real days, replayed</span></span><b id="vs-clock"><span class="vs-now">${esc(dateLabels[0] || '')}</span><span class="vs-final">${esc(dateLabels[dateLabels.length - 1] || '')}</span></b></div>
402
+ <ol class="vs-race" id="vs-race" style="--vs-n:${all.length}" aria-label="Your usage repriced on each model">${rowsHtml}
403
+ </ol>
404
+
405
+ <div class="vs-facts">
406
+ <p class="vs-fact">${emph(readLine(rp))}</p>
407
+ <p class="vs-fact">${emph(cacheLine(rp))}</p>
408
+ <p class="vs-fact" id="vs-paid-line"${paid ? '' : ' hidden'}>${paid ? boldFirst(paid.text, usd0(paid.paid)) : ''}</p>
409
+ <p class="vs-fact" id="vs-mult-line"${mult ? '' : ' hidden'}>${mult ? boldFirst(multipleFacts(rp.actualListCost, paid.paid, true, '{x}').text, '{x}').replace('{x}', timesLabel(mult.times)) : ''}</p>
410
+ <div class="vs-paid">
411
+ <label class="vs-plabel" for="vs-paid-in">What you actually paid</label>
412
+ <div class="vs-pbox"><span class="vs-cur" aria-hidden="true">$</span><input class="vs-pin" id="vs-paid-in" type="number" inputmode="decimal" min="0" step="any" placeholder="0" autocomplete="off" aria-describedby="vs-paid-help"></div>
413
+ <p class="vs-phelp" id="vs-paid-help">In US dollars, all months together. Optional. It stays on this page: nothing is stored or sent.</p>
414
+ </div>
415
+ ${driftLine ? `<p class="vs-fact vs-drift">${emph(driftLine)}</p>` : ''}
416
+ </div>
417
+
418
+ <div class="vs-actions">
419
+ <button class="vs-btn vs-primary" id="vs-dl" type="button">Download card</button>
420
+ <button class="vs-btn" id="vs-cp" type="button">Copy text</button>
421
+ <a class="vs-btn" id="vs-px" href="https://x.com/intent/post?text=${encodeURIComponent(text)}" target="_blank" rel="noopener noreferrer">Post on X</a>
422
+ <button class="vs-btn" id="vs-replay" type="button">Replay</button>
423
+ </div>
424
+ <div class="vs-toast" id="vs-toast" role="status" aria-live="polite"></div>
425
+
426
+ <div class="vs-fine">
427
+ <p class="vs-first">${esc(FINE_PRINT)}</p>
428
+ ${paid ? `<p>${esc(PAID_FINE_PRINT)}</p>` : ''}
429
+ <p>${esc(pricesLine(rp))}</p>
430
+ ${srcNotes.join('')}
431
+ ${fillNotes.join('')}
432
+ <p>&ldquo;What you ran&rdquo; is the same logs at the Claude list prices for the models you actually used. This ran on your machine; nothing was uploaded.</p>
433
+ </div>
434
+ </section>
435
+ <script type="application/json" id="vs-data">${json}</script>
436
+ <noscript><style>${FINAL_RULES}</style></noscript>
437
+ <script id="vs-js">${SCRIPT}</script>
438
+ `;
439
+ }
@@ -0,0 +1,149 @@
1
+ // Words and numbers shared by every --vs surface (terminal, page, card, post), so they cannot drift apart.
2
+ // Everything here is computed from the reprice object; nothing is a hardcoded figure.
3
+
4
+ export const FINE_PRINT = "Same tokens on each model's list price, cache included. Another model would use a different number of tokens, so this compares prices, not outcomes.";
5
+
6
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
7
+
8
+ export const usd0 = (n) => '$' + Math.round(n).toLocaleString('en-US');
9
+ export const int = (n) => Math.round(n).toLocaleString('en-US');
10
+
11
+ /** '2026-09-30' -> '30 Sep 2026' */
12
+ export function dayLabel(iso) {
13
+ const d = new Date(iso + 'T00:00:00Z');
14
+ return isNaN(d) ? String(iso) : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
15
+ }
16
+
17
+ /** epoch ms -> '30 Sep 2026, 22:14' in the user's own time zone */
18
+ export function localStamp(ms) {
19
+ const d = new Date(ms);
20
+ const hh = String(d.getHours()).padStart(2, '0');
21
+ const mm = String(d.getMinutes()).padStart(2, '0');
22
+ return `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}, ${hh}:${mm}`;
23
+ }
24
+
25
+ export function period(rp) {
26
+ const dates = rp.dailyCum?.dates || [];
27
+ return dates.length ? { first: dates[0], last: dates[dates.length - 1] } : null;
28
+ }
29
+
30
+ export function periodLabel(rp) {
31
+ const p = period(rp);
32
+ if (!p) return '';
33
+ return p.first === p.last ? dayLabel(p.first) : `${dayLabel(p.first)} to ${dayLabel(p.last)}`;
34
+ }
35
+
36
+ export function totalTokens(rp) {
37
+ const t = rp.tokens;
38
+ return t.input + t.output + t.cacheRead + t.cacheWrite;
39
+ }
40
+
41
+ export function tokensLabel(n) {
42
+ return n >= 1e9 ? `${(n / 1e9).toFixed(1)} billion` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} million` : int(n);
43
+ }
44
+
45
+ /** What a model's row is sorted and quoted by: its price today (an intro price stays the price until it ends). */
46
+ export const low = (rp) => rp.models[rp.models.length - 1];
47
+ export const high = (rp) => rp.models[0];
48
+
49
+ export function readLine(rp) {
50
+ return `For every token Claude wrote, it read ${int(rp.readPerWrite)}.`;
51
+ }
52
+
53
+ export function cacheShareRange(rp) {
54
+ const pcts = rp.models.map(m => Math.round(m.cacheShare * 100));
55
+ return { lo: Math.min(...pcts), hi: Math.max(...pcts) };
56
+ }
57
+
58
+ export function cacheLine(rp) {
59
+ const { lo, hi } = cacheShareRange(rp);
60
+ return lo === hi ? `Cache reads and writes are ${lo}% of every bill above.` : `Cache reads and writes are ${lo}% to ${hi}% of every bill above.`;
61
+ }
62
+
63
+ // The plan cost is an estimate, never a reading of the user's invoices: the plan's US list price times the months billed.
64
+ export const PAID_FINE_PRINT = "The plan cost is an estimate: the plan's US list price (or the price you gave) before tax, times the months billed. Tax, currency, upgrades and billing dates change what you really paid. Type what you actually paid to replace it.";
65
+
66
+ // Templates the page script fills when the user types what they actually paid. {amt} and {x} are swapped for figures.
67
+ export const PAID_ACTUAL_TEXT = 'You paid {amt}.';
68
+ export const PAID_ACTUAL_POST = 'I paid {amt}.';
69
+ export const PAID_ACTUAL_CAPTION = 'what I paid';
70
+
71
+ /** { paid, estimate: true, text, post, caption } or null when no plan was detected. `months` comes from monthsBilled. */
72
+ export function paidFacts(plan, months) {
73
+ if (!plan) return null;
74
+ const paid = plan.monthlyUSD * months;
75
+ const span = `${months} month${months === 1 ? '' : 's'}`;
76
+ const isList = plan.key !== 'custom';
77
+ const detail = isList ? `${plan.name} at the US list price of ${usd0(plan.monthlyUSD)} a month, ${span}` : `${span} at ${usd0(plan.monthlyUSD)} a month, the price you gave`;
78
+ return {
79
+ paid,
80
+ estimate: true,
81
+ text: `Estimated plan cost: about ${usd0(paid)} (${detail}).`,
82
+ post: isList ? `Plan cost, estimated at US list price: ${usd0(paid)}.` : `Plan cost, estimated: ${usd0(paid)}.`,
83
+ caption: isList ? 'estimated plan cost at US list price' : 'estimated plan cost',
84
+ };
85
+ }
86
+
87
+ // 15 for 14.6, 3.5 for 3.46: one decimal only while it still says something.
88
+ export const timesLabel = (r) => (r >= 10 ? String(Math.round(r)) : r.toFixed(1).replace(/\.0$/, ''));
89
+
90
+ /** What you ran (Claude list prices) against the paid figure. `template` holds {x} for the page script. Null without both numbers. */
91
+ export function multipleFacts(ran, paid, estimate, x = null) {
92
+ if (!(ran > 0) || !(paid > 0)) return null;
93
+ const n = x ?? timesLabel(ran / paid);
94
+ const text = estimate
95
+ ? `What you ran, at Claude list prices, is about ${n} times that estimate.`
96
+ : `What you ran, at Claude list prices, is ${n} times what you paid.`;
97
+ return { times: ran / paid, text };
98
+ }
99
+
100
+ export function overrideRows(rp) {
101
+ return rp.models.filter(m => m.source === 'override');
102
+ }
103
+
104
+ /** 'Prices: LiteLLM, fetched 30 Sep 2026, 22:14; Gemini 4 Argon from blog.google, 30 Sep 2026.' */
105
+ export function pricesLine(rp) {
106
+ const head = rp.offline ? `offline, prices as of ${dayLabel(rp.snapshotDate || '2026-09-30')}` : `LiteLLM, fetched ${localStamp(rp.fetchedAt || Date.now())}`;
107
+ const extra = overrideRows(rp).map(m => `${m.label} from ${m.sourceName || m.sourceUrl}, ${dayLabel(m.checked)}`);
108
+ return `Prices: ${[head, ...extra].join('; ')}.`;
109
+ }
110
+
111
+ /** Text for the row of a model that has a price after an introductory period. */
112
+ export function introNote(m) {
113
+ return m.after ? `intro price, ${usd0(m.after.total)} after` : '';
114
+ }
115
+
116
+ /** Where a provider lists no cache price and the input price was used instead, as plain sentences. */
117
+ export function filledNotes(rp) {
118
+ const name = { cacheRead: 'cache read', cacheWrite: 'cache write' };
119
+ const groups = {};
120
+ for (const m of rp.models) for (const f of m.filled || []) (groups[f] = groups[f] || []).push(m.label);
121
+ return Object.entries(groups).map(([f, labels]) => `No separate ${name[f]} price listed for ${labels.join(', ')}, so the input price is used.`);
122
+ }
123
+
124
+ export const SHARE_CMD = 'npx cchubber';
125
+
126
+ /** Greedy fit: add optional lines while the whole post (plus the command) stays inside 280 characters. The page script repeats this. */
127
+ export function assemblePost(head, optional) {
128
+ let body = head;
129
+ for (const line of optional.filter(Boolean)) {
130
+ const next = `${body} ${line}`;
131
+ if ((next + '\n\n' + SHARE_CMD).length <= 280) body = next;
132
+ }
133
+ if ((body + '\n\n' + SHARE_CMD).length > 280) body = head;
134
+ return `${body}\n\n${SHARE_CMD}`;
135
+ }
136
+
137
+ export function postHead(rp) {
138
+ const lo = low(rp), hi = high(rp);
139
+ return `My Claude Code tokens, repriced on ${rp.models.length} frontier models: ${usd0(lo.total)} (${lo.label}) to ${usd0(hi.total)} (${hi.label}).`;
140
+ }
141
+
142
+ export function driftPost(driftPct) {
143
+ return driftPct != null ? `${driftPct}% of my last week went to what I said I'd do.` : null;
144
+ }
145
+
146
+ /** The text for Copy text and Post on X: one text, kept inside 280 characters by adding lines only while they fit. */
147
+ export function postText(rp, { plan = null, months = 1, driftPct = null } = {}) {
148
+ return assemblePost(postHead(rp), [readLine(rp), paidFacts(plan, months)?.post, driftPost(driftPct)]);
149
+ }
package/src/telemetry.js CHANGED
@@ -19,10 +19,22 @@ function execSync(cmd, opts = {}) {
19
19
 
20
20
  const TELEMETRY_URL = process.env.CC_HUBBER_TELEMETRY_URL || 'https://cchubber-telemetry.asmirkhan087.workers.dev/collect';
21
21
 
22
- export function shouldSendTelemetry(flags) {
22
+ // The opt-outs only. The 24-hour throttle below limits the automatic stats send, not whether the user agreed to telemetry.
23
+ export function telemetryEnabled(flags = {}) {
23
24
  if (flags.noTelemetry) return false;
24
25
  if (process.env.CC_HUBBER_TELEMETRY === '0') return false;
25
26
  if (process.env.DO_NOT_TRACK === '1') return false;
27
+ return true;
28
+ }
29
+
30
+ /** What the report needs to send its three button counts: { on: false }, or the address, the anonymous id and the version. */
31
+ export function beaconConfig(flags = {}) {
32
+ if (!telemetryEnabled(flags)) return { on: false };
33
+ return { on: true, url: TELEMETRY_URL, uid: getOrCreateUID(), v: PKG_VERSION };
34
+ }
35
+
36
+ export function shouldSendTelemetry(flags) {
37
+ if (!telemetryEnabled(flags)) return false;
26
38
 
27
39
  // Throttle: once per 24 hours per machine
28
40
  const stampFile = join(homedir(), '.cchubber-last-telemetry');