token-larper 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-larper",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Token Larper — Multi-Harness AI Coding Usage & Cost Telemetry Dashboard powered by ccusage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,9 +1,10 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { AlertTriangle, Heart, Moon, Power, RefreshCw, Search, Settings, Sun } from "lucide-react";
2
+ import { AlertTriangle, Moon, Power, RefreshCw, Search, Settings, Sun } from "lucide-react";
3
3
  import type { DashboardPayload, HarnessId, StartupConfig } from "../types.ts";
4
4
  import { OTHER_SERIES, SERIES_SLOTS, seriesColor, type SeriesInfo } from "./charts.tsx";
5
5
  import { DashboardProvider, type Dashboard } from "./context.tsx";
6
6
  import { DateRangePicker } from "./components/DateRangePicker.tsx";
7
+ import { AppFooter } from "./components/AppFooter.tsx";
7
8
  import { Logo } from "./components/Logo.tsx";
8
9
  import { RankChip } from "./components/RankChip.tsx";
9
10
  import { SettingsDialog } from "./components/SettingsDialog.tsx";
@@ -553,14 +554,7 @@ export function App() {
553
554
  {view.id === "rank" && <Rank />}
554
555
  </main>
555
556
 
556
- <footer className="credit">
557
- <span>
558
- Made with <Heart size={12} aria-label="love" className="credit-heart" /> by{" "}
559
- <a href="https://github.com/YarooqH" target="_blank" rel="noreferrer">qray</a>
560
- </span>
561
- <span aria-hidden="true">·</span>
562
- <a href="https://github.com/YarooqH/token-larper" target="_blank" rel="noreferrer">Source on GitHub</a>
563
- </footer>
557
+ <AppFooter />
564
558
 
565
559
  {showSettings && (
566
560
  <SettingsDialog
@@ -0,0 +1,47 @@
1
+ import React from "react";
2
+
3
+ const JOKES = [
4
+ "Tokens were harmed in the making.",
5
+ "The context window remembers.",
6
+ "One more prompt. Famous last words.",
7
+ "Your cache misses have been noted.",
8
+ "The bill has entered the chat.",
9
+ "This footer also cost tokens.",
10
+ "The tokens know what they did.",
11
+ ] as const;
12
+
13
+ const LAST_JOKE_KEY = "token-larper-last-footer-joke";
14
+
15
+ function pickJoke(): string {
16
+ let previous = -1;
17
+ try {
18
+ const saved = sessionStorage.getItem(LAST_JOKE_KEY);
19
+ if (saved !== null) previous = Number(saved);
20
+ } catch {
21
+ // The footer still works when storage is unavailable.
22
+ }
23
+
24
+ const choices = JOKES.map((_, index) => index).filter((index) => index !== previous);
25
+ const index = choices[Math.floor(Math.random() * choices.length)]!;
26
+ try { sessionStorage.setItem(LAST_JOKE_KEY, String(index)); } catch {}
27
+ return JOKES[index];
28
+ }
29
+
30
+ const joke = pickJoke();
31
+
32
+ export function AppFooter() {
33
+ return (
34
+ <footer className="app-footer">
35
+ <span>Made by <a href="https://github.com/YarooqH" target="_blank" rel="noopener noreferrer">qray</a></span>
36
+ <span className="footer-separator" aria-hidden="true">·</span>
37
+ <span>{joke}</span>
38
+ <span className="footer-separator" aria-hidden="true">·</span>
39
+ <a className="footer-source" href="https://github.com/YarooqH/token-larper" target="_blank" rel="noopener noreferrer">
40
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
41
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.23.48-2.69-1.08-2.69-1.08-.36-.92-.89-1.16-.89-1.16-.73-.5.06-.49.06-.49.81.06 1.24.83 1.24.83.72 1.23 1.88.88 2.34.67.07-.52.28-.88.51-1.08-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.22 2.2.82a7.65 7.65 0 0 1 4.01 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.28.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.08-.01 1.95-.01 2.21 0 .21.15.46.55.38A8.02 8.02 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
42
+ </svg>
43
+ <span>Source on GitHub</span>
44
+ </a>
45
+ </footer>
46
+ );
47
+ }
@@ -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 () => {
@@ -310,10 +310,33 @@ img { display: block; }
310
310
 
311
311
  .app {
312
312
  max-width: 1240px;
313
+ min-height: 100vh;
314
+ display: flex;
315
+ flex-direction: column;
313
316
  margin: 0 auto;
314
- padding: 0 32px 72px;
317
+ padding: 0 32px 24px;
315
318
  }
316
319
 
320
+ .app-footer {
321
+ display: flex;
322
+ align-items: center;
323
+ justify-content: center;
324
+ flex-wrap: wrap;
325
+ column-gap: 8px;
326
+ row-gap: 4px;
327
+ margin-top: auto;
328
+ padding-top: 64px;
329
+ color: var(--text-3);
330
+ font-size: 11px;
331
+ line-height: 1.5;
332
+ text-align: center;
333
+ }
334
+ .app-footer a { color: var(--text-2); text-decoration: none; }
335
+ .app-footer a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
336
+ .app-footer a:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: 2px; }
337
+ .footer-source { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
338
+ .footer-separator { color: var(--text-3); }
339
+
317
340
  .topbar {
318
341
  display: flex;
319
342
  align-items: center;
@@ -435,7 +458,7 @@ img { display: block; }
435
458
  .update-link { color: var(--text-2); font-weight: 500; padding: 0 6px; text-decoration: none; }
436
459
  .update-link:hover { color: var(--text); text-decoration: underline; }
437
460
  .setting-copy code { font-size: 11.5px; }
438
- .setting-copy a { color: inherit; }
461
+ .setting-copy a { color: inherit; white-space: nowrap; }
439
462
 
440
463
  /* ---------- Filters ---------- */
441
464
 
@@ -1161,10 +1184,6 @@ html[data-theme="dark"] .toggle.on .toggle-knob { background: var(--accent-ink);
1161
1184
 
1162
1185
  /* ---------- Credit ---------- */
1163
1186
 
1164
- .credit { display: flex; justify-content: center; flex-wrap: wrap; gap: 6px 10px; margin-top: 48px; color: var(--text-3); font-size: 12px; }
1165
- .credit a { color: inherit; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; }
1166
- .credit a:hover { color: var(--text); text-decoration-color: currentColor; }
1167
- .credit-heart { vertical-align: -1px; color: var(--accent); fill: currentColor; }
1168
1187
 
1169
1188
  /* ---------- Splash screens ---------- */
1170
1189
 
@@ -1312,6 +1331,11 @@ html[data-theme="dark"] .toggle.on .toggle-knob { background: var(--accent-ink);
1312
1331
  .accent-custom input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
1313
1332
  .accent-custom:focus-within { outline: 2px solid var(--focus); outline-offset: 2px; }
1314
1333
 
1334
+ .theme-block-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
1335
+ .theme-row-hint { color: var(--text-3); font-size: 12px; }
1336
+ .accent-row[aria-disabled="true"] { opacity: 0.45; }
1337
+ .accent-row[aria-disabled="true"] .accent-custom { cursor: not-allowed; }
1338
+
1315
1339
  /* ---------- Responsive ---------- */
1316
1340
 
1317
1341
  @media (max-width: 1020px) {
@@ -1324,7 +1348,8 @@ html[data-theme="dark"] .toggle.on .toggle-knob { background: var(--accent-ink);
1324
1348
  @media (max-width: 700px) {
1325
1349
  .update-banner { flex-wrap: wrap; }
1326
1350
  .update-banner-actions { width: 100%; justify-content: flex-end; }
1327
- .app { padding: 0 16px 48px; }
1351
+ .app { padding: 0 16px 24px; }
1352
+ .app-footer { padding-top: 48px; }
1328
1353
  .topbar { margin: 0 -16px; padding: 12px 16px; }
1329
1354
  .updated { display: none; }
1330
1355
  .filters { margin: 0 -16px 16px; padding: 8px 16px 10px; }
@@ -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
 
@@ -145,6 +146,18 @@ function startServer(preferredPort: number) {
145
146
  });
146
147
  }
147
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
+
148
161
  if (url.pathname === "/app.js") {
149
162
  const code = process.env.NODE_ENV === "production" ? await clientBundle : await buildClientBundle();
150
163
  return new Response(code, {