@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.
- package/PRE_RELEASE.md +6 -6
- package/README.md +5 -3
- package/dist/PRICING.md +15 -16
- package/dist/packages/cli/src/index.js +109 -20
- package/dist/packages/cli/src/protocol.js +13 -0
- package/dist/packages/cli/src/runtime.js +44 -14
- package/dist/packages/cli/src/server.js +306 -62
- package/dist/packages/core/src/calendar.js +41 -18
- package/dist/packages/core/src/coverage.js +12 -0
- package/dist/packages/core/src/demo.js +61 -9
- package/dist/packages/core/src/metadata.js +52 -9
- package/dist/packages/core/src/pricing/current.js +346 -297
- package/dist/packages/core/src/pricing/index.js +373 -73
- package/dist/packages/core/src/providers/claude/index.js +93 -21
- package/dist/packages/core/src/providers/codex/index.js +213 -46
- package/dist/packages/core/src/scanner.js +226 -46
- package/dist/packages/core/src/security.js +127 -34
- package/dist/packages/core/src/storage.js +215 -47
- package/dist/packages/core/src/subscriptions.js +146 -21
- package/dist/packages/core/src/summary.js +408 -59
- package/dist/packages/core/src/types.js +71 -17
- package/dist/packages/core/src/usage-json.js +166 -47
- package/dist/packages/receipt/src/index.js +386 -82
- package/dist/packages/ui/src/brand.js +42 -27
- package/dist/public/app.css +2280 -61
- package/dist/public/apps/web/src/landing.js +200 -8
- package/dist/public/apps/web/src/main.js +1143 -98
- package/dist/public/index.html +24 -1
- package/dist/public/landing.css +3235 -80
- package/dist/public/landing.html +21 -1
- package/dist/public/packages/core/src/calendar.js +41 -18
- package/dist/public/packages/core/src/coverage.js +12 -0
- package/dist/public/packages/core/src/subscriptions.js +146 -21
- package/dist/public/packages/receipt/src/index.js +386 -82
- package/dist/public/packages/ui/src/brand.js +42 -27
- package/dist/public/privacy.css +197 -9
- package/package.json +1 -1
|
@@ -1,95 +1,399 @@
|
|
|
1
|
-
|
|
2
|
-
import {fullCalendarMonths} from '../../core/src/calendar.js';
|
|
3
|
-
import {
|
|
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
|
-
|
|
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,
|
|
10
|
-
|
|
11
|
-
if(
|
|
12
|
-
|
|
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
|
|
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 [
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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({
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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()
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
c.
|
|
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
|
-
|
|
71
|
-
text(
|
|
72
|
-
if(model.demo)text('EXAMPLE DATA',width/2,112,10,'#a3a5a9','center');
|
|
73
|
-
text('Period',left,137,12.5,'#b4b5b9');
|
|
74
|
-
text(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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);
|
|
85
|
-
text(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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=[
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 = {
|
|
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
|
|
9
|
+
const escapeName = (value ) =>
|
|
10
|
+
value.replace(
|
|
11
|
+
/[&<>"']/g,
|
|
12
|
+
(character) =>
|
|
13
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
|
19
|
-
const accentStart=nameParts.slice(0
|
|
20
|
-
let offset=0;
|
|
21
|
-
const paths=letters
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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>`;
|