@subvalue/cli 0.1.2 → 0.1.3
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/README.md +2 -2
- package/dist/packages/cli/src/server.js +1 -1
- package/dist/packages/core/src/calendar.js +24 -0
- package/dist/packages/core/src/summary.js +18 -9
- package/dist/packages/receipt/src/index.js +5 -2
- package/dist/packages/ui/src/brand.js +31 -1
- package/dist/public/app.css +6 -1
- package/dist/public/apps/web/src/landing.js +2 -2
- package/dist/public/apps/web/src/main.js +45 -10
- package/dist/public/landing.css +12 -2
- package/dist/public/logo/wordmark-monochrome.svg +1 -0
- package/dist/public/logo/wordmark.svg +1 -0
- package/dist/public/packages/core/src/calendar.js +24 -0
- package/dist/public/packages/receipt/src/index.js +5 -2
- package/dist/public/packages/ui/src/brand.js +31 -1
- package/dist/public/privacy.css +4 -1
- package/dist/public/privacy.html +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ npm run demo
|
|
|
42
42
|
|
|
43
43
|
First run shows detected history, then asks how each provider was used before rendering results. Subscription users can select a plan or enter a custom USD amount. API, mixed usage and an explicit “Set up later” choice never imply a subscription.
|
|
44
44
|
|
|
45
|
-
Month
|
|
45
|
+
Month opens a floating calendar-month picker (including historical months). Previous/next arrows move by one calendar month, seven or thirty days for rolling presets, or the selected custom duration. Comparisons use the full declared monthly price for each complete calendar month selected. The current month is marked In progress. 7D, 30D and partial custom ranges show API-equivalent usage without subscription comparisons. Custom ranges covering whole calendar months compare the corresponding number of monthly charges. Amounts and API prices are USD; no invented exchange rates. Billing declarations do not overwrite historical records or prove their original billing mode. Settings offers bundled, verified ChatGPT/Claude plan presets and a custom amount. Annual presets retain the exact annual price divided by 12, with per-seat plans labeled.
|
|
46
46
|
|
|
47
47
|
## Source access
|
|
48
48
|
|
|
@@ -66,7 +66,7 @@ Codex excludes inherited ordinal prefixes, uses cumulative deltas, suppresses re
|
|
|
66
66
|
|
|
67
67
|
The versioned catalog supports mappings with confidence and primary-source provenance, half-open effective intervals, cache reads/writes, Claude cache durations, and long-context bands. Rates and release evidence were checked against [OpenAI pricing](https://developers.openai.com/api/docs/pricing) and [Claude pricing](https://platform.claude.com/docs/en/about-claude/pricing) on **2026-10-05**. Historical versions include the July 30 Luna/Terra reductions and August 21 Sol promotion. Exact public GPT-5.6/6 and Claude identifiers carry mapping provenance; long-context rates use the verified 272k request threshold. New-model launch days use their documented initial API-equivalent rate. Calendar-dated changes between rates remain unknown because their exact UTC switch time is not documented. Sol pricing after the confirmed promotion remains unavailable. See [the pricing evidence ledger](dist/PRICING.md). Internal/unknown model identifiers are never mapped by resemblance.
|
|
68
68
|
|
|
69
|
-
Overview and receipt display the sum of priceable events; the compact priced percentage identifies partial pricing, and unpriced model identities remain visible in Details. No guessed price or zero is assigned to unknown events. Both views share a display comparison of the known priced amount with the declared
|
|
69
|
+
Overview and receipt display the sum of priceable events; the compact priced percentage identifies partial pricing, and unpriced model identities remain visible in Details. No guessed price or zero is assigned to unknown events. Both views share a display comparison of the known priced amount with the declared full-month subscription: Observed Value / You Saved is their difference, and Value Multiple is their ratio. The normalized summary still marks the full-usage cost and comparison unknown when pricing is incomplete. Partial coverage remains visible; these observed values do not infer a certain outcome or an exact break-even date. API and mixed billing have no subscription comparison. Totals from several subscriptions cannot include API/mixed providers in a subscription comparison.
|
|
70
70
|
|
|
71
71
|
## Architecture
|
|
72
72
|
|
|
@@ -39,7 +39,7 @@ export async function startServer(options
|
|
|
39
39
|
if(url.pathname==='/api/bootstrap'&&req.method==='GET'){json(200,{token:secret,settings:demoSettings??options.store .settings(),sources:demo?.statuses??options.store .statuses(),demo:!!demo,lastScan:options.store?.get('lastScan')??null,scanning,progress});return;}
|
|
40
40
|
if(url.pathname==='/api/summary'&&req.method==='GET'){
|
|
41
41
|
if(scanning){json(503,{error:'Scan in progress'});return;}
|
|
42
|
-
const range=dateRange(url.searchParams.get('period')??'30d',new Date(),url.searchParams.get('from')??undefined,url.searchParams.get('to')??undefined);
|
|
42
|
+
const range=dateRange(url.searchParams.get('period')??'30d',new Date(),url.searchParams.get('from')??undefined,url.searchParams.get('to')??undefined,url.searchParams.get('month')??undefined,url.searchParams.get('anchor')??undefined);
|
|
43
43
|
// History bounds use all normalized metadata, never original transcripts.
|
|
44
44
|
const currentRevision=demo?'demo':options.store .revision();if(currentRevision!==revision){invalidate();revision=currentRevision;}
|
|
45
45
|
const key=JSON.stringify(range);let result=summaries.get(key);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Count whole local calendar months; rolling presets are never subscription comparisons. */
|
|
2
|
+
export function fullCalendarMonths(range ) {
|
|
3
|
+
if(range.preset==='7d'||range.preset==='30d')return null;
|
|
4
|
+
// Browser timezone overrides must not change the server's calendar selection.
|
|
5
|
+
if(range.calendarMonths!==undefined)return Number.isInteger(range.calendarMonths)&&range.calendarMonths >0&&range.calendarMonths <=1200?range.calendarMonths:null;
|
|
6
|
+
const start=new Date(range.from),end=new Date(range.until);
|
|
7
|
+
const boundary=(date )=>Number.isFinite(+date)&&date.getDate()===1&&date.getHours()===0&&date.getMinutes()===0&&date.getSeconds()===0&&date.getMilliseconds()===0;
|
|
8
|
+
if(!boundary(start)||!boundary(end))return null;
|
|
9
|
+
const months=(end.getFullYear()-start.getFullYear())*12+end.getMonth()-start.getMonth();
|
|
10
|
+
return months>0&&months<=1200?months:null;
|
|
11
|
+
}
|
|
12
|
+
/** Civil-date movement uses UTC arithmetic so DST never drops or repeats a day. */
|
|
13
|
+
export function shiftCalendarDays(day ,days ) {
|
|
14
|
+
if(!/^\d{4}-\d{2}-\d{2}$/.test(day)||!Number.isInteger(days)||Math.abs(days)>36600)return null;
|
|
15
|
+
const date=new Date(day+'T12:00:00.000Z');
|
|
16
|
+
if(!Number.isFinite(+date)||date.toISOString().slice(0,10)!==day)return null;
|
|
17
|
+
date.setUTCDate(date.getUTCDate()+days);
|
|
18
|
+
const result=date.toISOString().slice(0,10);return /^\d{4}-\d{2}-\d{2}$/.test(result)?result:null;
|
|
19
|
+
}
|
|
20
|
+
export function shiftCalendarMonth(month ,months ) {
|
|
21
|
+
if(!/^(?:19|20|21)\d{2}-(?:0[1-9]|1[0-2])$/.test(month)||!Number.isInteger(months))return null;
|
|
22
|
+
const ordinal=Number(month.slice(0,4))*12+Number(month.slice(5))-1+months,year=Math.floor(ordinal/12);
|
|
23
|
+
return year>=1900&&year<=2199?`${year}-${String(ordinal%12+1).padStart(2,'0')}`:null;
|
|
24
|
+
}
|
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
|
|
2
2
|
import {priceRecord,catalog, } from './pricing/index.js';
|
|
3
|
+
import {fullCalendarMonths,shiftCalendarDays} from './calendar.js';
|
|
4
|
+
export {fullCalendarMonths} from './calendar.js';
|
|
3
5
|
const dayFormatter=new Intl.DateTimeFormat('en-CA',{year:'numeric',month:'2-digit',day:'2-digit'});
|
|
4
6
|
function localDay(timestamp ) {const parts=dayFormatter.formatToParts(new Date(timestamp));return ['year','month','day'].map(type=>parts.find(part=>part.type===type) .value).join('-');}
|
|
5
|
-
|
|
6
|
-
export function dateRange(preset ,now=new Date(),from ,to ) {
|
|
7
|
+
|
|
8
|
+
export function dateRange(preset ,now=new Date(),from ,to ,month ,anchor ) {
|
|
9
|
+
const today=localDay(now.toISOString());
|
|
10
|
+
if(anchor!==undefined){if((preset!=='7d'&&preset!=='30d')||!/^(?:19|20|21)\d{2}-\d{2}-\d{2}$/.test(anchor)||shiftCalendarDays(anchor,0)===null)throw new Error('Choose a valid period');now=new Date(anchor+'T12:00:00');}
|
|
7
11
|
const end=new Date(now.getFullYear(),now.getMonth(),now.getDate()+1);let start=new Date(now.getFullYear(),now.getMonth(),now.getDate()-29);let label='Last 30 days';
|
|
8
12
|
if(preset==='7d'){start=new Date(now.getFullYear(),now.getMonth(),now.getDate()-6);label='Last 7 days';}
|
|
9
|
-
else if(preset==='month'){
|
|
13
|
+
else if(preset==='month'){
|
|
14
|
+
if(month!==undefined&&!/^(?:19|20|21)\d{2}-(?:0[1-9]|1[0-2])$/.test(month))throw new Error('Choose a valid month');
|
|
15
|
+
const year=month?Number(month.slice(0,4)):now.getFullYear(),index=month?Number(month.slice(5,7))-1:now.getMonth();
|
|
16
|
+
start=new Date(year,index,1);end.setTime(+new Date(year,index+1,1));label=start.toLocaleDateString('en-US',{month:'long',year:'numeric'});
|
|
17
|
+
}
|
|
10
18
|
else if(preset==='custom'){
|
|
11
19
|
if(!from||!to||!/^\d{4}-\d{2}-\d{2}$/.test(from)||!/^\d{4}-\d{2}-\d{2}$/.test(to))throw new Error('Choose a valid date range');
|
|
12
20
|
start=new Date(from+'T00:00:00');const last=new Date(to+'T00:00:00');const matches=(date ,text )=>Number.isFinite(+date)&&date.getFullYear()===Number(text.slice(0,4))&&date.getMonth()+1===Number(text.slice(5,7))&&date.getDate()===Number(text.slice(8,10));if(!matches(start,from)||!matches(last,to)||start>last||+last-+start>36600*864e5)throw new Error('Choose a valid date range');end.setTime(+new Date(last.getFullYear(),last.getMonth(),last.getDate()+1));label=`${from} — ${to}`;
|
|
13
21
|
}else if(preset!=='30d')throw new Error('Invalid period');
|
|
14
|
-
|
|
22
|
+
const calendarFrom=localDay(start.toISOString()),calendarTo=localDay(new Date(+end-1).toISOString());
|
|
23
|
+
if(anchor!==undefined&&anchor!==today)label=`${calendarFrom} — ${calendarTo}`;
|
|
24
|
+
const range={from:start.toISOString(),until:end.toISOString(),label,preset,calendarFrom,calendarTo};
|
|
25
|
+
return {...range,calendarMonths:fullCalendarMonths(range)};
|
|
15
26
|
}
|
|
16
27
|
|
|
17
|
-
export function subscriptionForRange(monthly ,range )
|
|
18
|
-
|
|
19
|
-
let total=0;const day=new Date(range.from),until=new Date(range.until);let guard=0;
|
|
20
|
-
while(day<until){const days=new Date(day.getFullYear(),day.getMonth()+1,0).getDate();total+=monthly/days;day.setDate(day.getDate()+1);if(++guard>36600)throw new Error('Range too large');}return total;
|
|
28
|
+
export function subscriptionForRange(monthly ,range ) {
|
|
29
|
+
const months=fullCalendarMonths(range);return months===null?null:monthly*months;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
|
|
@@ -36,7 +45,7 @@ export function summarize(records ,statuses ,settings
|
|
|
36
45
|
const historyPartial=!demo; // Local transcripts cannot prove that all historical requests were retained.
|
|
37
46
|
const completePricing=rows.length>0&&priced.length===rows.length;const apiEquivalent=completePricing?subtotal:null;
|
|
38
47
|
const billing=settings.billing[provider];const subscription=billing.mode==='SUBSCRIPTION'&&billing.monthly!==null?subscriptionForRange(billing.monthly,range):null;
|
|
39
|
-
let comparison=neutral(billing.mode==='API'?'API usage':billing.mode==='MIXED'?'Mixed billing':billing.mode==='UNKNOWN'?'Billing not set':subscription===null?'Subscription price not set':!completePricing?'Pricing unavailable':'Partial history',subscription);
|
|
48
|
+
let comparison=neutral(billing.mode==='API'?'API usage':billing.mode==='MIXED'?'Mixed billing':billing.mode==='UNKNOWN'?'Billing not set':fullCalendarMonths(range)===null?'Choose a full month':subscription===null?'Subscription price not set':!completePricing?'Pricing unavailable':'Partial history',subscription);
|
|
40
49
|
if(completePricing&&subscription!==null){const value=apiEquivalent -subscription;let accumulated=0;let breakEven =null;for(const d of daily){accumulated+=d[provider]??0;if(accumulated>=subscription){breakEven=d.date;break;}}comparison={subscription,value,roi:subscription>0?apiEquivalent /subscription:null,breakEven:historyPartial?null:breakEven,outcome:historyPartial?'neutral':value>0?'positive':value<0?'negative':'neutral',reason:historyPartial?'Observed usage · Partial history':null};}
|
|
41
50
|
const sumTokens=(key ) =>rows.length&&rows.every(r=>r[key]!==null)?rows.reduce((total,r)=>total+r[key] ,0):null;
|
|
42
51
|
const tokenBreakdown={input:sumTokens('input_tokens'),output:sumTokens('output_tokens'),cacheRead:sumTokens('cached_input_tokens')};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
|
|
2
|
+
import {fullCalendarMonths} from '../../core/src/calendar.js';
|
|
2
3
|
import {product} from '../../ui/src/brand.js';
|
|
3
4
|
|
|
4
5
|
|
|
@@ -6,6 +7,7 @@ export const money=(n ,signed=false) =>n===null?'Unavailable':`
|
|
|
6
7
|
/** Compare only the displayed priced amount; preserve uncertainty and billing eligibility. */
|
|
7
8
|
export function comparisonForDisplay(s ) {
|
|
8
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'};
|
|
9
11
|
if(amount===null||comparison.subscription===null)return comparison;
|
|
10
12
|
return {...comparison,value:comparison.value??amount-comparison.subscription,roi:comparison.roi??(comparison.subscription>0?amount/comparison.subscription:null)};
|
|
11
13
|
}
|
|
@@ -26,15 +28,16 @@ export function receiptModel(s ,generatedAt=new Date()) {
|
|
|
26
28
|
const value=comp.value;
|
|
27
29
|
if(value!==null)rows.push({left:'You Saved',right:money(value,true),accent:value>=0?'positive':'negative',strong:true});
|
|
28
30
|
// With partial pricing, compare only the printed known amount to the declared
|
|
29
|
-
//
|
|
31
|
+
// full-month subscription. The coverage footer stays visible; no outcome is inferred.
|
|
30
32
|
const multiple=comp.roi;
|
|
31
33
|
if(multiple!==null)rows.push({left:'',right:''},{left:'VALUE MULTIPLE',right:`${multiple.toFixed(2)}×`,accent:multiple>=1?'positive':'negative',strong:true});
|
|
32
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()});
|
|
33
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 · ':'';
|
|
34
37
|
const coverage=s.total.apiEquivalent===null&&s.total.priceCoverage!==null?`${Math.round(s.total.priceCoverage*100)}% priced · `:'';
|
|
35
38
|
const date=(value )=>new Date(value).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
|
|
36
39
|
const generated=generatedAt.toLocaleString('en-US',{month:'short',day:'numeric',year:'numeric',hour:'numeric',minute:'2-digit',hour12:true}).replace(' at ', ' ');
|
|
37
|
-
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?`${coverage}API equivalent · Partial history`:`${coverage}API equivalent · ${s.confidence==='HIGH'?'High confidence':'Incomplete'}`,demo:s.demo};
|
|
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};
|
|
38
41
|
}
|
|
39
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"')]);}
|
|
40
43
|
const paperTextures=new WeakMap ();
|
|
@@ -3,7 +3,37 @@ export const product = {name:'SubValue',site:'subvalue.dev',exportBasename:'subv
|
|
|
3
3
|
// Typography remains separate from the neutral symbol and follows the display name.
|
|
4
4
|
const nameParts = product.name.split(/(?=[A-Z])/);
|
|
5
5
|
const escapeName = (value ) => value.replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character] ));
|
|
6
|
-
|
|
6
|
+
// 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']
|
|
15
|
+
};
|
|
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>`;
|
|
27
|
+
}
|
|
28
|
+
// A future rename automatically uses readable text if new glyphs are needed.
|
|
29
|
+
function nameMarkup(withCursor=false){
|
|
30
|
+
const wordmark=wordmarkSvg(false,withCursor);
|
|
31
|
+
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>`;
|
|
34
|
+
}
|
|
35
|
+
export const productNameMarkup=nameMarkup();
|
|
36
|
+
export const productTitleMarkup=nameMarkup(true);
|
|
7
37
|
// Opposed measurement corners. No letter, currency sign or provider-derived geometry.
|
|
8
38
|
export const markPath = 'M4 18V4h14v4H8v10H4Zm10 6h10V14h4v14H14v-4Z';
|
|
9
39
|
export const productMark = `<svg viewBox="0 0 32 32" fill="currentColor" aria-hidden="true"><path d="${markPath}"/></svg>`;
|
package/dist/public/app.css
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/* Both the public page and local app use the same receipt renderer and tokens. */
|
|
2
2
|
@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:block;src:url('/fonts/inter-latin.woff2') format('woff2')}
|
|
3
3
|
@font-face{font-family:'Plex Mono';font-style:normal;font-weight:400 700;font-display:block;src:url('/fonts/plex-mono.woff2') format('woff2')}
|
|
4
|
-
.brand-name{color:#
|
|
4
|
+
.brand-name{display:inline-flex;align-items:center;position:relative;color:#eef2f4;font-family:Inter,sans-serif;font-weight:800;letter-spacing:-.055em}.brand-name-accent{color:#37ec85}
|
|
5
|
+
.brand-name-label{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
|
6
|
+
.brand-wordmark{display:block;width:92px;height:14px;overflow:visible}
|
|
7
|
+
@media(forced-colors:active){.brand-name,.brand-name-accent{color:CanvasText}}
|
|
5
8
|
|
|
6
9
|
.chart{position:relative}.chart-tooltip{position:absolute;z-index:3;min-width:180px;max-width:calc(100% - 20px);padding:12px 14px;background:#191d22;border:1px solid #363e46;border-radius:5px;box-shadow:0 8px 22px #0006;pointer-events:none;font-size:11px}.chart-tooltip[hidden]{display:none}.chart-tooltip>strong{display:block;font-weight:550;margin-bottom:8px}.chart-tooltip dl{font-size:11px}.chart-tooltip dd{font-variant-numeric:tabular-nums}.chart-tip-total{border-top:1px solid #30363d;margin-top:5px;padding-top:8px!important}.chart-tip-total dd{color:#37ec85}.chart-svg [data-chart-day]{outline:none;cursor:pointer}.chart-svg [data-chart-day]:focus-visible .chart-hit-target,.chart-svg [data-chart-active] .chart-hit-target{stroke:#37ec85;stroke-opacity:.45;stroke-width:1;rx:2}.receipt-theme-control{display:flex;gap:3px;align-self:center;border:1px solid #2d343c;border-radius:5px;padding:3px;background:#111418}.receipt-theme-control button{background:none;border:0;color:#999fa6;border-radius:3px;min-width:68px;min-height:32px;padding:6px 12px;font-size:11px}.receipt-theme-control button[aria-pressed=true]{background:#262d33;color:#eff0f1}.receipt-theme-control button[data-receipt-theme=light][aria-pressed=true]{background:#eeece6;color:#22272d}
|
|
7
10
|
:root{color-scheme:dark;--bg:#080a0c;--panel:#111418;--border:#25282d;--text:#eff0f1;--muted:#999fa6;--green:#37ec85;--red:#ed9987;--mono:"Plex Mono",Consolas,monospace;--sans:Inter,"Segoe UI",Arial,sans-serif}
|
|
@@ -20,7 +23,9 @@ body{background:#080b0d;background-image:none}
|
|
|
20
23
|
.topbar{height:70px;padding:0 25px;gap:38px;background:#090b0d;border-bottom-color:#222a2f}
|
|
21
24
|
.brand{font-size:20px;gap:9px;letter-spacing:-.6px}.brand-mark{font-size:34px}.topbar nav{gap:25px}.nav-item{font-size:12px;padding:0 2px}
|
|
22
25
|
.header-period{margin-left:auto;display:flex;align-items:center;gap:12px}.local-status{margin-left:0;color:var(--green)}.local-status svg{width:17px}
|
|
26
|
+
.period-picker{position:fixed;inset:auto;margin:0;width:min(340px,calc(100vw - 32px));padding:20px;background:#14171b;color:var(--text);border:1px solid #32383e;border-radius:6px;box-shadow:0 16px 48px #0008;z-index:9}.period-picker::backdrop{background:transparent}.period-picker form{display:grid;gap:16px}.period-picker [hidden]{display:none}.period-picker label{display:grid;gap:8px;font-size:12px;color:var(--muted)}.period-picker input{min-height:44px;background:#0e1115;border-color:#353c43;color-scheme:dark}.period-picker .button{min-height:44px;width:100%}.period-date-fields{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px}.period-date-fields input{min-width:0;width:100%}
|
|
23
27
|
.date-controls{background:#101519;border-color:#252e33;padding:2px}.date-controls button{min-height:34px;min-width:45px;font-size:11px}.date-controls .selected{background:#252a2e;color:#eff0f1}
|
|
28
|
+
.period-selection{display:flex;align-items:center;gap:6px;min-width:0}.period-arrow{display:inline-flex;align-items:center;justify-content:center;width:34px;min-height:38px;padding:0;border:1px solid #252e33;border-radius:4px;background:#101519;color:var(--muted);flex-shrink:0}.period-arrow:hover:not(:disabled){color:var(--green);border-color:#3c5145}.period-arrow svg{width:16px;height:16px}.period-arrow:disabled{cursor:default}@media(max-width:760px){.period-selection{width:100%;gap:5px}.period-selection .date-controls{flex:1;min-width:0}.period-selection .date-controls button{min-width:0;padding:0 5px}.period-arrow{width:44px;min-height:46px}}
|
|
24
29
|
.app-main{padding:23px 25px 25px;min-height:0}.page-heading{margin-bottom:13px}.page-heading h1{font-size:19px;letter-spacing:-.4px;font-weight:550}
|
|
25
30
|
.coverage{border:0;padding-bottom:0;margin-bottom:20px;font-size:10px}.coverage summary{gap:7px}.coverage-content{border:1px solid var(--border);background:#101619;border-radius:5px;padding:18px;margin-top:12px;max-width:100%}
|
|
26
31
|
.overview-layout{grid-template-columns:minmax(0,1fr) 320px;gap:22px}.metrics{gap:12px;margin-bottom:16px}.metrics.single{grid-template-columns:minmax(0,1fr)}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
|
|
2
|
-
import {product,productMark,productNameMarkup} from '../../../packages/ui/src/brand.js';
|
|
2
|
+
import {product,productMark,productNameMarkup,productTitleMarkup} from '../../../packages/ui/src/brand.js';
|
|
3
3
|
document.title=product.name+' — Know what your AI subscription is worth';
|
|
4
4
|
import {drawReceipt,receiptModel,receiptFonts} from '../../../packages/receipt/src/index.js';
|
|
5
5
|
|
|
6
6
|
const example={range:{from:new Date(2026,9,1).toISOString(),until:new Date(2026,10,1).toISOString(),label:'October 2026'},providers:[{provider:'codex',records:1,tokens:20530000,tokensPartial:false,apiEquivalent:201.36,knownSubtotal:201.36,comparison:{subscription:200}},{provider:'claude',records:1,tokens:7840000,tokensPartial:false,apiEquivalent:86.06,knownSubtotal:86.06,comparison:{subscription:20}}],total:{apiEquivalent:287.42,comparison:{subscription:220,value:67.42,roi:287.42/220,breakEven:'2026-10-24',outcome:'positive'}},historyPartial:false,confidence:'HIGH',demo:true} ;
|
|
7
|
-
document.querySelector('#landing') .innerHTML=`<header class="landing-nav"><a class="brand" href="#"><span class="brand-mark">${productMark}</span>${productNameMarkup}</a><a class="github-link" href="#release">Install <span>↓</span></a></header><main><section class="hero"><h1>${
|
|
7
|
+
document.querySelector('#landing') .innerHTML=`<header class="landing-nav"><a class="brand" href="#"><span class="brand-mark">${productMark}</span>${productNameMarkup}</a><a class="github-link" href="#release">Install <span>↓</span></a></header><main><section class="hero"><h1>${productTitleMarkup}</h1><h2>Know what your AI<br class="desktop-break"> <span class="hero-emphasis">subscription is worth.</span></h2><p>See what your Codex and Claude Code usage would have cost via API.</p><div class="install-command"><code><span>$</span> npx @subvalue/cli</code><button class="copy-command" aria-label="Copy install command"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V4H4v12h4"/></svg></button></div><div class="trust-row"><span>✓ Local</span><span>✓ Read-only</span><span>✓ No telemetry</span></div><span class="release-note">Node.js 24.13+ · Windows · macOS · Linux</span></section><section class="product-preview" aria-label="Illustrative dashboard preview"><div class="preview-label"><span>PRODUCT PREVIEW</span><span>EXAMPLE DATA</span></div><div class="preview-window"><div class="preview-topbar"><span class="brand"><span class="brand-mark">${productMark}</span>${productNameMarkup}</span><span class="preview-tab">Overview</span><span>Receipt</span><span>Settings</span><span class="preview-date">October 2026</span></div><div class="preview-body"><div class="preview-main"><div class="preview-metrics"><div><span>API equivalent</span><strong>$287.42</strong></div><div><span>Subscription</span><strong>$220.00</strong></div><div><span>Value</span><strong class="green-text">+$67.42</strong></div><div><span>Value multiple</span><strong>1.31×</strong></div></div><div class="preview-providers"><div class="preview-provider"><span class="preview-provider-mark"><img class="codex-product-mark" src="/brands/codex-outline.svg" alt="" aria-hidden="true"></span><div><strong class="codex-product-name">Codex</strong><span>API equivalent</span></div><b>$201.36</b><span class="preview-active">Usage found</span></div><div class="preview-provider"><span class="preview-provider-mark"><img src="/brands/claude-code-clawd.svg" alt="" aria-hidden="true"></span><div><strong>Claude Code</strong><span>API equivalent</span></div><b>$86.06</b><span class="preview-active">Usage found</span></div></div><div class="preview-chart"><div><strong>Daily API equivalent</strong><span class="preview-legend"><span>● Codex</span><span class="claude">● Claude Code</span></span></div><div class="preview-bars" aria-hidden="true">${[15,24,19,34,29,38,21,25,39,26,32,28,43,25,31,38,49,44,52,36,41,29,45,40,53,45,66,51,57,49].map(h=>`<i style="height:${h}%"><span class="preview-codex-bar"></span><span class="preview-claude-bar"></span></i>`).join('')}</div><div class="preview-chart-axis"><span>Oct 1</span><span>Oct 15</span><span>Oct 31</span></div></div></div><div class="preview-receipt"><canvas id="example-receipt" role="img" aria-label="Illustrative ${product.name} receipt: API equivalent 287 dollars 42 cents, subscription 220 dollars, value 67 dollars 42 cents. Codex and Claude Code example data."></canvas><span>Export PNG ↓</span></div></div></div></section><section class="landing-privacy"><h2>Your data stays<br>on your machine.</h2><div><span>No account</span><span>No API keys</span><span>No uploads</span><span>No telemetry</span></div><details class="privacy-details"><summary>Details</summary><p>Usage is processed locally. Vercel hosts this page and can log website connections. <a href="/privacy.html" target="_blank" rel="noreferrer">Data access ↗</a></p></details></section><section class="install-section" id="release"><div class="install-command"><code><span>$</span> npx @subvalue/cli</code><button class="copy-command" aria-label="Copy install command"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V4H4v12h4"/></svg></button></div><span class="release-note">Node.js 24.13+ · Windows · macOS · Linux</span></section></main><div class="copy-status" role="status" aria-live="polite"></div>`;
|
|
8
8
|
await receiptFonts();
|
|
9
9
|
drawReceipt(document.querySelector ('#example-receipt') ,receiptModel(example,new Date(2026,10,1,9,30)));
|
|
10
10
|
document.querySelectorAll ('.copy-command').forEach(b=>b.addEventListener('click',async()=>{try{await navigator.clipboard.writeText('npx @subvalue/cli');const status=document.querySelector('.copy-status') ;status.textContent='Copied';b.setAttribute('aria-label','Copied');setTimeout(()=>{status.textContent='';b.setAttribute('aria-label','Copy install command');},2000);}catch{const status=document.querySelector('.copy-status') ;status.textContent='npx @subvalue/cli';}}));
|
|
@@ -4,19 +4,31 @@ document.title=product.name+' — Local usage';
|
|
|
4
4
|
|
|
5
5
|
import {subscriptionPlans,subscriptionPlan,subscriptionPlanLabel,subscriptionVerified} from '../../../packages/core/src/subscriptions.js';
|
|
6
6
|
|
|
7
|
+
import {fullCalendarMonths,shiftCalendarDays,shiftCalendarMonth} from '../../../packages/core/src/calendar.js';
|
|
7
8
|
import {drawReceipt,receiptModel,receiptFonts,exportReceipt,money,comparisonForDisplay} from '../../../packages/receipt/src/index.js';
|
|
8
9
|
const app=document.querySelector ('#app') ;
|
|
9
10
|
const name=(p )=>p==='codex'?'Codex':'Claude Code';
|
|
10
11
|
const esc=(s )=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c] ));
|
|
11
|
-
const icon=(key )=>{const paths ={bars:'M5 20V14 M12 20V8 M19 20V3',card:'M3 5h18v14H3z M3 9h18 M6 14h4',overview:'M3 10 12 3l9 7v10H3z M9 20v-7h6v7',receipt:'M6 3h12v18l-3-2-3 2-3-2-3 2z M9 8h6 M9 12h6',settings:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M12 2v3 M12 19v3 M2 12h3 M19 12h3 M5 5l2 2 M17 17l2 2 M5 19l2-2 M17 7l2-2',arrow:'M4 12h16 M14 6l6 6-6 6',download:'M12 3v12 M7 10l5 5 5-5 M4 17v4h16v-4',shield:'M12 3 4 6v6c0 5 8 9 8 9s8-4 8-9V6z',token:'M12 3 21 8v8l-9 5-9-5V8z M3 8l9 5 9-5 M12 13v8',check:'m5 12 4 4 10-10',refresh:'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 13 2 M18 18a8 8 0 0 1-13-2'};return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[key]??paths.token}"/></svg>`;};
|
|
12
|
-
let settings ,sources =[],summary ,token='',demo=false,lastScan =null;let page=location.pathname==='/receipt'?'receipt':location.pathname==='/settings'?'settings':'overview';let period='month',from='',to='',onboardingStage='found';let busy=false;let themeSaving=false;let displayedReceipt ;
|
|
12
|
+
const icon=(key )=>{const paths ={bars:'M5 20V14 M12 20V8 M19 20V3',card:'M3 5h18v14H3z M3 9h18 M6 14h4',overview:'M3 10 12 3l9 7v10H3z M9 20v-7h6v7',receipt:'M6 3h12v18l-3-2-3 2-3-2-3 2z M9 8h6 M9 12h6',settings:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M12 2v3 M12 19v3 M2 12h3 M19 12h3 M5 5l2 2 M17 17l2 2 M5 19l2-2 M17 7l2-2',arrow:'M4 12h16 M14 6l6 6-6 6',previous:'m15 6-6 6 6 6',next:'m9 6 6 6-6 6',download:'M12 3v12 M7 10l5 5 5-5 M4 17v4h16v-4',shield:'M12 3 4 6v6c0 5 8 9 8 9s8-4 8-9V6z',token:'M12 3 21 8v8l-9 5-9-5V8z M3 8l9 5 9-5 M12 13v8',check:'m5 12 4 4 10-10',refresh:'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 13 2 M18 18a8 8 0 0 1-13-2'};return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[key]??paths.token}"/></svg>`;};
|
|
13
|
+
let settings ,sources =[],summary ,token='',demo=false,lastScan =null;let page=location.pathname==='/receipt'?'receipt':location.pathname==='/settings'?'settings':'overview';let period='month',from='',to='',onboardingStage='found';let anchor='';let selectedMonth=localDate(new Date()).slice(0,7),periodLoading=false;let busy=false;let themeSaving=false;let displayedReceipt ;
|
|
14
|
+
function localDate(date ){return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`;}
|
|
15
|
+
function periodLabel(){return summary.range.label+(!summary.demo&&Date.parse(summary.range.from)<=Date.now()&&Date.parse(summary.range.until)>Date.now()&&fullCalendarMonths(summary.range)!==null?' · In progress':'');}
|
|
13
16
|
async function api (url ,method='GET',body ) {const response=await fetch(url,{method,headers:{'Content-Type':'application/json','X-Subvalue-Token':token},body:body===undefined?undefined:JSON.stringify(body)});if(!response.ok)throw new Error('Unable to complete request');return response.json();}
|
|
14
17
|
function toast(text ){const el=document.querySelector ('#toast') ;el.textContent=text;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),3000);}
|
|
15
|
-
let loadVersion=0;async function load(){const version=++loadVersion;const next=await api (`/api/summary?period=${period}${period==='custom'?`&from=${from}&to=${to}`:''}`);if(version!==loadVersion)return false;summary=next;return true;}
|
|
18
|
+
let loadVersion=0;async function load(){const version=++loadVersion;const next=await api (`/api/summary?period=${period}${period==='custom'?`&from=${from}&to=${to}`:period==='month'?`&month=${selectedMonth}`:anchor?`&anchor=${anchor}`:''}`);if(version!==loadVersion)return false;summary=next;return true;}
|
|
16
19
|
function metric(label ,value ,detail='',accent=''){const symbol=label==='Subscription'?'card':label==='Value multiple'?'bars':label.includes('value')||label==='Value'?'shield':'token';return `<article class="metric ${accent}"><span class="label">${icon(symbol)}${label}</span><strong>${value}</strong>${detail?`<span class="metric-detail">${detail}</span>`:''}</article>`;}
|
|
17
20
|
function navigation(){return `<header class="topbar"><a href="/" class="brand" aria-label="${esc(product.name)} overview"><span class="brand-mark">${productMark}</span>${productNameMarkup}</a><nav aria-label="Main navigation">${['overview','receipt','settings'].map(p=>`<button data-page="${p}" class="nav-item ${page===p?'active':''}" ${page===p?'aria-current="page"':''}>${icon(p)}<span>${p[0].toUpperCase()+p.slice(1)}</span></button>`).join('')}</nav><div class="header-period">${controls()}</div><span class="local-status" aria-label="Local only">${icon('shield')}</span></header>`;}
|
|
18
|
-
function
|
|
19
|
-
|
|
21
|
+
function adjacentPeriod(direction ){
|
|
22
|
+
if(period==='month'){const month=shiftCalendarMonth(selectedMonth,direction);return month?{month,from,to,anchor:''}:null;}
|
|
23
|
+
const first=summary.range.calendarFrom??localDate(new Date(summary.range.from)),last=summary.range.calendarTo??localDate(new Date(Date.parse(summary.range.until)-1));
|
|
24
|
+
const days=period==='7d'?7:period==='30d'?30:Math.round((Date.parse(last+'T12:00:00Z')-Date.parse(first+'T12:00:00Z'))/864e5)+1;
|
|
25
|
+
const nextFrom=shiftCalendarDays(first,direction*days),nextTo=shiftCalendarDays(last,direction*days);
|
|
26
|
+
if((period==='7d'||period==='30d')&&nextTo&&!/^(?:19|20|21)\d{2}-\d{2}-\d{2}$/.test(nextTo))return null;
|
|
27
|
+
return nextFrom&&nextTo?{month:selectedMonth,from:nextFrom,to:nextTo,anchor:nextTo}:null;
|
|
28
|
+
}
|
|
29
|
+
function periodArrow(direction ){const unit=period==='month'?'month':period==='7d'?'7 days':period==='30d'?'30 days':'period',label=(direction<0?'Previous ':'Next ')+unit;return `<button class="period-arrow" data-period-step="${direction}" type="button" aria-label="${label}" title="${label}" ${busy||!adjacentPeriod(direction)?'disabled':''}>${icon(direction<0?'previous':'next')}</button>`;}
|
|
30
|
+
function controls(){return `<div class="period-selection" role="group" aria-label="Navigate periods">${periodArrow(-1)}<div class="date-controls" aria-label="Period">${[['7d','7D'],['30d','30D'],['month','Month'],['custom','Custom']].map(([v,l])=>`<button data-period="${v}" class="${period===v?'selected':''}" aria-pressed="${period===v}" ${v==='month'||v==='custom'?'aria-haspopup="dialog" aria-controls="period-picker" aria-expanded="false"':''}>${l}</button>`).join('')}</div>${periodArrow(1)}</div><div id="period-picker" class="period-picker" popover="auto" role="dialog" aria-label="Choose period"><form id="month-range" hidden><label for="selected-month">Choose month</label><input id="selected-month" type="month" name="month" value="${selectedMonth}" pattern="[0-9]{4}-[0-9]{2}" placeholder="YYYY-MM" required><button class="button small">Apply</button></form><form id="custom-range" hidden><div class="period-date-fields"><label>From<input type="date" name="from" value="${from||summary.range.calendarFrom||localDate(new Date(summary.range.from))}" required></label><label>To<input type="date" name="to" value="${to||summary.range.calendarTo||localDate(new Date(Date.parse(summary.range.until)-1))}" required></label></div><button class="button small">Apply</button></form></div>`;}
|
|
31
|
+
function coverage(){return `<details class="coverage"><summary>${icon('shield')}<span>${summary.historyPartial?'Partial history':summary.confidence==='HIGH'?'High confidence':'Incomplete'}${summary.total.priceCoverage!==null?` · ${Math.round(summary.total.priceCoverage*100)}% priced`:''}</span><span class="details-label">Details</span></summary><div class="coverage-content"><dl><div><dt>Confidence</dt><dd>${esc(summary.confidence)}</dd></div><div><dt>Usage events</dt><dd>${summary.total.records.toLocaleString()}</dd></div><div><dt>Priced events</dt><dd>${summary.total.pricedRecords.toLocaleString()}</dd></div><div><dt>Pricing version</dt><dd>${esc(summary.pricingVersion)}</dd></div></dl><p>Coverage describes imported records, not all requests ever made. Missing history stays unknown. Subscription comparison is available for full calendar months, at the full monthly price.</p>${summary.providers.filter(p=>p.records||Object.keys(p.issues).length).map(p=>`<div class="issue-group"><strong>${name(p.provider)}</strong>${Object.entries(p.issues).map(([k,n])=>`<span>${esc(k.replaceAll('-',' '))} <b>${n.toLocaleString()}</b></span>`).join('')||'<span>All imported events priced</span>'}</div>`).join('')}${summary.unresolvedModels.length?`<p>Unresolved models: <code>${summary.unresolvedModels.map(esc).join(', ')}</code></p>`:''}${summary.models.map(m=>`<div class="model-coverage"><code>${esc(m.raw)}</code><span>${m.tokens===null?"Unknown tokens":m.tokens.toLocaleString()+" tokens"}</span><span>${m.pricedRecords}/${m.records} priced</span>${m.mappingSource?`<a href="${esc(m.mappingSource)}" target="_blank" rel="noreferrer">${esc(m.mappingConfidence)} mapping ↗</a>`:"<span>Mapping unavailable</span>"}</div>`).join('')}</div></details>`;}
|
|
20
32
|
function chart(){const daily=summary.daily;const values=daily.flatMap(d=>[d.codex,d.claude]).filter((n) =>n!==null);if(!values.length)return `<section class="panel chart"><div class="panel-title"><h2>Daily API equivalent</h2><span class="label">${summary.range.label}</span></div><div class="chart-empty"><span class="empty-chart-lines"></span><span>${summary.total.records?'No priced usage':'No data for this period'}</span></div></section>`;
|
|
21
33
|
const max=Math.max(...daily.map(d=>(d.codex??0)+(d.claude??0)),.01);const w=700,h=186,pad=30,barArea=620;const calendarDays=(Date.parse(daily.at(-1) .date)-Date.parse(daily[0].date))/864e5+1;const bars=daily.map((d,index)=>{const step=barArea/calendarDays,bw=Math.min(18,step*.7),x=55+(Date.parse(d.date)-Date.parse(daily[0].date))/864e5*step;const a=(d.codex??0)/max*140,b=(d.claude??0)/max*140;const label=dailyDescription(index);return `<g data-chart-day="${index}" tabindex="0" role="button" aria-label="${esc(label)}" aria-describedby="chart-tooltip"><rect x="${x}" y="${h-pad-a}" width="${bw}" height="${a}" rx="1" fill="url(#bar-green)"/><rect x="${x}" y="${h-pad-a-b}" width="${bw}" height="${b}" rx="1" fill="#8aadc2"/><rect class="chart-hit-target" x="${x-3}" y="16" width="${bw+6}" height="140" fill="transparent"/></g>`;}).join('');
|
|
22
34
|
const labels=[0,.5,1].map(f=>`<line x1="45" x2="690" y1="${h-pad-f*140}" y2="${h-pad-f*140}" stroke="#242a2e"/><text x="0" y="${h-pad-f*140+4}" fill="#899097" font-size="10">$${(max*f).toFixed(max<1?2:0)}</text>`).join('');
|
|
@@ -38,16 +50,38 @@ function providerCards(){return `<div class="provider-grid">${summary.providers.
|
|
|
38
50
|
function receiptPanel(compact=false){const model=displayedReceipt=receiptModel(summary);return `<section class="receipt-panel ${compact?'compact':''}" aria-label="AI usage receipt"><div class="receipt-theme-control" role="group" aria-label="Receipt color">${(['dark','light'] ).map(theme=>`<button type="button" data-receipt-theme="${theme}" aria-pressed="${(settings.receiptTheme??'dark')===theme}" aria-label="${theme==='dark'?'Black':'White'} receipt" ${themeSaving?'disabled':''}>${theme==='dark'?'Black':'White'}</button>`).join('')}</div><canvas class="receipt-canvas" id="receipt-canvas" role="img" aria-label="${esc(model.period+' · Generated '+model.generated+' · '+model.rows.map(r=>r.left+' '+r.right).join(' · ')+' · '+model.outcome+' · '+model.confidence)}"></canvas><button class="button export-button" id="export-png">${icon('download')}Export PNG</button></section>`;}
|
|
39
51
|
function refreshButton(){return `<button type="button" class="refresh-button" id="rescan" ${busy?'disabled':''} aria-busy="${busy}" title="${lastScan?'Last scan: '+esc(new Date(lastScan).toLocaleString()):'Scan local usage'}">${icon('refresh')}<span>${busy?'Scanning…':'Refresh usage'}</span></button>`;}
|
|
40
52
|
function updateRefreshButton(){const button=document.querySelector ('#rescan');if(button){button.disabled=busy;button.setAttribute('aria-busy',String(busy));button.innerHTML=icon('refresh')+'<span>'+(busy?'Scanning…':'Refresh usage')+'</span>';}}
|
|
41
|
-
function overview(){const t=summary.total,c=comparisonForDisplay(summary);const active=summary.providers.filter(p=>p.records);const showSubs=active.length&&active.every(p=>p.mode==='SUBSCRIPTION');return `<div class="page-heading"><div><h1>Overview</h1></div>${refreshButton()}</div>${coverage()}<div class="overview-layout"><div class="overview-main"><div class="metrics ${showSubs?'':'single'}">${metric('API equivalent',t.apiEquivalent===null?(t.knownSubtotal!==null?money(t.knownSubtotal):t.records?'—':'No data'):money(t.apiEquivalent),
|
|
42
|
-
function receiptPage(){return `<div class="page-heading"><div><h1>Receipt</h1></div></div><div class="receipt-page-layout">${receiptPanel()}<aside class="receipt-aside"><span class="eyebrow">API EQUIVALENT</span><strong>${summary.total.knownSubtotal!==null?money(summary.total.apiEquivalent??summary.total.knownSubtotal):summary.total.records?'No priced usage':'No data'}</strong><span>${
|
|
53
|
+
function overview(){const t=summary.total,c=comparisonForDisplay(summary);const active=summary.providers.filter(p=>p.records);const months=fullCalendarMonths(summary.range),showSubs=months!==null&&active.length&&active.every(p=>p.mode==='SUBSCRIPTION');return `<div class="page-heading"><div><h1>Overview</h1></div>${refreshButton()}</div>${coverage()}<div class="overview-layout"><div class="overview-main"><div class="metrics ${showSubs?'':'single'}">${metric('API equivalent',t.apiEquivalent===null?(t.knownSubtotal!==null?money(t.knownSubtotal):t.records?'—':'No data'):money(t.apiEquivalent),periodLabel(),'api-metric')}${showSubs?metric('Subscription',c.subscription===null?'Not set':money(c.subscription),months===1?'Full month':`${months} full months`):''}${showSubs?metric(summary.historyPartial?'Observed value':'Value',c.value===null?'—':money(c.value,true),c.value===null?'Comparison unavailable':summary.historyPartial?'Partial history':'',c.value!==null?(c.value>=0?'positive':'negative'):''):''}${showSubs?metric('Value multiple',c.roi===null?'—':`${c.roi.toFixed(2)}<span class="multiply">×</span>`,c.breakEven?`Break-even ${new Date(c.breakEven+'T12:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric'})}`:c.roi===null?'Unavailable':t.apiEquivalent===null?'Priced portion':''):''}</div>${providerCards()}${chart()}<div class="overview-bottom"><span>${icon('shield')}Local analysis. Read-only sources.</span><button class="text-button" data-page="settings">Manage sources ${icon('arrow')}</button></div>${!t.records?'<section class="panel empty-panel"><h2>No data for this period</h2><button class="text-button" data-page="settings">View sources →</button></section>':''}</div>${receiptPanel(true)}</div>`;}
|
|
54
|
+
function receiptPage(){return `<div class="page-heading"><div><h1>Receipt</h1></div></div><div class="receipt-page-layout">${receiptPanel()}<aside class="receipt-aside"><span class="eyebrow">API EQUIVALENT</span><strong>${summary.total.knownSubtotal!==null?money(summary.total.apiEquivalent??summary.total.knownSubtotal):summary.total.records?'No priced usage':'No data'}</strong><span>${periodLabel()}</span>${coverage()}<details class="privacy-note"><summary>Privacy</summary><p>Exports contain provider totals and the selected period. No projects, sessions, paths, prompts, or responses.</p></details></aside></div>`;}
|
|
43
55
|
function billingFields(p ,firstRun=false){const b=settings.billing[p],plan=subscriptionPlan(p,b.planId);return `<div class="billing-row"><label class="provider-setting-name" for="mode-${p}">${name(p)}</label><select id="mode-${p}" name="mode-${p}" data-billing-mode="${p}" ${firstRun?'required data-first-run="true"':''}><option value="${firstRun?'':'UNKNOWN'}" ${b.mode==='UNKNOWN'?'selected':''}>${firstRun?'Choose usage':'Not set'}</option><option value="SUBSCRIPTION" ${b.mode==='SUBSCRIPTION'?'selected':''}>Subscription</option><option value="API" ${b.mode==='API'?'selected':''}>API</option><option value="MIXED" ${b.mode==='MIXED'?'selected':''}>Mixed / not sure</option></select><div class="subscription-fields ${b.mode==='SUBSCRIPTION'?'':'hidden'}" id="amount-${p}"><label class="sr-only" for="plan-${p}">${name(p)} subscription plan</label><select id="plan-${p}" name="plan-${p}" data-subscription-plan="${p}"><option value="custom" ${!plan?'selected':''}>Custom amount</option>${subscriptionPlans.filter(plan=>plan.provider===p).map(option=>`<option value="${option.id}" ${plan?.id===option.id?'selected':''}>${esc(subscriptionPlanLabel(option))}</option>`).join('')}</select><label class="amount-field"><span class="sr-only">${name(p)} monthly subscription in USD</span><span>$</span><input name="monthly-${p}" type="number" min="0" max="1000000" step="0.01" value="${plan?plan.monthly.toFixed(2):b.monthly??''}" ${plan?'readonly':''} ${firstRun&&b.mode==='SUBSCRIPTION'?'required':''} placeholder="Amount"><span>/ month</span></label></div></div>`;}
|
|
44
|
-
function settingsPage(){return `<div class="page-heading"><div><h1>Settings</h1></div></div><div class="settings-layout"><form id="settings-form"><section class="panel settings-section"><h2>Sources</h2>${summary.providers.map(p=>{const s=sources.find(s=>s.provider===p.provider);return `<div class="source-row"><div><strong>${name(p.provider)}</strong><span class="source-state">${p.status==='USAGE_FOUND'?'Usage found':p.detected?'No usage in selected period':'Not detected'}</span></div><label class="include-check"><input type="checkbox" name="include-${p.provider}" ${settings.include[p.provider]?'checked':''}>Always show</label><details><summary>Details</summary><dl><div><dt>Detection</dt><dd>${p.detected?'Detected':'Not detected'}</dd></div><div><dt>Imported events</dt><dd>${p.importedRecords.toLocaleString()}</dd></div><div><dt>Last scan</dt><dd>${lastScan?new Date(lastScan).toLocaleString():'Not scanned'}</dd></div><div><dt>Source files</dt><dd>${s?.files??0}</dd></div><div><dt>Available history</dt><dd>${p.first?p.first.slice(0,10)+' → '+p.last?.slice(0,10):'No data'}</dd></div><div><dt>Read errors</dt><dd>${s?.errors??0}</dd></div><div><dt>Malformed / skipped</dt><dd>${(s?.diagnostics.malformed??0)+(s?.diagnostics.oversize??0)}</dd></div><div><dt>Incomplete final line</dt><dd>${s?.diagnostics.partialTail?'Yes':'No'}</dd></div></dl></details></div>`;}).join('')}${refreshButton()}</section><section class="panel settings-section"><h2>Billing</h2>${(['codex','claude'] ).filter(p=>sources.find(s=>s.provider===p)?.detected).map(p=>billingFields(p)).join('')}<details class="compact-details"><summary>Details</summary><p>Plans verified ${subscriptionVerified}. ${subscriptionPlans.filter((p,i,list)=>list.findIndex(q=>q.source===p.source)===i).map(p=>`<a href="${esc(p.source)}" target="_blank" rel="noreferrer">${p.provider==='codex'?'ChatGPT':'Claude'} ↗</a>`).join(' · ')}. Annual plans use the exact annual price divided by 12; team plans are per seat. Enterprise / Edu: custom amount.</p><p>These are your declarations, not inferred billing history. Amounts are in USD
|
|
56
|
+
function settingsPage(){return `<div class="page-heading"><div><h1>Settings</h1></div></div><div class="settings-layout"><form id="settings-form"><section class="panel settings-section"><h2>Sources</h2>${summary.providers.map(p=>{const s=sources.find(s=>s.provider===p.provider);return `<div class="source-row"><div><strong>${name(p.provider)}</strong><span class="source-state">${p.status==='USAGE_FOUND'?'Usage found':p.detected?'No usage in selected period':'Not detected'}</span></div><label class="include-check"><input type="checkbox" name="include-${p.provider}" ${settings.include[p.provider]?'checked':''}>Always show</label><details><summary>Details</summary><dl><div><dt>Detection</dt><dd>${p.detected?'Detected':'Not detected'}</dd></div><div><dt>Imported events</dt><dd>${p.importedRecords.toLocaleString()}</dd></div><div><dt>Last scan</dt><dd>${lastScan?new Date(lastScan).toLocaleString():'Not scanned'}</dd></div><div><dt>Source files</dt><dd>${s?.files??0}</dd></div><div><dt>Available history</dt><dd>${p.first?p.first.slice(0,10)+' → '+p.last?.slice(0,10):'No data'}</dd></div><div><dt>Read errors</dt><dd>${s?.errors??0}</dd></div><div><dt>Malformed / skipped</dt><dd>${(s?.diagnostics.malformed??0)+(s?.diagnostics.oversize??0)}</dd></div><div><dt>Incomplete final line</dt><dd>${s?.diagnostics.partialTail?'Yes':'No'}</dd></div></dl></details></div>`;}).join('')}${refreshButton()}</section><section class="panel settings-section"><h2>Billing</h2>${(['codex','claude'] ).filter(p=>sources.find(s=>s.provider===p)?.detected).map(p=>billingFields(p)).join('')}<details class="compact-details"><summary>Details</summary><p>Plans verified ${subscriptionVerified}. ${subscriptionPlans.filter((p,i,list)=>list.findIndex(q=>q.source===p.source)===i).map(p=>`<a href="${esc(p.source)}" target="_blank" rel="noreferrer">${p.provider==='codex'?'ChatGPT':'Claude'} ↗</a>`).join(' · ')}. Annual plans use the exact annual price divided by 12; team plans are per seat. Enterprise / Edu: custom amount.</p><p>These are your declarations, not inferred billing history. Amounts are in USD. Comparisons use the full monthly price for full calendar months only. API and mixed usage have no subscription comparison.</p></details></section><section class="panel settings-section"><h2>Currency</h2><div class="setting-line"><span>API prices & subscription</span><strong>USD</strong></div></section><section class="panel settings-section"><h2>Privacy / data</h2><div class="privacy-tags"><span>${icon('check')}Local only</span><span>${icon('check')}Read-only sources</span><span>${icon('check')}No telemetry</span></div><details><summary>Data access</summary><p>Only approved Codex and Claude JSONL session files are streamed. Only normalized usage metadata is stored. Credentials, project contents and conversation content are not stored.</p><code>~/.codex/sessions/**/*.jsonl<br>~/.codex/archived_sessions/*.jsonl<br>~/.claude/projects/**/*.jsonl</code></details></section><div class="form-actions"><button class="button" type="submit">Save changes</button></div></form></div>`;}
|
|
45
57
|
function onboarding(){const found=summary.providers.filter(p=>p.first),billable=summary.providers.filter(p=>p.importedRecords>0);return `<dialog id="onboarding" aria-labelledby="onboarding-title"><span class="brand-mark">${productMark}</span><span class="eyebrow">${esc(product.name.toUpperCase())} / FIRST RUN</span><h2 id="onboarding-title">${onboardingStage==='found'?(found.length?'Local usage found':'No local usage found'):'How did you use it?'}</h2>${onboardingStage==='found'?`<div class="found-sources">${summary.providers.map(p=>`<div><strong>${name(p.provider)}</strong><span>${p.first?`${Math.max(1,Math.ceil((Date.parse(p.last )-Date.parse(p.first))/864e5))} days ${icon('check')}`:p.detected?'No data found':'Not detected'}</span></div>`).join('')}</div><button class="button" id="onboarding-continue">Continue ${icon('arrow')}</button>`:`<form id="onboarding-billing">${billable.map(p=>`<h3>How did you use ${name(p.provider)}?</h3>${billingFields(p.provider,true)}`).join('')}<button class="button" type="submit">Open overview ${icon('arrow')}</button></form><button class="text-button onboarding-skip" id="onboarding-skip">Set up later</button>`}</dialog>`;}
|
|
46
58
|
function render(){app.innerHTML=`${demo?'<div class="demo-banner">DEMO / FIXTURE DATA — no local sources accessed</div>':''}${!settings.onboarded?onboarding():`<div class="app-shell">${navigation()}<main class="app-main">${page==='overview'?overview():page==='receipt'?receiptPage():settingsPage()}</main><footer class="app-footer"><span>${esc(product.name)} <span class="version">0.1</span></span><span>LOCAL / READ-ONLY</span></footer></div>`}`;
|
|
47
59
|
const canvas=document.querySelector ('#receipt-canvas');if(canvas)drawReceipt(canvas,displayedReceipt,settings.receiptTheme??'dark');const dialog=document.querySelector ('#onboarding');if(dialog){dialog.addEventListener('cancel',event=>event.preventDefault());dialog.showModal();}bind();}
|
|
48
60
|
function readBilling(form ){const data=new FormData(form);for(const p of ['codex','claude'] ){const mode=data.get(`mode-${p}`);if(mode){const amount=data.get(`monthly-${p}`),plan=subscriptionPlan(p,String(data.get(`plan-${p}`)));settings.billing[p]={mode:mode ,monthly:mode==='SUBSCRIPTION'?(plan?.monthly??(amount!==''&&amount!==null?Number(amount):null)):null,...(mode==='SUBSCRIPTION'?{planId:plan?.id??null}:{})};}}}
|
|
49
61
|
async function save(){await api('/api/settings','POST',settings);await load();render();}
|
|
50
|
-
function
|
|
62
|
+
async function choosePeriod(next ,nextFrom=from,nextTo=to,nextMonth=selectedMonth,nextAnchor='',focusSelector ){
|
|
63
|
+
if(periodLoading||busy)return;const previous={period,from,to,selectedMonth,anchor};periodLoading=true;
|
|
64
|
+
period=next;from=nextFrom;to=nextTo;selectedMonth=nextMonth;anchor=nextAnchor;
|
|
65
|
+
const controls=document.querySelectorAll ('[data-period],[data-period-step],#period-picker button');controls.forEach(button=>button.disabled=true);
|
|
66
|
+
try{if(await load()){render();document.querySelector (focusSelector??`[data-period="${period}"]`)?.focus({preventScroll:true});}}catch{({period,from,to,selectedMonth,anchor}=previous);toast('Choose a valid date range');}finally{periodLoading=false;document.querySelectorAll ('[data-period],[data-period-step],#period-picker button').forEach(button=>button.disabled=busy||!!(button.dataset.periodStep&&!adjacentPeriod(Number(button.dataset.periodStep))));}
|
|
67
|
+
}
|
|
68
|
+
function bind(){document.querySelectorAll ('[data-page]').forEach(b=>b.addEventListener('click',()=>{page=b.dataset.page ;history.replaceState(null,'',page==='overview'?'/':`/${page}`);render();window.scrollTo(0,0);}));
|
|
69
|
+
const picker=document.querySelector ('#period-picker');
|
|
70
|
+
picker?.addEventListener('toggle',()=>{document.querySelectorAll ('[aria-controls="period-picker"]').forEach(button=>button.setAttribute('aria-expanded',String(picker.matches(':popover-open')&&picker.dataset.period===button.dataset.period)));});
|
|
71
|
+
document.querySelectorAll ('[data-period]').forEach(button=>button.addEventListener('click',()=>{
|
|
72
|
+
if(busy||periodLoading)return;const next=button.dataset.period ;
|
|
73
|
+
if(next!=='month'&&next!=='custom'){void choosePeriod(next);return;}
|
|
74
|
+
if(!picker)return;picker.dataset.period=next;
|
|
75
|
+
picker.querySelector ('#month-range') .hidden=next!=='month';picker.querySelector ('#custom-range') .hidden=next!=='custom';
|
|
76
|
+
if(!picker.matches(':popover-open'))picker.showPopover();
|
|
77
|
+
const bounds=button.getBoundingClientRect();picker.style.left=Math.max(16,Math.min(innerWidth-picker.offsetWidth-16,bounds.right-picker.offsetWidth))+'px';picker.style.top=(bounds.bottom+8)+'px';
|
|
78
|
+
document.querySelectorAll ('[aria-controls="period-picker"]').forEach(control=>control.setAttribute('aria-expanded',String(control===button)));
|
|
79
|
+
picker.querySelector ('form:not([hidden]) input')?.focus();
|
|
80
|
+
}));
|
|
81
|
+
document.querySelectorAll ('[data-period-step]').forEach(button=>button.addEventListener('click',()=>{
|
|
82
|
+
if(busy||periodLoading)return;const direction=Number(button.dataset.periodStep),next=adjacentPeriod(direction);if(!next)return;
|
|
83
|
+
void choosePeriod(period,next.from,next.to,next.month,period==='7d'||period==='30d'?next.anchor:'',`[data-period-step="${direction}"]`);
|
|
84
|
+
}));
|
|
51
85
|
bindChart();
|
|
52
86
|
document.querySelectorAll ('[data-receipt-theme]').forEach(button=>button.addEventListener('click',async()=>{
|
|
53
87
|
if(themeSaving)return;const next=button.dataset.receiptTheme;if(next!=='dark'&&next!=='light')return;const previous=settings.receiptTheme;if((previous??'dark')===next)return;
|
|
@@ -55,7 +89,8 @@ function bind(){document.querySelectorAll ('[data-page]').forE
|
|
|
55
89
|
const update=()=>{const canvas=document.querySelector ('#receipt-canvas');if(canvas)drawReceipt(canvas,displayedReceipt,settings.receiptTheme??'dark');document.querySelectorAll ('[data-receipt-theme]').forEach(control=>{control.setAttribute('aria-pressed',String(control.dataset.receiptTheme===(settings.receiptTheme??'dark')));control.disabled=themeSaving;});};
|
|
56
90
|
update();try{await api('/api/settings','POST',settings);}catch{settings.receiptTheme=previous;toast('Unable to save receipt color');}finally{themeSaving=false;update();}
|
|
57
91
|
}));
|
|
58
|
-
document.querySelector ('#custom-range')?.addEventListener('submit',
|
|
92
|
+
document.querySelector ('#custom-range')?.addEventListener('submit',e=>{e.preventDefault();const data=new FormData(e.currentTarget );void choosePeriod('custom',String(data.get('from')),String(data.get('to')));});
|
|
93
|
+
document.querySelector ('#month-range')?.addEventListener('submit',e=>{e.preventDefault();const data=new FormData(e.currentTarget );void choosePeriod('month',from,to,String(data.get('month')));});
|
|
59
94
|
document.querySelectorAll ('[data-billing-mode]').forEach(select=>select.addEventListener('change',()=>{document.querySelector(`#amount-${select.dataset.billingMode}`)?.classList.toggle('hidden',select.value!=='SUBSCRIPTION');const amount=document.querySelector (`[name="monthly-${select.dataset.billingMode}"]`);if(amount)amount.required=select.dataset.firstRun==='true'&&select.value==='SUBSCRIPTION';}));
|
|
60
95
|
document.querySelectorAll ('[data-subscription-plan]').forEach(select=>select.addEventListener('change',()=>{const p=select.dataset.subscriptionPlan ,plan=subscriptionPlan(p,select.value),input=document.querySelector (`[name="monthly-${p}"]`) ;input.readOnly=!!plan;if(plan)input.value=plan.monthly.toFixed(2);}));
|
|
61
96
|
document.querySelector ('#settings-form')?.addEventListener('submit',async e=>{e.preventDefault();const form=e.currentTarget ;readBilling(form);const data=new FormData(form);for(const p of ['codex','claude'] )settings.include[p]=data.has(`include-${p}`);try{await save();toast('Saved locally');}catch{toast('Unable to save settings');}});
|
package/dist/public/landing.css
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/* Both the public page and local app use the same receipt renderer and tokens. */
|
|
2
2
|
@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:block;src:url('/fonts/inter-latin.woff2') format('woff2')}
|
|
3
3
|
@font-face{font-family:'Plex Mono';font-style:normal;font-weight:400 700;font-display:block;src:url('/fonts/plex-mono.woff2') format('woff2')}
|
|
4
|
-
.brand-name{color:#
|
|
4
|
+
.brand-name{display:inline-flex;align-items:center;position:relative;color:#eef2f4;font-family:Inter,sans-serif;font-weight:800;letter-spacing:-.055em}.brand-name-accent{color:#37ec85}
|
|
5
|
+
.brand-name-label{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
|
6
|
+
.brand-wordmark{display:block;width:92px;height:14px;overflow:visible}
|
|
7
|
+
@media(forced-colors:active){.brand-name,.brand-name-accent{color:CanvasText}}
|
|
5
8
|
|
|
6
9
|
.chart{position:relative}.chart-tooltip{position:absolute;z-index:3;min-width:180px;max-width:calc(100% - 20px);padding:12px 14px;background:#191d22;border:1px solid #363e46;border-radius:5px;box-shadow:0 8px 22px #0006;pointer-events:none;font-size:11px}.chart-tooltip[hidden]{display:none}.chart-tooltip>strong{display:block;font-weight:550;margin-bottom:8px}.chart-tooltip dl{font-size:11px}.chart-tooltip dd{font-variant-numeric:tabular-nums}.chart-tip-total{border-top:1px solid #30363d;margin-top:5px;padding-top:8px!important}.chart-tip-total dd{color:#37ec85}.chart-svg [data-chart-day]{outline:none;cursor:pointer}.chart-svg [data-chart-day]:focus-visible .chart-hit-target,.chart-svg [data-chart-active] .chart-hit-target{stroke:#37ec85;stroke-opacity:.45;stroke-width:1;rx:2}.receipt-theme-control{display:flex;gap:3px;align-self:center;border:1px solid #2d343c;border-radius:5px;padding:3px;background:#111418}.receipt-theme-control button{background:none;border:0;color:#999fa6;border-radius:3px;min-width:68px;min-height:32px;padding:6px 12px;font-size:11px}.receipt-theme-control button[aria-pressed=true]{background:#262d33;color:#eff0f1}.receipt-theme-control button[data-receipt-theme=light][aria-pressed=true]{background:#eeece6;color:#22272d}
|
|
7
10
|
:root{color-scheme:dark;--bg:#080a0c;--panel:#111418;--border:#25282d;--text:#eff0f1;--muted:#999fa6;--green:#37ec85;--red:#ed9987;--mono:"Plex Mono",Consolas,monospace;--sans:Inter,"Segoe UI",Arial,sans-serif}
|
|
@@ -20,7 +23,9 @@ body{background:#080b0d;background-image:none}
|
|
|
20
23
|
.topbar{height:70px;padding:0 25px;gap:38px;background:#090b0d;border-bottom-color:#222a2f}
|
|
21
24
|
.brand{font-size:20px;gap:9px;letter-spacing:-.6px}.brand-mark{font-size:34px}.topbar nav{gap:25px}.nav-item{font-size:12px;padding:0 2px}
|
|
22
25
|
.header-period{margin-left:auto;display:flex;align-items:center;gap:12px}.local-status{margin-left:0;color:var(--green)}.local-status svg{width:17px}
|
|
26
|
+
.period-picker{position:fixed;inset:auto;margin:0;width:min(340px,calc(100vw - 32px));padding:20px;background:#14171b;color:var(--text);border:1px solid #32383e;border-radius:6px;box-shadow:0 16px 48px #0008;z-index:9}.period-picker::backdrop{background:transparent}.period-picker form{display:grid;gap:16px}.period-picker [hidden]{display:none}.period-picker label{display:grid;gap:8px;font-size:12px;color:var(--muted)}.period-picker input{min-height:44px;background:#0e1115;border-color:#353c43;color-scheme:dark}.period-picker .button{min-height:44px;width:100%}.period-date-fields{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px}.period-date-fields input{min-width:0;width:100%}
|
|
23
27
|
.date-controls{background:#101519;border-color:#252e33;padding:2px}.date-controls button{min-height:34px;min-width:45px;font-size:11px}.date-controls .selected{background:#252a2e;color:#eff0f1}
|
|
28
|
+
.period-selection{display:flex;align-items:center;gap:6px;min-width:0}.period-arrow{display:inline-flex;align-items:center;justify-content:center;width:34px;min-height:38px;padding:0;border:1px solid #252e33;border-radius:4px;background:#101519;color:var(--muted);flex-shrink:0}.period-arrow:hover:not(:disabled){color:var(--green);border-color:#3c5145}.period-arrow svg{width:16px;height:16px}.period-arrow:disabled{cursor:default}@media(max-width:760px){.period-selection{width:100%;gap:5px}.period-selection .date-controls{flex:1;min-width:0}.period-selection .date-controls button{min-width:0;padding:0 5px}.period-arrow{width:44px;min-height:46px}}
|
|
24
29
|
.app-main{padding:23px 25px 25px;min-height:0}.page-heading{margin-bottom:13px}.page-heading h1{font-size:19px;letter-spacing:-.4px;font-weight:550}
|
|
25
30
|
.coverage{border:0;padding-bottom:0;margin-bottom:20px;font-size:10px}.coverage summary{gap:7px}.coverage-content{border:1px solid var(--border);background:#101619;border-radius:5px;padding:18px;margin-top:12px;max-width:100%}
|
|
26
31
|
.overview-layout{grid-template-columns:minmax(0,1fr) 320px;gap:22px}.metrics{gap:12px;margin-bottom:16px}.metrics.single{grid-template-columns:minmax(0,1fr)}
|
|
@@ -67,7 +72,7 @@ dialog{background:#131a1e;border-color:#34453c}dialog::backdrop{background:#0306
|
|
|
67
72
|
dialog .subscription-fields{grid-column:1/-1}
|
|
68
73
|
dialog{max-height:calc(100dvh - 32px);overflow-y:auto}
|
|
69
74
|
|
|
70
|
-
.hero h1 .brand-name{font-weight:
|
|
75
|
+
.hero h1 .brand-name{font-weight:850;letter-spacing:-.065em}.hero h2 .hero-emphasis{color:#37ec85;font-weight:530}.hero h1 .brand-name-accent{color:#37ec85}.landing-nav .brand-name,.preview-topbar .brand-name{font-weight:800}
|
|
71
76
|
.landing-nav{max-width:1120px;padding:30px 32px;margin:auto;display:flex;justify-content:space-between;align-items:center}.github-link{font-size:12px;color:var(--muted);display:flex;gap:8px}.github-link span{color:var(--green)}.hero{text-align:center;padding:57px 24px 43px;display:flex;flex-direction:column;align-items:center}.hero>.eyebrow{color:#819989;margin-bottom:24px;font-size:9px}.hero h1{font-size:61px;letter-spacing:-3px;font-weight:550;margin-bottom:17px;line-height:1.05}.hero h2{font-size:37px;line-height:1.22;letter-spacing:-1.25px;font-weight:450}.hero p{font-size:13px;max-width:450px;margin:20px 0 27px;line-height:1.65}.install-command{display:flex;align-items:center;justify-content:space-between;gap:32px;background:#151c17;border:1px solid #354339;border-radius:5px;padding:14px 14px 14px 20px;min-width:340px;max-width:100%}.install-command code{font:14px var(--mono);white-space:nowrap;color:#edf3ef}.install-command code>span{color:var(--green);margin-right:5px}.copy-command{border:0;background:none;color:var(--muted);display:flex;padding:4px;min-height:28px;align-items:center}.copy-command:hover{color:var(--green)}.copy-command svg{width:16px;height:16px}.trust-row{display:flex;justify-content:center;gap:25px;margin-top:22px;color:#a3b0a6;font-size:10px}.trust-row span{white-space:nowrap}.release-note{font:9px var(--mono);color:var(--muted);margin-top:16px}.product-preview{max-width:1120px;margin:13px auto 0;padding:0 32px}.preview-label{display:flex;justify-content:space-between;font:8px var(--mono);letter-spacing:1.2px;color:var(--muted);margin-bottom:12px}.preview-window{border:1px solid #35473b;border-radius:9px;background:#0d130f;overflow:hidden;box-shadow:0 24px 64px #0006}.preview-topbar{display:flex;align-items:center;gap:29px;padding:20px 24px;border-bottom:1px solid var(--border);font-size:10px;color:var(--muted)}.preview-topbar .brand{font-size:16px;margin-right:20px}.preview-topbar .brand-mark{font-size:29px}.preview-tab{color:var(--text);position:relative}.preview-tab:after{content:'';height:2px;background:var(--green);position:absolute;left:0;right:0;bottom:-23px}.preview-date{margin-left:auto;font:8px var(--mono)}.preview-body{display:grid;grid-template-columns:1fr 245px;gap:22px;padding:25px}.preview-main{min-width:0}.preview-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:16px}.preview-metrics>div{display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:16px 12px;gap:14px;min-width:0}.preview-metrics>div:first-child{border-top:2px solid var(--green);padding-top:15px}.preview-metrics span{font-size:8px;color:var(--muted)}.preview-metrics strong{font-size:24px;letter-spacing:-1px;font-weight:500;font-variant-numeric:tabular-nums}.green-text{color:var(--green)}.preview-provider{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:19px;display:flex;align-items:center;gap:14px;margin-bottom:16px}.preview-provider-mark{font-size:32px}.preview-provider>div{display:flex;flex-direction:column;gap:6px}.preview-provider strong{font-size:12px;font-weight:500}.preview-provider>div span{font-size:9px;color:var(--muted)}.preview-provider b{margin-left:auto;font-size:20px;font-weight:450;letter-spacing:-.5px}.preview-active{font:8px var(--mono);background:#153020;border:1px solid #284f35;color:var(--green);padding:4px 6px;border-radius:3px}.preview-chart{padding:20px;background:var(--panel);border:1px solid var(--border);border-radius:4px}.preview-chart>div:first-child{display:flex;justify-content:space-between;gap:12px;font-size:9px;margin-bottom:19px}.preview-chart strong{font-size:11px;font-weight:450}.preview-chart>div:first-child span{color:var(--green);font-size:8px}.preview-bars{height:143px;display:flex;align-items:end;gap:6px;border-bottom:1px solid #3a443d;background:repeating-linear-gradient(to top,transparent,transparent 35px,#263029 36px,transparent 37px)}.preview-bars i{background:var(--green);opacity:.87;flex:1;border-radius:1px 1px 0 0;min-width:0}.preview-chart-axis{display:flex;justify-content:space-between;font:8px var(--mono);color:var(--muted);margin-top:12px}.preview-bottom{display:flex;justify-content:space-between;gap:10px;margin-top:20px;font:7px var(--mono);letter-spacing:.6px;color:var(--muted)}.preview-receipt{display:flex;flex-direction:column;justify-content:start;gap:13px}.preview-receipt canvas{width:100%;display:block}.preview-receipt>span{background:var(--green);color:#12241a;border-radius:3px;text-align:center;padding:11px;font-size:10px;font-weight:600}.landing-privacy{max-width:1056px;margin:82px auto 0;padding:47px 0 49px;border-top:1px solid var(--border);border-bottom:1px solid var(--border);display:grid;grid-template-columns:1fr 1fr;gap:20px}.landing-privacy .eyebrow{grid-column:1/-1}.landing-privacy h2{font-size:31px;line-height:1.25;letter-spacing:-1px;font-weight:450}.landing-privacy>div{display:grid;grid-template-columns:1fr 1fr;align-content:center;gap:20px;font-size:12px;color:var(--muted)}.landing-privacy>div span:before{content:'✓';color:var(--green);margin-right:10px}.install-section{display:flex;flex-direction:column;align-items:center;padding:54px 24px 64px}.install-section .eyebrow{margin-bottom:22px}.landing-footer{max-width:1056px;margin:auto;padding:23px 0 27px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:20px}.landing-footer .brand{font-size:15px}.landing-footer .brand-mark{font-size:30px}.landing-footer>span:last-child{font:8px var(--mono);letter-spacing:1px;color:var(--muted)}.copy-status{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);font:12px var(--mono);background:#223c2b;border-radius:4px;color:var(--green)}.copy-status:not(:empty){padding:10px 18px}
|
|
72
77
|
@media(max-width:1100px){.landing-privacy,.landing-footer{margin-left:32px;margin-right:32px}.preview-metrics strong{font-size:20px}.preview-body{grid-template-columns:1fr 215px;gap:17px;padding:20px}.preview-topbar{gap:23px}.preview-active{display:none}.preview-metrics{gap:8px}.preview-metrics>div{padding:14px 10px}.preview-metrics span{font-size:7px}}
|
|
73
78
|
@media(max-width:760px){.landing-nav{padding:24px}.hero{padding:42px 24px 35px}.hero h1{font-size:49px;letter-spacing:-2px;margin-bottom:18px}.hero h2{font-size:29px;letter-spacing:-1px}.hero p{font-size:12px;max-width:350px;line-height:1.75}.hero>.eyebrow{font-size:8px}.install-command{min-width:0;width:340px;max-width:100%;gap:17px;padding:14px 13px 14px 17px}.install-command code{font-size:12px}.trust-row{gap:21px;font-size:9px}.product-preview{padding:0 20px;margin-top:13px}.preview-label{font-size:6px;letter-spacing:.8px}.preview-topbar{padding:18px;gap:19px;font-size:8px}.preview-topbar .brand{font-size:14px;margin-right:0}.preview-topbar>span:not(.brand):not(.preview-date){display:none}.preview-date{font-size:8px}.preview-body{grid-template-columns:1fr;padding:15px;gap:20px}.preview-metrics{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.preview-metrics>div{padding:15px;gap:12px}.preview-metrics strong{font-size:25px}.preview-metrics span{font-size:9px}.preview-provider{padding:17px}.preview-provider b{font-size:20px}.preview-provider strong{font-size:12px}.preview-chart{padding:16px}.preview-bars{gap:4px;height:118px}.preview-bottom{font-size:6px;line-height:1.6;margin-top:15px}.preview-receipt{max-width:290px;width:100%;margin:auto}.preview-receipt>span{font-size:11px;padding:12px}.landing-privacy{margin:50px 24px 0;padding:32px 0;grid-template-columns:1fr;gap:18px}.landing-privacy h2{font-size:28px}.landing-privacy>div{margin-top:12px;font-size:11px;gap:18px}.install-section{padding:43px 24px 49px}.landing-footer{margin:0 24px;padding:22px 0}.landing-footer>span:last-child{font-size:6px;letter-spacing:.3px}.hero .release-note{font-size:8px}}
|
|
@@ -84,3 +89,8 @@ dialog{max-height:calc(100dvh - 32px);overflow-y:auto}
|
|
|
84
89
|
.preview-providers{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:16px}.preview-providers .preview-provider{display:grid;grid-template-columns:30px minmax(0,1fr) auto;gap:8px 12px;margin:0;padding:17px}.preview-providers .preview-provider-mark{grid-column:1;grid-row:1}.preview-providers .preview-provider>div{grid-column:2;grid-row:1}.preview-providers .preview-provider b{grid-column:2/-1;grid-row:2;margin:0;font-variant-numeric:tabular-nums}.preview-providers .preview-active{grid-column:3;grid-row:1}.preview-legend{display:flex;gap:12px;align-items:center;flex-wrap:wrap;justify-content:flex-end}.preview-chart .preview-legend .claude{color:#7aade7}.preview-bars i{display:flex;flex-direction:column;background:none;overflow:hidden}.preview-codex-bar{height:70%;background:linear-gradient(to bottom,#37ec85,#278451)}.preview-claude-bar{height:30%;background:#6799ce}
|
|
85
90
|
@media(max-width:1000px){.preview-providers{grid-template-columns:1fr}.preview-providers .preview-provider b{grid-column:3;grid-row:1;align-self:center}.preview-chart .preview-legend{gap:8px}}
|
|
86
91
|
.privacy-details{margin-top:18px;color:var(--muted);font-size:11px}.privacy-details summary{cursor:pointer;width:max-content}.privacy-details p{max-width:440px;margin-top:10px;font-size:11px;line-height:1.6}.privacy-details a{text-decoration:underline;text-underline-offset:3px}
|
|
92
|
+
.hero h1{line-height:1;margin-bottom:25px}.hero h1 .brand-wordmark{width:312px;height:42px}
|
|
93
|
+
.hero h1 .brand-cursor{animation:terminal-cursor 1.1s step-end infinite}
|
|
94
|
+
@keyframes terminal-cursor{0%,100%{opacity:1}50%{opacity:0}}
|
|
95
|
+
@media(prefers-reduced-motion:reduce){.hero h1 .brand-cursor{animation:none}}
|
|
96
|
+
@media(max-width:760px){.hero h1 .brand-wordmark{width:260px;height:35px}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="brand-wordmark" viewBox="0 0 46 7" fill="currentColor" shape-rendering="crispEdges" aria-hidden="true"><g><rect x="1" y="0" width="1" height="1"/><rect x="2" y="0" width="1" height="1"/><rect x="3" y="0" width="1" height="1"/><rect x="4" y="0" width="1" height="1"/><rect x="0" y="1" width="1" height="1"/><rect x="0" y="2" width="1" height="1"/><rect x="1" y="3" width="1" height="1"/><rect x="2" y="3" width="1" height="1"/><rect x="3" y="3" width="1" height="1"/><rect x="4" y="4" width="1" height="1"/><rect x="4" y="5" width="1" height="1"/><rect x="0" y="6" width="1" height="1"/><rect x="1" y="6" width="1" height="1"/><rect x="2" y="6" width="1" height="1"/><rect x="3" y="6" width="1" height="1"/></g><g><rect x="6" y="0" width="1" height="1"/><rect x="10" y="0" width="1" height="1"/><rect x="6" y="1" width="1" height="1"/><rect x="10" y="1" width="1" height="1"/><rect x="6" y="2" width="1" height="1"/><rect x="10" y="2" width="1" height="1"/><rect x="6" y="3" width="1" height="1"/><rect x="10" y="3" width="1" height="1"/><rect x="6" y="4" width="1" height="1"/><rect x="10" y="4" width="1" height="1"/><rect x="6" y="5" width="1" height="1"/><rect x="10" y="5" width="1" height="1"/><rect x="7" y="6" width="1" height="1"/><rect x="8" y="6" width="1" height="1"/><rect x="9" y="6" width="1" height="1"/></g><g><rect x="12" y="0" width="1" height="1"/><rect x="13" y="0" width="1" height="1"/><rect x="14" y="0" width="1" height="1"/><rect x="15" y="0" width="1" height="1"/><rect x="12" y="1" width="1" height="1"/><rect x="16" y="1" width="1" height="1"/><rect x="12" y="2" width="1" height="1"/><rect x="16" y="2" width="1" height="1"/><rect x="12" y="3" width="1" height="1"/><rect x="13" y="3" width="1" height="1"/><rect x="14" y="3" width="1" height="1"/><rect x="15" y="3" width="1" height="1"/><rect x="12" y="4" width="1" height="1"/><rect x="16" y="4" width="1" height="1"/><rect x="12" y="5" width="1" height="1"/><rect x="16" y="5" width="1" height="1"/><rect x="12" y="6" width="1" height="1"/><rect x="13" y="6" width="1" height="1"/><rect x="14" y="6" width="1" height="1"/><rect x="15" y="6" width="1" height="1"/></g><g><rect x="18" y="0" width="1" height="1"/><rect x="22" y="0" width="1" height="1"/><rect x="18" y="1" width="1" height="1"/><rect x="22" y="1" width="1" height="1"/><rect x="18" y="2" width="1" height="1"/><rect x="22" y="2" width="1" height="1"/><rect x="18" y="3" width="1" height="1"/><rect x="22" y="3" width="1" height="1"/><rect x="18" y="4" width="1" height="1"/><rect x="22" y="4" width="1" height="1"/><rect x="19" y="5" width="1" height="1"/><rect x="21" y="5" width="1" height="1"/><rect x="20" y="6" width="1" height="1"/></g><g><rect x="25" y="0" width="1" height="1"/><rect x="26" y="0" width="1" height="1"/><rect x="27" y="0" width="1" height="1"/><rect x="24" y="1" width="1" height="1"/><rect x="28" y="1" width="1" height="1"/><rect x="24" y="2" width="1" height="1"/><rect x="28" y="2" width="1" height="1"/><rect x="24" y="3" width="1" height="1"/><rect x="25" y="3" width="1" height="1"/><rect x="26" y="3" width="1" height="1"/><rect x="27" y="3" width="1" height="1"/><rect x="28" y="3" width="1" height="1"/><rect x="24" y="4" width="1" height="1"/><rect x="28" y="4" width="1" height="1"/><rect x="24" y="5" width="1" height="1"/><rect x="28" y="5" width="1" height="1"/><rect x="24" y="6" width="1" height="1"/><rect x="28" y="6" width="1" height="1"/></g><g><rect x="30" y="0" width="1" height="1"/><rect x="30" y="1" width="1" height="1"/><rect x="30" y="2" width="1" height="1"/><rect x="30" y="3" width="1" height="1"/><rect x="30" y="4" width="1" height="1"/><rect x="30" y="5" width="1" height="1"/><rect x="30" y="6" width="1" height="1"/><rect x="31" y="6" width="1" height="1"/><rect x="32" y="6" width="1" height="1"/><rect x="33" y="6" width="1" height="1"/></g><g><rect x="35" y="0" width="1" height="1"/><rect x="39" y="0" width="1" height="1"/><rect x="35" y="1" width="1" height="1"/><rect x="39" y="1" width="1" height="1"/><rect x="35" y="2" width="1" height="1"/><rect x="39" y="2" width="1" height="1"/><rect x="35" y="3" width="1" height="1"/><rect x="39" y="3" width="1" height="1"/><rect x="35" y="4" width="1" height="1"/><rect x="39" y="4" width="1" height="1"/><rect x="35" y="5" width="1" height="1"/><rect x="39" y="5" width="1" height="1"/><rect x="36" y="6" width="1" height="1"/><rect x="37" y="6" width="1" height="1"/><rect x="38" y="6" width="1" height="1"/></g><g><rect x="41" y="0" width="1" height="1"/><rect x="42" y="0" width="1" height="1"/><rect x="43" y="0" width="1" height="1"/><rect x="44" y="0" width="1" height="1"/><rect x="45" y="0" width="1" height="1"/><rect x="41" y="1" width="1" height="1"/><rect x="41" y="2" width="1" height="1"/><rect x="41" y="3" width="1" height="1"/><rect x="42" y="3" width="1" height="1"/><rect x="43" y="3" width="1" height="1"/><rect x="44" y="3" width="1" height="1"/><rect x="41" y="4" width="1" height="1"/><rect x="41" y="5" width="1" height="1"/><rect x="41" y="6" width="1" height="1"/><rect x="42" y="6" width="1" height="1"/><rect x="43" y="6" width="1" height="1"/><rect x="44" y="6" width="1" height="1"/><rect x="45" y="6" width="1" height="1"/></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="brand-wordmark" viewBox="0 0 46 7" fill="#eef2f4" shape-rendering="crispEdges" aria-hidden="true"><g><rect x="1" y="0" width="1" height="1"/><rect x="2" y="0" width="1" height="1"/><rect x="3" y="0" width="1" height="1"/><rect x="4" y="0" width="1" height="1"/><rect x="0" y="1" width="1" height="1"/><rect x="0" y="2" width="1" height="1"/><rect x="1" y="3" width="1" height="1"/><rect x="2" y="3" width="1" height="1"/><rect x="3" y="3" width="1" height="1"/><rect x="4" y="4" width="1" height="1"/><rect x="4" y="5" width="1" height="1"/><rect x="0" y="6" width="1" height="1"/><rect x="1" y="6" width="1" height="1"/><rect x="2" y="6" width="1" height="1"/><rect x="3" y="6" width="1" height="1"/></g><g><rect x="6" y="0" width="1" height="1"/><rect x="10" y="0" width="1" height="1"/><rect x="6" y="1" width="1" height="1"/><rect x="10" y="1" width="1" height="1"/><rect x="6" y="2" width="1" height="1"/><rect x="10" y="2" width="1" height="1"/><rect x="6" y="3" width="1" height="1"/><rect x="10" y="3" width="1" height="1"/><rect x="6" y="4" width="1" height="1"/><rect x="10" y="4" width="1" height="1"/><rect x="6" y="5" width="1" height="1"/><rect x="10" y="5" width="1" height="1"/><rect x="7" y="6" width="1" height="1"/><rect x="8" y="6" width="1" height="1"/><rect x="9" y="6" width="1" height="1"/></g><g><rect x="12" y="0" width="1" height="1"/><rect x="13" y="0" width="1" height="1"/><rect x="14" y="0" width="1" height="1"/><rect x="15" y="0" width="1" height="1"/><rect x="12" y="1" width="1" height="1"/><rect x="16" y="1" width="1" height="1"/><rect x="12" y="2" width="1" height="1"/><rect x="16" y="2" width="1" height="1"/><rect x="12" y="3" width="1" height="1"/><rect x="13" y="3" width="1" height="1"/><rect x="14" y="3" width="1" height="1"/><rect x="15" y="3" width="1" height="1"/><rect x="12" y="4" width="1" height="1"/><rect x="16" y="4" width="1" height="1"/><rect x="12" y="5" width="1" height="1"/><rect x="16" y="5" width="1" height="1"/><rect x="12" y="6" width="1" height="1"/><rect x="13" y="6" width="1" height="1"/><rect x="14" y="6" width="1" height="1"/><rect x="15" y="6" width="1" height="1"/></g><g fill="#37ec85"><rect x="18" y="0" width="1" height="1"/><rect x="22" y="0" width="1" height="1"/><rect x="18" y="1" width="1" height="1"/><rect x="22" y="1" width="1" height="1"/><rect x="18" y="2" width="1" height="1"/><rect x="22" y="2" width="1" height="1"/><rect x="18" y="3" width="1" height="1"/><rect x="22" y="3" width="1" height="1"/><rect x="18" y="4" width="1" height="1"/><rect x="22" y="4" width="1" height="1"/><rect x="19" y="5" width="1" height="1"/><rect x="21" y="5" width="1" height="1"/><rect x="20" y="6" width="1" height="1"/></g><g fill="#37ec85"><rect x="25" y="0" width="1" height="1"/><rect x="26" y="0" width="1" height="1"/><rect x="27" y="0" width="1" height="1"/><rect x="24" y="1" width="1" height="1"/><rect x="28" y="1" width="1" height="1"/><rect x="24" y="2" width="1" height="1"/><rect x="28" y="2" width="1" height="1"/><rect x="24" y="3" width="1" height="1"/><rect x="25" y="3" width="1" height="1"/><rect x="26" y="3" width="1" height="1"/><rect x="27" y="3" width="1" height="1"/><rect x="28" y="3" width="1" height="1"/><rect x="24" y="4" width="1" height="1"/><rect x="28" y="4" width="1" height="1"/><rect x="24" y="5" width="1" height="1"/><rect x="28" y="5" width="1" height="1"/><rect x="24" y="6" width="1" height="1"/><rect x="28" y="6" width="1" height="1"/></g><g fill="#37ec85"><rect x="30" y="0" width="1" height="1"/><rect x="30" y="1" width="1" height="1"/><rect x="30" y="2" width="1" height="1"/><rect x="30" y="3" width="1" height="1"/><rect x="30" y="4" width="1" height="1"/><rect x="30" y="5" width="1" height="1"/><rect x="30" y="6" width="1" height="1"/><rect x="31" y="6" width="1" height="1"/><rect x="32" y="6" width="1" height="1"/><rect x="33" y="6" width="1" height="1"/></g><g fill="#37ec85"><rect x="35" y="0" width="1" height="1"/><rect x="39" y="0" width="1" height="1"/><rect x="35" y="1" width="1" height="1"/><rect x="39" y="1" width="1" height="1"/><rect x="35" y="2" width="1" height="1"/><rect x="39" y="2" width="1" height="1"/><rect x="35" y="3" width="1" height="1"/><rect x="39" y="3" width="1" height="1"/><rect x="35" y="4" width="1" height="1"/><rect x="39" y="4" width="1" height="1"/><rect x="35" y="5" width="1" height="1"/><rect x="39" y="5" width="1" height="1"/><rect x="36" y="6" width="1" height="1"/><rect x="37" y="6" width="1" height="1"/><rect x="38" y="6" width="1" height="1"/></g><g fill="#37ec85"><rect x="41" y="0" width="1" height="1"/><rect x="42" y="0" width="1" height="1"/><rect x="43" y="0" width="1" height="1"/><rect x="44" y="0" width="1" height="1"/><rect x="45" y="0" width="1" height="1"/><rect x="41" y="1" width="1" height="1"/><rect x="41" y="2" width="1" height="1"/><rect x="41" y="3" width="1" height="1"/><rect x="42" y="3" width="1" height="1"/><rect x="43" y="3" width="1" height="1"/><rect x="44" y="3" width="1" height="1"/><rect x="41" y="4" width="1" height="1"/><rect x="41" y="5" width="1" height="1"/><rect x="41" y="6" width="1" height="1"/><rect x="42" y="6" width="1" height="1"/><rect x="43" y="6" width="1" height="1"/><rect x="44" y="6" width="1" height="1"/><rect x="45" y="6" width="1" height="1"/></g></svg>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Count whole local calendar months; rolling presets are never subscription comparisons. */
|
|
2
|
+
export function fullCalendarMonths(range ) {
|
|
3
|
+
if(range.preset==='7d'||range.preset==='30d')return null;
|
|
4
|
+
// Browser timezone overrides must not change the server's calendar selection.
|
|
5
|
+
if(range.calendarMonths!==undefined)return Number.isInteger(range.calendarMonths)&&range.calendarMonths >0&&range.calendarMonths <=1200?range.calendarMonths:null;
|
|
6
|
+
const start=new Date(range.from),end=new Date(range.until);
|
|
7
|
+
const boundary=(date )=>Number.isFinite(+date)&&date.getDate()===1&&date.getHours()===0&&date.getMinutes()===0&&date.getSeconds()===0&&date.getMilliseconds()===0;
|
|
8
|
+
if(!boundary(start)||!boundary(end))return null;
|
|
9
|
+
const months=(end.getFullYear()-start.getFullYear())*12+end.getMonth()-start.getMonth();
|
|
10
|
+
return months>0&&months<=1200?months:null;
|
|
11
|
+
}
|
|
12
|
+
/** Civil-date movement uses UTC arithmetic so DST never drops or repeats a day. */
|
|
13
|
+
export function shiftCalendarDays(day ,days ) {
|
|
14
|
+
if(!/^\d{4}-\d{2}-\d{2}$/.test(day)||!Number.isInteger(days)||Math.abs(days)>36600)return null;
|
|
15
|
+
const date=new Date(day+'T12:00:00.000Z');
|
|
16
|
+
if(!Number.isFinite(+date)||date.toISOString().slice(0,10)!==day)return null;
|
|
17
|
+
date.setUTCDate(date.getUTCDate()+days);
|
|
18
|
+
const result=date.toISOString().slice(0,10);return /^\d{4}-\d{2}-\d{2}$/.test(result)?result:null;
|
|
19
|
+
}
|
|
20
|
+
export function shiftCalendarMonth(month ,months ) {
|
|
21
|
+
if(!/^(?:19|20|21)\d{2}-(?:0[1-9]|1[0-2])$/.test(month)||!Number.isInteger(months))return null;
|
|
22
|
+
const ordinal=Number(month.slice(0,4))*12+Number(month.slice(5))-1+months,year=Math.floor(ordinal/12);
|
|
23
|
+
return year>=1900&&year<=2199?`${year}-${String(ordinal%12+1).padStart(2,'0')}`:null;
|
|
24
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
|
|
2
|
+
import {fullCalendarMonths} from '../../core/src/calendar.js';
|
|
2
3
|
import {product} from '../../ui/src/brand.js';
|
|
3
4
|
|
|
4
5
|
|
|
@@ -6,6 +7,7 @@ export const money=(n ,signed=false) =>n===null?'Unavailable':`
|
|
|
6
7
|
/** Compare only the displayed priced amount; preserve uncertainty and billing eligibility. */
|
|
7
8
|
export function comparisonForDisplay(s ) {
|
|
8
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'};
|
|
9
11
|
if(amount===null||comparison.subscription===null)return comparison;
|
|
10
12
|
return {...comparison,value:comparison.value??amount-comparison.subscription,roi:comparison.roi??(comparison.subscription>0?amount/comparison.subscription:null)};
|
|
11
13
|
}
|
|
@@ -26,15 +28,16 @@ export function receiptModel(s ,generatedAt=new Date()) {
|
|
|
26
28
|
const value=comp.value;
|
|
27
29
|
if(value!==null)rows.push({left:'You Saved',right:money(value,true),accent:value>=0?'positive':'negative',strong:true});
|
|
28
30
|
// With partial pricing, compare only the printed known amount to the declared
|
|
29
|
-
//
|
|
31
|
+
// full-month subscription. The coverage footer stays visible; no outcome is inferred.
|
|
30
32
|
const multiple=comp.roi;
|
|
31
33
|
if(multiple!==null)rows.push({left:'',right:''},{left:'VALUE MULTIPLE',right:`${multiple.toFixed(2)}×`,accent:multiple>=1?'positive':'negative',strong:true});
|
|
32
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()});
|
|
33
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 · ':'';
|
|
34
37
|
const coverage=s.total.apiEquivalent===null&&s.total.priceCoverage!==null?`${Math.round(s.total.priceCoverage*100)}% priced · `:'';
|
|
35
38
|
const date=(value )=>new Date(value).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
|
|
36
39
|
const generated=generatedAt.toLocaleString('en-US',{month:'short',day:'numeric',year:'numeric',hour:'numeric',minute:'2-digit',hour12:true}).replace(' at ', ' ');
|
|
37
|
-
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?`${coverage}API equivalent · Partial history`:`${coverage}API equivalent · ${s.confidence==='HIGH'?'High confidence':'Incomplete'}`,demo:s.demo};
|
|
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};
|
|
38
41
|
}
|
|
39
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"')]);}
|
|
40
43
|
const paperTextures=new WeakMap ();
|
|
@@ -3,7 +3,37 @@ export const product = {name:'SubValue',site:'subvalue.dev',exportBasename:'subv
|
|
|
3
3
|
// Typography remains separate from the neutral symbol and follows the display name.
|
|
4
4
|
const nameParts = product.name.split(/(?=[A-Z])/);
|
|
5
5
|
const escapeName = (value ) => value.replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character] ));
|
|
6
|
-
|
|
6
|
+
// 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']
|
|
15
|
+
};
|
|
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>`;
|
|
27
|
+
}
|
|
28
|
+
// A future rename automatically uses readable text if new glyphs are needed.
|
|
29
|
+
function nameMarkup(withCursor=false){
|
|
30
|
+
const wordmark=wordmarkSvg(false,withCursor);
|
|
31
|
+
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>`;
|
|
34
|
+
}
|
|
35
|
+
export const productNameMarkup=nameMarkup();
|
|
36
|
+
export const productTitleMarkup=nameMarkup(true);
|
|
7
37
|
// Opposed measurement corners. No letter, currency sign or provider-derived geometry.
|
|
8
38
|
export const markPath = 'M4 18V4h14v4H8v10H4Zm10 6h10V14h4v14H14v-4Z';
|
|
9
39
|
export const productMark = `<svg viewBox="0 0 32 32" fill="currentColor" aria-hidden="true"><path d="${markPath}"/></svg>`;
|
package/dist/public/privacy.css
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/* Both the public page and local app use the same receipt renderer and tokens. */
|
|
2
2
|
@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:block;src:url('/fonts/inter-latin.woff2') format('woff2')}
|
|
3
3
|
@font-face{font-family:'Plex Mono';font-style:normal;font-weight:400 700;font-display:block;src:url('/fonts/plex-mono.woff2') format('woff2')}
|
|
4
|
-
.brand-name{color:#
|
|
4
|
+
.brand-name{display:inline-flex;align-items:center;position:relative;color:#eef2f4;font-family:Inter,sans-serif;font-weight:800;letter-spacing:-.055em}.brand-name-accent{color:#37ec85}
|
|
5
|
+
.brand-name-label{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
|
6
|
+
.brand-wordmark{display:block;width:92px;height:14px;overflow:visible}
|
|
7
|
+
@media(forced-colors:active){.brand-name,.brand-name-accent{color:CanvasText}}
|
|
5
8
|
|
|
6
9
|
:root{color-scheme:dark;--bg:#080b0d;--text:#eff0f1;--muted:#b2b7bd;--border:#25282d;--green:#37ec85}
|
|
7
10
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px Inter,"Segoe UI",Arial,sans-serif;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}a:focus-visible{outline:2px solid var(--green);outline-offset:5px}a:hover{color:var(--green)}.privacy-nav{max-width:880px;margin:auto;padding:28px 32px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}.brand{display:flex;align-items:center;gap:11px;font-size:19px;font-weight:650;letter-spacing:-.5px}.privacy-back{color:var(--muted);font-size:13px;min-height:44px;display:flex;align-items:center}.privacy-document{max-width:784px;margin:auto;padding:50px 32px 40px}.privacy-document h1{font-size:32px;font-weight:550;letter-spacing:-1px;line-height:1.2;margin:0 0 22px}.privacy-document h2{font-size:18px;font-weight:550;letter-spacing:-.3px;margin:40px 0 16px;padding-top:26px;border-top:1px solid var(--border)}.privacy-document p,.privacy-document ul{color:var(--muted);line-height:1.85;margin:0 0 18px}.privacy-document ul{padding-left:21px}.privacy-document li{padding-left:4px;margin-bottom:8px}.privacy-document code{font:12px "Plex Mono",Consolas,monospace;color:var(--text);background:#13171c;padding:2px 5px;border:1px solid #22282e;border-radius:3px;overflow-wrap:anywhere}.privacy-document p a{color:var(--text);text-decoration:underline;text-underline-offset:4px}.privacy-document footer{margin-top:38px;padding-top:22px;border-top:1px solid var(--border);font-size:12px;color:var(--muted)}.privacy-skip{position:fixed;top:-100px;left:20px;background:var(--green);color:#080b0d;padding:12px;z-index:2}.privacy-skip:focus{top:12px}
|
package/dist/public/privacy.html
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#080b0d"><title>Privacy and data access — SubValue</title><link rel="icon" href="/icon.svg"><link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin><link rel="preload" href="/fonts/plex-mono.woff2" as="font" type="font/woff2" crossorigin><link rel="stylesheet" href="/privacy.css"></head><body class="privacy-page"><a class="privacy-skip" href="#policy">Skip to content</a><header class="privacy-nav"><a class="brand" href="/"><img src="/logo/mark-green.svg" alt="" width="24" height="24">SubValue</a><a class="privacy-back" href="/">← Back</a></header><main id="policy" class="privacy-document" tabindex="-1"><article><h1>Privacy and data access</h1>
|
|
1
|
+
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#080b0d"><title>Privacy and data access — SubValue</title><link rel="icon" href="/icon.svg"><link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin><link rel="preload" href="/fonts/plex-mono.woff2" as="font" type="font/woff2" crossorigin><link rel="stylesheet" href="/privacy.css"></head><body class="privacy-page"><a class="privacy-skip" href="#policy">Skip to content</a><header class="privacy-nav"><a class="brand" href="/"><img src="/logo/mark-green.svg" alt="" width="24" height="24"><span class="brand-name"><span class="brand-name-label">SubValue</span><svg xmlns="http://www.w3.org/2000/svg" class="brand-wordmark" viewBox="0 0 46 7" fill="currentColor" shape-rendering="crispEdges" aria-hidden="true"><g><rect x="1" y="0" width="1" height="1"/><rect x="2" y="0" width="1" height="1"/><rect x="3" y="0" width="1" height="1"/><rect x="4" y="0" width="1" height="1"/><rect x="0" y="1" width="1" height="1"/><rect x="0" y="2" width="1" height="1"/><rect x="1" y="3" width="1" height="1"/><rect x="2" y="3" width="1" height="1"/><rect x="3" y="3" width="1" height="1"/><rect x="4" y="4" width="1" height="1"/><rect x="4" y="5" width="1" height="1"/><rect x="0" y="6" width="1" height="1"/><rect x="1" y="6" width="1" height="1"/><rect x="2" y="6" width="1" height="1"/><rect x="3" y="6" width="1" height="1"/></g><g><rect x="6" y="0" width="1" height="1"/><rect x="10" y="0" width="1" height="1"/><rect x="6" y="1" width="1" height="1"/><rect x="10" y="1" width="1" height="1"/><rect x="6" y="2" width="1" height="1"/><rect x="10" y="2" width="1" height="1"/><rect x="6" y="3" width="1" height="1"/><rect x="10" y="3" width="1" height="1"/><rect x="6" y="4" width="1" height="1"/><rect x="10" y="4" width="1" height="1"/><rect x="6" y="5" width="1" height="1"/><rect x="10" y="5" width="1" height="1"/><rect x="7" y="6" width="1" height="1"/><rect x="8" y="6" width="1" height="1"/><rect x="9" y="6" width="1" height="1"/></g><g><rect x="12" y="0" width="1" height="1"/><rect x="13" y="0" width="1" height="1"/><rect x="14" y="0" width="1" height="1"/><rect x="15" y="0" width="1" height="1"/><rect x="12" y="1" width="1" height="1"/><rect x="16" y="1" width="1" height="1"/><rect x="12" y="2" width="1" height="1"/><rect x="16" y="2" width="1" height="1"/><rect x="12" y="3" width="1" height="1"/><rect x="13" y="3" width="1" height="1"/><rect x="14" y="3" width="1" height="1"/><rect x="15" y="3" width="1" height="1"/><rect x="12" y="4" width="1" height="1"/><rect x="16" y="4" width="1" height="1"/><rect x="12" y="5" width="1" height="1"/><rect x="16" y="5" width="1" height="1"/><rect x="12" y="6" width="1" height="1"/><rect x="13" y="6" width="1" height="1"/><rect x="14" y="6" width="1" height="1"/><rect x="15" y="6" width="1" height="1"/></g><g class="brand-name-accent"><rect x="18" y="0" width="1" height="1"/><rect x="22" y="0" width="1" height="1"/><rect x="18" y="1" width="1" height="1"/><rect x="22" y="1" width="1" height="1"/><rect x="18" y="2" width="1" height="1"/><rect x="22" y="2" width="1" height="1"/><rect x="18" y="3" width="1" height="1"/><rect x="22" y="3" width="1" height="1"/><rect x="18" y="4" width="1" height="1"/><rect x="22" y="4" width="1" height="1"/><rect x="19" y="5" width="1" height="1"/><rect x="21" y="5" width="1" height="1"/><rect x="20" y="6" width="1" height="1"/></g><g class="brand-name-accent"><rect x="25" y="0" width="1" height="1"/><rect x="26" y="0" width="1" height="1"/><rect x="27" y="0" width="1" height="1"/><rect x="24" y="1" width="1" height="1"/><rect x="28" y="1" width="1" height="1"/><rect x="24" y="2" width="1" height="1"/><rect x="28" y="2" width="1" height="1"/><rect x="24" y="3" width="1" height="1"/><rect x="25" y="3" width="1" height="1"/><rect x="26" y="3" width="1" height="1"/><rect x="27" y="3" width="1" height="1"/><rect x="28" y="3" width="1" height="1"/><rect x="24" y="4" width="1" height="1"/><rect x="28" y="4" width="1" height="1"/><rect x="24" y="5" width="1" height="1"/><rect x="28" y="5" width="1" height="1"/><rect x="24" y="6" width="1" height="1"/><rect x="28" y="6" width="1" height="1"/></g><g class="brand-name-accent"><rect x="30" y="0" width="1" height="1"/><rect x="30" y="1" width="1" height="1"/><rect x="30" y="2" width="1" height="1"/><rect x="30" y="3" width="1" height="1"/><rect x="30" y="4" width="1" height="1"/><rect x="30" y="5" width="1" height="1"/><rect x="30" y="6" width="1" height="1"/><rect x="31" y="6" width="1" height="1"/><rect x="32" y="6" width="1" height="1"/><rect x="33" y="6" width="1" height="1"/></g><g class="brand-name-accent"><rect x="35" y="0" width="1" height="1"/><rect x="39" y="0" width="1" height="1"/><rect x="35" y="1" width="1" height="1"/><rect x="39" y="1" width="1" height="1"/><rect x="35" y="2" width="1" height="1"/><rect x="39" y="2" width="1" height="1"/><rect x="35" y="3" width="1" height="1"/><rect x="39" y="3" width="1" height="1"/><rect x="35" y="4" width="1" height="1"/><rect x="39" y="4" width="1" height="1"/><rect x="35" y="5" width="1" height="1"/><rect x="39" y="5" width="1" height="1"/><rect x="36" y="6" width="1" height="1"/><rect x="37" y="6" width="1" height="1"/><rect x="38" y="6" width="1" height="1"/></g><g class="brand-name-accent"><rect x="41" y="0" width="1" height="1"/><rect x="42" y="0" width="1" height="1"/><rect x="43" y="0" width="1" height="1"/><rect x="44" y="0" width="1" height="1"/><rect x="45" y="0" width="1" height="1"/><rect x="41" y="1" width="1" height="1"/><rect x="41" y="2" width="1" height="1"/><rect x="41" y="3" width="1" height="1"/><rect x="42" y="3" width="1" height="1"/><rect x="43" y="3" width="1" height="1"/><rect x="44" y="3" width="1" height="1"/><rect x="41" y="4" width="1" height="1"/><rect x="41" y="5" width="1" height="1"/><rect x="41" y="6" width="1" height="1"/><rect x="42" y="6" width="1" height="1"/><rect x="43" y="6" width="1" height="1"/><rect x="44" y="6" width="1" height="1"/><rect x="45" y="6" width="1" height="1"/></g></svg></span></a><a class="privacy-back" href="/">← Back</a></header><main id="policy" class="privacy-document" tabindex="-1"><article><h1>Privacy and data access</h1>
|
|
2
2
|
<p>SubValue processes usage on the user's machine. It has no account, cloud usage database, telemetry SDK or runtime upload endpoint.</p>
|
|
3
3
|
<h2>Files read</h2>
|
|
4
4
|
<p>Only these provider sources are opened for usage analysis:</p>
|