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.
- package/README.md +334 -0
- package/Start-TokenLarper.vbs +5 -0
- package/bin/cli.js +34 -0
- package/llms.txt +59 -0
- package/package.json +61 -0
- package/scripts/docs-demo-server.ts +141 -0
- package/scripts/launch-on-default-desktop.ps1 +42 -0
- package/scripts/launch-silent.vbs +6 -0
- package/scripts/run-server.ps1 +59 -0
- package/scripts/tray-host.ps1 +181 -0
- package/src/ccusage.ts +1075 -0
- package/src/client/App.tsx +560 -0
- package/src/client/charts.tsx +341 -0
- package/src/client/components/Calendar.tsx +207 -0
- package/src/client/components/DateRangePicker.tsx +131 -0
- package/src/client/components/KpiCards.tsx +122 -0
- package/src/client/components/RankChip.tsx +22 -0
- package/src/client/components/SelectMenu.tsx +116 -0
- package/src/client/components/SettingsDialog.tsx +207 -0
- package/src/client/components/ThemeSettings.tsx +131 -0
- package/src/client/context.tsx +60 -0
- package/src/client/index.html +54 -0
- package/src/client/lib/aggregate.ts +266 -0
- package/src/client/lib/range.ts +87 -0
- package/src/client/lib/rank.ts +323 -0
- package/src/client/lib/shareCard.ts +139 -0
- package/src/client/logo.svg +8 -0
- package/src/client/main.tsx +12 -0
- package/src/client/styles.css +1113 -0
- package/src/client/themes.ts +268 -0
- package/src/client/utils.ts +61 -0
- package/src/client/views/Models.tsx +71 -0
- package/src/client/views/Overview.tsx +280 -0
- package/src/client/views/Projects.tsx +88 -0
- package/src/client/views/Rank.tsx +255 -0
- package/src/client/views/Sessions.tsx +108 -0
- package/src/client/views/Tools.tsx +114 -0
- package/src/projects.ts +214 -0
- package/src/server.ts +173 -0
- package/src/startup.ts +110 -0
- package/src/tray.ts +293 -0
- 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
|
+
}
|