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 +1 -1
- package/package.json +1 -1
- package/src/client/App.tsx +3 -9
- package/src/client/components/AppFooter.tsx +47 -0
- 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 +32 -7
- package/src/client/themes.ts +133 -9
- package/src/icons.ts +147 -0
- package/src/server.ts +13 -0
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
|
---
|
package/package.json
CHANGED
package/src/client/App.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { AlertTriangle,
|
|
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
|
-
<
|
|
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,
|
|
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
|
@@ -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
|
|
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
|
|
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; }
|
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
|
|
|
@@ -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, {
|