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) <noreply@anthropic.com>
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
|
||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground">
|
||||
{queued.count}
|
||||
</span>
|
||||
<button
|
||||
@@ -215,12 +220,26 @@ function AddModeSuggestions({
|
||||
);
|
||||
}
|
||||
|
||||
const THEME_ICONS = {
|
||||
system: Monitor,
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
} as const;
|
||||
|
||||
const THEME_LABELS = {
|
||||
system: "Theme: System",
|
||||
light: "Theme: Light",
|
||||
dark: "Theme: Dark",
|
||||
} as const;
|
||||
|
||||
function buildOverflowItems(opts: {
|
||||
onManagePlayers?: () => void;
|
||||
onOpenSourceManager?: () => void;
|
||||
bestiaryLoaded: boolean;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
themePreference?: "system" | "light" | "dark";
|
||||
onCycleTheme?: () => void;
|
||||
}): OverflowMenuItem[] {
|
||||
const items: OverflowMenuItem[] = [];
|
||||
if (opts.onManagePlayers) {
|
||||
@@ -245,6 +264,16 @@ function buildOverflowItems(opts: {
|
||||
disabled: opts.bulkImportDisabled,
|
||||
});
|
||||
}
|
||||
if (opts.onCycleTheme) {
|
||||
const pref = opts.themePreference ?? "system";
|
||||
const ThemeIcon = THEME_ICONS[pref];
|
||||
items.push({
|
||||
icon: <ThemeIcon className="h-4 w-4" />,
|
||||
label: THEME_LABELS[pref],
|
||||
onClick: opts.onCycleTheme,
|
||||
keepOpen: true,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -265,6 +294,8 @@ export function ActionBar({
|
||||
rollAllInitiativeDisabled,
|
||||
onOpenSourceManager,
|
||||
autoFocus,
|
||||
themePreference,
|
||||
onCycleTheme,
|
||||
}: Readonly<ActionBarProps>) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
@@ -454,6 +485,8 @@ export function ActionBar({
|
||||
bestiaryLoaded,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
themePreference,
|
||||
onCycleTheme,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -367,9 +367,9 @@ function rowBorderClass(
|
||||
isConcentrating: boolean | undefined,
|
||||
): string {
|
||||
if (isActive && isConcentrating)
|
||||
return "border border-l-2 border-accent/40 border-l-purple-400 bg-accent/10 card-glow";
|
||||
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
|
||||
if (isActive)
|
||||
return "border border-l-2 border-accent/40 bg-accent/10 card-glow";
|
||||
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
|
||||
if (isConcentrating)
|
||||
return "border border-l-2 border-transparent border-l-purple-400";
|
||||
return "border border-l-2 border-transparent";
|
||||
|
||||
@@ -113,7 +113,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isValid}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-hp-damage-hover-bg hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => applyDelta(-1)}
|
||||
title="Apply damage"
|
||||
aria-label="Apply damage"
|
||||
@@ -123,7 +123,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isValid}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-hp-heal-hover-bg hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => applyDelta(1)}
|
||||
title="Apply healing"
|
||||
aria-label="Apply healing"
|
||||
|
||||
@@ -30,7 +30,7 @@ function PropertyLine({
|
||||
|
||||
function SectionDivider() {
|
||||
return (
|
||||
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
|
||||
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
<div className="space-y-1 text-foreground">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="font-bold text-amber-400 text-xl">{creature.name}</h2>
|
||||
<h2 className="font-bold text-stat-heading text-xl">{creature.name}</h2>
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
{creature.size} {creature.type}, {creature.alignment}
|
||||
</p>
|
||||
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
{creature.actions && creature.actions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">Actions</h3>
|
||||
<h3 className="font-bold text-base text-stat-heading">Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.actions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
@@ -209,7 +209,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">Bonus Actions</h3>
|
||||
<h3 className="font-bold text-base text-stat-heading">
|
||||
Bonus Actions
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.bonusActions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
@@ -224,7 +226,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
{creature.reactions && creature.reactions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">Reactions</h3>
|
||||
<h3 className="font-bold text-base text-stat-heading">Reactions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.reactions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
@@ -239,7 +241,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
{!!creature.legendaryActions && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">
|
||||
<h3 className="font-bold text-base text-stat-heading">
|
||||
Legendary Actions
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user