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,12 +1,15 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { AlertTriangle, Moon, Power, RefreshCw, Search, Settings, Sun } from "lucide-react";
2
+ import { AlertTriangle, Heart, 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 { Logo } from "./components/Logo.tsx";
7
8
  import { RankChip } from "./components/RankChip.tsx";
8
9
  import { SettingsDialog } from "./components/SettingsDialog.tsx";
9
10
  import { SelectMenu, type SelectMenuOption } from "./components/SelectMenu.tsx";
11
+ import { UpdateBanner } from "./components/UpdateBanner.tsx";
12
+ import { useUpdates } from "./lib/updates.ts";
10
13
  import { daysInRange, sessionsInRange, type Bucket, type HarnessFilter } from "./lib/aggregate.ts";
11
14
  import { RANGE_PRESETS, parseDay, presetRange, todayKey, type DateRange, type RangePreset } from "./lib/range.ts";
12
15
  import { Models } from "./views/Models.tsx";
@@ -18,13 +21,13 @@ import { Tools } from "./views/Tools.tsx";
18
21
  import {
19
22
  applyTheme,
20
23
  IMPORTED_THEME_STORAGE_KEY,
24
+ readAppearance,
21
25
  readImportedTheme,
22
- readThemePalette,
26
+ saveAppearance,
23
27
  THEME_MODE_STORAGE_KEY,
24
- THEME_PALETTE_STORAGE_KEY,
28
+ type Appearance,
25
29
  type ImportedTheme,
26
30
  type ThemeMode,
27
- type ThemePalette,
28
31
  } from "./themes.ts";
29
32
  import { localDateKey } from "./utils.ts";
30
33
 
@@ -50,10 +53,13 @@ interface Prefs {
50
53
  customEnd?: string;
51
54
  bucket: Bucket;
52
55
  estimated: boolean;
56
+ checkUpdates: boolean;
57
+ /** The version whose banner was closed; a later version shows it again. */
58
+ dismissedUpdate?: string;
53
59
  }
54
60
 
55
61
  function loadPrefs(): Prefs {
56
- const defaults: Prefs = { view: "overview", showRanks: true, preset: "30d", bucket: "daily", estimated: false };
62
+ const defaults: Prefs = { view: "overview", showRanks: true, preset: "30d", bucket: "daily", estimated: false, checkUpdates: true };
57
63
  try {
58
64
  const raw = JSON.parse(localStorage.getItem(PREFS_KEY) || "{}");
59
65
  const saved: Partial<Prefs> = raw && typeof raw === "object" ? raw : {};
@@ -73,6 +79,8 @@ function loadPrefs(): Prefs {
73
79
  bucket,
74
80
  preset: preset === "custom" && !customDatesValid ? defaults.preset : preset,
75
81
  estimated: saved.estimated === true,
82
+ checkUpdates: saved.checkUpdates !== false,
83
+ ...(typeof saved.dismissedUpdate === "string" ? { dismissedUpdate: saved.dismissedUpdate } : {}),
76
84
  ...(customDatesValid ? { customStart: saved.customStart, customEnd: saved.customEnd } : {}),
77
85
  };
78
86
  } catch {
@@ -121,10 +129,13 @@ export function App() {
121
129
  return true;
122
130
  }
123
131
  });
124
- const [palette, setPalette] = useState<ThemePalette>(readThemePalette);
132
+ const [appearance, setAppearance] = useState<Appearance>(readAppearance);
125
133
  const [importedTheme, setImportedTheme] = useState<ImportedTheme | null>(readImportedTheme);
126
134
 
127
135
  const updatePrefs = (patch: Partial<Prefs>) => setPrefs((p) => ({ ...p, ...patch }));
136
+ const updates = useUpdates(prefs.checkUpdates);
137
+ const showUpdateBanner = updates.phase !== "idle" || (prefs.checkUpdates && !!updates.status?.updateAvailable
138
+ && updates.status.latest !== prefs.dismissedUpdate);
128
139
 
129
140
  function setShowRanks(showRanks: boolean) {
130
141
  setPrefs((p) => ({ ...p, showRanks, view: !showRanks && p.view === "rank" ? "overview" : p.view }));
@@ -151,8 +162,8 @@ export function App() {
151
162
  }, [prefs.showRanks]);
152
163
 
153
164
  useEffect(() => {
154
- applyTheme(theme, palette, importedTheme);
155
- }, [theme, palette, importedTheme]);
165
+ applyTheme(theme, appearance, importedTheme);
166
+ }, [theme, appearance, importedTheme]);
156
167
 
157
168
  useEffect(() => {
158
169
  if (!followsSystemTheme) return;
@@ -170,25 +181,28 @@ export function App() {
170
181
  try { localStorage.setItem(THEME_MODE_STORAGE_KEY, next); } catch { /* Storage may be disabled. */ }
171
182
  }
172
183
 
173
- function changeThemeMode(mode: ThemeMode) {
184
+ function changeThemeMode(mode: ThemeMode | "system") {
185
+ if (mode === "system") {
186
+ setFollowsSystemTheme(true);
187
+ setTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
188
+ try { localStorage.removeItem(THEME_MODE_STORAGE_KEY); } catch { /* Storage may be disabled. */ }
189
+ return;
190
+ }
174
191
  setFollowsSystemTheme(false);
175
192
  setTheme(mode);
176
193
  try { localStorage.setItem(THEME_MODE_STORAGE_KEY, mode); } catch { /* Storage may be disabled. */ }
177
194
  }
178
195
 
179
- function changePalette(next: ThemePalette) {
180
- if (next === "custom" && !importedTheme) return;
181
- setPalette(next);
182
- try { localStorage.setItem(THEME_PALETTE_STORAGE_KEY, next); } catch { /* Storage may be disabled. */ }
196
+ function changeAppearance(next: Appearance) {
197
+ const safe = next.imported && !importedTheme ? { ...next, imported: false } : next;
198
+ setAppearance(safe);
199
+ saveAppearance(safe);
183
200
  }
184
201
 
185
202
  function saveImportedTheme(next: ImportedTheme) {
186
203
  setImportedTheme(next);
187
- setPalette("custom");
188
- try {
189
- localStorage.setItem(IMPORTED_THEME_STORAGE_KEY, JSON.stringify(next));
190
- localStorage.setItem(THEME_PALETTE_STORAGE_KEY, "custom");
191
- } catch { /* Storage may be disabled. */ }
204
+ changeAppearance({ ...appearance, imported: true });
205
+ try { localStorage.setItem(IMPORTED_THEME_STORAGE_KEY, JSON.stringify(next)); } catch { /* Storage may be disabled. */ }
192
206
  }
193
207
 
194
208
  async function fetchDashboard(opts?: { refresh?: boolean; forceDeepScan?: boolean }) {
@@ -369,7 +383,7 @@ export function App() {
369
383
  return (
370
384
  <div className="splash">
371
385
  <div className="splash-card">
372
- <img className="splash-logo" src="/logo.svg" alt="" />
386
+ <Logo className="splash-logo" />
373
387
  <h2>Summoning Token Larper…</h2>
374
388
  <p>Reading local usage from your coding tools with ccusage.</p>
375
389
  </div>
@@ -427,7 +441,7 @@ export function App() {
427
441
  <div className="app">
428
442
  <header className="topbar">
429
443
  <div className="brand">
430
- <img className="brand-logo" src="/logo.svg" alt="" />
444
+ <Logo className="brand-logo" />
431
445
  <h1>Token Larper</h1>
432
446
  {data.syncingHarnesses.length > 0 && (
433
447
  <span className="pill">
@@ -473,6 +487,10 @@ export function App() {
473
487
  </div>
474
488
  </header>
475
489
 
490
+ {showUpdateBanner && (
491
+ <UpdateBanner updates={updates} onDismiss={() => updatePrefs({ dismissedUpdate: updates.status?.latest ?? undefined })} />
492
+ )}
493
+
476
494
  <nav className="tabs" aria-label="Dashboard views">
477
495
  {VIEWS.filter((v) => v.id !== "rank" || prefs.showRanks).map((v) => (
478
496
  <button
@@ -535,18 +553,31 @@ export function App() {
535
553
  {view.id === "rank" && <Rank />}
536
554
  </main>
537
555
 
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>
564
+
538
565
  {showSettings && (
539
566
  <SettingsDialog
540
567
  startup={startup}
541
568
  startupError={startupError}
542
569
  saving={savingStartup}
543
570
  themeMode={theme}
544
- palette={palette}
571
+ followsSystemTheme={followsSystemTheme}
572
+ appearance={appearance}
545
573
  importedTheme={importedTheme}
546
574
  showRanks={prefs.showRanks}
575
+ updates={updates}
576
+ checkUpdates={prefs.checkUpdates}
577
+ onCheckUpdatesChange={(checkUpdates) => updatePrefs({ checkUpdates })}
547
578
  onClose={closeSettings}
548
579
  onThemeModeChange={changeThemeMode}
549
- onPaletteChange={changePalette}
580
+ onAppearanceChange={changeAppearance}
550
581
  onImportTheme={saveImportedTheme}
551
582
  onShowRanksChange={setShowRanks}
552
583
  onToggleStartup={(enabled, open) => void handleToggleStartup(enabled, open)}
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ import { LOGO_PATHS, LOGO_VIEWBOX } from "../logoMark.ts";
3
+
4
+ /** The logo in the current text color, so it follows the dashboard theme. */
5
+ export function Logo({ className, label }: { className?: string; label?: string }) {
6
+ return (
7
+ <svg
8
+ className={className}
9
+ viewBox={LOGO_VIEWBOX}
10
+ fill="currentColor"
11
+ role={label ? "img" : undefined}
12
+ aria-label={label}
13
+ aria-hidden={label ? undefined : true}
14
+ >
15
+ <path d={LOGO_PATHS.t} />
16
+ <path d={LOGO_PATHS.flame} fillRule="evenodd" />
17
+ </svg>
18
+ );
19
+ }
@@ -2,19 +2,25 @@ import React, { useEffect, useRef } from "react";
2
2
  import { ChevronDown, Power, X } from "lucide-react";
3
3
  import type { StartupConfig } from "../../types.ts";
4
4
  import { ThemeSettings } from "./ThemeSettings.tsx";
5
- import type { ImportedTheme, ThemeMode, ThemePalette } from "../themes.ts";
5
+ import { UpdateSettings } from "./UpdateSettings.tsx";
6
+ import type { Updates } from "../lib/updates.ts";
7
+ import type { Appearance, ImportedTheme, ThemeMode } from "../themes.ts";
6
8
 
7
9
  interface Props {
8
10
  startup: StartupConfig | null;
9
11
  startupError: string | null;
10
12
  saving: boolean;
11
13
  themeMode: ThemeMode;
12
- palette: ThemePalette;
14
+ followsSystemTheme: boolean;
15
+ appearance: Appearance;
13
16
  importedTheme: ImportedTheme | null;
14
17
  showRanks: boolean;
18
+ updates: Updates;
19
+ checkUpdates: boolean;
20
+ onCheckUpdatesChange: (checkUpdates: boolean) => void;
15
21
  onClose: () => void;
16
- onThemeModeChange: (mode: ThemeMode) => void;
17
- onPaletteChange: (palette: ThemePalette) => void;
22
+ onThemeModeChange: (mode: ThemeMode | "system") => void;
23
+ onAppearanceChange: (appearance: Appearance) => void;
18
24
  onImportTheme: (theme: ImportedTheme) => void;
19
25
  onShowRanksChange: (showRanks: boolean) => void;
20
26
  onToggleStartup: (enabled: boolean, openBrowserOnBoot?: boolean) => void;
@@ -27,12 +33,16 @@ export function SettingsDialog({
27
33
  startupError,
28
34
  saving,
29
35
  themeMode,
30
- palette,
36
+ followsSystemTheme,
37
+ appearance,
31
38
  importedTheme,
32
39
  showRanks,
40
+ updates,
41
+ checkUpdates,
42
+ onCheckUpdatesChange,
33
43
  onClose,
34
44
  onThemeModeChange,
35
- onPaletteChange,
45
+ onAppearanceChange,
36
46
  onImportTheme,
37
47
  onShowRanksChange,
38
48
  onToggleStartup,
@@ -84,7 +94,7 @@ export function SettingsDialog({
84
94
  <header className="modal-header">
85
95
  <div>
86
96
  <h3 id="settings-title">Settings</h3>
87
- <p>Appearance, dashboard, and startup</p>
97
+ <p>Appearance, dashboard, updates, and startup</p>
88
98
  </div>
89
99
  <button ref={closeRef} className="icon-btn" aria-label="Close settings" onClick={onClose}>
90
100
  <X size={17} />
@@ -95,10 +105,11 @@ export function SettingsDialog({
95
105
  {startupError && <div className="inline-error" role="alert">{startupError}</div>}
96
106
  <ThemeSettings
97
107
  mode={themeMode}
98
- palette={palette}
108
+ followsSystem={followsSystemTheme}
109
+ appearance={appearance}
99
110
  importedTheme={importedTheme}
100
111
  onModeChange={onThemeModeChange}
101
- onPaletteChange={onPaletteChange}
112
+ onAppearanceChange={onAppearanceChange}
102
113
  onImportTheme={onImportTheme}
103
114
  />
104
115
  <section className="settings-section" aria-labelledby="settings-dashboard-heading">
@@ -122,6 +133,7 @@ export function SettingsDialog({
122
133
  </button>
123
134
  </div>
124
135
  </section>
136
+ <UpdateSettings updates={updates} autoCheck={checkUpdates} onAutoCheckChange={onCheckUpdatesChange} />
125
137
  {startup ? (
126
138
  <>
127
139
  <section className="settings-section" aria-labelledby="settings-startup-heading">
@@ -1,14 +1,21 @@
1
1
  import React, { useState } from "react";
2
- import { ChevronDown } from "lucide-react";
3
- import { importThemeCss, THEME_PRESETS, type ImportedTheme, type ThemeMode, type ThemePalette } from "../themes.ts";
4
- import { SelectMenu, type SelectMenuOption } from "./SelectMenu.tsx";
2
+ import { Check, ChevronDown, Pipette } from "lucide-react";
3
+ import {
4
+ ACCENT_PRESETS,
5
+ importThemeCss,
6
+ STYLE_PRESETS,
7
+ type Appearance,
8
+ type ImportedTheme,
9
+ type ThemeMode,
10
+ } from "../themes.ts";
5
11
 
6
12
  interface Props {
7
13
  mode: ThemeMode;
8
- palette: ThemePalette;
14
+ followsSystem: boolean;
15
+ appearance: Appearance;
9
16
  importedTheme: ImportedTheme | null;
10
- onModeChange: (mode: ThemeMode) => void;
11
- onPaletteChange: (palette: ThemePalette) => void;
17
+ onModeChange: (mode: ThemeMode | "system") => void;
18
+ onAppearanceChange: (appearance: Appearance) => void;
12
19
  onImportTheme: (theme: ImportedTheme) => void;
13
20
  }
14
21
 
@@ -28,65 +35,132 @@ const SAMPLE_THEME = `:root {
28
35
  --accent-ink: #11261a;
29
36
  }`;
30
37
 
31
- export function ThemeSettings({ mode, palette, importedTheme, onModeChange, onPaletteChange, onImportTheme }: Props) {
38
+ export function ThemeSettings({ mode, followsSystem, appearance, importedTheme, onModeChange, onAppearanceChange, onImportTheme }: Props) {
32
39
  const [css, setCss] = useState("");
33
40
  const [error, setError] = useState<string | null>(null);
34
41
  const [message, setMessage] = useState<string | null>(null);
42
+ const accent = appearance.accent;
43
+ const customColor = accent.kind === "custom" ? accent.color : "#6d5bd0";
35
44
 
36
45
  function applyPastedTheme() {
37
46
  try {
38
- const imported = importThemeCss(css);
39
- onImportTheme(imported);
47
+ onImportTheme(importThemeCss(css));
40
48
  setError(null);
41
- setMessage("Theme applied.");
49
+ setMessage("Theme applied. Its colors now sit on top of the selected style.");
42
50
  } catch (cause) {
43
51
  setMessage(null);
44
52
  setError(cause instanceof Error ? cause.message : "Could not read this theme CSS.");
45
53
  }
46
54
  }
47
55
 
48
- const paletteOptions: SelectMenuOption<ThemePalette>[] = THEME_PRESETS.map((preset) => ({
49
- value: preset.id,
50
- label: preset.name,
51
- description: preset.description,
52
- visual: <span className="palette-color" aria-hidden="true" style={{ backgroundColor: mode === "dark" ? preset.darkAccent : preset.accent }} />,
53
- }));
54
- if (importedTheme) {
55
- const colors = importedTheme[mode];
56
- paletteOptions.push({
57
- value: "custom",
58
- label: "Imported theme",
59
- description: "Your saved CSS colors",
60
- visual: <span className="palette-color" aria-hidden="true" style={{ backgroundColor: colors["--accent"] }} />,
61
- });
62
- }
56
+ const modeValue = followsSystem ? "system" : mode;
63
57
 
64
58
  return (
65
59
  <section className="settings-section theme-settings" aria-labelledby="settings-appearance-heading">
66
60
  <div className="settings-section-heading">
67
61
  <h4 id="settings-appearance-heading">Appearance</h4>
68
- <span>{mode === "dark" ? "Dark mode" : "Light mode"}</span>
69
62
  </div>
70
63
 
71
- <div className="theme-mode-row">
72
- <span>Color mode</span>
73
- <div className="segmented" role="group" aria-label="Color mode">
74
- {(["light", "dark"] as const).map((option) => (
75
- <button key={option} type="button" aria-pressed={mode === option} onClick={() => onModeChange(option)}>
76
- {option === "light" ? "Light" : "Dark"}
64
+ <div className="theme-row">
65
+ <span className="theme-row-label" id="theme-mode-label">Mode</span>
66
+ <div className="segmented" role="group" aria-labelledby="theme-mode-label">
67
+ {(["system", "light", "dark"] as const).map((option) => (
68
+ <button key={option} type="button" aria-pressed={modeValue === option} onClick={() => onModeChange(option)}>
69
+ {option === "system" ? "System" : option === "light" ? "Light" : "Dark"}
77
70
  </button>
78
71
  ))}
79
72
  </div>
80
73
  </div>
81
74
 
82
- <SelectMenu
83
- label="Color palette"
84
- value={palette}
85
- options={paletteOptions}
86
- onChange={onPaletteChange}
87
- className="palette-picker"
88
- showDescriptionInTrigger
89
- />
75
+ <div className="theme-block">
76
+ <span className="theme-row-label" id="theme-style-label">Style</span>
77
+ <div className="style-grid" role="radiogroup" aria-labelledby="theme-style-label">
78
+ {STYLE_PRESETS.map((style) => {
79
+ const selected = appearance.style === style.id;
80
+ return (
81
+ <button
82
+ key={style.id}
83
+ type="button"
84
+ role="radio"
85
+ aria-checked={selected}
86
+ className="style-card"
87
+ onClick={() => onAppearanceChange({ ...appearance, style: style.id })}
88
+ >
89
+ {/* The preview renders with the style's own tokens, so it shows fonts, radius, borders and colors. */}
90
+ <span className="style-preview" data-style={style.id} aria-hidden="true">
91
+ <span className="sp-panel">
92
+ <span className="sp-title">Aa</span>
93
+ <span className="sp-bars">
94
+ <i style={{ height: "45%" }} />
95
+ <i style={{ height: "80%" }} />
96
+ <i style={{ height: "60%" }} />
97
+ </span>
98
+ <span className="sp-button" />
99
+ </span>
100
+ </span>
101
+ <span className="style-card-text">
102
+ <strong>{style.name}</strong>
103
+ <span>{style.description}</span>
104
+ </span>
105
+ {selected && <Check size={14} className="style-card-check" aria-hidden="true" />}
106
+ </button>
107
+ );
108
+ })}
109
+ </div>
110
+ </div>
111
+
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>
148
+
149
+ {importedTheme && (
150
+ <div className="theme-row">
151
+ <span className="theme-row-label" id="theme-imported-label">Imported colors</span>
152
+ <button
153
+ type="button"
154
+ role="switch"
155
+ aria-checked={appearance.imported}
156
+ aria-labelledby="theme-imported-label"
157
+ className={`toggle ${appearance.imported ? "on" : ""}`}
158
+ onClick={() => onAppearanceChange({ ...appearance, imported: !appearance.imported })}
159
+ >
160
+ <span className="toggle-knob" />
161
+ </button>
162
+ </div>
163
+ )}
90
164
 
91
165
  <details className="theme-import">
92
166
  <summary>
@@ -95,7 +169,8 @@ export function ThemeSettings({ mode, palette, importedTheme, onModeChange, onPa
95
169
  </summary>
96
170
  <div className="theme-import-body">
97
171
  <p>
98
- Paste light and dark CSS variable blocks. Use Token Larper colors or shadcn-style variables; extra CSS is ignored.
172
+ Paste light and dark CSS variable blocks, for example from tweakcn. Use Token Larper colors or
173
+ shadcn-style variables; the colors apply on top of the selected style, and extra CSS is ignored.
99
174
  </p>
100
175
  <details className="theme-format">
101
176
  <summary>Show example CSS <ChevronDown size={14} aria-hidden="true" /></summary>
@@ -0,0 +1,42 @@
1
+ import React from "react";
2
+ import { ArrowUpCircle, RefreshCw, X } from "lucide-react";
3
+ import { RELEASES_URL, type Updates } from "../lib/updates.ts";
4
+
5
+ export function UpdateBanner({ updates, onDismiss }: { updates: Updates; onDismiss: () => void }) {
6
+ const { status, phase, error, update } = updates;
7
+ if (!status?.latest) return null;
8
+ const updating = phase === "updating";
9
+
10
+ return (
11
+ <div className="update-banner" role="status">
12
+ {updating ? <RefreshCw size={16} className="spin" aria-hidden="true" /> : <ArrowUpCircle size={16} aria-hidden="true" />}
13
+ <p>
14
+ {updating ? (
15
+ <>Updating to {status.latest}. The dashboard reloads when it's ready.</>
16
+ ) : phase === "failed" ? (
17
+ <>{error}</>
18
+ ) : (
19
+ <>
20
+ <strong>Token Larper {status.latest} is out.</strong>{" "}
21
+ <span>
22
+ {status.source === "source" ? "This copy runs from git; update it with git pull." : `You have ${status.current}.`}
23
+ </span>
24
+ </>
25
+ )}
26
+ </p>
27
+ <div className="update-banner-actions">
28
+ <a className="update-link" href={RELEASES_URL} target="_blank" rel="noreferrer">What's new</a>
29
+ {status.source === "npm" && (
30
+ <button className="btn btn-primary" disabled={updating} onClick={() => void update()}>
31
+ {phase === "failed" ? "Try again" : updating ? "Updating…" : "Update now"}
32
+ </button>
33
+ )}
34
+ {!updating && (
35
+ <button className="icon-btn" aria-label={`Hide the ${status.latest} update`} title="Not now" onClick={onDismiss}>
36
+ <X size={15} />
37
+ </button>
38
+ )}
39
+ </div>
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,65 @@
1
+ import React from "react";
2
+ import { RefreshCw } from "lucide-react";
3
+ import { RELEASES_URL, UPDATE_COMMAND, type Updates } from "../lib/updates.ts";
4
+
5
+ interface Props {
6
+ updates: Updates;
7
+ autoCheck: boolean;
8
+ onAutoCheckChange: (autoCheck: boolean) => void;
9
+ }
10
+
11
+ function describe({ status, checking, phase }: Updates): string {
12
+ if (phase === "updating") return `Updating to ${status?.latest}…`;
13
+ if (checking) return "Checking…";
14
+ if (!status?.checkedAt) return "Not checked yet";
15
+ if (status.updateAvailable) return `${status.latest} is available`;
16
+ return "You're up to date";
17
+ }
18
+
19
+ export function UpdateSettings({ updates, autoCheck, onAutoCheckChange }: Props) {
20
+ const { status, checking, phase, error, check, update } = updates;
21
+ const canUpdate = status?.updateAvailable && status.source === "npm" && phase !== "updating";
22
+
23
+ return (
24
+ <section className="settings-section" aria-labelledby="settings-updates-heading">
25
+ <div className="settings-section-heading">
26
+ <h4 id="settings-updates-heading">Updates</h4>
27
+ <span>{status ? `Version ${status.current}` : ""}</span>
28
+ </div>
29
+ <div className="setting-row">
30
+ <div className="setting-copy">
31
+ <strong>{describe(updates)}</strong>
32
+ <p>
33
+ {error ? error : status?.source === "source"
34
+ ? "This copy runs from a git checkout. Update it with git pull."
35
+ : <>Updates install with <code>{UPDATE_COMMAND}</code>. <a href={RELEASES_URL} target="_blank" rel="noreferrer">What's new</a></>}
36
+ </p>
37
+ </div>
38
+ {canUpdate ? (
39
+ <button className="btn btn-primary" onClick={() => void update()}>Update now</button>
40
+ ) : (
41
+ <button className="btn" disabled={checking || phase === "updating"} onClick={() => void check(true)}>
42
+ <RefreshCw size={14} className={checking ? "spin" : ""} aria-hidden="true" />
43
+ <span>Check for updates</span>
44
+ </button>
45
+ )}
46
+ </div>
47
+ <div className="setting-row">
48
+ <div className="setting-copy">
49
+ <strong id="setting-autoupdate-label">Check automatically</strong>
50
+ <p>Ask npm for the latest version when the dashboard opens. Nothing else is sent.</p>
51
+ </div>
52
+ <button
53
+ type="button"
54
+ className={`toggle ${autoCheck ? "on" : ""}`}
55
+ role="switch"
56
+ aria-labelledby="setting-autoupdate-label"
57
+ aria-checked={autoCheck}
58
+ onClick={() => onAutoCheckChange(!autoCheck)}
59
+ >
60
+ <span className="toggle-knob" />
61
+ </button>
62
+ </div>
63
+ </section>
64
+ );
65
+ }
@@ -11,36 +11,27 @@
11
11
  stored === "dark" || stored === "light"
12
12
  ? stored
13
13
  : matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
14
- var savedPalette = null;
15
- try { savedPalette = localStorage.getItem("token-larper-palette"); } catch {}
16
- var palettes = ["pine", "slate", "ocean", "iris", "clay", "rose", "custom"];
17
- var palette = palettes.includes(savedPalette) ? savedPalette : "pine";
18
- document.documentElement.dataset.palette = palette;
19
- if (palette === "custom") {
20
- try {
21
- var imported = JSON.parse(localStorage.getItem("token-larper-imported-theme") || "null");
22
- var tokens = imported && imported[document.documentElement.dataset.theme];
23
- var properties = [
24
- "--bg", "--surface", "--surface-2", "--surface-3", "--border", "--border-strong",
25
- "--text", "--text-2", "--text-3", "--accent", "--accent-ink", "--accent-soft", "--gold", "--focus",
26
- "--warning-bg", "--warning-border", "--warning-text", "--error-bg", "--error-border", "--error-text",
27
- "--series-1", "--series-2", "--series-3", "--series-4", "--series-5", "--series-6", "--series-7",
28
- "--series-other", "--heat-0", "--heat-1", "--heat-2", "--heat-3", "--heat-4",
29
- ];
30
- properties.forEach(function (property) {
31
- var value = tokens && tokens[property];
32
- if (typeof value === "string" && value.length <= 180 && !/[;{}]|\bvar\s*\(|url\s*\(/i.test(value) && CSS.supports("color", value)) {
33
- document.documentElement.style.setProperty(property, value);
14
+ // Style and resolved theme variables were saved by the app (src/client/themes.ts);
15
+ // applying them here, before first paint, avoids a flash of the default theme.
16
+ var root = document.documentElement;
17
+ var appearance = null;
18
+ try { appearance = JSON.parse(localStorage.getItem("token-larper-appearance") || "null"); } catch {}
19
+ var styles = ["grove", "terminal", "paper", "brutal", "soft", "mono"];
20
+ root.dataset.style = appearance && styles.indexOf(appearance.style) >= 0 ? appearance.style : "grove";
21
+ root.dataset.accent = "style";
22
+ try {
23
+ var vars = JSON.parse(localStorage.getItem("token-larper-theme-vars") || "null");
24
+ var current = vars && vars[root.dataset.theme];
25
+ if (current && typeof current === "object") {
26
+ Object.keys(current).forEach(function (name) {
27
+ var value = current[name];
28
+ if (/^--[a-z0-9-]+$/.test(name) && typeof value === "string" && value.length <= 180 && !/[;{}]|url\s*\(/i.test(value)) {
29
+ root.style.setProperty(name, value);
30
+ root.dataset.accent = "custom";
34
31
  }
35
32
  });
36
- ["--radius", "--radius-sm"].forEach(function (property) {
37
- var radius = tokens && tokens[property];
38
- if (typeof radius === "string" && /^\d+(?:\.\d+)?(?:px|rem|em)$/.test(radius)) {
39
- document.documentElement.style.setProperty(property, radius);
40
- }
41
- });
42
- } catch {}
43
- }
33
+ }
34
+ } catch {}
44
35
  </script>
45
36
  <meta name="theme-color" content="#f8f9f7" />
46
37
  <title>Token Larper — Usage overview</title>