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 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, Any Accent**: Grove, Terminal, Paper, Brutal, Soft, and Mono each change fonts, corner radius, spacing, borders, and shadows, not just colors. Pair any of them with a preset or custom accent, or import a tweakcn/shadcn CSS theme on top.
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 tokenlarper
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
- * A green **`t.`** monogram icon will appear in your notification tray.
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 { existsSync } from "node:fs";
4
- import { dirname, join } from "node:path";
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
- // server.ts handles a copy that is already running (and replaces it if older).
14
+ const HELP = `Token Larper: a local dashboard for AI coding token usage.
12
15
 
13
- // If executed directly inside Bun runtime
14
- if (typeof Bun !== "undefined") {
15
- await import(serverScript);
16
- } else {
17
- // If executed via Node / npx, check for Bun and hand over
18
- const bunCheck = spawnSync("bun", ["--version"], { stdio: "ignore" });
19
- if (bunCheck.status === 0) {
20
- const child = spawnSync("bun", [serverScript, ...process.argv.slice(2)], {
21
- stdio: "inherit",
22
- cwd: rootDir,
23
- });
24
- process.exit(child.status ?? 0);
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("🔥 Token Larper requires Bun (v1.1+) to run.");
27
- console.error("\nInstall Bun with one command:");
28
- if (process.platform === "win32") {
29
- console.error(' powershell -c "irm bun.sh/install.ps1 | iex"');
30
- } else {
31
- console.error(" curl -fsSL https://bun.sh/install | bash");
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
- console.error("\nMore info: https://github.com/YarooqH/token-larper\n");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-larper",
3
- "version": "1.6.1",
3
+ "version": "1.8.1",
4
4
  "description": "Token Larper — Multi-Harness AI Coding Usage & Cost Telemetry Dashboard powered by ccusage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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, updates, and startup</p>
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
- <section className="settings-section" aria-labelledby="settings-startup-heading">
140
- <div className="settings-section-heading">
141
- <h4 id="settings-startup-heading">Startup</h4>
142
- <span>{startup.enabled ? "Starts with Windows" : "Manual launch"}</span>
143
- </div>
144
- <div className="setting-row">
145
- <div className="setting-copy">
146
- <strong id="setting-autostart-label">Start with Windows</strong>
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
- <div className="setting-row">
161
- <div className="setting-copy">
162
- <strong id="setting-browser-label">Open dashboard at sign-in</strong>
163
- <p>
164
- {startup.enabled
165
- ? "Open the dashboard in your browser after launch."
166
- : "Takes effect when Start with Windows is on."}
167
- </p>
168
- </div>
169
- <button
170
- disabled={saving}
171
- className={`toggle ${startup.openBrowserOnBoot ? "on" : ""}`}
172
- role="switch"
173
- aria-labelledby="setting-browser-label"
174
- aria-checked={startup.openBrowserOnBoot}
175
- onClick={() => onToggleStartup(startup.enabled, !startup.openBrowserOnBoot)}
176
- >
177
- <span className="toggle-knob" />
178
- </button>
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
- </section>
181
-
182
- <details className="settings-details">
183
- <summary>
184
- Technical details <ChevronDown size={14} aria-hidden="true" />
185
- </summary>
186
- <dl>
187
- <div>
188
- <dt>Dashboard</dt>
189
- <dd><code>http://127.0.0.1:{startup.port}</code></dd>
190
- </div>
191
- <div>
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
- <div className="theme-block">
113
- <span className="theme-row-label" id="theme-accent-label">Accent</span>
114
- <div className="accent-row" role="radiogroup" aria-labelledby="theme-accent-label">
115
- <button
116
- type="button"
117
- role="radio"
118
- aria-checked={accent.kind === "style"}
119
- className="accent-default"
120
- onClick={() => onAppearanceChange({ ...appearance, accent: { kind: "style" } })}
121
- >
122
- Style default
123
- </button>
124
- {ACCENT_PRESETS.map((preset) => (
125
- <button
126
- key={preset.id}
127
- type="button"
128
- role="radio"
129
- aria-checked={accent.kind === "preset" && accent.id === preset.id}
130
- aria-label={preset.name}
131
- title={preset.name}
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
+ }
@@ -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. Themes can use any CSS
2
- // color syntax (oklch, color-mix, …), which the tray can't parse, so each token is painted
3
- // onto a 1×1 canvas and read back as #rrggbb.
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 ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
27
- if (!ctx) return null;
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
- probe.style.color = `var(${token})`;
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 () => {
@@ -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) {
@@ -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 two independent choices. The style is the whole look and feel: fonts,
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 accent is either the style's
6
- // own, one of the preset colors below, or any color the user picks. An imported CSS
7
- // theme can replace the colors of whichever style is active.
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
- return { style: isStyle(v.style) ? v.style : "grove", accent, imported: v.imported === true };
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
- /** Inline variables for an accent override or an imported theme, for both modes. */
205
- export function resolveThemeVars(appearance: Appearance, importedTheme: ImportedTheme | null): ThemeVars {
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 vars = resolveThemeVars(appearance, importedTheme);
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 status = await fetch(`http://127.0.0.1:${port}/api/tray-status`, { signal: AbortSignal.timeout(600) });
69
- if (!status.ok) return null;
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
- return null;
75
+ // Nothing on the port, or it is busy; the next check decides.
72
76
  }
73
77
  try {
74
- const res = await fetch(`http://127.0.0.1:${port}/api/version`, { signal: AbortSignal.timeout(600) });
75
- const body = (await res.json()) as { current?: unknown };
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 "0.0.0";
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, {