token-larper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +334 -0
  2. package/Start-TokenLarper.vbs +5 -0
  3. package/bin/cli.js +34 -0
  4. package/llms.txt +59 -0
  5. package/package.json +61 -0
  6. package/scripts/docs-demo-server.ts +141 -0
  7. package/scripts/launch-on-default-desktop.ps1 +42 -0
  8. package/scripts/launch-silent.vbs +6 -0
  9. package/scripts/run-server.ps1 +59 -0
  10. package/scripts/tray-host.ps1 +181 -0
  11. package/src/ccusage.ts +1075 -0
  12. package/src/client/App.tsx +560 -0
  13. package/src/client/charts.tsx +341 -0
  14. package/src/client/components/Calendar.tsx +207 -0
  15. package/src/client/components/DateRangePicker.tsx +131 -0
  16. package/src/client/components/KpiCards.tsx +122 -0
  17. package/src/client/components/RankChip.tsx +22 -0
  18. package/src/client/components/SelectMenu.tsx +116 -0
  19. package/src/client/components/SettingsDialog.tsx +207 -0
  20. package/src/client/components/ThemeSettings.tsx +131 -0
  21. package/src/client/context.tsx +60 -0
  22. package/src/client/index.html +54 -0
  23. package/src/client/lib/aggregate.ts +266 -0
  24. package/src/client/lib/range.ts +87 -0
  25. package/src/client/lib/rank.ts +323 -0
  26. package/src/client/lib/shareCard.ts +139 -0
  27. package/src/client/logo.svg +8 -0
  28. package/src/client/main.tsx +12 -0
  29. package/src/client/styles.css +1113 -0
  30. package/src/client/themes.ts +268 -0
  31. package/src/client/utils.ts +61 -0
  32. package/src/client/views/Models.tsx +71 -0
  33. package/src/client/views/Overview.tsx +280 -0
  34. package/src/client/views/Projects.tsx +88 -0
  35. package/src/client/views/Rank.tsx +255 -0
  36. package/src/client/views/Sessions.tsx +108 -0
  37. package/src/client/views/Tools.tsx +114 -0
  38. package/src/projects.ts +214 -0
  39. package/src/server.ts +173 -0
  40. package/src/startup.ts +110 -0
  41. package/src/tray.ts +293 -0
  42. package/src/types.ts +155 -0
@@ -0,0 +1,323 @@
1
+ import type { DashboardPayload, HarnessId, TimePeriodRow } from "../../types.ts";
2
+ import { formatCompactNumber, formatCurrency, localDateKey } from "../utils.ts";
3
+ import { addDays, formatDay, formatSpan, parseDay, todayKey } from "./range.ts";
4
+
5
+ // Everything on the Rank tab is lifetime and computed from data the dashboard already
6
+ // has; filters never change it. Sessions can stay open for weeks (Antigravity keeps one
7
+ // conversation going for months), so records and badges are built from days, not sessions.
8
+
9
+ // ---------- Tiers ----------
10
+
11
+ export interface Tier {
12
+ level: number;
13
+ min: number;
14
+ title: string;
15
+ quip: string;
16
+ }
17
+
18
+ const TIER_LIST: [number, string, string][] = [
19
+ [0, "Free-Tier Tourist", "Barely warming up the GPU rack."],
20
+ [1e6, "Prompt Apprentice", "Still reads stack traces by hand."],
21
+ [10e6, "Context Goblin", "Stuffs whole files into the prompt like a raccoon at a buffet."],
22
+ [50e6, "Prompt Warlock", "Why write a five-line script when 40M tokens can do it?"],
23
+ [250e6, "Context Window Whale", "Their ~/.claude folder has its own gravitational field."],
24
+ [1e9, "Arch-Duke of Token Burn", "A billion tokens incinerated for one quick refactor."],
25
+ [2.5e9, "Giga-Token Sovereign", "Keeps an H100 warm at all times."],
26
+ [5e9, "Cache Lich", "No longer writes code. Summons it."],
27
+ [10e9, "Datacenter Warlord", "The utility company knows them by name."],
28
+ [25e9, "GPU Emperor", "Export controls were written with them in mind."],
29
+ [100e9, "The Final Boss of Inference", "There is nothing left to rank. Go outside."],
30
+ ];
31
+
32
+ export const TIERS: Tier[] = TIER_LIST.map(([min, title, quip], i) => ({ level: i + 1, min, title, quip }));
33
+
34
+ export interface RankState {
35
+ tier: Tier;
36
+ next: Tier | null;
37
+ /** 0–100 progress from this tier's floor to the next tier's. */
38
+ progress: number;
39
+ toNext: number;
40
+ }
41
+
42
+ export function rankFor(tokens: number): RankState {
43
+ let index = 0;
44
+ TIERS.forEach((t, i) => {
45
+ if (tokens >= t.min) index = i;
46
+ });
47
+ const tier = TIERS[index]!;
48
+ const next = TIERS[index + 1] ?? null;
49
+ const progress = next ? Math.min(100, ((tokens - tier.min) / (next.min - tier.min)) * 100) : 100;
50
+ return { tier, next, progress, toNext: next ? next.min - tokens : 0 };
51
+ }
52
+
53
+ // ---------- Rivals ----------
54
+
55
+ export interface Rival {
56
+ tokens: number;
57
+ name: string;
58
+ blurb: string;
59
+ }
60
+
61
+ /** Made-up opponents at lifetime-token milestones. */
62
+ export const RIVALS: Rival[] = [
63
+ { tokens: 2e6, name: "A curious intern", blurb: "Asked it for a haiku, once." },
64
+ { tokens: 8e6, name: "A weekend side project", blurb: "Still almost done." },
65
+ { tokens: 25e6, name: "The coworker who pastes into a chat window", blurb: "Copy, paste, repeat." },
66
+ { tokens: 60e6, name: "A hackathon team at 4am", blurb: "Twelve energy drinks, one demo." },
67
+ { tokens: 150e6, name: "A Discord bot with feelings", blurb: "Remembers everyone's birthday." },
68
+ { tokens: 400e6, name: "A SaaS support bot", blurb: "Has tried turning it off and on." },
69
+ { tokens: 800e6, name: "An agency selling AI transformation", blurb: "Bills by the slide." },
70
+ { tokens: 1.6e9, name: "A startup's entire demo day", blurb: "Pivoted to agents mid-pitch." },
71
+ { tokens: 3e9, name: "A CI pipeline that runs agents on every commit", blurb: "Nobody knows how to turn it off." },
72
+ { tokens: 6e9, name: "A Series A company that is basically a wrapper", blurb: "The wrapper has a wrapper." },
73
+ { tokens: 12e9, name: "An AI lab's intern cluster", blurb: "Unsupervised since March." },
74
+ { tokens: 30e9, name: "A frontier lab's eval suite", blurb: "Grades itself generously." },
75
+ { tokens: 75e9, name: "Every autocomplete in a Fortune 500", blurb: "Suggesting “Dear Sir or Madam”." },
76
+ { tokens: 200e9, name: "The internet's reply guys, combined", blurb: "Actually, …" },
77
+ { tokens: 1e12, name: "A sentient spreadsheet", blurb: "Has opinions about your formulas." },
78
+ ];
79
+
80
+ export function nextRival(tokens: number): Rival | null {
81
+ return RIVALS.find((r) => r.tokens > tokens) ?? null;
82
+ }
83
+
84
+ export function lastDefeated(tokens: number): Rival | null {
85
+ return [...RIVALS].reverse().find((r) => r.tokens <= tokens) ?? null;
86
+ }
87
+
88
+ // ---------- Lifetime stats ----------
89
+
90
+ export interface Streak {
91
+ days: number;
92
+ start: string;
93
+ end: string;
94
+ }
95
+
96
+ export interface LifetimeStats {
97
+ tokens: number;
98
+ verifiedCost: number;
99
+ estimatedCost: number;
100
+ activeDays: number;
101
+ days: TimePeriodRow[];
102
+ longestStreak: Streak | null;
103
+ currentStreak: Streak | null;
104
+ biggestDay: TimePeriodRow | null;
105
+ priciestDay: TimePeriodRow | null;
106
+ topTool: { id: HarnessId; tokens: number } | null;
107
+ toolCount: number;
108
+ topModel: string | null;
109
+ modelCount: number;
110
+ topProject: { name: string; tokens: number } | null;
111
+ lateNightSessions: number;
112
+ cacheRate: number;
113
+ }
114
+
115
+ function maxBy<T>(items: T[], score: (t: T) => number): T | null {
116
+ let best: T | null = null;
117
+ let bestScore = -Infinity;
118
+ for (const item of items) {
119
+ const s = score(item);
120
+ if (s > bestScore) {
121
+ best = item;
122
+ bestScore = s;
123
+ }
124
+ }
125
+ return best;
126
+ }
127
+
128
+ function hourOf(iso: string | undefined): number | null {
129
+ if (!iso) return null;
130
+ const d = new Date(iso);
131
+ return Number.isNaN(d.getTime()) ? null : d.getHours();
132
+ }
133
+
134
+ /** "Sep 14 – Sep 20, 2026" for the Monday-first week containing a day. */
135
+ export function weekSpan(day: string): string {
136
+ const monday = addDays(day, -((parseDay(day).getDay() + 6) % 7));
137
+ return formatSpan(monday, addDays(monday, 6));
138
+ }
139
+
140
+ export function lifetimeStats(data: DashboardPayload): LifetimeStats {
141
+ const days = data.daily.filter((d) => d.totalTokens > 0).sort((a, b) => a.period.localeCompare(b.period));
142
+
143
+ let longest: Streak | null = null;
144
+ let run: Streak | null = null;
145
+ for (const d of days) {
146
+ const prev: Streak | null = run;
147
+ const next: Streak =
148
+ prev && addDays(prev.end, 1) === d.period
149
+ ? { days: prev.days + 1, start: prev.start, end: d.period }
150
+ : { days: 1, start: d.period, end: d.period };
151
+ run = next;
152
+ if (!longest || next.days > longest.days) longest = next;
153
+ }
154
+ const today = todayKey();
155
+ const last: Streak | null = run;
156
+ const current = last && (last.end === today || last.end === addDays(today, -1)) ? last : null;
157
+
158
+ const projects = new Map<string, { name: string; tokens: number }>();
159
+ for (const s of data.sessions) {
160
+ if (!s.projectRoot) continue;
161
+ const p = projects.get(s.projectRoot) ?? { name: s.projectName ?? s.projectRoot, tokens: 0 };
162
+ p.tokens += s.totalTokens;
163
+ projects.set(s.projectRoot, p);
164
+ }
165
+
166
+ const hours = data.sessions.map((s) => [hourOf(s.firstActivity), hourOf(s.lastActivity)].filter((h): h is number => h !== null));
167
+ const timed = hours.filter((h) => h.length > 0);
168
+ const lateNight = timed.filter((h) => h.some((x) => x >= 2 && x < 5)).length;
169
+
170
+ const prompt = days.reduce((acc, d) => acc + d.inputTokens + d.cacheReadTokens + d.cacheCreationTokens, 0);
171
+ const cacheRead = days.reduce((acc, d) => acc + d.cacheReadTokens, 0);
172
+ const topTool = maxBy(data.harnesses, (h) => h.totalTokens);
173
+
174
+ return {
175
+ tokens: data.totals.totalTokens,
176
+ verifiedCost: data.totals.verifiedCost,
177
+ estimatedCost: data.totals.estimatedCost,
178
+ activeDays: days.length,
179
+ days,
180
+ longestStreak: longest,
181
+ currentStreak: current,
182
+ biggestDay: maxBy(days, (d) => d.totalTokens),
183
+ priciestDay: maxBy(days, (d) => d.verifiedCost),
184
+ topTool: topTool && topTool.totalTokens > 0 ? { id: topTool.meta.id, tokens: topTool.totalTokens } : null,
185
+ toolCount: data.harnesses.filter((h) => h.meta.hasUsage).length,
186
+ topModel: maxBy(data.models, (m) => m.totalTokens)?.modelName ?? null,
187
+ modelCount: new Set(data.models.map((m) => m.modelName)).size,
188
+ topProject: maxBy([...projects.values()], (p) => p.tokens),
189
+ lateNightSessions: lateNight,
190
+ cacheRate: prompt > 0 ? (cacheRead / prompt) * 100 : 0,
191
+ };
192
+ }
193
+
194
+ // ---------- Achievements ----------
195
+
196
+ export interface Achievement {
197
+ id: string;
198
+ name: string;
199
+ requirement: string;
200
+ earned: boolean;
201
+ /** When earned: what earned it. */
202
+ detail?: string;
203
+ progress: { current: number; target: number; label: string };
204
+ }
205
+
206
+ function isoWeekKey(day: string): string {
207
+ const d = parseDay(day);
208
+ const monday = new Date(d);
209
+ monday.setDate(d.getDate() - ((d.getDay() + 6) % 7));
210
+ return localDateKey(monday);
211
+ }
212
+
213
+ export function achievements(data: DashboardPayload, stats: LifetimeStats): Achievement[] {
214
+ const list: Achievement[] = [];
215
+ const add = (
216
+ id: string,
217
+ name: string,
218
+ requirement: string,
219
+ current: number,
220
+ target: number,
221
+ fmt: (n: number) => string,
222
+ detail?: string
223
+ ) =>
224
+ list.push({
225
+ id,
226
+ name,
227
+ requirement,
228
+ earned: current >= target,
229
+ detail: current >= target ? detail : undefined,
230
+ progress: { current: Math.min(current, target), target, label: `${fmt(Math.min(current, target))} / ${fmt(target)}` },
231
+ });
232
+ // Three significant digits reads cleaner in "206M / 500M" than the dashboard's fixed decimals.
233
+ const tokens = (n: number) => new Intl.NumberFormat("en-US", { notation: "compact", maximumSignificantDigits: 3 }).format(n);
234
+ const count = (n: number) => String(Math.round(n));
235
+ const dayOf = (d: TimePeriodRow | null) => (d ? formatDay(d.period, true) : undefined);
236
+
237
+ add("billion", "Billionaire", "Burn 1B lifetime tokens.", stats.tokens, 1e9, tokens, `${tokens(stats.tokens)} lifetime`);
238
+ add("ten-billion", "Ten Billion Club", "Burn 10B lifetime tokens.", stats.tokens, 10e9, tokens, `${tokens(stats.tokens)} lifetime`);
239
+
240
+ const streak = stats.longestStreak?.days ?? 0;
241
+ const streakSpan = stats.longestStreak ? formatSpan(stats.longestStreak.start, stats.longestStreak.end) : undefined;
242
+ add("streak-7", "Streak Keeper", "Use a coding agent 7 days in a row.", streak, 7, count, streakSpan);
243
+ add("streak-30", "Iron Streak", "Use a coding agent 30 days in a row.", streak, 30, count, streakSpan);
244
+
245
+ add("night-owl", "Night Owl", "Ten sessions still running between 2 and 5am.", stats.lateNightSessions, 10, count, `${stats.lateNightSessions} sessions`);
246
+
247
+ const toolsDay = maxBy(stats.days, (d) => Object.keys(d.byHarness).length);
248
+ const toolsMax = toolsDay ? Object.keys(toolsDay.byHarness).length : 0;
249
+ add("polyglot", "Polyglot", "Use 5 different coding tools in one day.", toolsMax, 5, count, dayOf(toolsDay));
250
+
251
+ add("whale", "Whale Day", "Burn 100M tokens in one day.", stats.biggestDay?.totalTokens ?? 0, 100e6, tokens, dayOf(stats.biggestDay));
252
+ add("leviathan", "Leviathan", "Burn 500M tokens in one day.", stats.biggestDay?.totalTokens ?? 0, 500e6, tokens, dayOf(stats.biggestDay));
253
+ add("big-spender", "Big Spender", "Spend $50 of verified cost in one day.", stats.priciestDay?.verifiedCost ?? 0, 50, (n) => formatCurrency(n, 0), dayOf(stats.priciestDay));
254
+
255
+ // Cheapest big day: 10M+ tokens for under $1 (free and cheap models count).
256
+ const cheap = stats.days.filter((d) => d.totalTokens >= 10e6 && d.verifiedCost < 1);
257
+ add("free-lunch", "Free Lunch", "Burn 10M tokens in a day for under $1.", cheap.length ? 1 : 0, 1, (n) => (n ? "done" : "not yet"), cheap[0] ? dayOf(cheap[0]) : undefined);
258
+
259
+ const weekends = new Map<string, number>();
260
+ for (const d of stats.days) {
261
+ const dow = parseDay(d.period).getDay();
262
+ if (dow !== 0 && dow !== 6) continue;
263
+ const saturday = dow === 6 ? d.period : addDays(d.period, -1);
264
+ weekends.set(saturday, (weekends.get(saturday) ?? 0) + d.totalTokens);
265
+ }
266
+ const weekend = maxBy([...weekends.entries()], ([, t]) => t);
267
+ add("weekend", "Weekend Warrior", "Burn 250M tokens over one weekend.", weekend?.[1] ?? 0, 250e6, tokens, weekend ? `Weekend of ${formatDay(weekend[0], true)}` : undefined);
268
+
269
+ const weeks = new Map<string, { input: number; read: number; write: number; days: number }>();
270
+ for (const d of stats.days) {
271
+ const k = isoWeekKey(d.period);
272
+ const w = weeks.get(k) ?? { input: 0, read: 0, write: 0, days: 0 };
273
+ w.input += d.inputTokens;
274
+ w.read += d.cacheReadTokens;
275
+ w.write += d.cacheCreationTokens;
276
+ w.days += 1;
277
+ weeks.set(k, w);
278
+ }
279
+ const cacheWeek = maxBy(
280
+ [...weeks.entries()].filter(([, w]) => w.days >= 3).map(([k, w]) => ({ k, rate: (w.read / Math.max(1, w.input + w.read + w.write)) * 100 })),
281
+ (w) => w.rate
282
+ );
283
+ add("cache-goblin", "Cache Goblin", "A week (3+ active days) with 97% of prompt tokens from cache.", cacheWeek?.rate ?? 0, 97, (n) => `${n.toFixed(1)}%`, cacheWeek ? weekSpan(cacheWeek.k) : undefined);
284
+
285
+ const hops = new Map<string, Set<string>>();
286
+ for (const s of data.sessions) {
287
+ if (!s.projectRoot || s.date === "Unknown") continue;
288
+ const k = isoWeekKey(s.date);
289
+ hops.set(k, (hops.get(k) ?? new Set<string>()).add(s.projectRoot));
290
+ }
291
+ const hopWeek = maxBy([...hops.entries()], ([, r]) => r.size);
292
+ add("hopper", "Project Hopper", "Work in 10 repositories in one week.", hopWeek?.[1].size ?? 0, 10, count, hopWeek ? weekSpan(hopWeek[0]) : undefined);
293
+
294
+ add("collector", "Model Collector", "Use 75 different models.", stats.modelCount, 75, count, `${stats.modelCount} models`);
295
+
296
+ const months = new Map<string, Map<string, number>>();
297
+ for (const d of stats.days) {
298
+ const m = months.get(d.period.slice(0, 7)) ?? new Map<string, number>();
299
+ for (const hb of Object.values(d.byHarness)) for (const mm of hb.models) m.set(mm.modelName, (m.get(mm.modelName) ?? 0) + mm.totalTokens);
300
+ months.set(d.period.slice(0, 7), m);
301
+ }
302
+ const loyal = maxBy(
303
+ [...months.entries()]
304
+ .map(([month, models]) => {
305
+ const total = [...models.values()].reduce((a, b) => a + b, 0);
306
+ const top = maxBy([...models.entries()], ([, t]) => t);
307
+ return { month, total, model: top?.[0] ?? "", share: total > 0 && top ? (top[1] / total) * 100 : 0 };
308
+ })
309
+ .filter((m) => m.total >= 10e6),
310
+ (m) => m.share
311
+ );
312
+ add(
313
+ "loyalist",
314
+ "Loyalist",
315
+ "A month where one model did 90% of the work.",
316
+ loyal?.share ?? 0,
317
+ 90,
318
+ (n) => `${Math.round(n)}%`,
319
+ loyal ? `${loyal.model}, ${parseDay(`${loyal.month}-01`).toLocaleDateString("en-US", { month: "short", year: "numeric" })}` : undefined
320
+ );
321
+
322
+ return list;
323
+ }
@@ -0,0 +1,139 @@
1
+ import { formatCompactNumber, formatCurrency } from "../utils.ts";
2
+ import { formatSpan } from "./range.ts";
3
+ import { lastDefeated, nextRival, rankFor, TIERS, type LifetimeStats } from "./rank.ts";
4
+
5
+ // The share card is one SVG string, so the preview and the exported PNG are the same image.
6
+ // It never includes project names or paths; cost is opt-in. Colors come from the live
7
+ // theme, so the card matches whichever palette and light/dark mode is active.
8
+
9
+ export const CARD_W = 1200;
10
+ export const CARD_H = 630;
11
+
12
+ export interface CardOptions {
13
+ showCost: boolean;
14
+ earnedBadges: number;
15
+ totalBadges: number;
16
+ topToolName: string | null;
17
+ }
18
+
19
+ function esc(s: string): string {
20
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
21
+ }
22
+
23
+ function clip(s: string, max: number): string {
24
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
25
+ }
26
+
27
+ const FONT = "'Segoe UI Variable Display','Segoe UI',system-ui,sans-serif";
28
+
29
+ export interface CardColors {
30
+ bg: string;
31
+ line: string;
32
+ track: string;
33
+ text: string;
34
+ text2: string;
35
+ text3: string;
36
+ accent: string;
37
+ accentInk: string;
38
+ gold: string;
39
+ }
40
+
41
+ /** Resolve the theme tokens to concrete colors; an SVG rendered as an image can't see CSS variables. */
42
+ export function themeCardColors(): CardColors {
43
+ const css = getComputedStyle(document.documentElement);
44
+ const v = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
45
+ return {
46
+ bg: v("--surface", "#1b221e"),
47
+ line: v("--border", "#2e3932"),
48
+ track: v("--surface-3", "#2b362f"),
49
+ text: v("--text", "#e8eee7"),
50
+ text2: v("--text-2", "#b5c1b7"),
51
+ text3: v("--text-3", "#98a59b"),
52
+ accent: v("--accent", "#2f5a43"),
53
+ accentInk: v("--accent-ink", "#ffffff"),
54
+ gold: v("--gold", "#e1b75c"),
55
+ };
56
+ }
57
+
58
+ export function shareCardSvg(stats: LifetimeStats, opts: CardOptions, c: CardColors): string {
59
+ const rank = rankFor(stats.tokens);
60
+ const passed = lastDefeated(stats.tokens);
61
+ const next = nextRival(stats.tokens);
62
+ const barW = 520;
63
+ const fill = Math.max(8, (rank.progress / 100) * barW);
64
+
65
+ const cells: [string, string][] = [
66
+ ["Lifetime tokens", formatCompactNumber(stats.tokens)],
67
+ opts.showCost ? ["Verified cost", formatCurrency(stats.verifiedCost)] : ["Active days", String(stats.activeDays)],
68
+ ["Longest streak", `${stats.longestStreak?.days ?? 0} days`],
69
+ ["Badges", `${opts.earnedBadges} / ${opts.totalBadges}`],
70
+ ];
71
+
72
+ const statBlocks = cells
73
+ .map(([label, value], i) => {
74
+ const x = 80 + i * 262;
75
+ return `<text x="${x}" y="420" font-size="20" fill="${c.text3}">${esc(label)}</text>
76
+ <text x="${x}" y="464" font-size="40" font-weight="700" fill="${c.text}">${esc(value)}</text>`;
77
+ })
78
+ .join("\n");
79
+
80
+ const favorites = [
81
+ opts.topToolName ? `Main tool: ${opts.topToolName}` : null,
82
+ stats.topModel ? `Top model: ${clip(stats.topModel, 34)}` : null,
83
+ ]
84
+ .filter(Boolean)
85
+ .join(" · ");
86
+
87
+ const rivalLine = next
88
+ ? `Next rival: ${clip(next.name, 52)} in ${formatCompactNumber(next.tokens - stats.tokens)}`
89
+ : passed
90
+ ? `Passed every rival, last one: ${clip(passed.name, 48)}`
91
+ : "";
92
+
93
+ const since = stats.days[0] ? `Since ${formatSpan(stats.days[0].period, stats.days[stats.days.length - 1]!.period)}` : "";
94
+
95
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_W}" height="${CARD_H}" viewBox="0 0 ${CARD_W} ${CARD_H}" font-family="${FONT}">
96
+ <rect width="${CARD_W}" height="${CARD_H}" fill="${c.bg}"/>
97
+ <g transform="translate(80 64) scale(0.875)">
98
+ <rect x="3" y="3" width="58" height="58" rx="14" fill="${c.accent}"/>
99
+ <path d="M20 12h8v31.5c0 1.7.8 2.5 2.5 2.5H35v7h-6.5C22.8 53 20 50.2 20 44.5z" fill="${c.accentInk}"/>
100
+ <rect x="13" y="21" width="22" height="7" fill="${c.accentInk}"/>
101
+ <circle cx="45" cy="47.5" r="5.5" fill="${c.gold}"/>
102
+ </g>
103
+ <text x="148" y="104" font-size="28" font-weight="700" fill="${c.text}">Token Larper</text>
104
+ <text x="${CARD_W - 80}" y="104" font-size="20" fill="${c.text3}" text-anchor="end">${esc(since)}</text>
105
+ <text x="80" y="196" font-size="22" font-weight="700" letter-spacing="3" fill="${c.gold}">LARP RANK · LEVEL ${rank.tier.level} OF ${TIERS.length}</text>
106
+ <text x="80" y="268" font-size="68" font-weight="700" fill="${c.text}">${esc(rank.tier.title)}</text>
107
+ <rect x="80" y="304" width="${barW}" height="14" rx="7" fill="${c.track}"/>
108
+ <rect x="80" y="304" width="${fill}" height="14" rx="7" fill="${c.gold}"/>
109
+ <text x="${80 + barW + 24}" y="318" font-size="22" fill="${c.text2}">${esc(
110
+ rank.next ? `${formatCompactNumber(rank.toNext)} to ${rank.next.title}` : "Top tier reached"
111
+ )}</text>
112
+ <text x="80" y="370" font-size="24" font-style="italic" fill="${c.text2}">${esc(`“${rank.tier.quip}”`)}</text>
113
+ ${statBlocks}
114
+ <line x1="80" y1="506" x2="${CARD_W - 80}" y2="506" stroke="${c.line}" stroke-width="2"/>
115
+ <text x="80" y="550" font-size="21" fill="${c.text2}">${esc(favorites)}</text>
116
+ <text x="80" y="588" font-size="21" fill="${c.text3}">${esc(rivalLine)}</text>
117
+ </svg>`;
118
+ }
119
+
120
+ export function svgDataUrl(svg: string): string {
121
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
122
+ }
123
+
124
+ /** Rasterize at 2x for a crisp PNG. */
125
+ export async function cardPng(svg: string): Promise<Blob> {
126
+ const img = new Image();
127
+ img.decoding = "async";
128
+ img.src = svgDataUrl(svg);
129
+ await img.decode();
130
+ const canvas = document.createElement("canvas");
131
+ canvas.width = CARD_W * 2;
132
+ canvas.height = CARD_H * 2;
133
+ const ctx = canvas.getContext("2d");
134
+ if (!ctx) throw new Error("Canvas is unavailable");
135
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
136
+ return new Promise((resolve, reject) =>
137
+ canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("Could not create the image"))), "image/png")
138
+ );
139
+ }
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
2
+ <title id="title">Token Larper</title>
3
+ <!-- The tray icon in src/tray.ts draws this same geometry; keep the two in sync. -->
4
+ <rect x="3" y="3" width="58" height="58" rx="14" fill="#2f5a43"/>
5
+ <path d="M20 12h8v31.5c0 1.7.8 2.5 2.5 2.5H35v7h-6.5C22.8 53 20 50.2 20 44.5z" fill="#f3e3bf"/>
6
+ <rect x="13" y="21" width="22" height="7" fill="#f3e3bf"/>
7
+ <circle cx="45" cy="47.5" r="5.5" fill="#e1b75c"/>
8
+ </svg>
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App.tsx";
4
+
5
+ const rootEl = document.getElementById("root");
6
+ if (rootEl) {
7
+ createRoot(rootEl).render(
8
+ <React.StrictMode>
9
+ <App />
10
+ </React.StrictMode>
11
+ );
12
+ }