@subvalue/cli 0.1.4 → 0.1.6

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,10 +1,11 @@
1
1
 
2
+
2
3
  import { fullCalendarMonths } from '../../core/src/calendar.js';
3
4
  import { formatPricingCoverage } from '../../core/src/coverage.js';
4
- import { product } from '../../ui/src/brand.js';
5
+ import { product, wordmarkSvg } from '../../ui/src/brand.js';
5
6
  /** Only public, aggregated fields needed by the receipt and its example. */
6
7
 
7
-
8
+
8
9
 
9
10
 
10
11
 
@@ -14,7 +15,8 @@ import { product } from '../../ui/src/brand.js';
14
15
 
15
16
 
16
17
 
17
-
18
+
19
+
18
20
 
19
21
 
20
22
 
@@ -37,23 +39,67 @@ export const money = (n , signed = false) =>
37
39
  ? 'Unavailable'
38
40
  : `${signed && n >= 0 ? '+' : ''}${new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)}`;
39
41
  /** Compare only the displayed priced amount; preserve uncertainty and billing eligibility. */
40
- export function comparisonForDisplay(s ) {
42
+
43
+
44
+
45
+
46
+
47
+ export function comparisonForDisplay(s ) {
41
48
  const comparison = s.total.comparison,
42
49
  amount = s.total.apiEquivalent ?? s.total.knownSubtotal;
43
50
  if (fullCalendarMonths(s.range) === null)
44
51
  return {
45
52
  ...comparison,
46
53
  subscription: null,
54
+ apiSpend: null,
55
+ paid: null,
47
56
  value: null,
48
57
  roi: null,
49
58
  breakEven: null,
50
59
  outcome: 'neutral',
60
+ scope: [],
61
+ apiEquivalent: null,
51
62
  };
52
- if (amount === null || comparison.subscription === null) return comparison;
63
+ const paidFor = (p ) =>
64
+ p.comparison?.paid !== undefined
65
+ ? p.comparison.paid
66
+ : p.mode === 'SUBSCRIPTION'
67
+ ? (p.comparison?.subscription ?? null)
68
+ : null;
69
+ const active = s.providers.filter((p) => p.records > 0),
70
+ included = active.filter((p) => paidFor(p) !== null);
71
+ if (included.length > 0 && included.length < active.length) {
72
+ const subscription = included.reduce((n, p) => n + (p.comparison?.subscription ?? 0), 0),
73
+ apiSpend = included.reduce((n, p) => n + (p.comparison?.apiSpend ?? 0), 0),
74
+ paid = included.reduce((n, p) => n + paidFor(p) , 0),
75
+ priced = included.map((p) => p.apiEquivalent ?? p.knownSubtotal),
76
+ scopedAmount = priced.every((cost) => cost !== null)
77
+ ? priced.reduce ((n, cost) => n + cost , 0)
78
+ : null,
79
+ certain = !s.historyPartial && included.every((p) => p.apiEquivalent !== null);
80
+ return {
81
+ subscription,
82
+ apiSpend,
83
+ paid,
84
+ value: scopedAmount === null ? null : scopedAmount - paid,
85
+ roi: scopedAmount === null || paid <= 0 ? null : scopedAmount / paid,
86
+ outcome: certain && included.length === 1 ? included[0].comparison .outcome : 'neutral',
87
+ breakEven: certain && included.length === 1 ? included[0].comparison .breakEven : null,
88
+ reason: 'Providers with declared expenses only',
89
+ scope: included.map((p) => p.provider),
90
+ apiEquivalent: scopedAmount,
91
+ };
92
+ }
93
+ const paid = comparison.paid === undefined ? comparison.subscription : comparison.paid;
94
+ if (amount === null || paid === null)
95
+ return { ...comparison, paid, scope: [], apiEquivalent: amount };
53
96
  return {
54
97
  ...comparison,
55
- value: comparison.value ?? amount - comparison.subscription,
56
- roi: comparison.roi ?? (comparison.subscription > 0 ? amount / comparison.subscription : null),
98
+ paid,
99
+ value: comparison.value ?? amount - paid,
100
+ roi: comparison.roi ?? (paid > 0 ? amount / paid : null),
101
+ scope: [],
102
+ apiEquivalent: amount,
57
103
  };
58
104
  }
59
105
  export function receiptModel(s , generatedAt = new Date()) {
@@ -86,12 +132,33 @@ export function receiptModel(s , generatedAt = new Date())
86
132
  strong: true,
87
133
  });
88
134
  const comp = comparisonForDisplay(s);
89
- if (comp.subscription !== null)
135
+ if (comp.scope.length)
136
+ rows.push(
137
+ { left: '', right: '' },
138
+ {
139
+ left:
140
+ comp.scope
141
+ .map((provider) => (provider === 'codex' ? 'CODEX' : 'CLAUDE CODE'))
142
+ .join(' + ') + ' COMPARISON',
143
+ right: '',
144
+ strong: true,
145
+ },
146
+ {
147
+ left: 'API Equivalent',
148
+ right: comp.apiEquivalent === null ? '—' : money(comp.apiEquivalent),
149
+ },
150
+ );
151
+ if (comp.subscription != null && comp.subscription > 0)
90
152
  rows.push({ left: 'Subscription Cost', right: money(comp.subscription) });
153
+ if (comp.apiSpend != null && (comp.apiSpend > 0 || comp.subscription === 0)) {
154
+ rows.push({ left: 'API Paid', right: money(comp.apiSpend) });
155
+ if (comp.subscription != null && comp.subscription > 0)
156
+ rows.push({ left: 'Total Paid', right: money(comp.paid ?? null), strong: true });
157
+ }
91
158
  const value = comp.value;
92
159
  if (value !== null)
93
160
  rows.push({
94
- left: 'You Saved',
161
+ left: 'Total Saved',
95
162
  right: money(value, true),
96
163
  accent: value >= 0 ? 'positive' : 'negative',
97
164
  strong: true,
@@ -179,8 +246,53 @@ export async function receiptFonts() {
179
246
  await Promise.all([
180
247
  document.fonts.load('14px "Plex Mono"'),
181
248
  document.fonts.load('600 14px "Plex Mono"'),
249
+ receiptBrandAssets(document),
182
250
  ]);
183
251
  }
252
+ // Decode the same bundled product marks used by the dashboard before drawing.
253
+ // Canvas previews and exported PNGs therefore include identical local artwork.
254
+ const brandAssets = new WeakMap ();
255
+ const decodedBrands = new WeakMap ();
256
+ const decodedWordmarks = new WeakMap ();
257
+ function receiptBrandAssets(document ) {
258
+ let assets = brandAssets.get(document);
259
+ if (!assets) {
260
+ assets = Promise.all(
261
+ ['codex-outline.svg', 'claude-code-clawd.svg'].map(async (file) => {
262
+ const image = document.createElement('img');
263
+ image.src = `/brands/${file}`;
264
+ await image.decode();
265
+ return image;
266
+ }),
267
+ ).then(async ([codex, claude]) => {
268
+ const svg = wordmarkSvg();
269
+ if (svg) {
270
+ const dimensions = /viewBox="0 0 (\d+) (\d+)"/.exec(svg) ;
271
+ const images = await Promise.all(
272
+ (['dark', 'light'] ).map(async (theme) => {
273
+ const image = document.createElement('img');
274
+ const artwork = svg
275
+ .replace('<svg ', `<svg width="${dimensions[1]}" height="${dimensions[2]}" `)
276
+ .replace('fill="currentColor"', `fill="${theme === 'dark' ? '#eef2f4' : '#22272d'}"`)
277
+ .replaceAll(
278
+ 'class="brand-name-accent"',
279
+ `fill="${theme === 'dark' ? '#37ec85' : '#137744'}"`,
280
+ );
281
+ image.src = 'data:image/svg+xml,' + encodeURIComponent(artwork);
282
+ await image.decode();
283
+ return image;
284
+ }),
285
+ );
286
+ decodedWordmarks.set(document, { dark: images[0], light: images[1] });
287
+ }
288
+ const marks = { codex, claude };
289
+ decodedBrands.set(document, marks);
290
+ return marks;
291
+ });
292
+ brandAssets.set(document, assets);
293
+ }
294
+ return assets;
295
+ }
184
296
  const paperTextures = new WeakMap ();
185
297
  function paperTexture(canvas ) {
186
298
  const document = canvas.ownerDocument;
@@ -202,6 +314,8 @@ function paperTexture(canvas ) {
202
314
  paperTextures.set(document, texture);
203
315
  return texture;
204
316
  }
317
+ const isTokenRow = (row ) =>
318
+ ['Input Tokens', 'Output Tokens', 'Cache Reads', 'Tokens', 'Known Tokens'].includes(row.left);
205
319
  const rowHeight = (row ) =>
206
320
  !row.left && !row.right
207
321
  ? 34
@@ -211,12 +325,19 @@ const rowHeight = (row ) =>
211
325
  ? 46
212
326
  : row.accent
213
327
  ? 32
214
- : 28;
328
+ : isTokenRow(row)
329
+ ? 24
330
+ : 28;
215
331
  export function drawReceipt(
216
332
  canvas ,
217
333
  model ,
218
334
  theme = 'dark',
335
+ presentation = 'standard',
219
336
  ) {
337
+ // A public example needs larger print when viewed inside the dashboard preview.
338
+ // The paper, layout and export renderer remain shared with the local application.
339
+ const printSize = (size ) =>
340
+ presentation === 'preview' ? Math.max(13, size * 1.12) : size;
220
341
  const light = theme === 'light';
221
342
  const ink = (color ) =>
222
343
  light
@@ -288,6 +409,7 @@ export function drawReceipt(
288
409
  bold = false,
289
410
  maxWidth = width - 48,
290
411
  ) => {
412
+ size = printSize(size);
291
413
  c.font = `${bold ? '600 ' : ''}${size}px ${mono}`;
292
414
  while (c.measureText(value).width > maxWidth && size > 8) {
293
415
  size -= 0.5;
@@ -307,7 +429,13 @@ export function drawReceipt(
307
429
  c.stroke();
308
430
  c.setLineDash([]);
309
431
  };
310
- text(product.name, width / 2, 64, 29, '#f0f0ef', 'center');
432
+ const wordmark = decodedWordmarks.get(canvas.ownerDocument)?.[theme];
433
+ if (wordmark) {
434
+ // Compact, centered lettering only; no symbol or terminal cursor on the paper.
435
+ const logoWidth = 156,
436
+ logoHeight = logoWidth * (wordmark.naturalHeight / wordmark.naturalWidth);
437
+ c.drawImage(wordmark, (width - logoWidth) / 2, 64 - logoHeight, logoWidth, logoHeight);
438
+ } else text(product.name, width / 2, 64, 29, '#f0f0ef', 'center');
311
439
  text('USAGE RECEIPT', width / 2, 92, 13, '#b6b8bd', 'center');
312
440
  if (model.demo) text('EXAMPLE DATA', width / 2, 112, 10, '#a3a5a9', 'center');
313
441
  text('Period', left, 137, 12.5, '#b4b5b9');
@@ -325,7 +453,8 @@ export function drawReceipt(
325
453
  }
326
454
  const provider = !row.right,
327
455
  multiple = row.left === 'VALUE MULTIPLE';
328
- const label = provider ? (row.left === 'CODEX' ? 'Codex' : 'Claude Code') : row.left;
456
+ const label =
457
+ row.left === 'CODEX' ? 'Codex' : row.left === 'CLAUDE CODE' ? 'Claude Code' : row.left;
329
458
  const color =
330
459
  row.accent === 'positive'
331
460
  ? '#37ec85'
@@ -334,18 +463,37 @@ export function drawReceipt(
334
463
  : row.strong
335
464
  ? '#e1e2e4'
336
465
  : '#bdc0c6';
337
- const valueSize = multiple ? 25 : row.accent ? 22 : row.strong ? 18 : 16;
338
- c.font = `${valueSize}px ${mono}`;
466
+ const tokenRow = isTokenRow(row);
467
+ const valueSize = multiple ? 25 : row.accent ? 22 : row.strong ? 18 : tokenRow ? 13 : 16;
468
+ c.font = `${printSize(valueSize)}px ${mono}`;
339
469
  const valueWidth = c.measureText(row.right).width;
470
+ const brand = row.left === 'CODEX' ? 'codex' : row.left === 'CLAUDE CODE' ? 'claude' : null;
471
+ const mark = brand && decodedBrands.get(canvas.ownerDocument)?.[brand];
472
+ let labelLeft = left;
473
+ if (mark) {
474
+ const size = 22;
475
+ const markHeight = size * (mark.naturalHeight / mark.naturalWidth);
476
+ if (brand === 'codex') {
477
+ const tinted = canvas.ownerDocument.createElement('canvas');
478
+ tinted.width = tinted.height = size * scale;
479
+ const context = tinted.getContext('2d') ;
480
+ context.drawImage(mark, 0, 0, tinted.width, tinted.height);
481
+ context.globalCompositeOperation = 'source-in';
482
+ context.fillStyle = ink('#e1e2e4');
483
+ context.fillRect(0, 0, tinted.width, tinted.height);
484
+ c.drawImage(tinted, left, y - 17, size, size);
485
+ } else c.drawImage(mark, left, y - 14, size, markHeight);
486
+ labelLeft += size + 8;
487
+ }
340
488
  text(
341
489
  label,
342
- left,
490
+ labelLeft,
343
491
  y,
344
- multiple ? 17 : provider ? 17 : row.strong ? 14 : 14,
492
+ multiple ? 17 : provider ? 17 : tokenRow ? 12 : 14,
345
493
  multiple ? '#e1e2e4' : color,
346
494
  'left',
347
495
  provider,
348
- provider ? width - 48 : width - 62 - valueWidth,
496
+ provider ? right - labelLeft : width - 62 - valueWidth,
349
497
  );
350
498
  text(row.right, right, y, valueSize, row.accent ? color : '#e3e4e7', 'right', row.strong, 160);
351
499
  y += step;
@@ -2,6 +2,7 @@
2
2
  export const product = {
3
3
  name: 'SubValue',
4
4
  site: 'subvalue.dev',
5
+ repository: 'https://github.com/axelmtn/subvalue',
5
6
  exportBasename: 'subvalue-receipt',
6
7
  };
7
8
  // Typography remains separate from the neutral symbol and follows the display name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@subvalue/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Local, read-only Codex and Claude Code API-equivalent usage dashboard",
5
5
  "type": "module",
6
6
  "license": "MIT",