token-larper 1.0.0 → 1.6.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.
@@ -1,3 +1,4 @@
1
+ import { logoPaths } from "../logoMark.ts";
1
2
  import { formatCompactNumber, formatCurrency } from "../utils.ts";
2
3
  import { formatSpan } from "./range.ts";
3
4
  import { lastDefeated, nextRival, rankFor, TIERS, type LifetimeStats } from "./rank.ts";
@@ -94,12 +95,7 @@ export function shareCardSvg(stats: LifetimeStats, opts: CardOptions, c: CardCol
94
95
 
95
96
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_W}" height="${CARD_H}" viewBox="0 0 ${CARD_W} ${CARD_H}" font-family="${FONT}">
96
97
  <rect width="${CARD_W}" height="${CARD_H}" fill="${c.bg}"/>
97
- <g transform="translate(80 64) scale(0.875)">
98
- <rect x="3" y="3" width="58" height="58" rx="14" fill="${c.accent}"/>
99
- <path d="M20 12h8v31.5c0 1.7.8 2.5 2.5 2.5H35v7h-6.5C22.8 53 20 50.2 20 44.5z" fill="${c.accentInk}"/>
100
- <rect x="13" y="21" width="22" height="7" fill="${c.accentInk}"/>
101
- <circle cx="45" cy="47.5" r="5.5" fill="${c.gold}"/>
102
- </g>
98
+ <g transform="translate(80 64) scale(0.875)">${logoPaths(c.text)}</g>
103
99
  <text x="148" y="104" font-size="28" font-weight="700" fill="${c.text}">Token Larper</text>
104
100
  <text x="${CARD_W - 80}" y="104" font-size="20" fill="${c.text3}" text-anchor="end">${esc(since)}</text>
105
101
  <text x="80" y="196" font-size="22" font-weight="700" letter-spacing="3" fill="${c.gold}">LARP RANK · LEVEL ${rank.tier.level} OF ${TIERS.length}</text>
@@ -0,0 +1,71 @@
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.
4
+
5
+ const TOKENS = {
6
+ surface: "--surface",
7
+ text: "--text",
8
+ text2: "--text-2",
9
+ text3: "--text-3",
10
+ border: "--border",
11
+ accent: "--accent",
12
+ accentInk: "--accent-ink",
13
+ gold: "--gold",
14
+ } as const;
15
+
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
+ 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);
32
+ 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();
38
+ return out;
39
+ }
40
+
41
+ export function startTrayThemeSync(): () => void {
42
+ let last = "";
43
+ let timer: ReturnType<typeof setTimeout> | undefined;
44
+
45
+ const send = () => {
46
+ const theme = readTheme();
47
+ if (!theme) return;
48
+ const body = JSON.stringify(theme);
49
+ if (body === last) return;
50
+ last = body;
51
+ void fetch("/api/ui-theme", { method: "POST", headers: { "Content-Type": "application/json" }, body }).catch(() => {
52
+ last = ""; // Retry on the next change if the server was briefly unavailable.
53
+ });
54
+ };
55
+ const schedule = () => {
56
+ clearTimeout(timer);
57
+ timer = setTimeout(send, 250);
58
+ };
59
+
60
+ const observer = new MutationObserver(schedule);
61
+ // data-palette is today's theme switch; data-style and data-accent come with style presets.
62
+ observer.observe(document.documentElement, {
63
+ attributes: true,
64
+ attributeFilter: ["data-theme", "data-palette", "data-style", "data-accent", "style", "class"],
65
+ });
66
+ schedule();
67
+ return () => {
68
+ observer.disconnect();
69
+ clearTimeout(timer);
70
+ };
71
+ }
@@ -0,0 +1,94 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import type { UpdateStatus } from "../../types.ts";
3
+
4
+ export const RELEASES_URL = "https://github.com/YarooqH/token-larper/releases";
5
+ export const UPDATE_COMMAND = "bunx token-larper@latest";
6
+
7
+ export type UpdatePhase = "idle" | "updating" | "failed";
8
+
9
+ export interface Updates {
10
+ status: UpdateStatus | null;
11
+ checking: boolean;
12
+ phase: UpdatePhase;
13
+ error: string | null;
14
+ check: (force?: boolean) => Promise<void>;
15
+ update: () => Promise<void>;
16
+ }
17
+
18
+ async function readVersion(query: string): Promise<UpdateStatus> {
19
+ const res = await fetch(`/api/version${query}`);
20
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
21
+ return (await res.json()) as UpdateStatus;
22
+ }
23
+
24
+ // The new version downloads, then replaces this server. Give a slow connection time.
25
+ const UPDATE_TIMEOUT_MS = 3 * 60 * 1000;
26
+
27
+ export function useUpdates(autoCheck: boolean): Updates {
28
+ const [status, setStatus] = useState<UpdateStatus | null>(null);
29
+ const [checking, setChecking] = useState(false);
30
+ const [phase, setPhase] = useState<UpdatePhase>("idle");
31
+ const [error, setError] = useState<string | null>(null);
32
+ const polling = useRef<number | null>(null);
33
+
34
+ const check = useCallback(async (force = false) => {
35
+ setChecking(true);
36
+ setError(null);
37
+ try {
38
+ const next = await readVersion(`?check=1${force ? "&force=1" : ""}`);
39
+ setStatus(next);
40
+ if (next.error) setError(`Couldn't check for updates: ${next.error}`);
41
+ } catch (e) {
42
+ setError(`Couldn't check for updates: ${e instanceof Error ? e.message : "unknown error"}`);
43
+ } finally {
44
+ setChecking(false);
45
+ }
46
+ }, []);
47
+
48
+ // Without automatic checks, only the installed version is read; nothing leaves the machine.
49
+ useEffect(() => {
50
+ if (autoCheck) void check();
51
+ else readVersion("").then(setStatus).catch(() => {});
52
+ }, [autoCheck, check]);
53
+
54
+ useEffect(() => () => {
55
+ if (polling.current !== null) window.clearInterval(polling.current);
56
+ }, []);
57
+
58
+ const update = useCallback(async () => {
59
+ const from = status?.current;
60
+ setPhase("updating");
61
+ setError(null);
62
+ try {
63
+ const res = await fetch("/api/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
64
+ if (!res.ok) {
65
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
66
+ throw new Error(body.error || `HTTP ${res.status}`);
67
+ }
68
+ } catch (e) {
69
+ setPhase("failed");
70
+ setError(e instanceof Error ? e.message : "Could not start the update");
71
+ return;
72
+ }
73
+
74
+ // While the servers swap, requests fail; keep asking until a different version answers.
75
+ const started = Date.now();
76
+ polling.current = window.setInterval(async () => {
77
+ if (Date.now() - started > UPDATE_TIMEOUT_MS) {
78
+ window.clearInterval(polling.current!);
79
+ polling.current = null;
80
+ setPhase("failed");
81
+ setError(`The update didn't finish. Run ${UPDATE_COMMAND} in a terminal to update.`);
82
+ return;
83
+ }
84
+ try {
85
+ const next = await readVersion("");
86
+ if (next.current !== from) location.reload();
87
+ } catch {
88
+ // The old server has stopped and the new one isn't up yet.
89
+ }
90
+ }, 1500);
91
+ }, [status?.current]);
92
+
93
+ return { status, checking, phase, error, check, update };
94
+ }
@@ -0,0 +1,103 @@
1
+ // The Burning t.: one-color logo on a transparent background. Every surface draws it
2
+ // from these paths (the dashboard inline, the favicon SVG, the share card, and the tray
3
+ // icon via flattenPath), so they can't drift apart. 64×64 units.
4
+
5
+ export const LOGO_VIEWBOX = "0 0 64 64";
6
+
7
+ export const LOGO_PATHS = {
8
+ t: "M20 7h10v12h13v10H30v14c0 2 1 3 3 3h4v10h-6c-7 0-11-4-11-11V29H10V19h10z",
9
+ // The flame and its hollow core are one even-odd shape.
10
+ flame:
11
+ "M50 34c1 5 8 8 8 15c0 4.418-3.582 8-8 8c-4.418 0-8-3.582-8-8c0-4 2-6.5 4-8.5c.3 2.6 1.4 4.3 3.2 5c-1.2-4.6-.2-8.3.8-11.5z" +
12
+ "M50 44.5c.5 2 3 3.5 3 6c0 1.657-1.343 3-3 3c-1.657 0-3-1.343-3-3c0-1.8 1.5-3.5 3-6z",
13
+ };
14
+
15
+ /** The logo as SVG markup in one fill color. */
16
+ export function logoPaths(fill: string): string {
17
+ return `<path d="${LOGO_PATHS.t}" fill="${fill}"/><path d="${LOGO_PATHS.flame}" fill="${fill}" fill-rule="evenodd"/>`;
18
+ }
19
+
20
+ /**
21
+ * A standalone SVG file. The favicon has no page theme to read, so it follows the OS:
22
+ * black on light, white on dark.
23
+ */
24
+ export function logoSvgFile(): string {
25
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${LOGO_VIEWBOX}" role="img" aria-labelledby="title">
26
+ <title id="title">Token Larper</title>
27
+ <style>path { fill: #141414 } @media (prefers-color-scheme: dark) { path { fill: #f5f5f5 } }</style>
28
+ <path d="${LOGO_PATHS.t}"/>
29
+ <path d="${LOGO_PATHS.flame}" fill-rule="evenodd"/>
30
+ </svg>
31
+ `;
32
+ }
33
+
34
+ /**
35
+ * Flatten an SVG path (M, L, H, V, C and Z, absolute or relative) into polygons of
36
+ * [x, y] points, sampling each cubic curve. The tray draws these with GDI+, which has
37
+ * no SVG parser; filling them even-odd reproduces the flame's hollow core.
38
+ */
39
+ export function flattenPath(d: string, curveSteps = 10): [number, number][][] {
40
+ const tokens = d.match(/[a-zA-Z]|-?(?:\d+\.?\d*|\.\d+)(?:e-?\d+)?/g) ?? [];
41
+ const polygons: [number, number][][] = [];
42
+ let current: [number, number][] = [];
43
+ let x = 0;
44
+ let y = 0;
45
+ let startX = 0;
46
+ let startY = 0;
47
+ let command = "";
48
+ let i = 0;
49
+ const num = () => Number(tokens[i++]);
50
+ const isCommand = (t: string | undefined) => t !== undefined && /[a-zA-Z]/.test(t);
51
+
52
+ while (i < tokens.length) {
53
+ if (isCommand(tokens[i])) command = tokens[i++]!;
54
+ const rel = command === command.toLowerCase();
55
+ switch (command.toUpperCase()) {
56
+ case "M": {
57
+ if (current.length) polygons.push(current);
58
+ x = (rel ? x : 0) + num();
59
+ y = (rel ? y : 0) + num();
60
+ [startX, startY] = [x, y];
61
+ current = [[x, y]];
62
+ command = rel ? "l" : "L"; // Further pairs after M are line-tos.
63
+ break;
64
+ }
65
+ case "L":
66
+ x = (rel ? x : 0) + num();
67
+ y = (rel ? y : 0) + num();
68
+ current.push([x, y]);
69
+ break;
70
+ case "H":
71
+ x = (rel ? x : 0) + num();
72
+ current.push([x, y]);
73
+ break;
74
+ case "V":
75
+ y = (rel ? y : 0) + num();
76
+ current.push([x, y]);
77
+ break;
78
+ case "C": {
79
+ const [ox, oy] = rel ? [x, y] : [0, 0];
80
+ const x1 = ox + num(), y1 = oy + num(), x2 = ox + num(), y2 = oy + num(), ex = ox + num(), ey = oy + num();
81
+ for (let s = 1; s <= curveSteps; s++) {
82
+ const t = s / curveSteps;
83
+ const u = 1 - t;
84
+ current.push([
85
+ u * u * u * x + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * ex,
86
+ u * u * u * y + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * ey,
87
+ ]);
88
+ }
89
+ [x, y] = [ex, ey];
90
+ break;
91
+ }
92
+ case "Z":
93
+ [x, y] = [startX, startY];
94
+ if (current.length) polygons.push(current);
95
+ current = [];
96
+ break;
97
+ default:
98
+ throw new Error(`Unsupported path command "${command}" in logo path`);
99
+ }
100
+ }
101
+ if (current.length) polygons.push(current);
102
+ return polygons;
103
+ }
@@ -1,6 +1,9 @@
1
1
  import React from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { App } from "./App.tsx";
4
+ import { startTrayThemeSync } from "./lib/trayTheme.ts";
5
+
6
+ startTrayThemeSync();
4
7
 
5
8
  const rootEl = document.getElementById("root");
6
9
  if (rootEl) {