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,280 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { ChevronDown, ChevronRight } from "lucide-react";
3
+ import type { TimePeriodRow } from "../../types.ts";
4
+ import { ActivityCalendar, TOKEN_TYPE_SERIES, UsageBarChart, formatPeriodLabel, type ChartMode } from "../charts.tsx";
5
+ import { ShareBar, ToolTag, pct, useDashboard } from "../context.tsx";
6
+ import { KpiCards } from "../components/KpiCards.tsx";
7
+ import { bucketize, daysInRange, harnessTotals, summarize, type Bucket } from "../lib/aggregate.ts";
8
+ import { formatCompactNumber, formatCurrency } from "../utils.ts";
9
+
10
+ const BUCKETS: { id: Bucket; label: string }[] = [
11
+ { id: "daily", label: "Daily" },
12
+ { id: "weekly", label: "Weekly" },
13
+ { id: "monthly", label: "Monthly" },
14
+ ];
15
+
16
+ const MODES: { id: ChartMode; label: string }[] = [
17
+ { id: "tokens-by-harness", label: "Tokens by tool" },
18
+ { id: "cost-by-harness", label: "Cost" },
19
+ { id: "token-types", label: "Token mix" },
20
+ ];
21
+
22
+ function Segmented<T extends string>({ label, value, options, onChange }: {
23
+ label: string;
24
+ value: T;
25
+ options: { id: T; label: string }[];
26
+ onChange: (v: T) => void;
27
+ }) {
28
+ return (
29
+ <div className="segmented" role="group" aria-label={label}>
30
+ {options.map((o) => (
31
+ <button key={o.id} type="button" aria-pressed={value === o.id} onClick={() => onChange(o.id)}>
32
+ {o.label}
33
+ </button>
34
+ ))}
35
+ </div>
36
+ );
37
+ }
38
+
39
+ export function Overview({ bucket, setBucket }: { bucket: Bucket; setBucket: (b: Bucket) => void }) {
40
+ const { data, days, range, harness, setHarness, costOf, estimated, series, seriesOf } = useDashboard();
41
+ const [mode, setMode] = useState<ChartMode>("tokens-by-harness");
42
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
43
+
44
+ const rows = useMemo(() => bucketize(days, bucket, range.start, range.end), [days, bucket, range.start, range.end]);
45
+ const activeRows = useMemo(() => rows.filter((r) => r.totalTokens > 0), [rows]);
46
+ const totals = useMemo(() => summarize(days), [days]);
47
+ const tools = useMemo(() => harnessTotals(days), [days]);
48
+ // The calendar keeps its own 26-week window and shades the selected range inside it.
49
+ const calendarDays = useMemo(() => daysInRange(data.daily, harness, "0000-00-00", "9999-99-99"), [data.daily, harness]);
50
+
51
+ const legend = useMemo(() => {
52
+ if (mode === "token-types") return TOKEN_TYPE_SERIES;
53
+ const present = new Set(tools.map((t) => seriesOf(t.harness).key));
54
+ return series.filter((s) => present.has(s.key));
55
+ }, [mode, tools, series, seriesOf]);
56
+
57
+ function openPeriod(row: TimePeriodRow) {
58
+ if (row.totalTokens <= 0) return;
59
+ setExpanded((prev) => ({ ...prev, [row.period]: true }));
60
+ requestAnimationFrame(() =>
61
+ document.getElementById(`period-${row.period}`)?.scrollIntoView({ behavior: "smooth", block: "center" })
62
+ );
63
+ }
64
+
65
+ const toggle = (period: string) => setExpanded((prev) => ({ ...prev, [period]: !prev[period] }));
66
+ const maxTool = tools[0]?.totalTokens ?? 0;
67
+ const mix = [
68
+ { ...TOKEN_TYPE_SERIES[0]!, value: totals.cacheReadTokens },
69
+ { ...TOKEN_TYPE_SERIES[1]!, value: totals.inputTokens },
70
+ { ...TOKEN_TYPE_SERIES[2]!, value: totals.outputTokens },
71
+ { ...TOKEN_TYPE_SERIES[3]!, value: totals.cacheCreationTokens },
72
+ ];
73
+ const bucketNoun = { daily: "days", weekly: "weeks", monthly: "months" }[bucket];
74
+
75
+ return (
76
+ <>
77
+ <KpiCards />
78
+
79
+ <section className="panel">
80
+ <header className="panel-head">
81
+ <div>
82
+ <h2>Usage over time</h2>
83
+ <p>
84
+ {rows.length} {bucketNoun}, {activeRows.length} with usage. Hover for a breakdown, click a bar to see its models.
85
+ </p>
86
+ </div>
87
+ <div className="panel-controls">
88
+ <Segmented label="Group by" value={bucket} options={BUCKETS} onChange={setBucket} />
89
+ <Segmented label="Chart shows" value={mode} options={MODES} onChange={setMode} />
90
+ </div>
91
+ </header>
92
+ <UsageBarChart
93
+ rows={rows}
94
+ mode={mode}
95
+ granularity={bucket}
96
+ costOf={costOf}
97
+ series={series}
98
+ seriesOf={seriesOf}
99
+ onSelect={openPeriod}
100
+ />
101
+ <div className="legend">
102
+ {legend.map((s) => (
103
+ <span key={s.key} className="legend-item">
104
+ <i style={{ background: s.color }} aria-hidden="true" /> {s.name}
105
+ </span>
106
+ ))}
107
+ </div>
108
+ </section>
109
+
110
+ <div className="split overview-split">
111
+ <section className="panel">
112
+ <header className="panel-head">
113
+ <div>
114
+ <h2>Usage by tool</h2>
115
+ <p>{harness === "all" ? "Select a tool to filter the whole dashboard." : "Select again to show all tools."}</p>
116
+ </div>
117
+ </header>
118
+ {tools.length === 0 ? (
119
+ <p className="muted">No usage in this range.</p>
120
+ ) : (
121
+ <div className="table-scroll">
122
+ <table className="table table-compact">
123
+ <thead>
124
+ <tr>
125
+ <th>Tool</th>
126
+ <th className="bar-col"><span className="sr-only">Share</span></th>
127
+ <th className="num">Tokens</th>
128
+ <th className="num">Share</th>
129
+ <th className="num">{estimated ? "Est. value" : "Cost"}</th>
130
+ </tr>
131
+ </thead>
132
+ <tbody>
133
+ {tools.map((t) => (
134
+ <tr
135
+ key={t.harness}
136
+ className={`row-button ${harness === t.harness ? "is-selected" : ""}`}
137
+ onClick={() => setHarness(harness === t.harness ? "all" : t.harness)}
138
+ >
139
+ <td>
140
+ {/* The row handles the click; the button gives it a keyboard and screen-reader target. */}
141
+ <button type="button" className="cell-button" aria-pressed={harness === t.harness}>
142
+ <ToolTag id={t.harness} />
143
+ </button>
144
+ <span className="cell-note">{t.models[0]?.modelName ?? ""}</span>
145
+ </td>
146
+ <td className="bar-col"><ShareBar value={t.totalTokens} max={maxTool} color={seriesOf(t.harness).color} /></td>
147
+ <td className="num">{formatCompactNumber(t.totalTokens)}</td>
148
+ <td className="num muted">{pct(t.totalTokens, totals.totalTokens)}</td>
149
+ <td className="num">{formatCurrency(costOf(t))}</td>
150
+ </tr>
151
+ ))}
152
+ </tbody>
153
+ </table>
154
+ </div>
155
+ )}
156
+ </section>
157
+
158
+ <section className="panel">
159
+ <header className="panel-head">
160
+ <div>
161
+ <h2>Token mix</h2>
162
+ <p>Where the {formatCompactNumber(totals.totalTokens)} tokens in this range went.</p>
163
+ </div>
164
+ </header>
165
+ <div className="mix-bar" aria-hidden="true">
166
+ {mix.filter((m) => m.value > 0).map((m) => (
167
+ <span key={m.key} style={{ flexGrow: m.value, background: m.color }} />
168
+ ))}
169
+ </div>
170
+ <dl className="mix-list">
171
+ {mix.map((m) => (
172
+ <div key={m.key}>
173
+ <dt><i style={{ background: m.color }} aria-hidden="true" />{m.name}</dt>
174
+ <dd>
175
+ <strong>{formatCompactNumber(m.value)}</strong>
176
+ <span>{pct(m.value, totals.totalTokens)}</span>
177
+ </dd>
178
+ </div>
179
+ ))}
180
+ <div>
181
+ <dt><i className="swatch-none" aria-hidden="true" />Reasoning (within output)</dt>
182
+ <dd><strong>{formatCompactNumber(totals.reasoningOutputTokens)}</strong><span /></dd>
183
+ </div>
184
+ </dl>
185
+ </section>
186
+ </div>
187
+
188
+ <section className="panel" aria-labelledby="daily-activity-heading">
189
+ <header className="panel-head">
190
+ <div>
191
+ <h2 id="daily-activity-heading">Daily activity</h2>
192
+ </div>
193
+ </header>
194
+ <ActivityCalendar days={calendarDays} costOf={costOf} rangeStart={range.start} rangeEnd={range.end} />
195
+ </section>
196
+
197
+ <section className="panel">
198
+ <header className="panel-head">
199
+ <div>
200
+ <h2>Period details</h2>
201
+ <p>{activeRows.length} {bucketNoun} with usage. Expand a row for its tools and models.</p>
202
+ </div>
203
+ </header>
204
+ <div className="table-scroll">
205
+ <table className="table">
206
+ <thead>
207
+ <tr>
208
+ <th>Period</th>
209
+ <th>Tools</th>
210
+ <th className="num">Input</th>
211
+ <th className="num">Output</th>
212
+ <th className="num">Cache read</th>
213
+ <th className="num">Cache write</th>
214
+ <th className="num">Total</th>
215
+ <th className="num">{estimated ? "Est. value" : "Cost"}</th>
216
+ </tr>
217
+ </thead>
218
+ <tbody>
219
+ {[...activeRows].reverse().map((row) => {
220
+ const open = Boolean(expanded[row.period]);
221
+ const entries = Object.values(row.byHarness).sort((a, b) => b.totalTokens - a.totalTokens);
222
+ return (
223
+ <React.Fragment key={row.period}>
224
+ <tr
225
+ id={`period-${row.period}`}
226
+ className={`row-button ${open ? "is-open" : ""}`}
227
+ onClick={() => toggle(row.period)}
228
+ >
229
+ <td className="nowrap">
230
+ <button type="button" className="cell-button" aria-expanded={open}>
231
+ {open ? <ChevronDown size={14} aria-hidden="true" /> : <ChevronRight size={14} aria-hidden="true" />}
232
+ <strong>{formatPeriodLabel(row, bucket)}</strong>
233
+ </button>
234
+ </td>
235
+ <td>
236
+ <div className="tag-row">{entries.map((hb) => <ToolTag key={hb.harness} id={hb.harness} />)}</div>
237
+ </td>
238
+ <td className="num">{formatCompactNumber(row.inputTokens)}</td>
239
+ <td className="num">{formatCompactNumber(row.outputTokens)}</td>
240
+ <td className="num">{formatCompactNumber(row.cacheReadTokens)}</td>
241
+ <td className="num">{formatCompactNumber(row.cacheCreationTokens)}</td>
242
+ <td className="num strong">{formatCompactNumber(row.totalTokens)}</td>
243
+ <td className="num strong">{formatCurrency(costOf(row))}</td>
244
+ </tr>
245
+ {open && (
246
+ <tr className="detail-row">
247
+ <td colSpan={8}>
248
+ <div className="detail-grid">
249
+ {entries.map((hb) => (
250
+ <div key={hb.harness} className="detail-card">
251
+ <div className="detail-head">
252
+ <ToolTag id={hb.harness} />
253
+ <span>{formatCompactNumber(hb.totalTokens)} tokens</span>
254
+ <span>{formatCurrency(costOf(hb))}</span>
255
+ </div>
256
+ <ul>
257
+ {[...hb.models].sort((a, b) => b.totalTokens - a.totalTokens).map((m) => (
258
+ <li key={m.modelName}>
259
+ <code>{m.modelName}</code>
260
+ <span>{formatCompactNumber(m.totalTokens)}</span>
261
+ <span>{formatCurrency(costOf(m))}</span>
262
+ </li>
263
+ ))}
264
+ </ul>
265
+ </div>
266
+ ))}
267
+ </div>
268
+ </td>
269
+ </tr>
270
+ )}
271
+ </React.Fragment>
272
+ );
273
+ })}
274
+ </tbody>
275
+ </table>
276
+ </div>
277
+ </section>
278
+ </>
279
+ );
280
+ }
@@ -0,0 +1,88 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { ChevronDown, ChevronRight } from "lucide-react";
3
+ import { ShareBar, ToolTag, pct, useDashboard } from "../context.tsx";
4
+ import { groupProjects } from "../lib/aggregate.ts";
5
+ import { formatCompactNumber, formatCurrency } from "../utils.ts";
6
+ import { SessionTable, matchesSession, sessionWhen } from "./Sessions.tsx";
7
+
8
+ export function Projects() {
9
+ const { sessions, search, costOf, estimated, nameOf } = useDashboard();
10
+ const [open, setOpen] = useState<string | null>(null);
11
+ const q = search.trim().toLowerCase();
12
+ const all = useMemo(() => groupProjects(sessions), [sessions]);
13
+ const projects = useMemo(
14
+ () => (q ? all.filter((p) => p.name.toLowerCase().includes(q) || p.sessions.some((s) => matchesSession(s, q))) : all),
15
+ [all, q]
16
+ );
17
+ const total = all.reduce((acc, p) => acc + p.totalTokens, 0);
18
+ const attributed = all.filter((p) => p.root).reduce((acc, p) => acc + p.totalTokens, 0);
19
+ const max = all[0]?.totalTokens ?? 0;
20
+
21
+ return (
22
+ <section className="panel">
23
+ <header className="panel-head">
24
+ <div>
25
+ <h2>Projects</h2>
26
+ <p>
27
+ Sessions grouped by repository. {pct(attributed, total, 0)} of tokens in this range are tied to a project;
28
+ tools that don't record a working directory are listed separately.
29
+ </p>
30
+ </div>
31
+ </header>
32
+ {projects.length === 0 ? (
33
+ <p className="muted">No sessions in this range.</p>
34
+ ) : (
35
+ <div className="table-scroll">
36
+ <table className="table">
37
+ <thead>
38
+ <tr>
39
+ <th>Project</th>
40
+ <th>Tools</th>
41
+ <th className="bar-col"><span className="sr-only">Share</span></th>
42
+ <th className="num">Sessions</th>
43
+ <th className="num">Tokens</th>
44
+ <th className="num">Share</th>
45
+ <th className="num">{estimated ? "Est. value" : "Cost"}</th>
46
+ <th>Last active</th>
47
+ </tr>
48
+ </thead>
49
+ <tbody>
50
+ {projects.map((p) => {
51
+ const isOpen = open === p.key;
52
+ return (
53
+ <React.Fragment key={p.key}>
54
+ <tr className={`row-button ${isOpen ? "is-open" : ""}`} onClick={() => setOpen(isOpen ? null : p.key)}>
55
+ <td className="project-cell">
56
+ <button type="button" className="cell-button" aria-expanded={isOpen}>
57
+ {isOpen ? <ChevronDown size={14} aria-hidden="true" /> : <ChevronRight size={14} aria-hidden="true" />}
58
+ <strong className={p.root ? "" : "muted"}>
59
+ {p.root ? p.name : `No project recorded · ${nameOf(p.harnesses[0]!)}`}
60
+ </strong>
61
+ </button>
62
+ {p.root && <span className="cell-note" title={p.root}>{p.root}</span>}
63
+ </td>
64
+ <td><div className="tag-row">{p.harnesses.map((h) => <ToolTag key={h} id={h} />)}</div></td>
65
+ <td className="bar-col"><ShareBar value={p.totalTokens} max={max} /></td>
66
+ <td className="num">{p.sessions.length}</td>
67
+ <td className="num strong">{formatCompactNumber(p.totalTokens)}</td>
68
+ <td className="num muted">{pct(p.totalTokens, total)}</td>
69
+ <td className="num">{formatCurrency(costOf(p))}</td>
70
+ <td className="nowrap muted">{sessionWhen(p.sessions[0]!)}</td>
71
+ </tr>
72
+ {isOpen && (
73
+ <tr className="detail-row">
74
+ <td colSpan={8}>
75
+ <SessionTable sessions={p.sessions} showProject={false} />
76
+ </td>
77
+ </tr>
78
+ )}
79
+ </React.Fragment>
80
+ );
81
+ })}
82
+ </tbody>
83
+ </table>
84
+ </div>
85
+ )}
86
+ </section>
87
+ );
88
+ }
@@ -0,0 +1,255 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ Banknote,
4
+ Boxes,
5
+ CalendarDays,
6
+ Coins,
7
+ Copy,
8
+ Database,
9
+ Download,
10
+ Fish,
11
+ Flame,
12
+ FolderGit2,
13
+ Heart,
14
+ Languages,
15
+ Moon,
16
+ Share2,
17
+ Shield,
18
+ Sparkles,
19
+ Trophy,
20
+ Waves,
21
+ X,
22
+ type LucideIcon,
23
+ } from "lucide-react";
24
+ import { useDashboard } from "../context.tsx";
25
+ import { achievements, lastDefeated, lifetimeStats, nextRival, rankFor, TIERS } from "../lib/rank.ts";
26
+ import { cardPng, shareCardSvg, svgDataUrl, themeCardColors } from "../lib/shareCard.ts";
27
+ import { formatCompactNumber, formatCurrency } from "../utils.ts";
28
+
29
+ const TROPHY_ICONS: Record<string, LucideIcon> = {
30
+ billion: Trophy,
31
+ "ten-billion": Sparkles,
32
+ "streak-7": Flame,
33
+ "streak-30": Shield,
34
+ "night-owl": Moon,
35
+ polyglot: Languages,
36
+ whale: Fish,
37
+ leviathan: Waves,
38
+ "big-spender": Banknote,
39
+ "free-lunch": Coins,
40
+ weekend: CalendarDays,
41
+ "cache-goblin": Database,
42
+ hopper: FolderGit2,
43
+ collector: Boxes,
44
+ loyalist: Heart,
45
+ };
46
+
47
+ function ShareDialog({ svg, showCost, setShowCost, onClose }: {
48
+ svg: string;
49
+ showCost: boolean;
50
+ setShowCost: (v: boolean) => void;
51
+ onClose: () => void;
52
+ }) {
53
+ const [status, setStatus] = useState("");
54
+ const closeRef = useRef<HTMLButtonElement>(null);
55
+
56
+ useEffect(() => {
57
+ const previous = document.activeElement as HTMLElement | null;
58
+ closeRef.current?.focus();
59
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
60
+ window.addEventListener("keydown", onKey);
61
+ return () => {
62
+ window.removeEventListener("keydown", onKey);
63
+ previous?.focus();
64
+ };
65
+ }, [onClose]);
66
+
67
+ async function download() {
68
+ try {
69
+ const url = URL.createObjectURL(await cardPng(svg));
70
+ const a = document.createElement("a");
71
+ a.href = url;
72
+ a.download = `token-larper-rank-${new Date().toISOString().slice(0, 10)}.png`;
73
+ a.click();
74
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
75
+ setStatus("Saved to your downloads.");
76
+ } catch {
77
+ setStatus("Couldn't create the image.");
78
+ }
79
+ }
80
+
81
+ async function copy() {
82
+ try {
83
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": await cardPng(svg) })]);
84
+ setStatus("Copied. Paste it anywhere.");
85
+ } catch {
86
+ setStatus("Your browser blocked copying. Download it instead.");
87
+ }
88
+ }
89
+
90
+ return (
91
+ <div className="modal-backdrop" onClick={onClose}>
92
+ <div className="modal share-dialog" role="dialog" aria-modal="true" aria-labelledby="share-title" onClick={(e) => e.stopPropagation()}>
93
+ <header className="modal-header">
94
+ <div>
95
+ <h3 id="share-title">Share your rank</h3>
96
+ <p>A 1200 × 630 image. It never includes project names or file paths.</p>
97
+ </div>
98
+ <button ref={closeRef} className="icon-btn" aria-label="Close" onClick={onClose}>
99
+ <X size={17} />
100
+ </button>
101
+ </header>
102
+ <div className="share-body">
103
+ <img className="share-preview" src={svgDataUrl(svg)} alt="Preview of your share card" />
104
+ </div>
105
+ <footer className="share-actions">
106
+ <label className="check">
107
+ <input type="checkbox" checked={showCost} onChange={(e) => setShowCost(e.target.checked)} />
108
+ Include verified cost
109
+ </label>
110
+ <span className="share-status" role="status">{status}</span>
111
+ <button className="btn" onClick={() => void copy()}>
112
+ <Copy size={14} aria-hidden="true" /> Copy
113
+ </button>
114
+ <button className="btn btn-primary" onClick={() => void download()}>
115
+ <Download size={14} aria-hidden="true" /> Download PNG
116
+ </button>
117
+ </footer>
118
+ </div>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ export function Rank() {
124
+ const { data, nameOf } = useDashboard();
125
+ const stats = useMemo(() => lifetimeStats(data), [data]);
126
+ const rank = rankFor(stats.tokens);
127
+ const badges = useMemo(() => achievements(data, stats), [data, stats]);
128
+ const earned = badges.filter((b) => b.earned).length;
129
+ // Earned first, then locked by how close they are, so the row reads left to right.
130
+ const ordered = useMemo(
131
+ () =>
132
+ [...badges].sort((a, b) =>
133
+ a.earned !== b.earned ? (a.earned ? -1 : 1) : b.progress.current / b.progress.target - a.progress.current / a.progress.target
134
+ ),
135
+ [badges]
136
+ );
137
+ const closest = ordered.find((b) => !b.earned) ?? null;
138
+ const rival = nextRival(stats.tokens);
139
+ const floor = lastDefeated(stats.tokens)?.tokens ?? 0;
140
+ const rivalPct = rival ? Math.min(100, ((stats.tokens - floor) / (rival.tokens - floor)) * 100) : 100;
141
+
142
+ const [selectedId, setSelectedId] = useState<string | null>(null);
143
+ const [sharing, setSharing] = useState(false);
144
+ const [showCost, setShowCost] = useState(false);
145
+ const closeShare = React.useCallback(() => setSharing(false), []);
146
+ const selected = badges.find((b) => b.id === selectedId) ?? closest;
147
+
148
+ // Read the theme when the dialog opens, so the card matches the active palette and mode.
149
+ const svg = useMemo(
150
+ () =>
151
+ sharing
152
+ ? shareCardSvg(
153
+ stats,
154
+ {
155
+ showCost,
156
+ earnedBadges: earned,
157
+ totalBadges: badges.length,
158
+ topToolName: stats.topTool ? nameOf(stats.topTool.id) : null,
159
+ },
160
+ themeCardColors()
161
+ )
162
+ : "",
163
+ [sharing, stats, showCost, earned, badges.length, nameOf]
164
+ );
165
+
166
+ const numbers = [
167
+ { label: "Tokens", value: formatCompactNumber(stats.tokens) },
168
+ { label: "Best streak", value: `${stats.longestStreak?.days ?? 0} days` },
169
+ { label: "Tools", value: String(stats.toolCount) },
170
+ { label: "Spent", value: formatCurrency(stats.verifiedCost, 0) },
171
+ ];
172
+
173
+ return (
174
+ <article className="sheet" aria-labelledby="sheet-title">
175
+ <header className="sheet-head">
176
+ <div className="sheet-level" aria-label={`Level ${rank.tier.level} of ${TIERS.length}`}>
177
+ <span aria-hidden="true">Lv</span>
178
+ <strong aria-hidden="true">{rank.tier.level}</strong>
179
+ </div>
180
+ <div className="sheet-identity">
181
+ <h2 id="sheet-title">{rank.tier.title}</h2>
182
+ <span
183
+ className="xp-bar"
184
+ role="meter"
185
+ aria-valuemin={0}
186
+ aria-valuemax={100}
187
+ aria-valuenow={Math.round(rank.progress)}
188
+ aria-label={rank.next ? `Progress to ${rank.next.title}` : "Maximum level"}
189
+ >
190
+ <span style={{ width: `${rank.progress}%` }} />
191
+ </span>
192
+ <span className="xp-label">
193
+ {rank.next ? `${formatCompactNumber(rank.toNext)} to ${rank.next.title}` : "Maximum level"}
194
+ </span>
195
+ </div>
196
+ <button className="icon-btn sheet-share" onClick={() => setSharing(true)} aria-label="Share your rank" title="Share">
197
+ <Share2 size={16} />
198
+ </button>
199
+ </header>
200
+
201
+ <dl className="sheet-numbers">
202
+ {numbers.map((n) => (
203
+ <div key={n.label}>
204
+ <dt>{n.label}</dt>
205
+ <dd>{n.value}</dd>
206
+ </div>
207
+ ))}
208
+ </dl>
209
+
210
+ {rival && (
211
+ <section className="sheet-row" aria-label="Next rival">
212
+ <span className="sheet-row-label">Next rival</span>
213
+ <div className="sheet-rival">
214
+ <strong>{rival.name}</strong>
215
+ <span className="rival-bar" aria-hidden="true"><span style={{ width: `${rivalPct}%` }} /></span>
216
+ </div>
217
+ <span className="sheet-row-value">{formatCompactNumber(rival.tokens - stats.tokens)} to go</span>
218
+ </section>
219
+ )}
220
+
221
+ <section className="sheet-row sheet-badges" aria-label="Badges">
222
+ <span className="sheet-row-label">Badges <span>{earned}/{badges.length}</span></span>
223
+ <div>
224
+ <ul className="seals">
225
+ {ordered.map((b) => {
226
+ const Icon = TROPHY_ICONS[b.id] ?? Trophy;
227
+ return (
228
+ <li key={b.id}>
229
+ <button
230
+ type="button"
231
+ className={`seal ${b.earned ? "is-earned" : ""}`}
232
+ aria-pressed={selected?.id === b.id}
233
+ aria-label={`${b.name}, ${b.earned ? "earned" : `locked, ${b.progress.label}`}`}
234
+ title={b.name}
235
+ onClick={() => setSelectedId(b.id)}
236
+ >
237
+ <Icon size={16} aria-hidden="true" />
238
+ </button>
239
+ </li>
240
+ );
241
+ })}
242
+ </ul>
243
+ {selected && (
244
+ <p className="seal-detail" aria-live="polite">
245
+ <strong>{selected.name}</strong> {selected.requirement}{" "}
246
+ <span>{selected.earned ? selected.detail : selected.progress.label}</span>
247
+ </p>
248
+ )}
249
+ </div>
250
+ </section>
251
+
252
+ {sharing && <ShareDialog svg={svg} showCost={showCost} setShowCost={setShowCost} onClose={closeShare} />}
253
+ </article>
254
+ );
255
+ }