) {
{!!creature.legendaryActions && (
<>
-
+
Legendary Actions
diff --git a/apps/web/src/components/ui/overflow-menu.tsx b/apps/web/src/components/ui/overflow-menu.tsx
index 19199db..f8f1fd7 100644
--- a/apps/web/src/components/ui/overflow-menu.tsx
+++ b/apps/web/src/components/ui/overflow-menu.tsx
@@ -7,6 +7,7 @@ export interface OverflowMenuItem {
readonly label: string;
readonly onClick: () => void;
readonly disabled?: boolean;
+ readonly keepOpen?: boolean;
}
interface OverflowMenuProps {
@@ -58,7 +59,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
disabled={item.disabled}
onClick={() => {
item.onClick();
- setOpen(false);
+ if (!item.keepOpen) setOpen(false);
}}
>
{item.icon}
diff --git a/apps/web/src/hooks/use-theme.ts b/apps/web/src/hooks/use-theme.ts
new file mode 100644
index 0000000..ce99866
--- /dev/null
+++ b/apps/web/src/hooks/use-theme.ts
@@ -0,0 +1,98 @@
+import { useCallback, useEffect, useSyncExternalStore } from "react";
+
+type ThemePreference = "system" | "light" | "dark";
+type ResolvedTheme = "light" | "dark";
+
+const STORAGE_KEY = "initiative:theme";
+
+const listeners = new Set<() => void>();
+let currentPreference: ThemePreference = loadPreference();
+
+function loadPreference(): ThemePreference {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw === "light" || raw === "dark" || raw === "system") return raw;
+ } catch {
+ // storage unavailable
+ }
+ return "system";
+}
+
+function savePreference(pref: ThemePreference): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, pref);
+ } catch {
+ // quota exceeded or storage unavailable
+ }
+}
+
+function getSystemTheme(): ResolvedTheme {
+ if (typeof globalThis.matchMedia !== "function") return "dark";
+ return globalThis.matchMedia("(prefers-color-scheme: light)").matches
+ ? "light"
+ : "dark";
+}
+
+function resolve(pref: ThemePreference): ResolvedTheme {
+ return pref === "system" ? getSystemTheme() : pref;
+}
+
+function applyTheme(resolved: ResolvedTheme): void {
+ document.documentElement.dataset.theme = resolved;
+}
+
+function notifyAll(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+// Apply on load
+applyTheme(resolve(currentPreference));
+
+// Listen for OS preference changes
+if (typeof globalThis.matchMedia === "function") {
+ globalThis
+ .matchMedia("(prefers-color-scheme: light)")
+ .addEventListener("change", () => {
+ if (currentPreference === "system") {
+ applyTheme(resolve("system"));
+ notifyAll();
+ }
+ });
+}
+
+function subscribe(callback: () => void): () => void {
+ listeners.add(callback);
+ return () => listeners.delete(callback);
+}
+
+function getSnapshot(): ThemePreference {
+ return currentPreference;
+}
+
+const CYCLE: ThemePreference[] = ["system", "light", "dark"];
+
+export function useTheme() {
+ const preference = useSyncExternalStore(subscribe, getSnapshot);
+ const resolved = resolve(preference);
+
+ useEffect(() => {
+ applyTheme(resolved);
+ }, [resolved]);
+
+ const setPreference = useCallback((pref: ThemePreference) => {
+ currentPreference = pref;
+ savePreference(pref);
+ applyTheme(resolve(pref));
+ notifyAll();
+ }, []);
+
+ const cycleTheme = useCallback(() => {
+ const idx = CYCLE.indexOf(currentPreference);
+ const next = CYCLE[(idx + 1) % CYCLE.length];
+ setPreference(next);
+ }, [setPreference]);
+
+ return { preference, resolved, setPreference, cycleTheme } as const;
+}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index c2df818..0aef3aa 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -19,12 +19,47 @@
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
+ --color-stat-heading: #fbbf24;
+ --color-stat-divider-from: oklch(0.5 0.1 65 / 0.6);
+ --color-stat-divider-via: oklch(0.5 0.1 65 / 0.4);
+ --color-hp-damage-hover-bg: oklch(0.25 0.05 25);
+ --color-hp-heal-hover-bg: oklch(0.25 0.05 155);
+ --color-active-row-bg: oklch(0.623 0.214 259 / 0.1);
+ --color-active-row-border: oklch(0.623 0.214 259 / 0.4);
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
+[data-theme="light"] {
+ --color-background: #eeecea;
+ --color-foreground: #374151;
+ --color-muted: #e0ddd9;
+ --color-muted-foreground: #6b7280;
+ --color-card: #f7f6f4;
+ --color-card-foreground: #374151;
+ --color-border: #ddd9d5;
+ --color-input: #cdc8c3;
+ --color-primary: #2563eb;
+ --color-primary-foreground: #ffffff;
+ --color-accent: #2563eb;
+ --color-destructive: #dc2626;
+ --color-hover-neutral: var(--color-primary);
+ --color-hover-action: var(--color-primary);
+ --color-hover-destructive: var(--color-destructive);
+ --color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.08);
+ --color-hover-action-bg: var(--color-muted);
+ --color-hover-destructive-bg: transparent;
+ --color-stat-heading: #92400e;
+ --color-stat-divider-from: oklch(0.55 0.1 65 / 0.5);
+ --color-stat-divider-via: oklch(0.55 0.1 65 / 0.25);
+ --color-hp-damage-hover-bg: #fef2f2;
+ --color-hp-heal-hover-bg: #ecfdf5;
+ --color-active-row-bg: oklch(0.623 0.214 259 / 0.08);
+ --color-active-row-border: oklch(0.623 0.214 259 / 0.25);
+}
+
@keyframes concentration-shake {
0% {
translate: 0;
@@ -178,6 +213,11 @@
box-shadow:
0 0 15px -2px oklch(0.623 0.214 259 / 0.2),
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
+
+ [data-theme="light"] & {
+ background-image: none;
+ box-shadow: 0 1px 3px 0 oklch(0 0 0 / 0.08);
+ }
}
@utility panel-glow {
@@ -189,6 +229,11 @@
box-shadow:
0 0 20px -2px oklch(0.623 0.214 259 / 0.15),
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
+
+ [data-theme="light"] & {
+ background-image: none;
+ box-shadow: -1px 0 6px 0 oklch(0 0 0 / 0.1);
+ }
}
* {
@@ -207,3 +252,7 @@ body {
color: var(--color-foreground);
font-family: var(--font-sans);
}
+
+[data-theme="light"] body {
+ background-image: none;
+}