From 2971898f0cc77d50739fe15cc4ef496595510474 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 17 Mar 2026 13:20:22 +0100 Subject: [PATCH] Add dark and light theme with OS preference support Follow OS color scheme by default, with a three-way toggle (System / Light / Dark) in the kebab menu. Light theme uses warm, neutral tones with soft card-to-background contrast. Semantic colors (damage, healing, conditions) keep their hue across themes. Closes #10 Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/App.tsx | 6 ++ .../__tests__/combatant-row.test.tsx | 2 +- apps/web/src/components/action-bar.tsx | 35 ++++++- apps/web/src/components/combatant-row.tsx | 4 +- apps/web/src/components/hp-adjust-popover.tsx | 4 +- apps/web/src/components/stat-block.tsx | 14 +-- apps/web/src/components/ui/overflow-menu.tsx | 3 +- apps/web/src/hooks/use-theme.ts | 98 +++++++++++++++++++ apps/web/src/index.css | 49 ++++++++++ 9 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/hooks/use-theme.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cb4927c..fe73588 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -30,6 +30,7 @@ import { useBulkImport } from "./hooks/use-bulk-import"; import { useEncounter } from "./hooks/use-encounter"; import { usePlayerCharacters } from "./hooks/use-player-characters"; import { useSidePanelState } from "./hooks/use-side-panel-state"; +import { useTheme } from "./hooks/use-theme"; import { cn } from "./lib/utils"; function rollDice(): number { @@ -115,6 +116,7 @@ export function App() { const bulkImport = useBulkImport(); const sidePanel = useSidePanelState(); + const { preference: themePreference, cycleTheme } = useTheme(); const [rollSkippedCount, setRollSkippedCount] = useState(0); const [rollSingleSkipped, setRollSingleSkipped] = useState(false); @@ -263,6 +265,8 @@ export function App() { showRollAllInitiative={hasCreatureCombatants} rollAllInitiativeDisabled={!canRollAllInitiative} onOpenSourceManager={sidePanel.showSourceManager} + themePreference={themePreference} + onCycleTheme={cycleTheme} autoFocus /> @@ -325,6 +329,8 @@ export function App() { showRollAllInitiative={hasCreatureCombatants} rollAllInitiativeDisabled={!canRollAllInitiative} onOpenSourceManager={sidePanel.showSourceManager} + themePreference={themePreference} + onCycleTheme={cycleTheme} /> diff --git a/apps/web/src/components/__tests__/combatant-row.test.tsx b/apps/web/src/components/__tests__/combatant-row.test.tsx index b169bce..9850f49 100644 --- a/apps/web/src/components/__tests__/combatant-row.test.tsx +++ b/apps/web/src/components/__tests__/combatant-row.test.tsx @@ -75,7 +75,7 @@ describe("CombatantRow", () => { it("active combatant gets active border styling", () => { const { container } = renderRow({ isActive: true }); const row = container.firstElementChild; - expect(row?.className).toContain("border-accent/40"); + expect(row?.className).toContain("border-active-row-border"); }); it("unconscious combatant (currentHp === 0) gets dimmed styling", () => { diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index 16199c1..8f284f4 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -6,7 +6,10 @@ import { Import, Library, Minus, + Monitor, + Moon, Plus, + Sun, Users, } from "lucide-react"; import React, { type RefObject, useDeferredValue, useState } from "react"; @@ -43,6 +46,8 @@ interface ActionBarProps { rollAllInitiativeDisabled?: boolean; onOpenSourceManager?: () => void; autoFocus?: boolean; + themePreference?: "system" | "light" | "dark"; + onCycleTheme?: () => void; } function creatureKey(r: SearchResult): string { @@ -171,7 +176,7 @@ function AddModeSuggestions({ > - + {queued.count}