token-larper 1.6.1 → 1.8.1
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 +10 -3
- package/bin/cli.js +142 -23
- package/package.json +1 -1
- package/src/client/components/SettingsDialog.tsx +63 -64
- package/src/client/components/ThemeSettings.tsx +85 -38
- package/src/client/index.html +2 -0
- package/src/client/lib/cssColor.ts +32 -0
- package/src/client/lib/trayTheme.ts +9 -24
- package/src/client/styles.css +6 -1
- package/src/client/themes.ts +133 -9
- package/src/icons.ts +147 -0
- package/src/server.ts +22 -7
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ It consolidates all sessions into a real-time, interactive local dashboard runni
|
|
|
24
24
|
* **100% Private & Offline**: No API keys required, no external proxies to configure, and zero analytics or telemetry sent to the cloud. The one outbound request is an optional update check that asks npm for the latest version number; turn it off in **Settings → Updates**.
|
|
25
25
|
* **Cross-Platform Core**: The telemetry engine, 18 harness scrapers, and React dashboard run identically across Windows, macOS (Apple Silicon & Intel), and Linux.
|
|
26
26
|
* **Gamified Hall of Larp**: Track your lifetime token burn, unlock 14 achievements, climb an 11-tier ranking ladder past rival personas, and export a shareable 1200×630 flex badge.
|
|
27
|
-
* **Six Styles,
|
|
27
|
+
* **Six Styles, Your Colors**: Grove, Terminal, Paper, Brutal, Soft, and Mono each change fonts, corner radius, spacing, borders, and shadows, not just colors. Pick a base color that tints backgrounds, borders, and text, and an accent for highlights, from presets or any custom color. Or import a tweakcn/shadcn CSS theme on top.
|
|
28
28
|
* **Subscription-Aware**: Distinguishes between strict verified API billing and estimated **"LARP Value"** (the theoretical API cost of tokens consumed through flat-rate subscriptions like Claude Pro/Max, Copilot, or Antigravity).
|
|
29
29
|
|
|
30
30
|
---
|
|
@@ -99,8 +99,15 @@ You can run Token Larper instantly without cloning:
|
|
|
99
99
|
```bash
|
|
100
100
|
npx token-larper
|
|
101
101
|
# or
|
|
102
|
-
bunx
|
|
102
|
+
bunx token-larper
|
|
103
103
|
```
|
|
104
|
+
It starts in the background, opens the dashboard, and keeps running after you close the terminal. On Windows, quit it from the tray icon. On any platform:
|
|
105
|
+
```bash
|
|
106
|
+
bunx token-larper stop # stop it
|
|
107
|
+
bunx token-larper --foreground # run in the terminal with logs instead
|
|
108
|
+
bunx token-larper --help # all options
|
|
109
|
+
```
|
|
110
|
+
The background server writes its output to `server.log` in the data folder.
|
|
104
111
|
|
|
105
112
|
---
|
|
106
113
|
|
|
@@ -132,7 +139,7 @@ bun run tray
|
|
|
132
139
|
```
|
|
133
140
|
> **Shortcut**: You can also simply **double-click** `Start-TokenLarper.vbs` in File Explorer!
|
|
134
141
|
|
|
135
|
-
*
|
|
142
|
+
* The **Burning t.** icon will appear in your notification tray.
|
|
136
143
|
* Hover over the icon to see your real-time token count and cost estimate.
|
|
137
144
|
* Left-click the icon for a compact usage popup with an **Open dashboard** button. Right-click it to sync or configure boot auto-start.
|
|
138
145
|
|
package/bin/cli.js
CHANGED
|
@@ -1,36 +1,155 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
|
|
7
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const rootDir = join(__dirname, "..");
|
|
9
10
|
const serverScript = join(rootDir, "src", "server.ts");
|
|
11
|
+
const port = Number(process.env.PORT || 4269);
|
|
12
|
+
const args = process.argv.slice(2);
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
const HELP = `Token Larper: a local dashboard for AI coding token usage.
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
Usage:
|
|
17
|
+
token-larper Start in the background and open the dashboard
|
|
18
|
+
token-larper --foreground Run in this terminal and show logs (Ctrl+C stops it)
|
|
19
|
+
token-larper --no-open Start without opening the browser
|
|
20
|
+
token-larper stop Stop the running copy
|
|
21
|
+
|
|
22
|
+
Environment: PORT (default 4269), TOKEN_LARPER_DATA_DIR, NO_TRAY=1`;
|
|
23
|
+
|
|
24
|
+
// Same folder as src/paths.ts; the background server's log goes there.
|
|
25
|
+
function dataDir() {
|
|
26
|
+
if (process.env.TOKEN_LARPER_DATA_DIR) return resolve(process.env.TOKEN_LARPER_DATA_DIR);
|
|
27
|
+
const home = homedir();
|
|
28
|
+
if (process.platform === "win32") return join(process.env.LOCALAPPDATA || join(home, "AppData", "Local"), "TokenLarper");
|
|
29
|
+
if (process.platform === "darwin") return join(home, "Library", "Application Support", "TokenLarper");
|
|
30
|
+
return join(process.env.XDG_DATA_HOME || join(home, ".local", "share"), "token-larper");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function bunPath() {
|
|
34
|
+
if (typeof Bun !== "undefined") return process.execPath;
|
|
35
|
+
if (spawnSync("bun", ["--version"], { stdio: "ignore" }).status === 0) return "bun";
|
|
36
|
+
console.error("🔥 Token Larper requires Bun (v1.1+) to run.");
|
|
37
|
+
console.error("\nInstall Bun with one command:");
|
|
38
|
+
if (process.platform === "win32") {
|
|
39
|
+
console.error(' powershell -c "irm bun.sh/install.ps1 | iex"');
|
|
25
40
|
} else {
|
|
26
|
-
console.error("
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
console.error(" curl -fsSL https://bun.sh/install | bash");
|
|
42
|
+
}
|
|
43
|
+
console.error("\nMore info: https://github.com/YarooqH/token-larper\n");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function isRunning(p) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`http://127.0.0.1:${p}/api/tray-status`, { signal: AbortSignal.timeout(800) });
|
|
50
|
+
return res.ok;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function openBrowser(url) {
|
|
57
|
+
const [cmd, cmdArgs] =
|
|
58
|
+
process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
59
|
+
: process.platform === "darwin" ? ["open", [url]]
|
|
60
|
+
: ["xdg-open", [url]];
|
|
61
|
+
spawn(cmd, cmdArgs, { stdio: "ignore", detached: true, windowsHide: true }).unref();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
65
|
+
|
|
66
|
+
async function stop() {
|
|
67
|
+
try {
|
|
68
|
+
await fetch(`http://127.0.0.1:${port}/api/shutdown`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: "{}",
|
|
72
|
+
signal: AbortSignal.timeout(2000),
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
console.log(`Token Larper isn't running on port ${port}.`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (let i = 0; i < 20 && (await isRunning(port)); i++) await sleep(250);
|
|
79
|
+
console.log("Stopped Token Larper.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function foreground() {
|
|
83
|
+
if (typeof Bun !== "undefined") return import(serverScript);
|
|
84
|
+
const child = spawnSync("bun", [serverScript], { stdio: "inherit", cwd: rootDir });
|
|
85
|
+
process.exit(child.status ?? 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function background(open) {
|
|
89
|
+
const bun = bunPath();
|
|
90
|
+
const dir = dataDir();
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
const logFile = join(dir, "server.log");
|
|
93
|
+
const log = openSync(logFile, "w");
|
|
94
|
+
|
|
95
|
+
// The server moves to the next free port if another program has this one, and takes
|
|
96
|
+
// over from an older Token Larper on the same port (see src/server.ts).
|
|
97
|
+
const child = spawn(bun, [serverScript], {
|
|
98
|
+
cwd: rootDir,
|
|
99
|
+
detached: true,
|
|
100
|
+
windowsHide: true,
|
|
101
|
+
stdio: ["ignore", log, log],
|
|
102
|
+
env: { ...process.env, TOKEN_LARPER_NO_BROWSER: "1" },
|
|
103
|
+
});
|
|
104
|
+
let exited = false;
|
|
105
|
+
child.on("exit", () => (exited = true));
|
|
106
|
+
child.unref();
|
|
107
|
+
closeSync(log);
|
|
108
|
+
|
|
109
|
+
const readLog = () => {
|
|
110
|
+
try {
|
|
111
|
+
return readFileSync(logFile, "utf8");
|
|
112
|
+
} catch {
|
|
113
|
+
return "";
|
|
32
114
|
}
|
|
33
|
-
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Ready once the server says where it is listening. A copy of the same version that
|
|
118
|
+
// is already running says so too ("already running at ..."), then the new one exits.
|
|
119
|
+
let url = null;
|
|
120
|
+
for (let i = 0; i < 120 && !url; i++) {
|
|
121
|
+
await sleep(250);
|
|
122
|
+
url = readLog().match(/running at (http:\/\/localhost:\d+)/)?.[1] ?? null;
|
|
123
|
+
if (!url && exited) break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!url) {
|
|
127
|
+
console.error("🔥 Token Larper didn't start.");
|
|
128
|
+
const tail = readLog().trim().split(/\r?\n/).slice(-15).join("\n");
|
|
129
|
+
if (tail) console.error(`\n${tail}\n`);
|
|
130
|
+
console.error(`Full log: ${logFile}`);
|
|
131
|
+
console.error("Run with --foreground to see the server output live.");
|
|
34
132
|
process.exit(1);
|
|
35
133
|
}
|
|
134
|
+
|
|
135
|
+
console.log(`🔥 Token Larper is running at ${url}`);
|
|
136
|
+
console.log(" It keeps running after you close this window.");
|
|
137
|
+
console.log(
|
|
138
|
+
process.platform === "win32"
|
|
139
|
+
? " Quit it from the tray icon, or run: bunx token-larper stop"
|
|
140
|
+
: " Stop it with: bunx token-larper stop"
|
|
141
|
+
);
|
|
142
|
+
if (open) openBrowser(url);
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
147
|
+
console.log(HELP);
|
|
148
|
+
} else if (args[0] === "stop") {
|
|
149
|
+
await stop();
|
|
150
|
+
} else if (args.includes("--foreground") || args.includes("-f")) {
|
|
151
|
+
bunPath();
|
|
152
|
+
await foreground();
|
|
153
|
+
} else {
|
|
154
|
+
await background(!args.includes("--no-open") && process.env.TOKEN_LARPER_NO_BROWSER !== "1");
|
|
36
155
|
}
|
package/package.json
CHANGED
|
@@ -94,7 +94,7 @@ export function SettingsDialog({
|
|
|
94
94
|
<header className="modal-header">
|
|
95
95
|
<div>
|
|
96
96
|
<h3 id="settings-title">Settings</h3>
|
|
97
|
-
<p>Appearance, dashboard,
|
|
97
|
+
<p>Appearance, dashboard, startup, and updates</p>
|
|
98
98
|
</div>
|
|
99
99
|
<button ref={closeRef} className="icon-btn" aria-label="Close settings" onClick={onClose}>
|
|
100
100
|
<X size={17} />
|
|
@@ -133,78 +133,77 @@ export function SettingsDialog({
|
|
|
133
133
|
</button>
|
|
134
134
|
</div>
|
|
135
135
|
</section>
|
|
136
|
-
<UpdateSettings updates={updates} autoCheck={checkUpdates} onAutoCheckChange={onCheckUpdatesChange} />
|
|
137
136
|
{startup ? (
|
|
138
|
-
|
|
139
|
-
<
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
<div className="setting-
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
<p>Launch Token Larper when you sign in.</p>
|
|
148
|
-
</div>
|
|
149
|
-
<button
|
|
150
|
-
disabled={saving}
|
|
151
|
-
className={`toggle ${startup.enabled ? "on" : ""}`}
|
|
152
|
-
role="switch"
|
|
153
|
-
aria-labelledby="setting-autostart-label"
|
|
154
|
-
aria-checked={startup.enabled}
|
|
155
|
-
onClick={() => onToggleStartup(!startup.enabled)}
|
|
156
|
-
>
|
|
157
|
-
<span className="toggle-knob" />
|
|
158
|
-
</button>
|
|
137
|
+
<section className="settings-section" aria-labelledby="settings-startup-heading">
|
|
138
|
+
<div className="settings-section-heading">
|
|
139
|
+
<h4 id="settings-startup-heading">Startup</h4>
|
|
140
|
+
<span>{startup.enabled ? "Starts with Windows" : "Manual launch"}</span>
|
|
141
|
+
</div>
|
|
142
|
+
<div className="setting-row">
|
|
143
|
+
<div className="setting-copy">
|
|
144
|
+
<strong id="setting-autostart-label">Start with Windows</strong>
|
|
145
|
+
<p>Launch Token Larper when you sign in.</p>
|
|
159
146
|
</div>
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
</
|
|
147
|
+
<button
|
|
148
|
+
disabled={saving}
|
|
149
|
+
className={`toggle ${startup.enabled ? "on" : ""}`}
|
|
150
|
+
role="switch"
|
|
151
|
+
aria-labelledby="setting-autostart-label"
|
|
152
|
+
aria-checked={startup.enabled}
|
|
153
|
+
onClick={() => onToggleStartup(!startup.enabled)}
|
|
154
|
+
>
|
|
155
|
+
<span className="toggle-knob" />
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
<div className="setting-row">
|
|
159
|
+
<div className="setting-copy">
|
|
160
|
+
<strong id="setting-browser-label">Open dashboard at sign-in</strong>
|
|
161
|
+
<p>
|
|
162
|
+
{startup.enabled
|
|
163
|
+
? "Open the dashboard in your browser after launch."
|
|
164
|
+
: "Takes effect when Start with Windows is on."}
|
|
165
|
+
</p>
|
|
179
166
|
</div>
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
<dt>Windows startup</dt>
|
|
193
|
-
<dd><code>{startup.registryValue || "Not registered"}</code></dd>
|
|
194
|
-
</div>
|
|
195
|
-
<div>
|
|
196
|
-
<dt>Launcher</dt>
|
|
197
|
-
<dd><code>{startup.launcherPath}</code></dd>
|
|
198
|
-
</div>
|
|
199
|
-
</dl>
|
|
200
|
-
</details>
|
|
201
|
-
</>
|
|
167
|
+
<button
|
|
168
|
+
disabled={saving}
|
|
169
|
+
className={`toggle ${startup.openBrowserOnBoot ? "on" : ""}`}
|
|
170
|
+
role="switch"
|
|
171
|
+
aria-labelledby="setting-browser-label"
|
|
172
|
+
aria-checked={startup.openBrowserOnBoot}
|
|
173
|
+
onClick={() => onToggleStartup(startup.enabled, !startup.openBrowserOnBoot)}
|
|
174
|
+
>
|
|
175
|
+
<span className="toggle-knob" />
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
</section>
|
|
202
179
|
) : (
|
|
203
180
|
<div className="settings-empty">
|
|
204
181
|
<p>Startup settings are unavailable.</p>
|
|
205
182
|
<button className="btn" onClick={onRetry}>Try again</button>
|
|
206
183
|
</div>
|
|
207
184
|
)}
|
|
185
|
+
<UpdateSettings updates={updates} autoCheck={checkUpdates} onAutoCheckChange={onCheckUpdatesChange} />
|
|
186
|
+
{startup && (
|
|
187
|
+
<details className="settings-details">
|
|
188
|
+
<summary>
|
|
189
|
+
Technical details <ChevronDown size={14} aria-hidden="true" />
|
|
190
|
+
</summary>
|
|
191
|
+
<dl>
|
|
192
|
+
<div>
|
|
193
|
+
<dt>Dashboard</dt>
|
|
194
|
+
<dd><code>http://127.0.0.1:{startup.port}</code></dd>
|
|
195
|
+
</div>
|
|
196
|
+
<div>
|
|
197
|
+
<dt>Windows startup</dt>
|
|
198
|
+
<dd><code>{startup.registryValue || "Not registered"}</code></dd>
|
|
199
|
+
</div>
|
|
200
|
+
<div>
|
|
201
|
+
<dt>Launcher</dt>
|
|
202
|
+
<dd><code>{startup.launcherPath}</code></dd>
|
|
203
|
+
</div>
|
|
204
|
+
</dl>
|
|
205
|
+
</details>
|
|
206
|
+
)}
|
|
208
207
|
</div>
|
|
209
208
|
|
|
210
209
|
<footer className="settings-footer">
|
|
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
|
|
2
2
|
import { Check, ChevronDown, Pipette } from "lucide-react";
|
|
3
3
|
import {
|
|
4
4
|
ACCENT_PRESETS,
|
|
5
|
+
BASE_PRESETS,
|
|
5
6
|
importThemeCss,
|
|
6
7
|
STYLE_PRESETS,
|
|
7
8
|
type Appearance,
|
|
@@ -39,8 +40,6 @@ export function ThemeSettings({ mode, followsSystem, appearance, importedTheme,
|
|
|
39
40
|
const [css, setCss] = useState("");
|
|
40
41
|
const [error, setError] = useState<string | null>(null);
|
|
41
42
|
const [message, setMessage] = useState<string | null>(null);
|
|
42
|
-
const accent = appearance.accent;
|
|
43
|
-
const customColor = accent.kind === "custom" ? accent.color : "#6d5bd0";
|
|
44
43
|
|
|
45
44
|
function applyPastedTheme() {
|
|
46
45
|
try {
|
|
@@ -109,42 +108,26 @@ export function ThemeSettings({ mode, followsSystem, appearance, importedTheme,
|
|
|
109
108
|
</div>
|
|
110
109
|
</div>
|
|
111
110
|
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
className="accent-swatch"
|
|
133
|
-
style={{ background: mode === "dark" ? preset.dark : preset.light }}
|
|
134
|
-
onClick={() => onAppearanceChange({ ...appearance, accent: { kind: "preset", id: preset.id } })}
|
|
135
|
-
/>
|
|
136
|
-
))}
|
|
137
|
-
<label className={`accent-custom ${accent.kind === "custom" ? "is-selected" : ""}`} title="Pick any color">
|
|
138
|
-
<Pipette size={14} aria-hidden="true" />
|
|
139
|
-
<span className="sr-only">Custom accent color</span>
|
|
140
|
-
<input
|
|
141
|
-
type="color"
|
|
142
|
-
value={customColor}
|
|
143
|
-
onChange={(event) => onAppearanceChange({ ...appearance, accent: { kind: "custom", color: event.target.value } })}
|
|
144
|
-
/>
|
|
145
|
-
</label>
|
|
146
|
-
</div>
|
|
147
|
-
</div>
|
|
111
|
+
<ColorChoices
|
|
112
|
+
id="theme-base"
|
|
113
|
+
label="Base"
|
|
114
|
+
hint={appearance.imported ? "Set by your imported colors" : "Backgrounds, borders and text"}
|
|
115
|
+
disabled={appearance.imported}
|
|
116
|
+
choice={appearance.base}
|
|
117
|
+
presets={BASE_PRESETS}
|
|
118
|
+
fallbackCustom="#5f7390"
|
|
119
|
+
onChange={(base) => onAppearanceChange({ ...appearance, base })}
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
<ColorChoices
|
|
123
|
+
id="theme-accent"
|
|
124
|
+
label="Accent"
|
|
125
|
+
hint="Buttons, links and highlights"
|
|
126
|
+
choice={appearance.accent}
|
|
127
|
+
presets={ACCENT_PRESETS.map((preset) => ({ ...preset, color: mode === "dark" ? preset.dark : preset.light }))}
|
|
128
|
+
fallbackCustom="#6d5bd0"
|
|
129
|
+
onChange={(accent) => onAppearanceChange({ ...appearance, accent })}
|
|
130
|
+
/>
|
|
148
131
|
|
|
149
132
|
{importedTheme && (
|
|
150
133
|
<div className="theme-row">
|
|
@@ -204,3 +187,67 @@ export function ThemeSettings({ mode, followsSystem, appearance, importedTheme,
|
|
|
204
187
|
</section>
|
|
205
188
|
);
|
|
206
189
|
}
|
|
190
|
+
|
|
191
|
+
type ColorChoice<Id extends string> = { kind: "style" } | { kind: "preset"; id: Id } | { kind: "custom"; color: string };
|
|
192
|
+
|
|
193
|
+
/** "Style default", a row of preset swatches, and a picker for any color. */
|
|
194
|
+
function ColorChoices<Id extends string>({ id, label, hint, disabled = false, choice, presets, fallbackCustom, onChange }: {
|
|
195
|
+
id: string;
|
|
196
|
+
label: string;
|
|
197
|
+
hint: string;
|
|
198
|
+
disabled?: boolean;
|
|
199
|
+
choice: ColorChoice<Id>;
|
|
200
|
+
presets: { id: Id; name: string; color: string }[];
|
|
201
|
+
fallbackCustom: string;
|
|
202
|
+
onChange: (choice: ColorChoice<Id>) => void;
|
|
203
|
+
}) {
|
|
204
|
+
const labelId = `${id}-label`;
|
|
205
|
+
return (
|
|
206
|
+
<div className="theme-block">
|
|
207
|
+
<div className="theme-block-heading">
|
|
208
|
+
<span className="theme-row-label" id={labelId}>{label}</span>
|
|
209
|
+
<span className="theme-row-hint">{hint}</span>
|
|
210
|
+
</div>
|
|
211
|
+
<div className="accent-row" role="radiogroup" aria-labelledby={labelId} aria-disabled={disabled || undefined}>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
role="radio"
|
|
215
|
+
aria-checked={choice.kind === "style"}
|
|
216
|
+
className="accent-default"
|
|
217
|
+
disabled={disabled}
|
|
218
|
+
onClick={() => onChange({ kind: "style" })}
|
|
219
|
+
>
|
|
220
|
+
Style default
|
|
221
|
+
</button>
|
|
222
|
+
{presets.map((preset) => (
|
|
223
|
+
<button
|
|
224
|
+
key={preset.id}
|
|
225
|
+
type="button"
|
|
226
|
+
role="radio"
|
|
227
|
+
aria-checked={choice.kind === "preset" && choice.id === preset.id}
|
|
228
|
+
aria-label={preset.name}
|
|
229
|
+
title={preset.name}
|
|
230
|
+
className="accent-swatch"
|
|
231
|
+
disabled={disabled}
|
|
232
|
+
style={{ background: preset.color }}
|
|
233
|
+
onClick={() => onChange({ kind: "preset", id: preset.id })}
|
|
234
|
+
/>
|
|
235
|
+
))}
|
|
236
|
+
<label
|
|
237
|
+
className={`accent-custom ${choice.kind === "custom" ? "is-selected" : ""}`}
|
|
238
|
+
style={choice.kind === "custom" ? { background: choice.color } : undefined}
|
|
239
|
+
title="Pick any color"
|
|
240
|
+
>
|
|
241
|
+
<Pipette size={14} aria-hidden="true" />
|
|
242
|
+
<span className="sr-only">Custom {label.toLowerCase()} color</span>
|
|
243
|
+
<input
|
|
244
|
+
type="color"
|
|
245
|
+
disabled={disabled}
|
|
246
|
+
value={choice.kind === "custom" ? choice.color : fallbackCustom}
|
|
247
|
+
onChange={(event) => onChange({ kind: "custom", color: event.target.value })}
|
|
248
|
+
/>
|
|
249
|
+
</label>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
package/src/client/index.html
CHANGED
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
</script>
|
|
36
36
|
<meta name="theme-color" content="#f8f9f7" />
|
|
37
37
|
<title>Token Larper — Usage overview</title>
|
|
38
|
+
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
|
38
39
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
|
40
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
39
41
|
<link rel="stylesheet" href="/styles.css" />
|
|
40
42
|
</head>
|
|
41
43
|
<body>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Themes can use any CSS color syntax (oklch, color-mix, var() chains). To get a plain
|
|
2
|
+
// #rrggbb, a hidden probe element resolves the expression to a computed color, which is
|
|
3
|
+
// painted onto a 1×1 canvas and read back.
|
|
4
|
+
|
|
5
|
+
export interface ColorReader {
|
|
6
|
+
/** Resolve a CSS color expression, e.g. "var(--bg)", to #rrggbb. */
|
|
7
|
+
read(expression: string): string;
|
|
8
|
+
dispose(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createColorReader(): ColorReader | null {
|
|
12
|
+
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
|
13
|
+
if (!ctx || !document.body) return null;
|
|
14
|
+
const probe = document.createElement("span");
|
|
15
|
+
probe.style.display = "none";
|
|
16
|
+
document.body.appendChild(probe);
|
|
17
|
+
return {
|
|
18
|
+
read(expression) {
|
|
19
|
+
probe.style.color = "";
|
|
20
|
+
probe.style.color = expression;
|
|
21
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
22
|
+
ctx.fillStyle = "#000";
|
|
23
|
+
ctx.fillStyle = getComputedStyle(probe).color;
|
|
24
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
25
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
26
|
+
return `#${[r, g, b].map((v) => (v ?? 0).toString(16).padStart(2, "0")).join("")}`;
|
|
27
|
+
},
|
|
28
|
+
dispose() {
|
|
29
|
+
probe.remove();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Keeps the Windows tray popup in the same colors as the dashboard.
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// Keeps the Windows tray popup in the same colors as the dashboard. The tray only
|
|
2
|
+
// understands #rrggbb, so each token is resolved to hex first.
|
|
3
|
+
|
|
4
|
+
import { createColorReader } from "./cssColor.ts";
|
|
4
5
|
|
|
5
6
|
const TOKENS = {
|
|
6
7
|
surface: "--surface",
|
|
@@ -13,28 +14,12 @@ const TOKENS = {
|
|
|
13
14
|
gold: "--gold",
|
|
14
15
|
} as const;
|
|
15
16
|
|
|
16
|
-
function toHex(color: string, ctx: CanvasRenderingContext2D): string {
|
|
17
|
-
ctx.clearRect(0, 0, 1, 1);
|
|
18
|
-
ctx.fillStyle = "#000";
|
|
19
|
-
ctx.fillStyle = color;
|
|
20
|
-
ctx.fillRect(0, 0, 1, 1);
|
|
21
|
-
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
22
|
-
return `#${[r, g, b].map((v) => (v ?? 0).toString(16).padStart(2, "0")).join("")}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
17
|
function readTheme(): Record<string, string> | null {
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
28
|
-
// A probe element resolves var() chains and color-mix() to a computed color.
|
|
29
|
-
const probe = document.createElement("span");
|
|
30
|
-
probe.style.display = "none";
|
|
31
|
-
document.body.appendChild(probe);
|
|
18
|
+
const reader = createColorReader();
|
|
19
|
+
if (!reader) return null;
|
|
32
20
|
const out: Record<string, string> = { mode: document.documentElement.dataset.theme === "light" ? "light" : "dark" };
|
|
33
|
-
for (const [key, token] of Object.entries(TOKENS)) {
|
|
34
|
-
|
|
35
|
-
out[key] = toHex(getComputedStyle(probe).color, ctx);
|
|
36
|
-
}
|
|
37
|
-
probe.remove();
|
|
21
|
+
for (const [key, token] of Object.entries(TOKENS)) out[key] = reader.read(`var(${token})`);
|
|
22
|
+
reader.dispose();
|
|
38
23
|
return out;
|
|
39
24
|
}
|
|
40
25
|
|
|
@@ -61,7 +46,7 @@ export function startTrayThemeSync(): () => void {
|
|
|
61
46
|
// data-palette is today's theme switch; data-style and data-accent come with style presets.
|
|
62
47
|
observer.observe(document.documentElement, {
|
|
63
48
|
attributes: true,
|
|
64
|
-
attributeFilter: ["data-theme", "data-palette", "data-style", "data-accent", "style", "class"],
|
|
49
|
+
attributeFilter: ["data-theme", "data-palette", "data-style", "data-accent", "data-base", "style", "class"],
|
|
65
50
|
});
|
|
66
51
|
schedule();
|
|
67
52
|
return () => {
|
package/src/client/styles.css
CHANGED
|
@@ -435,7 +435,7 @@ img { display: block; }
|
|
|
435
435
|
.update-link { color: var(--text-2); font-weight: 500; padding: 0 6px; text-decoration: none; }
|
|
436
436
|
.update-link:hover { color: var(--text); text-decoration: underline; }
|
|
437
437
|
.setting-copy code { font-size: 11.5px; }
|
|
438
|
-
.setting-copy a { color: inherit; }
|
|
438
|
+
.setting-copy a { color: inherit; white-space: nowrap; }
|
|
439
439
|
|
|
440
440
|
/* ---------- Filters ---------- */
|
|
441
441
|
|
|
@@ -1312,6 +1312,11 @@ html[data-theme="dark"] .toggle.on .toggle-knob { background: var(--accent-ink);
|
|
|
1312
1312
|
.accent-custom input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
|
1313
1313
|
.accent-custom:focus-within { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
1314
1314
|
|
|
1315
|
+
.theme-block-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
|
1316
|
+
.theme-row-hint { color: var(--text-3); font-size: 12px; }
|
|
1317
|
+
.accent-row[aria-disabled="true"] { opacity: 0.45; }
|
|
1318
|
+
.accent-row[aria-disabled="true"] .accent-custom { cursor: not-allowed; }
|
|
1319
|
+
|
|
1315
1320
|
/* ---------- Responsive ---------- */
|
|
1316
1321
|
|
|
1317
1322
|
@media (max-width: 1020px) {
|
package/src/client/themes.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { createColorReader } from "./lib/cssColor.ts";
|
|
2
|
+
|
|
1
3
|
export type ThemeMode = "light" | "dark";
|
|
2
4
|
|
|
3
|
-
// A theme is
|
|
5
|
+
// A theme is three independent choices. The style is the whole look and feel: fonts,
|
|
4
6
|
// radius, spacing, borders, shadows, label casing, and its own light and dark colors
|
|
5
|
-
// (all defined in styles.css under html[data-style]). The
|
|
6
|
-
//
|
|
7
|
-
//
|
|
7
|
+
// (all defined in styles.css under html[data-style]). The base color tints the style's
|
|
8
|
+
// backgrounds, borders and text, keeping their lightness. The accent colors highlights.
|
|
9
|
+
// Base and accent are each the style's own, a preset below, or any color the user picks.
|
|
10
|
+
// An imported CSS theme can replace the colors of whichever style is active.
|
|
8
11
|
|
|
9
12
|
export type StyleId = "grove" | "terminal" | "paper" | "brutal" | "soft" | "mono";
|
|
10
13
|
|
|
@@ -39,14 +42,29 @@ export const ACCENT_PRESETS: { id: AccentId; name: string; light: string; dark:
|
|
|
39
42
|
|
|
40
43
|
export type AccentChoice = { kind: "style" } | { kind: "preset"; id: AccentId } | { kind: "custom"; color: string };
|
|
41
44
|
|
|
45
|
+
export type BaseId = "neutral" | "slate" | "stone" | "sage" | "plum" | "sand";
|
|
46
|
+
|
|
47
|
+
/** Only the hue and saturation of these are used; lightness comes from the style. */
|
|
48
|
+
export const BASE_PRESETS: { id: BaseId; name: string; color: string }[] = [
|
|
49
|
+
{ id: "neutral", name: "Neutral", color: "#808080" },
|
|
50
|
+
{ id: "slate", name: "Slate", color: "#5f7390" },
|
|
51
|
+
{ id: "stone", name: "Stone", color: "#8a7b6d" },
|
|
52
|
+
{ id: "sage", name: "Sage", color: "#6d8a72" },
|
|
53
|
+
{ id: "plum", name: "Plum", color: "#86688a" },
|
|
54
|
+
{ id: "sand", name: "Sand", color: "#a08a5c" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export type BaseChoice = { kind: "style" } | { kind: "preset"; id: BaseId } | { kind: "custom"; color: string };
|
|
58
|
+
|
|
42
59
|
export interface Appearance {
|
|
43
60
|
style: StyleId;
|
|
61
|
+
base: BaseChoice;
|
|
44
62
|
accent: AccentChoice;
|
|
45
63
|
/** Use the imported CSS theme's colors on top of the style. */
|
|
46
64
|
imported: boolean;
|
|
47
65
|
}
|
|
48
66
|
|
|
49
|
-
export const DEFAULT_APPEARANCE: Appearance = { style: "grove", accent: { kind: "style" }, imported: false };
|
|
67
|
+
export const DEFAULT_APPEARANCE: Appearance = { style: "grove", base: { kind: "style" }, accent: { kind: "style" }, imported: false };
|
|
50
68
|
|
|
51
69
|
export const THEME_MODE_STORAGE_KEY = "token-larper-theme";
|
|
52
70
|
export const APPEARANCE_STORAGE_KEY = "token-larper-appearance";
|
|
@@ -118,6 +136,7 @@ export function readImportedTheme(): ImportedTheme | null {
|
|
|
118
136
|
|
|
119
137
|
const isStyle = (value: unknown): value is StyleId => STYLE_PRESETS.some((s) => s.id === value);
|
|
120
138
|
const isAccentId = (value: unknown): value is AccentId => ACCENT_PRESETS.some((a) => a.id === value);
|
|
139
|
+
const isBaseId = (value: unknown): value is BaseId => BASE_PRESETS.some((b) => b.id === value);
|
|
121
140
|
const isHex = (value: unknown): value is string => typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value);
|
|
122
141
|
|
|
123
142
|
function sanitizeAppearance(value: unknown): Appearance | null {
|
|
@@ -130,7 +149,14 @@ function sanitizeAppearance(value: unknown): Appearance | null {
|
|
|
130
149
|
: a.kind === "custom" && isHex(a.color)
|
|
131
150
|
? { kind: "custom", color: a.color.toLowerCase() }
|
|
132
151
|
: { kind: "style" };
|
|
133
|
-
|
|
152
|
+
const b = (v.base ?? {}) as Record<string, unknown>;
|
|
153
|
+
const base: BaseChoice =
|
|
154
|
+
b.kind === "preset" && isBaseId(b.id)
|
|
155
|
+
? { kind: "preset", id: b.id }
|
|
156
|
+
: b.kind === "custom" && isHex(b.color)
|
|
157
|
+
? { kind: "custom", color: b.color.toLowerCase() }
|
|
158
|
+
: { kind: "style" };
|
|
159
|
+
return { style: isStyle(v.style) ? v.style : "grove", base, accent, imported: v.imported === true };
|
|
134
160
|
}
|
|
135
161
|
|
|
136
162
|
export function readAppearance(): Appearance {
|
|
@@ -199,17 +225,97 @@ function accentFor(choice: AccentChoice, mode: ThemeMode): string | null {
|
|
|
199
225
|
return null;
|
|
200
226
|
}
|
|
201
227
|
|
|
228
|
+
function rgbToHsl(hex: string): [number, number, number] {
|
|
229
|
+
const [r, g, b] = hexToRgb(hex).map((v) => v / 255) as [number, number, number];
|
|
230
|
+
const max = Math.max(r, g, b);
|
|
231
|
+
const min = Math.min(r, g, b);
|
|
232
|
+
const l = (max + min) / 2;
|
|
233
|
+
if (max === min) return [0, 0, l];
|
|
234
|
+
const d = max - min;
|
|
235
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
236
|
+
const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
237
|
+
return [h * 60, s, l];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function hslToHex(h: number, s: number, l: number): string {
|
|
241
|
+
const k = (n: number) => (n + h / 30) % 12;
|
|
242
|
+
const a = s * Math.min(l, 1 - l);
|
|
243
|
+
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
244
|
+
return rgbToHex([f(0) * 255, f(8) * 255, f(4) * 255]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The style colors a base color retints. */
|
|
248
|
+
export const BASE_PROPERTIES = [
|
|
249
|
+
"--bg", "--surface", "--surface-2", "--surface-3", "--border", "--border-strong", "--text", "--text-2", "--text-3",
|
|
250
|
+
] as const;
|
|
251
|
+
type BaseProperty = (typeof BASE_PROPERTIES)[number];
|
|
252
|
+
export type StyleNeutrals = Record<ThemeMode, Record<BaseProperty, string>>;
|
|
253
|
+
|
|
254
|
+
// How much of the base color's saturation each role takes, and the most it may take.
|
|
255
|
+
const TINT: Record<BaseProperty, [factor: number, cap: number]> = {
|
|
256
|
+
"--bg": [0.8, 0.6], "--surface": [0.8, 0.6], "--surface-2": [0.8, 0.6], "--surface-3": [0.8, 0.6],
|
|
257
|
+
"--border": [0.6, 0.45], "--border-strong": [0.6, 0.45],
|
|
258
|
+
"--text": [0.35, 0.3], "--text-2": [0.4, 0.35], "--text-3": [0.4, 0.35],
|
|
259
|
+
};
|
|
260
|
+
const TEXT_CONTRAST: Partial<Record<BaseProperty, number>> = { "--text": 7, "--text-2": 4.5, "--text-3": 4.5 };
|
|
261
|
+
|
|
262
|
+
function baseColor(choice: BaseChoice): string | null {
|
|
263
|
+
if (choice.kind === "preset") return BASE_PRESETS.find((b) => b.id === choice.id)!.color;
|
|
264
|
+
return choice.kind === "custom" ? choice.color : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Give the style's neutrals the base color's hue and saturation while keeping each one's
|
|
269
|
+
* lightness, so the style's structure (pale paper, black brutal outlines) survives.
|
|
270
|
+
* Near-white surfaces come down slightly so the tint shows, and text is nudged until it
|
|
271
|
+
* reads at least as well as the contrast targets above.
|
|
272
|
+
*/
|
|
273
|
+
function tintNeutrals(neutrals: Record<BaseProperty, string>, base: string, mode: ThemeMode): Record<string, string> {
|
|
274
|
+
const [hue, saturation] = rgbToHsl(base);
|
|
275
|
+
const out = {} as Record<BaseProperty, string>;
|
|
276
|
+
for (const property of BASE_PROPERTIES) {
|
|
277
|
+
const [factor, cap] = TINT[property];
|
|
278
|
+
let l = rgbToHsl(neutrals[property])[2];
|
|
279
|
+
if (mode === "light" && !TEXT_CONTRAST[property] && l > 0.93) l = 0.93 + (l - 0.93) * 0.7;
|
|
280
|
+
if (mode === "dark" && !TEXT_CONTRAST[property]) l = Math.max(l, 0.07); // Pure black can't take a tint.
|
|
281
|
+
// Saturation barely shows on near-black, so dark backgrounds and borders take more.
|
|
282
|
+
const boost = mode === "dark" && !TEXT_CONTRAST[property] ? 2 : 1;
|
|
283
|
+
out[property] = hslToHex(hue, Math.min(saturation * factor * boost, cap), l);
|
|
284
|
+
}
|
|
285
|
+
for (const [property, target] of Object.entries(TEXT_CONTRAST) as [BaseProperty, number][]) {
|
|
286
|
+
const [h, s, start] = rgbToHsl(out[property]);
|
|
287
|
+
let l = start;
|
|
288
|
+
for (let step = 0; step < 25; step++) {
|
|
289
|
+
const color = hslToHex(h, s, l);
|
|
290
|
+
out[property] = color;
|
|
291
|
+
if (Math.min(contrast(color, out["--bg"]), contrast(color, out["--surface"])) >= target) break;
|
|
292
|
+
l = Math.max(0, Math.min(1, l + (mode === "light" ? -0.02 : 0.02)));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
202
298
|
export type ThemeVars = Record<ThemeMode, Record<string, string>>;
|
|
203
299
|
|
|
204
|
-
/**
|
|
205
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Inline variables for the base, accent, or imported colors, for both modes. A base
|
|
302
|
+
* color needs the style's own neutrals as hex (read from the page by applyTheme).
|
|
303
|
+
*/
|
|
304
|
+
export function resolveThemeVars(
|
|
305
|
+
appearance: Appearance,
|
|
306
|
+
importedTheme: ImportedTheme | null,
|
|
307
|
+
styleNeutrals: StyleNeutrals | null = null
|
|
308
|
+
): ThemeVars {
|
|
206
309
|
const vars: ThemeVars = { light: {}, dark: {} };
|
|
310
|
+
const base = baseColor(appearance.base);
|
|
207
311
|
for (const mode of ["light", "dark"] as const) {
|
|
208
312
|
const out = vars[mode];
|
|
209
313
|
if (appearance.imported && importedTheme) {
|
|
210
314
|
const tokens = importedTheme[mode];
|
|
211
315
|
for (const property of APP_COLOR_PROPERTIES) if (validColor(tokens[property])) out[property] = tokens[property]!;
|
|
212
316
|
for (const property of APP_RADIUS_PROPERTIES) if (validRadius(tokens[property])) out[property] = tokens[property]!;
|
|
317
|
+
} else if (base && styleNeutrals) {
|
|
318
|
+
Object.assign(out, tintNeutrals(styleNeutrals[mode], base, mode));
|
|
213
319
|
}
|
|
214
320
|
const accent = accentFor(appearance.accent, mode);
|
|
215
321
|
if (accent) {
|
|
@@ -224,16 +330,34 @@ export function resolveThemeVars(appearance: Appearance, importedTheme: Imported
|
|
|
224
330
|
|
|
225
331
|
let appliedProperties: string[] = [];
|
|
226
332
|
|
|
333
|
+
/** The active style's neutrals in both modes, read from styles.css with no overrides applied. */
|
|
334
|
+
function readStyleNeutrals(): StyleNeutrals | null {
|
|
335
|
+
const reader = createColorReader();
|
|
336
|
+
if (!reader) return null;
|
|
337
|
+
const root = document.documentElement;
|
|
338
|
+
const current = root.dataset.theme;
|
|
339
|
+
const out = { light: {}, dark: {} } as StyleNeutrals;
|
|
340
|
+
for (const mode of ["light", "dark"] as const) {
|
|
341
|
+
root.dataset.theme = mode;
|
|
342
|
+
for (const property of BASE_PROPERTIES) out[mode][property] = reader.read(`var(${property})`);
|
|
343
|
+
}
|
|
344
|
+
root.dataset.theme = current;
|
|
345
|
+
reader.dispose();
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
227
349
|
export function applyTheme(mode: ThemeMode, appearance: Appearance, importedTheme: ImportedTheme | null): void {
|
|
228
350
|
const root = document.documentElement;
|
|
229
351
|
root.dataset.theme = mode;
|
|
230
352
|
root.dataset.style = appearance.style;
|
|
231
353
|
root.dataset.accent = appearance.accent.kind === "style" && !appearance.imported ? "style" : "custom";
|
|
354
|
+
root.dataset.base = appearance.base.kind === "style" || appearance.imported ? "style" : "custom";
|
|
232
355
|
// Clear what an earlier call (or index.html) set before applying the new values.
|
|
233
356
|
for (const property of new Set([...appliedProperties, ...APP_COLOR_PROPERTIES, ...APP_RADIUS_PROPERTIES])) {
|
|
234
357
|
root.style.removeProperty(property);
|
|
235
358
|
}
|
|
236
|
-
const
|
|
359
|
+
const neutrals = root.dataset.base === "custom" ? readStyleNeutrals() : null;
|
|
360
|
+
const vars = resolveThemeVars(appearance, importedTheme, neutrals);
|
|
237
361
|
for (const [property, value] of Object.entries(vars[mode])) root.style.setProperty(property, value);
|
|
238
362
|
appliedProperties = Object.keys(vars[mode]);
|
|
239
363
|
try {
|
package/src/icons.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { deflateSync } from "node:zlib";
|
|
2
|
+
import { flattenPath, LOGO_PATHS } from "./client/logoMark.ts";
|
|
3
|
+
|
|
4
|
+
// PNG and ICO versions of the logo for places that can't use the SVG favicon: /favicon.ico
|
|
5
|
+
// (requested by some browsers and tools on their own) and the Apple touch icon. They are
|
|
6
|
+
// rasterized here from the same paths, so no image files or image libraries are needed.
|
|
7
|
+
// Both put the white mark on a dark tile, which reads on light and dark tab strips alike;
|
|
8
|
+
// iOS fills transparency with black anyway.
|
|
9
|
+
|
|
10
|
+
const INK = [0xf5, 0xf5, 0xf5] as const;
|
|
11
|
+
const TILE = [0x14, 0x14, 0x14] as const;
|
|
12
|
+
const SUPERSAMPLE = 4;
|
|
13
|
+
|
|
14
|
+
// The mark's polygons in 64-unit logo space. Filled even-odd, which also cuts out the flame's core.
|
|
15
|
+
const POLYGONS = [LOGO_PATHS.t, LOGO_PATHS.flame].flatMap((d) => flattenPath(d));
|
|
16
|
+
|
|
17
|
+
/** Fraction of each pixel covered, from SUPERSAMPLE² samples per pixel. */
|
|
18
|
+
function coverage(size: number, inside: (x: number, y: number) => boolean): Float32Array {
|
|
19
|
+
const out = new Float32Array(size * size);
|
|
20
|
+
const step = 1 / SUPERSAMPLE;
|
|
21
|
+
for (let py = 0; py < size; py++) {
|
|
22
|
+
for (let px = 0; px < size; px++) {
|
|
23
|
+
let hits = 0;
|
|
24
|
+
for (let sy = 0; sy < SUPERSAMPLE; sy++) {
|
|
25
|
+
for (let sx = 0; sx < SUPERSAMPLE; sx++) {
|
|
26
|
+
if (inside(px + (sx + 0.5) * step, py + (sy + 0.5) * step)) hits++;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
out[py * size + px] = hits / (SUPERSAMPLE * SUPERSAMPLE);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function insideMark(x: number, y: number): boolean {
|
|
36
|
+
let inside = false;
|
|
37
|
+
for (const polygon of POLYGONS) {
|
|
38
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
39
|
+
const [xi, yi] = polygon[i]!;
|
|
40
|
+
const [xj, yj] = polygon[j]!;
|
|
41
|
+
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return inside;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function insideRoundedSquare(x: number, y: number, size: number, radius: number): boolean {
|
|
48
|
+
const cx = Math.min(Math.max(x, radius), size - radius);
|
|
49
|
+
const cy = Math.min(Math.max(y, radius), size - radius);
|
|
50
|
+
return (x - cx) ** 2 + (y - cy) ** 2 <= radius ** 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* RGBA pixels: the mark centered at markScale of the icon, on a tile with the given
|
|
55
|
+
* corner radius (as a fraction of the size; 0 for a full square).
|
|
56
|
+
*/
|
|
57
|
+
function renderIcon(size: number, markScale: number, cornerRadius: number): Uint8Array {
|
|
58
|
+
const markSize = size * markScale;
|
|
59
|
+
const offset = (size - markSize) / 2;
|
|
60
|
+
const unit = 64 / markSize;
|
|
61
|
+
const radius = size * cornerRadius;
|
|
62
|
+
const tile = cornerRadius > 0 ? coverage(size, (x, y) => insideRoundedSquare(x, y, size, radius)) : null;
|
|
63
|
+
const mark = coverage(size, (x, y) => insideMark((x - offset) * unit, (y - offset) * unit));
|
|
64
|
+
const rgba = new Uint8Array(size * size * 4);
|
|
65
|
+
for (let i = 0; i < size * size; i++) {
|
|
66
|
+
const t = tile ? tile[i]! : 1;
|
|
67
|
+
const m = Math.min(mark[i]!, t);
|
|
68
|
+
// The mark over the tile, then the tile's own edge over transparency.
|
|
69
|
+
for (let c = 0; c < 3; c++) rgba[i * 4 + c] = Math.round(INK[c]! * m + TILE[c]! * (1 - m));
|
|
70
|
+
rgba[i * 4 + 3] = Math.round(t * 255);
|
|
71
|
+
}
|
|
72
|
+
return rgba;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
76
|
+
let c = n;
|
|
77
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
78
|
+
return c >>> 0;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function crc32(bytes: Uint8Array): number {
|
|
82
|
+
let c = 0xffffffff;
|
|
83
|
+
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8);
|
|
84
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function chunk(type: string, data: Uint8Array): Buffer {
|
|
88
|
+
const out = Buffer.alloc(12 + data.length);
|
|
89
|
+
out.writeUInt32BE(data.length, 0);
|
|
90
|
+
out.write(type, 4, "ascii");
|
|
91
|
+
Buffer.from(data).copy(out, 8);
|
|
92
|
+
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function encodePng(size: number, rgba: Uint8Array): Buffer {
|
|
97
|
+
const header = Buffer.alloc(13);
|
|
98
|
+
header.writeUInt32BE(size, 0);
|
|
99
|
+
header.writeUInt32BE(size, 4);
|
|
100
|
+
header[8] = 8; // bits per channel
|
|
101
|
+
header[9] = 6; // RGBA
|
|
102
|
+
const rows = Buffer.alloc(size * (size * 4 + 1));
|
|
103
|
+
for (let y = 0; y < size; y++) {
|
|
104
|
+
rows[y * (size * 4 + 1)] = 0; // no filter
|
|
105
|
+
Buffer.from(rgba.buffer, y * size * 4, size * 4).copy(rows, y * (size * 4 + 1) + 1);
|
|
106
|
+
}
|
|
107
|
+
return Buffer.concat([
|
|
108
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
109
|
+
chunk("IHDR", header),
|
|
110
|
+
chunk("IDAT", deflateSync(rows)),
|
|
111
|
+
chunk("IEND", new Uint8Array()),
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** An ICO file holding PNG images, which every browser since IE 9 and Windows Vista reads. */
|
|
116
|
+
function encodeIco(images: { size: number; png: Buffer }[]): Buffer {
|
|
117
|
+
const header = Buffer.alloc(6 + images.length * 16);
|
|
118
|
+
header.writeUInt16LE(1, 2); // icon
|
|
119
|
+
header.writeUInt16LE(images.length, 4);
|
|
120
|
+
let offset = header.length;
|
|
121
|
+
images.forEach(({ size, png }, i) => {
|
|
122
|
+
const entry = 6 + i * 16;
|
|
123
|
+
header[entry] = size >= 256 ? 0 : size;
|
|
124
|
+
header[entry + 1] = size >= 256 ? 0 : size;
|
|
125
|
+
header.writeUInt16LE(1, entry + 4); // planes
|
|
126
|
+
header.writeUInt16LE(32, entry + 6); // bits per pixel
|
|
127
|
+
header.writeUInt32LE(png.length, entry + 8);
|
|
128
|
+
header.writeUInt32LE(offset, entry + 12);
|
|
129
|
+
offset += png.length;
|
|
130
|
+
});
|
|
131
|
+
return Buffer.concat([header, ...images.map((image) => image.png)]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// At 16px the flame is a few pixels, so the mark fills more of the tile there.
|
|
135
|
+
const tilePng = (size: number) => encodePng(size, renderIcon(size, size <= 16 ? 0.9 : 0.78, size <= 16 ? 0.16 : 0.22));
|
|
136
|
+
|
|
137
|
+
let favicon: Uint8Array<ArrayBuffer> | null = null;
|
|
138
|
+
let touchIcon: Uint8Array<ArrayBuffer> | null = null;
|
|
139
|
+
|
|
140
|
+
export function faviconIco(): Uint8Array<ArrayBuffer> {
|
|
141
|
+
return (favicon ??= new Uint8Array(encodeIco([16, 32, 48].map((size) => ({ size, png: tilePng(size) })))));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 180×180, full-bleed: iOS rounds the corners itself. */
|
|
145
|
+
export function appleTouchIconPng(): Uint8Array<ArrayBuffer> {
|
|
146
|
+
return (touchIcon ??= new Uint8Array(encodePng(180, renderIcon(180, 0.62, 0))));
|
|
147
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getStartupStatus, repointStartupIfStale, setStartupStatus } from "./sta
|
|
|
4
4
|
import { startSystemTray, stopSystemTray } from "./tray.ts";
|
|
5
5
|
import { buildTrayStatus, parseTheme, saveTheme } from "./trayStatus.ts";
|
|
6
6
|
import { logoSvgFile } from "./client/logoMark.ts";
|
|
7
|
+
import { appleTouchIconPng, faviconIco } from "./icons.ts";
|
|
7
8
|
import { APP_VERSION, RUNNING_FROM_SOURCE } from "./paths.ts";
|
|
8
9
|
import { checkForUpdates, compareVersions, localStatus, startUpdate } from "./updates.ts";
|
|
9
10
|
|
|
@@ -64,18 +65,20 @@ function openDashboard(port: number) {
|
|
|
64
65
|
|
|
65
66
|
/** The version of a Token Larper already on the port, "0.0.0" for one too old to say, or null if none. */
|
|
66
67
|
async function runningVersion(port: number): Promise<string | null> {
|
|
68
|
+
// /api/version answers instantly; versions before 1.5.0 don't have it, so fall back to
|
|
69
|
+
// /api/tray-status, which can take a few seconds while usage data loads.
|
|
67
70
|
try {
|
|
68
|
-
const
|
|
69
|
-
|
|
71
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/version`, { signal: AbortSignal.timeout(1500) });
|
|
72
|
+
const body = (await res.json().catch(() => null)) as { current?: unknown } | null;
|
|
73
|
+
if (res.ok && typeof body?.current === "string") return body.current;
|
|
70
74
|
} catch {
|
|
71
|
-
|
|
75
|
+
// Nothing on the port, or it is busy; the next check decides.
|
|
72
76
|
}
|
|
73
77
|
try {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
return typeof body.current === "string" ? body.current : "0.0.0";
|
|
78
|
+
const status = await fetch(`http://127.0.0.1:${port}/api/tray-status`, { signal: AbortSignal.timeout(5000) });
|
|
79
|
+
return status.ok ? "0.0.0" : null;
|
|
77
80
|
} catch {
|
|
78
|
-
return
|
|
81
|
+
return null;
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
|
|
@@ -143,6 +146,18 @@ function startServer(preferredPort: number) {
|
|
|
143
146
|
});
|
|
144
147
|
}
|
|
145
148
|
|
|
149
|
+
if (url.pathname === "/favicon.ico") {
|
|
150
|
+
return new Response(faviconIco(), {
|
|
151
|
+
headers: { "Content-Type": "image/x-icon", "Cache-Control": "public, max-age=86400" },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (url.pathname === "/apple-touch-icon.png" || url.pathname === "/apple-touch-icon-precomposed.png") {
|
|
156
|
+
return new Response(appleTouchIconPng(), {
|
|
157
|
+
headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=86400" },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
146
161
|
if (url.pathname === "/app.js") {
|
|
147
162
|
const code = process.env.NODE_ENV === "production" ? await clientBundle : await buildClientBundle();
|
|
148
163
|
return new Response(code, {
|