Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2971898f0c | ||
|
|
43780772f6 | ||
|
|
7b3dbe2069 | ||
|
|
827a3978e9 | ||
|
|
f024562a7d | ||
|
|
dfef2194a5 | ||
|
|
502adca81b | ||
|
|
12e8bf6e69 |
@@ -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,8 +116,10 @@ export function App() {
|
||||
|
||||
const bulkImport = useBulkImport();
|
||||
const sidePanel = useSidePanelState();
|
||||
const { preference: themePreference, cycleTheme } = useTheme();
|
||||
|
||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
|
||||
|
||||
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
||||
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
||||
@@ -128,9 +131,12 @@ export function App() {
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
addFromBestiary(result);
|
||||
const creatureId = addFromBestiary(result);
|
||||
if (creatureId && sidePanel.panelView.mode === "closed") {
|
||||
sidePanel.showCreature(creatureId);
|
||||
}
|
||||
},
|
||||
[addFromBestiary],
|
||||
[addFromBestiary, sidePanel.panelView.mode, sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleCombatantStatBlock = useCallback(
|
||||
@@ -142,9 +148,21 @@ export function App() {
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId) => {
|
||||
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
||||
const result = rollInitiativeUseCase(
|
||||
makeStore(),
|
||||
id,
|
||||
rollDice(),
|
||||
getCreature,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
setRollSingleSkipped(true);
|
||||
const combatant = encounter.combatants.find((c) => c.id === id);
|
||||
if (combatant?.creatureId) {
|
||||
sidePanel.showCreature(combatant.creatureId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[makeStore, getCreature],
|
||||
[makeStore, getCreature, encounter.combatants, sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleRollAllInitiative = useCallback(() => {
|
||||
@@ -187,6 +205,15 @@ export function App() {
|
||||
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
|
||||
// Auto-update stat block panel when the active combatant changes
|
||||
const activeCreatureId =
|
||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
||||
useEffect(() => {
|
||||
if (activeCreatureId && sidePanel.panelView.mode === "creature") {
|
||||
sidePanel.showCreature(activeCreatureId);
|
||||
}
|
||||
}, [activeCreatureId, sidePanel.panelView.mode, sidePanel.showCreature]);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -238,6 +265,8 @@ export function App() {
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
themePreference={themePreference}
|
||||
onCycleTheme={cycleTheme}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -266,6 +295,9 @@ export function App() {
|
||||
? () => handleCombatantStatBlock(c.creatureId as string)
|
||||
: undefined
|
||||
}
|
||||
isStatBlockOpen={
|
||||
c.creatureId === sidePanel.selectedCreatureId
|
||||
}
|
||||
onRollInitiative={
|
||||
c.creatureId ? handleRollInitiative : undefined
|
||||
}
|
||||
@@ -297,6 +329,8 @@ export function App() {
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
themePreference={themePreference}
|
||||
onCycleTheme={cycleTheme}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -360,6 +394,14 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!rollSingleSkipped && (
|
||||
<Toast
|
||||
message="Can't roll — bestiary source not loaded"
|
||||
onDismiss={() => setRollSingleSkipped(false)}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PlayerCharacterSection
|
||||
ref={playerCharacterRef}
|
||||
characters={playerCharacters}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
deriveHpStatus,
|
||||
type PlayerIcon,
|
||||
} from "@initiative/domain";
|
||||
import { BookOpen, Brain, X } from "lucide-react";
|
||||
import { Book, BookOpen, Brain, X } from "lucide-react";
|
||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { AcShield } from "./ac-shield";
|
||||
@@ -41,6 +41,7 @@ interface CombatantRowProps {
|
||||
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
||||
onToggleConcentration: (id: CombatantId) => void;
|
||||
onShowStatBlock?: () => void;
|
||||
isStatBlockOpen?: boolean;
|
||||
onRollInitiative?: (id: CombatantId) => void;
|
||||
}
|
||||
|
||||
@@ -79,7 +80,7 @@ function EditableName({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={draft}
|
||||
className="h-7 text-sm"
|
||||
className="h-7 max-w-48 text-sm"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
@@ -365,9 +366,13 @@ function rowBorderClass(
|
||||
isActive: boolean,
|
||||
isConcentrating: boolean | undefined,
|
||||
): string {
|
||||
if (isActive) return "border border-accent/40 bg-accent/10 card-glow";
|
||||
if (isConcentrating) return "border-l-2 border-l-purple-400";
|
||||
return "border-l-2 border-l-transparent";
|
||||
if (isActive && isConcentrating)
|
||||
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-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";
|
||||
}
|
||||
|
||||
function concentrationIconClass(
|
||||
@@ -392,6 +397,7 @@ export function CombatantRow({
|
||||
onToggleCondition,
|
||||
onToggleConcentration,
|
||||
onShowStatBlock,
|
||||
isStatBlockOpen,
|
||||
onRollInitiative,
|
||||
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||
@@ -476,9 +482,9 @@ export function CombatantRow({
|
||||
onClick={onShowStatBlock}
|
||||
title="View stat block"
|
||||
aria-label="View stat block"
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-hover-neutral"
|
||||
className="shrink-0 text-foreground transition-colors hover:text-hover-neutral"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
{isStatBlockOpen ? <BookOpen size={16} /> : <Book size={16} />}
|
||||
</button>
|
||||
)}
|
||||
{!!combatant.icon &&
|
||||
@@ -491,7 +497,7 @@ export function CombatantRow({
|
||||
];
|
||||
return PcIcon ? (
|
||||
<PcIcon
|
||||
size={14}
|
||||
size={16}
|
||||
style={{ color: iconColor }}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -23,7 +23,7 @@ export function TurnNavigation({
|
||||
return (
|
||||
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRetreatTurn}
|
||||
disabled={!hasCombatants || isAtStart}
|
||||
@@ -53,7 +53,7 @@ export function TurnNavigation({
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onAdvanceTurn}
|
||||
disabled={!hasCombatants}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -137,7 +137,9 @@ describe("useEncounter", () => {
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
act(() => {
|
||||
result.current.addFromBestiary(entry);
|
||||
});
|
||||
|
||||
expect(result.current.hasCreatureCombatants).toBe(true);
|
||||
expect(result.current.canRollAllInitiative).toBe(true);
|
||||
@@ -158,7 +160,9 @@ describe("useEncounter", () => {
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
act(() => {
|
||||
result.current.addFromBestiary(entry);
|
||||
});
|
||||
|
||||
const combatant = result.current.encounter.combatants[0];
|
||||
expect(combatant.name).toBe("Goblin");
|
||||
@@ -183,8 +187,12 @@ describe("useEncounter", () => {
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
act(() => {
|
||||
result.current.addFromBestiary(entry);
|
||||
});
|
||||
act(() => {
|
||||
result.current.addFromBestiary(entry);
|
||||
});
|
||||
|
||||
const names = result.current.encounter.combatants.map((c) => c.name);
|
||||
expect(names).toContain("Goblin 1");
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
BestiaryIndexEntry,
|
||||
CombatantId,
|
||||
ConditionId,
|
||||
CreatureId,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
@@ -265,7 +266,7 @@ export function useEncounter() {
|
||||
}, [makeStore]);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry) => {
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(
|
||||
@@ -284,7 +285,7 @@ export function useEncounter() {
|
||||
// Add combatant with resolved name
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||
if (isDomainError(addResult)) return;
|
||||
if (isDomainError(addResult)) return null;
|
||||
|
||||
// Set HP
|
||||
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
||||
@@ -317,6 +318,8 @@ export function useEncounter() {
|
||||
});
|
||||
|
||||
setEvents((prev) => [...prev, ...addResult]);
|
||||
|
||||
return cId;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
98
apps/web/src/hooks/use-theme.ts
Normal file
98
apps/web/src/hooks/use-theme.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -198,6 +243,16 @@
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
background-image: radial-gradient(
|
||||
ellipse at 50% 40%,
|
||||
oklch(0.26 0.055 250) 0%,
|
||||
var(--color-background) 70%
|
||||
);
|
||||
background-attachment: fixed;
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
[data-theme="light"] body {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
|
||||
### Edge Cases
|
||||
|
||||
- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded).
|
||||
- Active combatant changes while panel is open or collapsed: advancing turns does not auto-show or update the stat block panel. The panel only changes when the user explicitly clicks a book icon. If the panel is collapsed, it stays collapsed.
|
||||
- Active combatant changes while panel is open: if the new active combatant has a creature, the panel auto-updates to show that creature's stat block. If the new active combatant has no creature, the panel remains on the previous creature. If the panel is collapsed, it stays collapsed. If the panel is closed, it stays closed.
|
||||
- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel.
|
||||
- User is in bulk import mode and tries to collapse: the collapse/expand behavior applies to the bulk import panel identically.
|
||||
- Panel showing a source fetch prompt: the pin button is hidden.
|
||||
|
||||
Reference in New Issue
Block a user