@subvalue/cli 0.1.3 → 0.1.4

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.
Files changed (37) hide show
  1. package/PRE_RELEASE.md +6 -6
  2. package/README.md +5 -3
  3. package/dist/PRICING.md +15 -16
  4. package/dist/packages/cli/src/index.js +109 -20
  5. package/dist/packages/cli/src/protocol.js +13 -0
  6. package/dist/packages/cli/src/runtime.js +44 -14
  7. package/dist/packages/cli/src/server.js +306 -62
  8. package/dist/packages/core/src/calendar.js +41 -18
  9. package/dist/packages/core/src/coverage.js +12 -0
  10. package/dist/packages/core/src/demo.js +61 -9
  11. package/dist/packages/core/src/metadata.js +52 -9
  12. package/dist/packages/core/src/pricing/current.js +346 -297
  13. package/dist/packages/core/src/pricing/index.js +373 -73
  14. package/dist/packages/core/src/providers/claude/index.js +93 -21
  15. package/dist/packages/core/src/providers/codex/index.js +213 -46
  16. package/dist/packages/core/src/scanner.js +226 -46
  17. package/dist/packages/core/src/security.js +127 -34
  18. package/dist/packages/core/src/storage.js +215 -47
  19. package/dist/packages/core/src/subscriptions.js +146 -21
  20. package/dist/packages/core/src/summary.js +408 -59
  21. package/dist/packages/core/src/types.js +71 -17
  22. package/dist/packages/core/src/usage-json.js +166 -47
  23. package/dist/packages/receipt/src/index.js +386 -82
  24. package/dist/packages/ui/src/brand.js +42 -27
  25. package/dist/public/app.css +2280 -61
  26. package/dist/public/apps/web/src/landing.js +200 -8
  27. package/dist/public/apps/web/src/main.js +1143 -98
  28. package/dist/public/index.html +24 -1
  29. package/dist/public/landing.css +3235 -80
  30. package/dist/public/landing.html +21 -1
  31. package/dist/public/packages/core/src/calendar.js +41 -18
  32. package/dist/public/packages/core/src/coverage.js +12 -0
  33. package/dist/public/packages/core/src/subscriptions.js +146 -21
  34. package/dist/public/packages/receipt/src/index.js +386 -82
  35. package/dist/public/packages/ui/src/brand.js +42 -27
  36. package/dist/public/privacy.css +197 -9
  37. package/package.json +1 -1
@@ -1,95 +1,399 @@
1
-
2
- import {fullCalendarMonths} from '../../core/src/calendar.js';
3
- import {product} from '../../ui/src/brand.js';
1
+
2
+ import { fullCalendarMonths } from '../../core/src/calendar.js';
3
+ import { formatPricingCoverage } from '../../core/src/coverage.js';
4
+ import { product } from '../../ui/src/brand.js';
5
+ /** Only public, aggregated fields needed by the receipt and its example. */
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
4
18
 
5
-
6
- export const money=(n ,signed=false) =>n===null?'Unavailable':`${signed&&n>=0?'+':''}${new Intl.NumberFormat('en-US',{style:'currency',currency:'USD'}).format(n)}`;
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+ export const money = (n , signed = false) =>
36
+ n === null
37
+ ? 'Unavailable'
38
+ : `${signed && n >= 0 ? '+' : ''}${new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)}`;
7
39
  /** Compare only the displayed priced amount; preserve uncertainty and billing eligibility. */
8
- export function comparisonForDisplay(s ) {
9
- const comparison=s.total.comparison,amount=s.total.apiEquivalent??s.total.knownSubtotal;
10
- if(fullCalendarMonths(s.range)===null)return {...comparison,subscription:null,value:null,roi:null,breakEven:null,outcome:'neutral'};
11
- if(amount===null||comparison.subscription===null)return comparison;
12
- return {...comparison,value:comparison.value??amount-comparison.subscription,roi:comparison.roi??(comparison.subscription>0?amount/comparison.subscription:null)};
40
+ export function comparisonForDisplay(s ) {
41
+ const comparison = s.total.comparison,
42
+ amount = s.total.apiEquivalent ?? s.total.knownSubtotal;
43
+ if (fullCalendarMonths(s.range) === null)
44
+ return {
45
+ ...comparison,
46
+ subscription: null,
47
+ value: null,
48
+ roi: null,
49
+ breakEven: null,
50
+ outcome: 'neutral',
51
+ };
52
+ if (amount === null || comparison.subscription === null) return comparison;
53
+ return {
54
+ ...comparison,
55
+ value: comparison.value ?? amount - comparison.subscription,
56
+ roi: comparison.roi ?? (comparison.subscription > 0 ? amount / comparison.subscription : null),
57
+ };
13
58
  }
14
- export function receiptModel(s ,generatedAt=new Date()) {
15
- const rows =[];
16
- for(const p of s.providers.filter(p=>p.records>0&&p.visible!==false)){
17
- rows.push({left:p.provider==='codex'?'CODEX':'CLAUDE CODE',right:'',strong:true});
18
- if(p.tokenBreakdown){
19
- for(const [label,value] of [['Input Tokens',p.tokenBreakdown.input],['Output Tokens',p.tokenBreakdown.output],['Cache Reads',p.tokenBreakdown.cacheRead]] )rows.push({left:label,right:value===null?'Unknown':value.toLocaleString('en-US')});
20
- }else if(p.tokens!==undefined)rows.push({left:p.tokensPartial?'Known Tokens':'Tokens',right:p.tokens===null?'Unknown':p.tokens.toLocaleString('en-US')});
21
- const amount=p.apiEquivalent??p.knownSubtotal;
22
- rows.push({left:'Estimated API Cost',right:amount===null?'—':money(amount)});
23
- rows.push({left:'',right:''});
59
+ export function receiptModel(s , generatedAt = new Date()) {
60
+ const rows = [];
61
+ for (const p of s.providers.filter((p) => p.records > 0 && p.visible !== false)) {
62
+ rows.push({ left: p.provider === 'codex' ? 'CODEX' : 'CLAUDE CODE', right: '', strong: true });
63
+ if (p.tokenBreakdown) {
64
+ for (const [label, value] of [
65
+ ['Input Tokens', p.tokenBreakdown.input],
66
+ ['Output Tokens', p.tokenBreakdown.output],
67
+ ['Cache Reads', p.tokenBreakdown.cacheRead],
68
+ ] )
69
+ rows.push({
70
+ left: label,
71
+ right: value === null ? 'Unknown' : value.toLocaleString('en-US'),
72
+ });
73
+ } else if (p.tokens !== undefined)
74
+ rows.push({
75
+ left: p.tokensPartial ? 'Known Tokens' : 'Tokens',
76
+ right: p.tokens === null ? 'Unknown' : p.tokens.toLocaleString('en-US'),
77
+ });
78
+ const amount = p.apiEquivalent ?? p.knownSubtotal;
79
+ rows.push({ left: 'Estimated API Cost', right: amount === null ? '—' : money(amount) });
80
+ rows.push({ left: '', right: '' });
24
81
  }
25
- const amount=s.total.apiEquivalent??s.total.knownSubtotal;
26
- rows.push({left:'Total API Equivalent',right:amount===null?'—':money(amount),strong:true});
27
- const comp=comparisonForDisplay(s);if(comp.subscription!==null)rows.push({left:'Subscription Cost',right:money(comp.subscription)});
28
- const value=comp.value;
29
- if(value!==null)rows.push({left:'You Saved',right:money(value,true),accent:value>=0?'positive':'negative',strong:true});
82
+ const amount = s.total.apiEquivalent ?? s.total.knownSubtotal;
83
+ rows.push({
84
+ left: 'Total API Equivalent',
85
+ right: amount === null ? '—' : money(amount),
86
+ strong: true,
87
+ });
88
+ const comp = comparisonForDisplay(s);
89
+ if (comp.subscription !== null)
90
+ rows.push({ left: 'Subscription Cost', right: money(comp.subscription) });
91
+ const value = comp.value;
92
+ if (value !== null)
93
+ rows.push({
94
+ left: 'You Saved',
95
+ right: money(value, true),
96
+ accent: value >= 0 ? 'positive' : 'negative',
97
+ strong: true,
98
+ });
30
99
  // With partial pricing, compare only the printed known amount to the declared
31
100
  // full-month subscription. The coverage footer stays visible; no outcome is inferred.
32
- const multiple=comp.roi;
33
- if(multiple!==null)rows.push({left:'',right:''},{left:'VALUE MULTIPLE',right:`${multiple.toFixed(2)}×`,accent:multiple>=1?'positive':'negative',strong:true});
34
- if(comp.breakEven)rows.push({left:'BREAK-EVEN',right:new Date(comp.breakEven+'T12:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric'}).toUpperCase()});
35
- const outcome=comp.outcome==='positive'?'YOUR PLAN WAS WORTH IT':comp.outcome==='negative'?'API WOULD HAVE BEEN CHEAPER':'';
36
- const progress=!s.demo&&Date.parse(s.range.from)<=generatedAt.getTime()&&Date.parse(s.range.until)>generatedAt.getTime()&&fullCalendarMonths(s.range)!==null?'In progress · ':'';
37
- const coverage=s.total.apiEquivalent===null&&s.total.priceCoverage!==null?`${Math.round(s.total.priceCoverage*100)}% priced · `:'';
38
- const date=(value )=>new Date(value).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
39
- const generated=generatedAt.toLocaleString('en-US',{month:'short',day:'numeric',year:'numeric',hour:'numeric',minute:'2-digit',hour12:true}).replace(' at ', ' ');
40
- return {period:s.range.label.toUpperCase(),periodDetail:date(s.range.from)+' – '+date(new Date(Date.parse(s.range.until)-1).toISOString()),generated,rows,outcome,confidence:s.historyPartial?`${progress}${coverage}API equivalent · Partial history`:`${progress}${coverage}API equivalent · ${s.confidence==='HIGH'?'High confidence':'Incomplete'}`,demo:s.demo};
101
+ const multiple = comp.roi;
102
+ if (multiple !== null)
103
+ rows.push(
104
+ { left: '', right: '' },
105
+ {
106
+ left: 'VALUE MULTIPLE',
107
+ right: `${multiple.toFixed(2)}×`,
108
+ accent: multiple >= 1 ? 'positive' : 'negative',
109
+ strong: true,
110
+ },
111
+ );
112
+ if (comp.breakEven)
113
+ rows.push({
114
+ left: 'BREAK-EVEN',
115
+ right: new Date(comp.breakEven + 'T12:00:00')
116
+ .toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
117
+ .toUpperCase(),
118
+ });
119
+ const outcome =
120
+ comp.outcome === 'positive'
121
+ ? 'YOUR PLAN WAS WORTH IT'
122
+ : comp.outcome === 'negative'
123
+ ? 'API WOULD HAVE BEEN CHEAPER'
124
+ : '';
125
+ const progress =
126
+ !s.demo &&
127
+ Date.parse(s.range.from) <= generatedAt.getTime() &&
128
+ Date.parse(s.range.until) > generatedAt.getTime() &&
129
+ fullCalendarMonths(s.range) !== null
130
+ ? 'In progress · '
131
+ : '';
132
+ const coverage =
133
+ s.total.apiEquivalent === null && s.total.priceCoverage !== null
134
+ ? `${formatPricingCoverage(s.total.priceCoverage)} priced · `
135
+ : '';
136
+ const date = (value ) =>
137
+ new Date(value).toLocaleDateString('en-US', {
138
+ month: 'short',
139
+ day: 'numeric',
140
+ year: 'numeric',
141
+ });
142
+ // Civil dates belong to the server's selected calendar, not the browser's
143
+ // timezone. Legacy summaries without calendar fields retain their fallback.
144
+ const calendarDate = (value ) =>
145
+ new Date(value + 'T12:00:00Z').toLocaleDateString('en-US', {
146
+ month: 'short',
147
+ day: 'numeric',
148
+ year: 'numeric',
149
+ timeZone: 'UTC',
150
+ });
151
+ const periodFrom = s.range.calendarFrom ? calendarDate(s.range.calendarFrom) : date(s.range.from);
152
+ const periodTo = s.range.calendarTo
153
+ ? calendarDate(s.range.calendarTo)
154
+ : date(new Date(Date.parse(s.range.until) - 1).toISOString());
155
+ const generated = generatedAt
156
+ .toLocaleString('en-US', {
157
+ month: 'short',
158
+ day: 'numeric',
159
+ year: 'numeric',
160
+ hour: 'numeric',
161
+ minute: '2-digit',
162
+ hour12: true,
163
+ })
164
+ .replace(' at ', ' ');
165
+ return {
166
+ period: s.range.label.toUpperCase(),
167
+ periodDetail: periodFrom + ' – ' + periodTo,
168
+ generated,
169
+ rows,
170
+ outcome,
171
+ confidence: s.historyPartial
172
+ ? `${progress}${coverage}API equivalent · Partial history`
173
+ : `${progress}${coverage}API equivalent · ${s.confidence === 'HIGH' ? 'High confidence' : 'Incomplete'}`,
174
+ demo: s.demo,
175
+ };
41
176
  }
42
- export async function receiptFonts() {if(typeof document!=='undefined'&&document.fonts)await Promise.all([document.fonts.load('14px "Plex Mono"'),document.fonts.load('600 14px "Plex Mono"')]);}
43
- const paperTextures=new WeakMap ();
44
- function paperTexture(canvas ) {
45
- const document=canvas.ownerDocument;let texture=paperTextures.get(document);if(texture)return texture;
46
- texture=document.createElement('canvas');texture.width=64;texture.height=64;
47
- const context=texture.getContext('2d') ;const pixels=context.createImageData(64,64);let seed=619;
48
- for(let i=0;i<64*64;i++){seed=(Math.imul(seed,1664525)+1013904223)>>>0;if(seed%7!==0)continue;const color=seed%2?255:0;pixels.data.set([color,color,color,color?4:8],i*4);}
49
- context.putImageData(pixels,0,0);paperTextures.set(document,texture);return texture;
177
+ export async function receiptFonts() {
178
+ if (typeof document !== 'undefined' && document.fonts)
179
+ await Promise.all([
180
+ document.fonts.load('14px "Plex Mono"'),
181
+ document.fonts.load('600 14px "Plex Mono"'),
182
+ ]);
50
183
  }
51
- const rowHeight=(row )=>!row.left&&!row.right?34:!row.right?34:row.left==='VALUE MULTIPLE'?46:row.accent?32:28;
52
- export function drawReceipt(canvas ,model ,theme ='dark') {
53
- const light=theme==='light';
54
- const ink=(color )=>light?({'#e3e4e5':'#25282d','#f0f0ef':'#171b20','#b6b8bd':'#575d65','#a3a5a9':'#646971','#b4b5b9':'#626870','#d0d1d5':'#393f47','#37ec85':'#137744','#ed9987':'#b34436','#e1e2e4':'#22272d','#bdc0c6':'#505861','#e3e4e7':'#25282d','#b2b3b9':'#535a63','#a9adb5':'#626870'} )[color]??color:color;
55
- const width=360,height=196+model.rows.reduce((n,row)=>n+rowHeight(row),0)+174,scale=6;
56
- canvas.width=width*scale;canvas.height=height*scale;canvas.style.aspectRatio=`${width}/${height}`;
57
- const c=canvas.getContext('2d');if(!c)throw new Error('Canvas unavailable');c.scale(scale,scale);
184
+ const paperTextures = new WeakMap ();
185
+ function paperTexture(canvas ) {
186
+ const document = canvas.ownerDocument;
187
+ let texture = paperTextures.get(document);
188
+ if (texture) return texture;
189
+ texture = document.createElement('canvas');
190
+ texture.width = 64;
191
+ texture.height = 64;
192
+ const context = texture.getContext('2d') ;
193
+ const pixels = context.createImageData(64, 64);
194
+ let seed = 619;
195
+ for (let i = 0; i < 64 * 64; i++) {
196
+ seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
197
+ if (seed % 7 !== 0) continue;
198
+ const color = seed % 2 ? 255 : 0;
199
+ pixels.data.set([color, color, color, color ? 4 : 8], i * 4);
200
+ }
201
+ context.putImageData(pixels, 0, 0);
202
+ paperTextures.set(document, texture);
203
+ return texture;
204
+ }
205
+ const rowHeight = (row ) =>
206
+ !row.left && !row.right
207
+ ? 34
208
+ : !row.right
209
+ ? 34
210
+ : row.left === 'VALUE MULTIPLE'
211
+ ? 46
212
+ : row.accent
213
+ ? 32
214
+ : 28;
215
+ export function drawReceipt(
216
+ canvas ,
217
+ model ,
218
+ theme = 'dark',
219
+ ) {
220
+ const light = theme === 'light';
221
+ const ink = (color ) =>
222
+ light
223
+ ? ((
224
+ {
225
+ '#e3e4e5': '#25282d',
226
+ '#f0f0ef': '#171b20',
227
+ '#b6b8bd': '#575d65',
228
+ '#a3a5a9': '#646971',
229
+ '#b4b5b9': '#626870',
230
+ '#d0d1d5': '#393f47',
231
+ '#37ec85': '#137744',
232
+ '#ed9987': '#b34436',
233
+ '#e1e2e4': '#22272d',
234
+ '#bdc0c6': '#505861',
235
+ '#e3e4e7': '#25282d',
236
+ '#b2b3b9': '#535a63',
237
+ '#a9adb5': '#626870',
238
+ }
239
+ )[color] ?? color)
240
+ : color;
241
+ const width = 360,
242
+ height = 196 + model.rows.reduce((n, row) => n + rowHeight(row), 0) + 174,
243
+ scale = 6;
244
+ canvas.width = width * scale;
245
+ canvas.height = height * scale;
246
+ canvas.style.aspectRatio = `${width}/${height}`;
247
+ const c = canvas.getContext('2d');
248
+ if (!c) throw new Error('Canvas unavailable');
249
+ c.scale(scale, scale);
58
250
  // The same clipped paper silhouette is used in preview and PNG, including both torn edges.
59
- c.beginPath();c.moveTo(0,7);
60
- for(let x=0;x<width;x+=10){const tooth=4+(x%3);c.lineTo(x+5,tooth);c.lineTo(x+10,8);}
61
- c.lineTo(width,height-8);for(let x=width;x>0;x-=10){c.lineTo(x-5,height-4-(x%3));c.lineTo(x-10,height-8);}c.closePath();
62
- const paper=c.createLinearGradient(0,0,width*.3,height);paper.addColorStop(0,light?'#fbf9f4':'#181e24');paper.addColorStop(.48,light?'#f6f3ed':'#131920');paper.addColorStop(1,light?'#f0eee7':'#11171c');c.fillStyle=paper;c.fill();c.save();c.clip();
63
- c.fillStyle=c.createPattern(paperTexture(canvas),'repeat') ;c.fillRect(0,0,width,height);
64
- c.restore();c.strokeStyle=light?'#484e5826':'#b4b8bd20';c.lineWidth=.6;c.stroke();
65
- const mono='"Plex Mono", Consolas, monospace',left=24,right=width-24;
66
- const text=(value ,x ,y ,size ,color='#e3e4e5',align ='left',bold=false,maxWidth=width-48)=>{
67
- c.font=`${bold?'600 ':''}${size}px ${mono}`;while(c.measureText(value).width>maxWidth&&size>8){size-=.5;c.font=`${bold?'600 ':''}${size}px ${mono}`;}
68
- c.fillStyle=ink(color);c.textAlign=align;c.fillText(value,x,y);
251
+ c.beginPath();
252
+ c.moveTo(0, 7);
253
+ for (let x = 0; x < width; x += 10) {
254
+ const tooth = 4 + (x % 3);
255
+ c.lineTo(x + 5, tooth);
256
+ c.lineTo(x + 10, 8);
257
+ }
258
+ c.lineTo(width, height - 8);
259
+ for (let x = width; x > 0; x -= 10) {
260
+ c.lineTo(x - 5, height - 4 - (x % 3));
261
+ c.lineTo(x - 10, height - 8);
262
+ }
263
+ c.closePath();
264
+ const paper = c.createLinearGradient(0, 0, width * 0.3, height);
265
+ paper.addColorStop(0, light ? '#fbf9f4' : '#181e24');
266
+ paper.addColorStop(0.48, light ? '#f6f3ed' : '#131920');
267
+ paper.addColorStop(1, light ? '#f0eee7' : '#11171c');
268
+ c.fillStyle = paper;
269
+ c.fill();
270
+ c.save();
271
+ c.clip();
272
+ c.fillStyle = c.createPattern(paperTexture(canvas), 'repeat') ;
273
+ c.fillRect(0, 0, width, height);
274
+ c.restore();
275
+ c.strokeStyle = light ? '#484e5826' : '#b4b8bd20';
276
+ c.lineWidth = 0.6;
277
+ c.stroke();
278
+ const mono = '"Plex Mono", Consolas, monospace',
279
+ left = 24,
280
+ right = width - 24;
281
+ const text = (
282
+ value ,
283
+ x ,
284
+ y ,
285
+ size ,
286
+ color = '#e3e4e5',
287
+ align = 'left',
288
+ bold = false,
289
+ maxWidth = width - 48,
290
+ ) => {
291
+ c.font = `${bold ? '600 ' : ''}${size}px ${mono}`;
292
+ while (c.measureText(value).width > maxWidth && size > 8) {
293
+ size -= 0.5;
294
+ c.font = `${bold ? '600 ' : ''}${size}px ${mono}`;
295
+ }
296
+ c.fillStyle = ink(color);
297
+ c.textAlign = align;
298
+ c.fillText(value, x, y);
299
+ };
300
+ const line = (y ) => {
301
+ c.strokeStyle = light ? '#636a7359' : '#6c6f754f';
302
+ c.lineWidth = 1;
303
+ c.setLineDash([4, 5]);
304
+ c.beginPath();
305
+ c.moveTo(left, y);
306
+ c.lineTo(right, y);
307
+ c.stroke();
308
+ c.setLineDash([]);
69
309
  };
70
- const line=(y )=>{c.strokeStyle=light?'#636a7359':'#6c6f754f';c.lineWidth=1;c.setLineDash([4,5]);c.beginPath();c.moveTo(left,y);c.lineTo(right,y);c.stroke();c.setLineDash([]);};
71
- text(product.name,width/2,64,29,'#f0f0ef','center');text('USAGE RECEIPT',width/2,92,13,'#b6b8bd','center');
72
- if(model.demo)text('EXAMPLE DATA',width/2,112,10,'#a3a5a9','center');
73
- text('Period',left,137,12.5,'#b4b5b9');text(model.periodDetail,right,137,12.5,'#d0d1d5','right',false,245);
74
- text('Generated',left,158,12.5,'#b4b5b9');text(model.generated,right,158,12.5,'#d0d1d5','right',false,230);line(180);
75
- let y=210;
76
- for(const row of model.rows){const step=rowHeight(row);if(!row.left&&!row.right){line(y-9);y+=step;continue;}
77
- const provider=!row.right,multiple=row.left==='VALUE MULTIPLE';const label=provider?(row.left==='CODEX'?'Codex':'Claude Code'):row.left;
78
- const color=row.accent==='positive'?'#37ec85':row.accent==='negative'?'#ed9987':row.strong?'#e1e2e4':'#bdc0c6';
79
- const valueSize=multiple?25:row.accent?22:row.strong?18:16;
80
- c.font=`${valueSize}px ${mono}`;const valueWidth=c.measureText(row.right).width;
81
- text(label,left,y,multiple?17:provider?17:row.strong?14:14,multiple?'#e1e2e4':color,'left',provider,provider?width-48:width-62-valueWidth);
82
- text(row.right,right,y,valueSize,row.accent?color:'#e3e4e7','right',row.strong,160);y+=step;
310
+ text(product.name, width / 2, 64, 29, '#f0f0ef', 'center');
311
+ text('USAGE RECEIPT', width / 2, 92, 13, '#b6b8bd', 'center');
312
+ if (model.demo) text('EXAMPLE DATA', width / 2, 112, 10, '#a3a5a9', 'center');
313
+ text('Period', left, 137, 12.5, '#b4b5b9');
314
+ text(model.periodDetail, right, 137, 12.5, '#d0d1d5', 'right', false, 245);
315
+ text('Generated', left, 158, 12.5, '#b4b5b9');
316
+ text(model.generated, right, 158, 12.5, '#d0d1d5', 'right', false, 230);
317
+ line(180);
318
+ let y = 210;
319
+ for (const row of model.rows) {
320
+ const step = rowHeight(row);
321
+ if (!row.left && !row.right) {
322
+ line(y - 9);
323
+ y += step;
324
+ continue;
325
+ }
326
+ const provider = !row.right,
327
+ multiple = row.left === 'VALUE MULTIPLE';
328
+ const label = provider ? (row.left === 'CODEX' ? 'Codex' : 'Claude Code') : row.left;
329
+ const color =
330
+ row.accent === 'positive'
331
+ ? '#37ec85'
332
+ : row.accent === 'negative'
333
+ ? '#ed9987'
334
+ : row.strong
335
+ ? '#e1e2e4'
336
+ : '#bdc0c6';
337
+ const valueSize = multiple ? 25 : row.accent ? 22 : row.strong ? 18 : 16;
338
+ c.font = `${valueSize}px ${mono}`;
339
+ const valueWidth = c.measureText(row.right).width;
340
+ text(
341
+ label,
342
+ left,
343
+ y,
344
+ multiple ? 17 : provider ? 17 : row.strong ? 14 : 14,
345
+ multiple ? '#e1e2e4' : color,
346
+ 'left',
347
+ provider,
348
+ provider ? width - 48 : width - 62 - valueWidth,
349
+ );
350
+ text(row.right, right, y, valueSize, row.accent ? color : '#e3e4e7', 'right', row.strong, 160);
351
+ y += step;
83
352
  }
84
- line(y-3);text(model.outcome,width/2,y+27,10,model.outcome==='YOUR PLAN WAS WORTH IT'?'#37ec85':model.outcome==='API WOULD HAVE BEEN CHEAPER'?'#ed9987':'#b2b3b9','center');
85
- text(model.confidence,width/2,y+(model.outcome?51:28),11,'#a9adb5','center');
86
- const footerDivider=y+73;
87
- c.strokeStyle=light?'#62687033':'#767b8126';c.beginPath();c.moveTo(left,footerDivider);c.lineTo(right,footerDivider);c.stroke();
88
- text(product.site,left,height-44,11.5,'#a9adb5');
353
+ line(y - 3);
354
+ text(
355
+ model.outcome,
356
+ width / 2,
357
+ y + 27,
358
+ 10,
359
+ model.outcome === 'YOUR PLAN WAS WORTH IT'
360
+ ? '#37ec85'
361
+ : model.outcome === 'API WOULD HAVE BEEN CHEAPER'
362
+ ? '#ed9987'
363
+ : '#b2b3b9',
364
+ 'center',
365
+ );
366
+ text(model.confidence, width / 2, y + (model.outcome ? 51 : 28), 11, '#a9adb5', 'center');
367
+ const footerDivider = y + 73;
368
+ c.strokeStyle = light ? '#62687033' : '#767b8126';
369
+ c.beginPath();
370
+ c.moveTo(left, footerDivider);
371
+ c.lineTo(right, footerDivider);
372
+ c.stroke();
373
+ text(product.site, left, height - 44, 11.5, '#a9adb5');
89
374
  // Decorative print detail only: fixed pattern, no symbology or user-derived information.
90
- const pattern=[2,1,1,1,3,1,1,2,2,1,1,1,4,1,1,2,3,1,2,1,1,2,2,1,3,1,1,1,2,2,1,1,3,1,2,1,1,2,4,1,1,1,2,1,3,2,1,1,2,1,1,2,3,1,2,1,1,1,3,2,1,1,2,1];
91
- const module=134/pattern.reduce((n,w)=>n+w,0);let bx=right-134;
92
- const barcodeHeight=41,barcodeTop=footerDivider+(height-8-footerDivider-barcodeHeight)/2;
93
- c.fillStyle=light?'#303740db':'#b8bec8c9';for(const [i,w]of pattern.entries()){if(i%2===0)c.fillRect(bx,barcodeTop,w*module,barcodeHeight);bx+=w*module;}
375
+ const pattern = [
376
+ 2, 1, 1, 1, 3, 1, 1, 2, 2, 1, 1, 1, 4, 1, 1, 2, 3, 1, 2, 1, 1, 2, 2, 1, 3, 1, 1, 1, 2, 2, 1, 1,
377
+ 3, 1, 2, 1, 1, 2, 4, 1, 1, 1, 2, 1, 3, 2, 1, 1, 2, 1, 1, 2, 3, 1, 2, 1, 1, 1, 3, 2, 1, 1, 2, 1,
378
+ ];
379
+ const module = 134 / pattern.reduce((n, w) => n + w, 0);
380
+ let bx = right - 134;
381
+ const barcodeHeight = 41,
382
+ barcodeTop = footerDivider + (height - 8 - footerDivider - barcodeHeight) / 2;
383
+ c.fillStyle = light ? '#303740db' : '#b8bec8c9';
384
+ for (const [i, w] of pattern.entries()) {
385
+ if (i % 2 === 0) c.fillRect(bx, barcodeTop, w * module, barcodeHeight);
386
+ bx += w * module;
387
+ }
388
+ }
389
+ export async function exportReceipt(canvas ) {
390
+ const blob = await new Promise ((resolve, reject) =>
391
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('PNG export failed'))), 'image/png'),
392
+ );
393
+ const url = URL.createObjectURL(blob);
394
+ const a = document.createElement('a');
395
+ a.href = url;
396
+ a.download = product.exportBasename + '.png';
397
+ a.click();
398
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
94
399
  }
95
- export async function exportReceipt(canvas ) {const blob=await new Promise ((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(new Error('PNG export failed')),'image/png'));const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=product.exportBasename+'.png';a.click();setTimeout(()=>URL.revokeObjectURL(url),5000);}
@@ -1,39 +1,54 @@
1
1
  /** Display identity only. Package names and local data paths are intentionally independent. */
2
- export const product = {name:'SubValue',site:'subvalue.dev',exportBasename:'subvalue-receipt'};
2
+ export const product = {
3
+ name: 'SubValue',
4
+ site: 'subvalue.dev',
5
+ exportBasename: 'subvalue-receipt',
6
+ };
3
7
  // Typography remains separate from the neutral symbol and follows the display name.
4
8
  const nameParts = product.name.split(/(?=[A-Z])/);
5
- const escapeName = (value ) => value.replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character] ));
9
+ const escapeName = (value ) =>
10
+ value.replace(
11
+ /[&<>"']/g,
12
+ (character) =>
13
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character] ,
14
+ );
6
15
  // Original 5×7 terminal lettering, kept separate from the name-independent symbol.
7
- const glyphs = {
8
- S:['01111','10000','10000','01110','00001','00001','11110'],
9
- U:['10001','10001','10001','10001','10001','10001','01110'],
10
- B:['11110','10001','10001','11110','10001','10001','11110'],
11
- V:['10001','10001','10001','10001','10001','01010','00100'],
12
- A:['01110','10001','10001','11111','10001','10001','10001'],
13
- L:['1000','1000','1000','1000','1000','1000','1111'],
14
- E:['11111','10000','10000','11110','10000','10000','11111']
16
+ const glyphs = {
17
+ S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
18
+ U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
19
+ B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
20
+ V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
21
+ A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
22
+ L: ['1000', '1000', '1000', '1000', '1000', '1000', '1111'],
23
+ E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
15
24
  };
16
- export function wordmarkSvg(monochrome=false,withCursor=false) {
17
- const letters=[...product.name.toUpperCase()];
18
- if(!letters.length||letters.some(letter=>!glyphs[letter]))return null;
19
- const accentStart=nameParts.slice(0,-1).join('').length;
20
- let offset=0;
21
- const paths=letters.map((letter,index)=>{
22
- const rows=glyphs[letter],start=offset;offset+=rows[0].length+1;
23
- return `<g${!monochrome&&index>=accentStart?' class="brand-name-accent"':''}>${rows.map((row,y)=>[...row].map((pixel,x)=>pixel==='1'?`<rect x="${start+x}" y="${y}" width="1" height="1"/>`:'').join('')).join('')}</g>`;
24
- }).join('');
25
- const cursor=withCursor?`<rect class="brand-cursor${monochrome?'':' brand-name-accent'}" x="${offset}" y="6" width="5" height="1"/>`:'';
26
- return `<svg xmlns="http://www.w3.org/2000/svg" class="brand-wordmark" viewBox="0 0 ${withCursor?offset+5:offset-1} 7" fill="currentColor" shape-rendering="crispEdges" aria-hidden="true">${paths}${cursor}</svg>`;
25
+ export function wordmarkSvg(monochrome = false, withCursor = false) {
26
+ const letters = [...product.name.toUpperCase()];
27
+ if (!letters.length || letters.some((letter) => !glyphs[letter])) return null;
28
+ const accentStart = nameParts.slice(0, -1).join('').length;
29
+ let offset = 0;
30
+ const paths = letters
31
+ .map((letter, index) => {
32
+ const rows = glyphs[letter],
33
+ start = offset;
34
+ offset += rows[0].length + 1;
35
+ return `<g${!monochrome && index >= accentStart ? ' class="brand-name-accent"' : ''}>${rows.map((row, y) => [...row].map((pixel, x) => (pixel === '1' ? `<rect x="${start + x}" y="${y}" width="1" height="1"/>` : '')).join('')).join('')}</g>`;
36
+ })
37
+ .join('');
38
+ const cursor = withCursor
39
+ ? `<rect class="brand-cursor${monochrome ? '' : ' brand-name-accent'}" x="${offset}" y="6" width="5" height="1"/>`
40
+ : '';
41
+ return `<svg xmlns="http://www.w3.org/2000/svg" class="brand-wordmark" viewBox="0 0 ${withCursor ? offset + 5 : offset - 1} 7" fill="currentColor" shape-rendering="crispEdges" aria-hidden="true">${paths}${cursor}</svg>`;
27
42
  }
28
43
  // A future rename automatically uses readable text if new glyphs are needed.
29
- function nameMarkup(withCursor=false){
30
- const wordmark=wordmarkSvg(false,withCursor);
44
+ function nameMarkup(withCursor = false) {
45
+ const wordmark = wordmarkSvg(false, withCursor);
31
46
  return wordmark
32
- ? `<span class="brand-name"><span class="brand-name-label">${escapeName(product.name)}</span>${wordmark}</span>`
33
- : `<span class="brand-name">${nameParts.map((part,index) => index===nameParts.length-1 ? `<span class="brand-name-accent">${escapeName(part)}</span>` : escapeName(part)).join('')}</span>`;
47
+ ? `<span class="brand-name"><span class="brand-name-label">${escapeName(product.name)}</span>${wordmark}</span>`
48
+ : `<span class="brand-name">${nameParts.map((part, index) => (index === nameParts.length - 1 ? `<span class="brand-name-accent">${escapeName(part)}</span>` : escapeName(part))).join('')}</span>`;
34
49
  }
35
- export const productNameMarkup=nameMarkup();
36
- export const productTitleMarkup=nameMarkup(true);
50
+ export const productNameMarkup = nameMarkup();
51
+ export const productTitleMarkup = nameMarkup(true);
37
52
  // Opposed measurement corners. No letter, currency sign or provider-derived geometry.
38
53
  export const markPath = 'M4 18V4h14v4H8v10H4Zm10 6h10V14h4v14H14v-4Z';
39
54
  export const productMark = `<svg viewBox="0 0 32 32" fill="currentColor" aria-hidden="true"><path d="${markPath}"/></svg>`;