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,122 @@
1
+ import React, { useMemo } from "react";
2
+ import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react";
3
+ import { useDashboard } from "../context.tsx";
4
+ import { daysInRange, sparkline, summarize, type RangeTotals } from "../lib/aggregate.ts";
5
+ import { daysInRange as spanDays, formatDay, previousRange } from "../lib/range.ts";
6
+ import { formatCompactNumber, formatCurrency, formatExactNumber } from "../utils.ts";
7
+
8
+ function Sparkline({ values }: { values: number[] }) {
9
+ if (values.length < 2 || values.every((v) => v === 0)) return <div className="spark spark-empty" aria-hidden="true" />;
10
+ const max = Math.max(...values);
11
+ const w = 100;
12
+ const h = 28;
13
+ const pts = values.map((v, i) => [(i / (values.length - 1)) * w, h - 2 - (max > 0 ? (v / max) * (h - 4) : 0)] as const);
14
+ const line = pts.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`).join(" ");
15
+ const [lx, ly] = pts[pts.length - 1]!;
16
+ return (
17
+ <svg className="spark" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" aria-hidden="true">
18
+ <path d={`${line} L${w},${h} L0,${h} Z`} className="spark-area" />
19
+ <path d={line} className="spark-line" vectorEffect="non-scaling-stroke" />
20
+ <circle cx={lx} cy={ly} r="2.2" className="spark-dot" />
21
+ </svg>
22
+ );
23
+ }
24
+
25
+ type DeltaUnit = "percent" | "points" | "days";
26
+
27
+ function Delta({ current, previous, unit, days }: { current: number; previous: number | null; unit: DeltaUnit; days: number }) {
28
+ if (previous === null) return <span className="kpi-delta">All recorded usage</span>;
29
+ const vs = `vs previous ${days} ${days === 1 ? "day" : "days"}`;
30
+ if (unit === "percent" && previous === 0) {
31
+ return <span className="kpi-delta">{current > 0 ? `No usage in the previous ${days} days` : `None ${vs}`}</span>;
32
+ }
33
+ const diff = unit === "percent" ? ((current - previous) / previous) * 100 : current - previous;
34
+ const flat = Math.abs(diff) < (unit === "days" ? 0.5 : 0.05);
35
+ const Icon = flat ? Minus : diff > 0 ? ArrowUpRight : ArrowDownRight;
36
+ const amount =
37
+ unit === "percent"
38
+ ? `${Math.abs(diff) >= 100 ? Math.round(Math.abs(diff)).toLocaleString("en-US") : Math.abs(diff).toFixed(1)}%`
39
+ : unit === "points"
40
+ ? `${Math.abs(diff).toFixed(1)} pts`
41
+ : `${Math.round(Math.abs(diff))} ${Math.round(Math.abs(diff)) === 1 ? "day" : "days"}`;
42
+ return (
43
+ <span className="kpi-delta">
44
+ <Icon size={13} aria-hidden="true" />
45
+ {!flat && <span className="sr-only">{diff > 0 ? "Up" : "Down"}</span>}
46
+ {flat ? "No change" : amount} {vs}
47
+ </span>
48
+ );
49
+ }
50
+
51
+ export function KpiCards() {
52
+ const { data, days, range, harness, costOf, estimated } = useDashboard();
53
+ const prev = previousRange(range);
54
+ const current = useMemo(() => summarize(days), [days]);
55
+ const previous: RangeTotals | null = useMemo(
56
+ () => (prev ? summarize(daysInRange(data.daily, harness, prev.start, prev.end)) : null),
57
+ [data.daily, harness, prev?.start, prev?.end]
58
+ );
59
+ const rangeDays = spanDays(range.start, range.end);
60
+ const prevDays = prev?.days ?? 0;
61
+ const spark = (pick: Parameters<typeof sparkline>[3]) => sparkline(days, range.start, range.end, pick);
62
+
63
+ const cost = costOf(current);
64
+ const unpriced = Math.max(0, current.estimatedCost - current.verifiedCost);
65
+
66
+ return (
67
+ <section className="kpi-grid" aria-label="Summary for the selected range">
68
+ <article className="kpi">
69
+ <h3 className="kpi-label">Tokens</h3>
70
+ <div className="kpi-value">{formatCompactNumber(current.totalTokens)}</div>
71
+ <p className="kpi-sub">{formatExactNumber(current.totalTokens)} tokens</p>
72
+ <Delta current={current.totalTokens} previous={previous?.totalTokens ?? null} unit="percent" days={prevDays} />
73
+ <Sparkline values={spark((d) => d.totalTokens)} />
74
+ </article>
75
+
76
+ <article className="kpi">
77
+ <h3 className="kpi-label">{estimated ? "Estimated API value" : "Verified cost"}</h3>
78
+ <div className="kpi-value">{formatCurrency(cost)}</div>
79
+ <p className="kpi-sub">
80
+ {estimated
81
+ ? `${formatCurrency(current.verifiedCost)} verified + ${formatCurrency(unpriced)} estimated`
82
+ : `${formatCurrency(current.estimatedCost)} estimated API value`}
83
+ </p>
84
+ <Delta current={cost} previous={previous ? costOf(previous) : null} unit="percent" days={prevDays} />
85
+ <Sparkline values={spark((d) => costOf(d))} />
86
+ </article>
87
+
88
+ <article className="kpi">
89
+ <h3 className="kpi-label">Active days</h3>
90
+ <div className="kpi-value">
91
+ {current.activeDays}
92
+ <span className="kpi-of"> / {rangeDays}</span>
93
+ </div>
94
+ <p className="kpi-sub">
95
+ {current.busiestDay
96
+ ? `Busiest: ${formatDay(current.busiestDay.period)} · ${formatCompactNumber(current.busiestDay.totalTokens)}`
97
+ : "No recorded usage"}
98
+ </p>
99
+ <Delta current={current.activeDays} previous={previous?.activeDays ?? null} unit="days" days={prevDays} />
100
+ <Sparkline values={spark((d) => (d.totalTokens > 0 ? 1 : 0))} />
101
+ </article>
102
+
103
+ <article className="kpi">
104
+ <h3 className="kpi-label">Cache read rate</h3>
105
+ <div className="kpi-value">{current.cacheHitRate.toFixed(1)}%</div>
106
+ <p className="kpi-sub">
107
+ {current.activeDays > 0
108
+ ? `${formatCurrency(cost / current.activeDays)} per active day · ${formatCompactNumber(current.cacheReadTokens)} cached`
109
+ : `${formatCompactNumber(current.cacheReadTokens)} cached tokens`}
110
+ </p>
111
+ {previous && previous.activeDays === 0 ? (
112
+ <span className="kpi-delta">No usage in the previous {prevDays} days</span>
113
+ ) : (
114
+ <Delta current={current.cacheHitRate} previous={previous?.cacheHitRate ?? null} unit="points" days={prevDays} />
115
+ )}
116
+ <div className="kpi-meter" role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(current.cacheHitRate)} aria-label="Cache read rate">
117
+ <span style={{ width: `${Math.min(100, current.cacheHitRate)}%` }} />
118
+ </div>
119
+ </article>
120
+ </section>
121
+ );
122
+ }
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import { Crown } from "lucide-react";
3
+ import { rankFor } from "../lib/rank.ts";
4
+ import { formatCompactNumber } from "../utils.ts";
5
+
6
+ /** Compact lifetime rank in the header; opens the Rank tab. */
7
+ export function RankChip({ tokens, onOpen, active }: { tokens: number; onOpen: () => void; active: boolean }) {
8
+ const rank = rankFor(tokens);
9
+ const label = rank.next
10
+ ? `${rank.tier.title}, level ${rank.tier.level}. ${formatCompactNumber(rank.toNext)} tokens to ${rank.next.title}. Open rank.`
11
+ : `${rank.tier.title}, top level. Open rank.`;
12
+ return (
13
+ <button type="button" className={`rank-chip ${active ? "is-active" : ""}`} onClick={onOpen} aria-label={label} title={label}>
14
+ <Crown size={14} aria-hidden="true" />
15
+ <span className="rank-chip-level">Lv {rank.tier.level}</span>
16
+ <span className="rank-chip-title">{rank.tier.title}</span>
17
+ <span className="rank-chip-bar" aria-hidden="true">
18
+ <span style={{ width: `${rank.progress}%` }} />
19
+ </span>
20
+ </button>
21
+ );
22
+ }
@@ -0,0 +1,116 @@
1
+ import React, { useEffect, useId, useRef, useState } from "react";
2
+ import { Check, ChevronDown } from "lucide-react";
3
+
4
+ export interface SelectMenuOption<T extends string> {
5
+ value: T;
6
+ label: string;
7
+ description?: string;
8
+ visual?: React.ReactNode;
9
+ }
10
+
11
+ interface Props<T extends string> {
12
+ label: string;
13
+ value: T;
14
+ options: SelectMenuOption<T>[];
15
+ onChange: (value: T) => void;
16
+ className?: string;
17
+ showDescriptionInTrigger?: boolean;
18
+ }
19
+
20
+ export function SelectMenu<T extends string>({ label, value, options, onChange, className = "", showDescriptionInTrigger = false }: Props<T>) {
21
+ const [open, setOpen] = useState(false);
22
+ const rootRef = useRef<HTMLDivElement>(null);
23
+ const triggerRef = useRef<HTMLButtonElement>(null);
24
+ const labelId = useId();
25
+ const selected = options.find((option) => option.value === value) ?? options[0];
26
+
27
+ useEffect(() => {
28
+ if (!open) return;
29
+ const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value));
30
+ rootRef.current?.querySelectorAll<HTMLButtonElement>("[role='option']")[selectedIndex]?.focus();
31
+ const onPointer = (event: PointerEvent) => {
32
+ if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
33
+ };
34
+ document.addEventListener("pointerdown", onPointer);
35
+ return () => document.removeEventListener("pointerdown", onPointer);
36
+ }, [open]);
37
+
38
+ function moveFocus(direction: number) {
39
+ const items = rootRef.current?.querySelectorAll<HTMLButtonElement>("[role='option']");
40
+ if (!items?.length) return;
41
+ const current = Array.from(items).findIndex((item) => item === document.activeElement);
42
+ const next = (current + direction + items.length) % items.length;
43
+ items[next]?.focus();
44
+ }
45
+
46
+ function choose(next: T) {
47
+ onChange(next);
48
+ setOpen(false);
49
+ triggerRef.current?.focus();
50
+ }
51
+
52
+ return (
53
+ <div
54
+ ref={rootRef}
55
+ className={`select-menu ${className}`.trim()}
56
+ onBlur={(event) => {
57
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(false);
58
+ }}
59
+ onKeyDown={(event) => {
60
+ if (event.key === "Escape" && open) {
61
+ event.preventDefault();
62
+ event.stopPropagation();
63
+ setOpen(false);
64
+ triggerRef.current?.focus();
65
+ } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
66
+ event.preventDefault();
67
+ if (!open) setOpen(true);
68
+ else moveFocus(event.key === "ArrowDown" ? 1 : -1);
69
+ } else if (open && (event.key === "Home" || event.key === "End")) {
70
+ event.preventDefault();
71
+ const items = rootRef.current?.querySelectorAll<HTMLButtonElement>("[role='option']");
72
+ items?.[event.key === "Home" ? 0 : items.length - 1]?.focus();
73
+ }
74
+ }}
75
+ >
76
+ <span className="field-label" id={labelId}>{label}</span>
77
+ <button
78
+ ref={triggerRef}
79
+ type="button"
80
+ className="select-menu-trigger"
81
+ aria-label={`${label}: ${selected?.label ?? ""}`}
82
+ aria-haspopup="listbox"
83
+ aria-expanded={open}
84
+ onClick={() => setOpen((current) => !current)}
85
+ >
86
+ {selected?.visual}
87
+ <span className="select-menu-copy">
88
+ <strong>{selected?.label}</strong>
89
+ {showDescriptionInTrigger && selected?.description && <small>{selected.description}</small>}
90
+ </span>
91
+ <ChevronDown size={15} className="select-menu-chevron" aria-hidden="true" />
92
+ </button>
93
+ {open && (
94
+ <div className="select-menu-list" role="listbox" aria-labelledby={labelId}>
95
+ {options.map((option) => (
96
+ <button
97
+ key={option.value}
98
+ type="button"
99
+ role="option"
100
+ aria-selected={option.value === value}
101
+ className="select-menu-option"
102
+ onClick={() => choose(option.value)}
103
+ >
104
+ {option.visual}
105
+ <span className="select-menu-copy">
106
+ <strong>{option.label}</strong>
107
+ {option.description && <small>{option.description}</small>}
108
+ </span>
109
+ {option.value === value && <Check size={15} className="select-menu-check" aria-hidden="true" />}
110
+ </button>
111
+ ))}
112
+ </div>
113
+ )}
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,207 @@
1
+ import React, { useEffect, useRef } from "react";
2
+ import { ChevronDown, Power, X } from "lucide-react";
3
+ import type { StartupConfig } from "../../types.ts";
4
+ import { ThemeSettings } from "./ThemeSettings.tsx";
5
+ import type { ImportedTheme, ThemeMode, ThemePalette } from "../themes.ts";
6
+
7
+ interface Props {
8
+ startup: StartupConfig | null;
9
+ startupError: string | null;
10
+ saving: boolean;
11
+ themeMode: ThemeMode;
12
+ palette: ThemePalette;
13
+ importedTheme: ImportedTheme | null;
14
+ showRanks: boolean;
15
+ onClose: () => void;
16
+ onThemeModeChange: (mode: ThemeMode) => void;
17
+ onPaletteChange: (palette: ThemePalette) => void;
18
+ onImportTheme: (theme: ImportedTheme) => void;
19
+ onShowRanksChange: (showRanks: boolean) => void;
20
+ onToggleStartup: (enabled: boolean, openBrowserOnBoot?: boolean) => void;
21
+ onRetry: () => void;
22
+ onQuit: () => void;
23
+ }
24
+
25
+ export function SettingsDialog({
26
+ startup,
27
+ startupError,
28
+ saving,
29
+ themeMode,
30
+ palette,
31
+ importedTheme,
32
+ showRanks,
33
+ onClose,
34
+ onThemeModeChange,
35
+ onPaletteChange,
36
+ onImportTheme,
37
+ onShowRanksChange,
38
+ onToggleStartup,
39
+ onRetry,
40
+ onQuit,
41
+ }: Props) {
42
+ const dialogRef = useRef<HTMLDivElement>(null);
43
+ const closeRef = useRef<HTMLButtonElement>(null);
44
+
45
+ useEffect(() => {
46
+ const previousFocus = document.activeElement as HTMLElement | null;
47
+ closeRef.current?.focus();
48
+ const handleKeys = (event: KeyboardEvent) => {
49
+ if (event.key === "Escape") onClose();
50
+ if (event.key !== "Tab") return;
51
+ const focusable = Array.from(
52
+ dialogRef.current?.querySelectorAll<HTMLElement>(
53
+ "button:not([disabled]), summary, textarea:not([disabled]), input:not([disabled]), select:not([disabled]), a[href]"
54
+ ) ?? []
55
+ );
56
+ if (!focusable.length) return;
57
+ const first = focusable[0];
58
+ const last = focusable[focusable.length - 1];
59
+ if (event.shiftKey && document.activeElement === first) {
60
+ event.preventDefault();
61
+ last?.focus();
62
+ } else if (!event.shiftKey && document.activeElement === last) {
63
+ event.preventDefault();
64
+ first?.focus();
65
+ }
66
+ };
67
+ window.addEventListener("keydown", handleKeys);
68
+ return () => {
69
+ window.removeEventListener("keydown", handleKeys);
70
+ previousFocus?.focus();
71
+ };
72
+ }, [onClose]);
73
+
74
+ return (
75
+ <div className="modal-backdrop" onClick={onClose}>
76
+ <div
77
+ ref={dialogRef}
78
+ className="modal settings-dialog"
79
+ role="dialog"
80
+ aria-modal="true"
81
+ aria-labelledby="settings-title"
82
+ onClick={(e) => e.stopPropagation()}
83
+ >
84
+ <header className="modal-header">
85
+ <div>
86
+ <h3 id="settings-title">Settings</h3>
87
+ <p>Appearance, dashboard, and startup</p>
88
+ </div>
89
+ <button ref={closeRef} className="icon-btn" aria-label="Close settings" onClick={onClose}>
90
+ <X size={17} />
91
+ </button>
92
+ </header>
93
+
94
+ <div className="modal-body">
95
+ {startupError && <div className="inline-error" role="alert">{startupError}</div>}
96
+ <ThemeSettings
97
+ mode={themeMode}
98
+ palette={palette}
99
+ importedTheme={importedTheme}
100
+ onModeChange={onThemeModeChange}
101
+ onPaletteChange={onPaletteChange}
102
+ onImportTheme={onImportTheme}
103
+ />
104
+ <section className="settings-section" aria-labelledby="settings-dashboard-heading">
105
+ <div className="settings-section-heading">
106
+ <h4 id="settings-dashboard-heading">Dashboard</h4>
107
+ </div>
108
+ <div className="setting-row">
109
+ <div className="setting-copy">
110
+ <strong id="setting-ranks-label">Show ranks</strong>
111
+ <p>Show the Rank tab and level label in the header.</p>
112
+ </div>
113
+ <button
114
+ type="button"
115
+ className={`toggle ${showRanks ? "on" : ""}`}
116
+ role="switch"
117
+ aria-labelledby="setting-ranks-label"
118
+ aria-checked={showRanks}
119
+ onClick={() => onShowRanksChange(!showRanks)}
120
+ >
121
+ <span className="toggle-knob" />
122
+ </button>
123
+ </div>
124
+ </section>
125
+ {startup ? (
126
+ <>
127
+ <section className="settings-section" aria-labelledby="settings-startup-heading">
128
+ <div className="settings-section-heading">
129
+ <h4 id="settings-startup-heading">Startup</h4>
130
+ <span>{startup.enabled ? "Starts with Windows" : "Manual launch"}</span>
131
+ </div>
132
+ <div className="setting-row">
133
+ <div className="setting-copy">
134
+ <strong id="setting-autostart-label">Start with Windows</strong>
135
+ <p>Launch Token Larper when you sign in.</p>
136
+ </div>
137
+ <button
138
+ disabled={saving}
139
+ className={`toggle ${startup.enabled ? "on" : ""}`}
140
+ role="switch"
141
+ aria-labelledby="setting-autostart-label"
142
+ aria-checked={startup.enabled}
143
+ onClick={() => onToggleStartup(!startup.enabled)}
144
+ >
145
+ <span className="toggle-knob" />
146
+ </button>
147
+ </div>
148
+ <div className="setting-row">
149
+ <div className="setting-copy">
150
+ <strong id="setting-browser-label">Open dashboard at sign-in</strong>
151
+ <p>
152
+ {startup.enabled
153
+ ? "Open the dashboard in your browser after launch."
154
+ : "Takes effect when Start with Windows is on."}
155
+ </p>
156
+ </div>
157
+ <button
158
+ disabled={saving}
159
+ className={`toggle ${startup.openBrowserOnBoot ? "on" : ""}`}
160
+ role="switch"
161
+ aria-labelledby="setting-browser-label"
162
+ aria-checked={startup.openBrowserOnBoot}
163
+ onClick={() => onToggleStartup(startup.enabled, !startup.openBrowserOnBoot)}
164
+ >
165
+ <span className="toggle-knob" />
166
+ </button>
167
+ </div>
168
+ </section>
169
+
170
+ <details className="settings-details">
171
+ <summary>
172
+ Technical details <ChevronDown size={14} aria-hidden="true" />
173
+ </summary>
174
+ <dl>
175
+ <div>
176
+ <dt>Dashboard</dt>
177
+ <dd><code>http://127.0.0.1:{startup.port}</code></dd>
178
+ </div>
179
+ <div>
180
+ <dt>Windows startup</dt>
181
+ <dd><code>{startup.registryValue || "Not registered"}</code></dd>
182
+ </div>
183
+ <div>
184
+ <dt>Launcher</dt>
185
+ <dd><code>{startup.launcherPath}</code></dd>
186
+ </div>
187
+ </dl>
188
+ </details>
189
+ </>
190
+ ) : (
191
+ <div className="settings-empty">
192
+ <p>Startup settings are unavailable.</p>
193
+ <button className="btn" onClick={onRetry}>Try again</button>
194
+ </div>
195
+ )}
196
+ </div>
197
+
198
+ <footer className="settings-footer">
199
+ <span>Token Larper runs on this computer.</span>
200
+ <button className="settings-quit" onClick={onQuit}>
201
+ <Power size={14} /> Quit app
202
+ </button>
203
+ </footer>
204
+ </div>
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,131 @@
1
+ import React, { useState } from "react";
2
+ import { ChevronDown } from "lucide-react";
3
+ import { importThemeCss, THEME_PRESETS, type ImportedTheme, type ThemeMode, type ThemePalette } from "../themes.ts";
4
+ import { SelectMenu, type SelectMenuOption } from "./SelectMenu.tsx";
5
+
6
+ interface Props {
7
+ mode: ThemeMode;
8
+ palette: ThemePalette;
9
+ importedTheme: ImportedTheme | null;
10
+ onModeChange: (mode: ThemeMode) => void;
11
+ onPaletteChange: (palette: ThemePalette) => void;
12
+ onImportTheme: (theme: ImportedTheme) => void;
13
+ }
14
+
15
+ const SAMPLE_THEME = `:root {
16
+ --bg: #f7f8f6;
17
+ --surface: #ffffff;
18
+ --text: #1f2a23;
19
+ --accent: #2f5a43;
20
+ --accent-ink: #ffffff;
21
+ }
22
+
23
+ .dark {
24
+ --bg: #141916;
25
+ --surface: #1b221e;
26
+ --text: #e8eee7;
27
+ --accent: #9fcaa8;
28
+ --accent-ink: #11261a;
29
+ }`;
30
+
31
+ export function ThemeSettings({ mode, palette, importedTheme, onModeChange, onPaletteChange, onImportTheme }: Props) {
32
+ const [css, setCss] = useState("");
33
+ const [error, setError] = useState<string | null>(null);
34
+ const [message, setMessage] = useState<string | null>(null);
35
+
36
+ function applyPastedTheme() {
37
+ try {
38
+ const imported = importThemeCss(css);
39
+ onImportTheme(imported);
40
+ setError(null);
41
+ setMessage("Theme applied.");
42
+ } catch (cause) {
43
+ setMessage(null);
44
+ setError(cause instanceof Error ? cause.message : "Could not read this theme CSS.");
45
+ }
46
+ }
47
+
48
+ const paletteOptions: SelectMenuOption<ThemePalette>[] = THEME_PRESETS.map((preset) => ({
49
+ value: preset.id,
50
+ label: preset.name,
51
+ description: preset.description,
52
+ visual: <span className="palette-color" aria-hidden="true" style={{ backgroundColor: mode === "dark" ? preset.darkAccent : preset.accent }} />,
53
+ }));
54
+ if (importedTheme) {
55
+ const colors = importedTheme[mode];
56
+ paletteOptions.push({
57
+ value: "custom",
58
+ label: "Imported theme",
59
+ description: "Your saved CSS colors",
60
+ visual: <span className="palette-color" aria-hidden="true" style={{ backgroundColor: colors["--accent"] }} />,
61
+ });
62
+ }
63
+
64
+ return (
65
+ <section className="settings-section theme-settings" aria-labelledby="settings-appearance-heading">
66
+ <div className="settings-section-heading">
67
+ <h4 id="settings-appearance-heading">Appearance</h4>
68
+ <span>{mode === "dark" ? "Dark mode" : "Light mode"}</span>
69
+ </div>
70
+
71
+ <div className="theme-mode-row">
72
+ <span>Color mode</span>
73
+ <div className="segmented" role="group" aria-label="Color mode">
74
+ {(["light", "dark"] as const).map((option) => (
75
+ <button key={option} type="button" aria-pressed={mode === option} onClick={() => onModeChange(option)}>
76
+ {option === "light" ? "Light" : "Dark"}
77
+ </button>
78
+ ))}
79
+ </div>
80
+ </div>
81
+
82
+ <SelectMenu
83
+ label="Color palette"
84
+ value={palette}
85
+ options={paletteOptions}
86
+ onChange={onPaletteChange}
87
+ className="palette-picker"
88
+ showDescriptionInTrigger
89
+ />
90
+
91
+ <details className="theme-import">
92
+ <summary>
93
+ <span>Import a CSS theme</span>
94
+ <ChevronDown size={14} aria-hidden="true" />
95
+ </summary>
96
+ <div className="theme-import-body">
97
+ <p>
98
+ Paste light and dark CSS variable blocks. Use Token Larper colors or shadcn-style variables; extra CSS is ignored.
99
+ </p>
100
+ <details className="theme-format">
101
+ <summary>Show example CSS <ChevronDown size={14} aria-hidden="true" /></summary>
102
+ <div className="theme-format-body">
103
+ <p>This example has the five required Token Larper colors in both modes.</p>
104
+ <pre>{SAMPLE_THEME}</pre>
105
+ </div>
106
+ </details>
107
+ <label htmlFor="theme-css-input">Theme CSS</label>
108
+ <textarea
109
+ id="theme-css-input"
110
+ rows={7}
111
+ value={css}
112
+ onChange={(event) => {
113
+ setCss(event.target.value);
114
+ setError(null);
115
+ setMessage(null);
116
+ }}
117
+ placeholder="Paste the light and dark CSS variable blocks here…"
118
+ spellCheck={false}
119
+ />
120
+ {error && <p className="theme-import-feedback is-error" role="alert">{error}</p>}
121
+ {message && <p className="theme-import-feedback" role="status">{message}</p>}
122
+ <div className="theme-import-actions">
123
+ <button className="btn btn-primary" type="button" disabled={!css.trim()} onClick={applyPastedTheme}>
124
+ Apply theme
125
+ </button>
126
+ </div>
127
+ </div>
128
+ </details>
129
+ </section>
130
+ );
131
+ }
@@ -0,0 +1,60 @@
1
+ import React, { createContext, useContext } from "react";
2
+ import type { DashboardPayload, HarnessId, SessionEntry, TimePeriodRow } from "../types.ts";
3
+ import type { HarnessFilter } from "./lib/aggregate.ts";
4
+ import type { DateRange } from "./lib/range.ts";
5
+ import type { SeriesInfo } from "./charts.tsx";
6
+
7
+ export interface Dashboard {
8
+ data: DashboardPayload;
9
+ range: DateRange;
10
+ harness: HarnessFilter;
11
+ setHarness: (h: HarnessFilter) => void;
12
+ estimated: boolean;
13
+ costOf: (c: { verifiedCost: number; estimatedCost: number }) => number;
14
+ /** Days with usage in the selected range, narrowed to the selected tool. */
15
+ days: TimePeriodRow[];
16
+ /** Sessions whose last activity falls in the range, narrowed to the selected tool. */
17
+ sessions: SessionEntry[];
18
+ series: SeriesInfo[];
19
+ seriesOf: (id: HarnessId) => SeriesInfo;
20
+ nameOf: (id: HarnessId) => string;
21
+ search: string;
22
+ }
23
+
24
+ const DashboardContext = createContext<Dashboard | null>(null);
25
+
26
+ export const DashboardProvider = DashboardContext.Provider;
27
+
28
+ export function useDashboard(): Dashboard {
29
+ const ctx = useContext(DashboardContext);
30
+ if (!ctx) throw new Error("useDashboard needs a DashboardProvider");
31
+ return ctx;
32
+ }
33
+
34
+ export function ToolTag({ id }: { id: HarnessId }) {
35
+ const { seriesOf, nameOf } = useDashboard();
36
+ return (
37
+ <span className="tool-tag">
38
+ <i style={{ background: seriesOf(id).color }} aria-hidden="true" />
39
+ {nameOf(id)}
40
+ </span>
41
+ );
42
+ }
43
+
44
+ /** A thin inline bar for a row's share of the largest row in the table. */
45
+ export function ShareBar({ value, max, color }: { value: number; max: number; color?: string }) {
46
+ const pct = max > 0 ? Math.max(value > 0 ? 1.5 : 0, (value / max) * 100) : 0;
47
+ return (
48
+ <span className="share-bar" aria-hidden="true">
49
+ <span style={{ width: `${pct}%`, background: color }} />
50
+ </span>
51
+ );
52
+ }
53
+
54
+ export function EmptyState({ children }: { children: React.ReactNode }) {
55
+ return <div className="empty-state">{children}</div>;
56
+ }
57
+
58
+ export function pct(part: number, whole: number, digits = 1): string {
59
+ return whole > 0 ? `${((part / whole) * 100).toFixed(digits)}%` : "0%";
60
+ }