Compare commits
11 Commits
0.7.7
...
2971898f0c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2971898f0c | ||
|
|
43780772f6 | ||
|
|
7b3dbe2069 | ||
|
|
827a3978e9 | ||
|
|
f024562a7d | ||
|
|
dfef2194a5 | ||
|
|
502adca81b | ||
|
|
12e8bf6e69 | ||
|
|
472574ac31 | ||
|
|
f4a7b53393 | ||
|
|
8aec460ee4 |
@@ -30,6 +30,8 @@ import { useBulkImport } from "./hooks/use-bulk-import";
|
|||||||
import { useEncounter } from "./hooks/use-encounter";
|
import { useEncounter } from "./hooks/use-encounter";
|
||||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||||
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
||||||
|
import { useTheme } from "./hooks/use-theme";
|
||||||
|
import { cn } from "./lib/utils";
|
||||||
|
|
||||||
function rollDice(): number {
|
function rollDice(): number {
|
||||||
return Math.floor(Math.random() * 20) + 1;
|
return Math.floor(Math.random() * 20) + 1;
|
||||||
@@ -53,12 +55,12 @@ function useActionBarAnimation(combatantCount: number) {
|
|||||||
}, [combatantCount]);
|
}, [combatantCount]);
|
||||||
|
|
||||||
const empty = combatantCount === 0;
|
const empty = combatantCount === 0;
|
||||||
const risingClass = rising ? " animate-rise-to-center" : "";
|
const risingClass = rising ? "animate-rise-to-center" : "";
|
||||||
const settlingClass = settling ? " animate-settle-to-bottom" : "";
|
const settlingClass = settling ? "animate-settle-to-bottom" : "";
|
||||||
const exitingClass = topBarExiting
|
const exitingClass = topBarExiting
|
||||||
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
? "absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
||||||
: "";
|
: "";
|
||||||
const topBarClass = settling ? " animate-slide-down-in" : exitingClass;
|
const topBarClass = settling ? "animate-slide-down-in" : exitingClass;
|
||||||
const showTopBar = !empty || topBarExiting;
|
const showTopBar = !empty || topBarExiting;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -114,8 +116,10 @@ export function App() {
|
|||||||
|
|
||||||
const bulkImport = useBulkImport();
|
const bulkImport = useBulkImport();
|
||||||
const sidePanel = useSidePanelState();
|
const sidePanel = useSidePanelState();
|
||||||
|
const { preference: themePreference, cycleTheme } = useTheme();
|
||||||
|
|
||||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||||
|
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
|
||||||
|
|
||||||
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
||||||
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
||||||
@@ -127,9 +131,12 @@ export function App() {
|
|||||||
|
|
||||||
const handleAddFromBestiary = useCallback(
|
const handleAddFromBestiary = useCallback(
|
||||||
(result: SearchResult) => {
|
(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(
|
const handleCombatantStatBlock = useCallback(
|
||||||
@@ -141,9 +148,21 @@ export function App() {
|
|||||||
|
|
||||||
const handleRollInitiative = useCallback(
|
const handleRollInitiative = useCallback(
|
||||||
(id: CombatantId) => {
|
(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(() => {
|
const handleRollAllInitiative = useCallback(() => {
|
||||||
@@ -186,6 +205,15 @@ export function App() {
|
|||||||
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
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
|
// Auto-scroll to the active combatant when the turn changes
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -200,7 +228,7 @@ export function App() {
|
|||||||
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
||||||
{!!actionBarAnim.showTopBar && (
|
{!!actionBarAnim.showTopBar && (
|
||||||
<div
|
<div
|
||||||
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
||||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||||
>
|
>
|
||||||
<TurnNavigation
|
<TurnNavigation
|
||||||
@@ -216,7 +244,7 @@ export function App() {
|
|||||||
/* Empty state — ActionBar centered */
|
/* Empty state — ActionBar centered */
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||||
<div
|
<div
|
||||||
className={`w-full${actionBarAnim.risingClass}`}
|
className={cn("w-full", actionBarAnim.risingClass)}
|
||||||
onAnimationEnd={actionBarAnim.onRiseEnd}
|
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||||
>
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
@@ -237,6 +265,8 @@ export function App() {
|
|||||||
showRollAllInitiative={hasCreatureCombatants}
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
onOpenSourceManager={sidePanel.showSourceManager}
|
onOpenSourceManager={sidePanel.showSourceManager}
|
||||||
|
themePreference={themePreference}
|
||||||
|
onCycleTheme={cycleTheme}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,6 +295,9 @@ export function App() {
|
|||||||
? () => handleCombatantStatBlock(c.creatureId as string)
|
? () => handleCombatantStatBlock(c.creatureId as string)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
isStatBlockOpen={
|
||||||
|
c.creatureId === sidePanel.selectedCreatureId
|
||||||
|
}
|
||||||
onRollInitiative={
|
onRollInitiative={
|
||||||
c.creatureId ? handleRollInitiative : undefined
|
c.creatureId ? handleRollInitiative : undefined
|
||||||
}
|
}
|
||||||
@@ -275,7 +308,7 @@ export function App() {
|
|||||||
|
|
||||||
{/* Action Bar — fixed at bottom */}
|
{/* Action Bar — fixed at bottom */}
|
||||||
<div
|
<div
|
||||||
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
|
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
|
||||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||||
>
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
@@ -296,6 +329,8 @@ export function App() {
|
|||||||
showRollAllInitiative={hasCreatureCombatants}
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
onOpenSourceManager={sidePanel.showSourceManager}
|
onOpenSourceManager={sidePanel.showSourceManager}
|
||||||
|
themePreference={themePreference}
|
||||||
|
onCycleTheme={cycleTheme}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -359,6 +394,14 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!!rollSingleSkipped && (
|
||||||
|
<Toast
|
||||||
|
message="Can't roll — bestiary source not loaded"
|
||||||
|
onDismiss={() => setRollSingleSkipped(false)}
|
||||||
|
autoDismissMs={4000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<PlayerCharacterSection
|
<PlayerCharacterSection
|
||||||
ref={playerCharacterRef}
|
ref={playerCharacterRef}
|
||||||
characters={playerCharacters}
|
characters={playerCharacters}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ describe("CombatantRow", () => {
|
|||||||
it("active combatant gets active border styling", () => {
|
it("active combatant gets active border styling", () => {
|
||||||
const { container } = renderRow({ isActive: true });
|
const { container } = renderRow({ isActive: true });
|
||||||
const row = container.firstElementChild;
|
const row = container.firstElementChild;
|
||||||
expect(row?.className).toContain("border-l-accent");
|
expect(row?.className).toContain("border-active-row-border");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
Import,
|
Import,
|
||||||
Library,
|
Library,
|
||||||
Minus,
|
Minus,
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { type RefObject, useDeferredValue, useState } from "react";
|
import React, { type RefObject, useDeferredValue, useState } from "react";
|
||||||
@@ -43,6 +46,8 @@ interface ActionBarProps {
|
|||||||
rollAllInitiativeDisabled?: boolean;
|
rollAllInitiativeDisabled?: boolean;
|
||||||
onOpenSourceManager?: () => void;
|
onOpenSourceManager?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
themePreference?: "system" | "light" | "dark";
|
||||||
|
onCycleTheme?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function creatureKey(r: SearchResult): string {
|
function creatureKey(r: SearchResult): string {
|
||||||
@@ -77,7 +82,7 @@ function AddModeSuggestions({
|
|||||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
||||||
@@ -137,12 +142,14 @@ function AddModeSuggestions({
|
|||||||
<li key={key}>
|
<li key={key}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${(() => {
|
className={cn(
|
||||||
if (isQueued) return "bg-accent/30 text-foreground";
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
|
||||||
if (i === suggestionIndex)
|
isQueued && "bg-accent/30",
|
||||||
return "bg-accent/20 text-foreground";
|
!isQueued && i === suggestionIndex && "bg-accent/20",
|
||||||
return "text-foreground hover:bg-hover-neutral-bg";
|
!isQueued &&
|
||||||
})()}`}
|
i !== suggestionIndex &&
|
||||||
|
"hover:bg-hover-neutral-bg",
|
||||||
|
)}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => onClickSuggestion(result)}
|
onClick={() => onClickSuggestion(result)}
|
||||||
onMouseEnter={() => onSetSuggestionIndex(i)}
|
onMouseEnter={() => onSetSuggestionIndex(i)}
|
||||||
@@ -169,7 +176,7 @@ function AddModeSuggestions({
|
|||||||
>
|
>
|
||||||
<Minus className="h-3 w-3" />
|
<Minus className="h-3 w-3" />
|
||||||
</button>
|
</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}
|
{queued.count}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -213,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: {
|
function buildOverflowItems(opts: {
|
||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
onOpenSourceManager?: () => void;
|
onOpenSourceManager?: () => void;
|
||||||
bestiaryLoaded: boolean;
|
bestiaryLoaded: boolean;
|
||||||
onBulkImport?: () => void;
|
onBulkImport?: () => void;
|
||||||
bulkImportDisabled?: boolean;
|
bulkImportDisabled?: boolean;
|
||||||
|
themePreference?: "system" | "light" | "dark";
|
||||||
|
onCycleTheme?: () => void;
|
||||||
}): OverflowMenuItem[] {
|
}): OverflowMenuItem[] {
|
||||||
const items: OverflowMenuItem[] = [];
|
const items: OverflowMenuItem[] = [];
|
||||||
if (opts.onManagePlayers) {
|
if (opts.onManagePlayers) {
|
||||||
@@ -243,6 +264,16 @@ function buildOverflowItems(opts: {
|
|||||||
disabled: opts.bulkImportDisabled,
|
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;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +294,8 @@ export function ActionBar({
|
|||||||
rollAllInitiativeDisabled,
|
rollAllInitiativeDisabled,
|
||||||
onOpenSourceManager,
|
onOpenSourceManager,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
|
themePreference,
|
||||||
|
onCycleTheme,
|
||||||
}: Readonly<ActionBarProps>) {
|
}: Readonly<ActionBarProps>) {
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
@@ -452,10 +485,12 @@ export function ActionBar({
|
|||||||
bestiaryLoaded,
|
bestiaryLoaded,
|
||||||
onBulkImport,
|
onBulkImport,
|
||||||
bulkImportDisabled,
|
bulkImportDisabled,
|
||||||
|
themePreference,
|
||||||
|
onCycleTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleAdd}
|
onSubmit={handleAdd}
|
||||||
className="relative flex flex-1 items-center gap-2"
|
className="relative flex flex-1 items-center gap-2"
|
||||||
@@ -496,17 +531,18 @@ export function ActionBar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{browseMode && deferredSuggestions.length > 0 && (
|
{browseMode && deferredSuggestions.length > 0 && (
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||||
<ul className="max-h-48 overflow-y-auto py-1">
|
<ul className="max-h-48 overflow-y-auto py-1">
|
||||||
{deferredSuggestions.map((result, i) => (
|
{deferredSuggestions.map((result, i) => (
|
||||||
<li key={creatureKey(result)}>
|
<li key={creatureKey(result)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
||||||
i === suggestionIndex
|
i === suggestionIndex
|
||||||
? "bg-accent/20 text-foreground"
|
? "bg-accent/20 text-foreground"
|
||||||
: "text-foreground hover:bg-hover-neutral-bg"
|
: "text-foreground hover:bg-hover-neutral-bg",
|
||||||
}`}
|
)}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => handleBrowseSelect(result)}
|
onClick={() => handleBrowseSelect(result)}
|
||||||
onMouseEnter={() => setSuggestionIndex(i)}
|
onMouseEnter={() => setSuggestionIndex(i)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
} from "@initiative/domain";
|
} 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 { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { AcShield } from "./ac-shield";
|
import { AcShield } from "./ac-shield";
|
||||||
@@ -41,6 +41,7 @@ interface CombatantRowProps {
|
|||||||
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
||||||
onToggleConcentration: (id: CombatantId) => void;
|
onToggleConcentration: (id: CombatantId) => void;
|
||||||
onShowStatBlock?: () => void;
|
onShowStatBlock?: () => void;
|
||||||
|
isStatBlockOpen?: boolean;
|
||||||
onRollInitiative?: (id: CombatantId) => void;
|
onRollInitiative?: (id: CombatantId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ function EditableName({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={draft}
|
value={draft}
|
||||||
className="h-7 text-sm"
|
className="h-7 max-w-48 text-sm"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -365,9 +366,13 @@ function rowBorderClass(
|
|||||||
isActive: boolean,
|
isActive: boolean,
|
||||||
isConcentrating: boolean | undefined,
|
isConcentrating: boolean | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
|
if (isActive && isConcentrating)
|
||||||
if (isConcentrating) return "border-l-2 border-l-purple-400";
|
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
|
||||||
return "border-l-2 border-l-transparent";
|
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(
|
function concentrationIconClass(
|
||||||
@@ -392,6 +397,7 @@ export function CombatantRow({
|
|||||||
onToggleCondition,
|
onToggleCondition,
|
||||||
onToggleConcentration,
|
onToggleConcentration,
|
||||||
onShowStatBlock,
|
onShowStatBlock,
|
||||||
|
isStatBlockOpen,
|
||||||
onRollInitiative,
|
onRollInitiative,
|
||||||
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
||||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||||
@@ -434,7 +440,7 @@ export function CombatantRow({
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group rounded-md pr-3 transition-colors",
|
"group rounded-lg pr-3 transition-colors",
|
||||||
rowBorderClass(isActive, combatant.isConcentrating),
|
rowBorderClass(isActive, combatant.isConcentrating),
|
||||||
isPulsing && "animate-concentration-pulse",
|
isPulsing && "animate-concentration-pulse",
|
||||||
)}
|
)}
|
||||||
@@ -476,9 +482,9 @@ export function CombatantRow({
|
|||||||
onClick={onShowStatBlock}
|
onClick={onShowStatBlock}
|
||||||
title="View stat block"
|
title="View stat block"
|
||||||
aria-label="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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!!combatant.icon &&
|
{!!combatant.icon &&
|
||||||
@@ -491,7 +497,7 @@ export function CombatantRow({
|
|||||||
];
|
];
|
||||||
return PcIcon ? (
|
return PcIcon ? (
|
||||||
<PcIcon
|
<PcIcon
|
||||||
size={14}
|
size={16}
|
||||||
style={{ color: iconColor }}
|
style={{ color: iconColor }}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function ConditionPicker({
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
|
"card-glow absolute left-0 z-10 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1",
|
||||||
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
||||||
)}
|
)}
|
||||||
style={maxHeight ? { maxHeight } : undefined}
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -75,7 +76,10 @@ export function ConditionTags({
|
|||||||
type="button"
|
type="button"
|
||||||
title={def.label}
|
title={def.label}
|
||||||
aria-label={`Remove ${def.label}`}
|
aria-label={`Remove ${def.label}`}
|
||||||
className={`inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg ${colorClass}`}
|
className={cn(
|
||||||
|
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||||
|
colorClass,
|
||||||
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove(condId);
|
onRemove(condId);
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function CreatePlayerModal({
|
|||||||
return (
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className="m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
|
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="fixed z-10 rounded-md border border-border bg-background p-2 shadow-lg"
|
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
|
||||||
style={
|
style={
|
||||||
pos
|
pos
|
||||||
? { top: pos.top, left: pos.left }
|
? { top: pos.top, left: pos.left }
|
||||||
@@ -113,7 +113,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isValid}
|
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)}
|
onClick={() => applyDelta(-1)}
|
||||||
title="Apply damage"
|
title="Apply damage"
|
||||||
aria-label="Apply damage"
|
aria-label="Apply damage"
|
||||||
@@ -123,7 +123,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isValid}
|
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)}
|
onClick={() => applyDelta(1)}
|
||||||
title="Apply healing"
|
title="Apply healing"
|
||||||
aria-label="Apply healing"
|
aria-label="Apply healing"
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function PlayerManagement({
|
|||||||
return (
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className="m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
|
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
||||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||||
import { SourceManager } from "./source-manager.js";
|
import { SourceManager } from "./source-manager.js";
|
||||||
@@ -55,9 +56,10 @@ function CollapsedTab({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
|
className={cn(
|
||||||
side === "right" ? "self-start" : "self-end"
|
"flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral",
|
||||||
}`}
|
side === "right" ? "self-start" : "self-end",
|
||||||
|
)}
|
||||||
aria-label="Expand stat block panel"
|
aria-label="Expand stat block panel"
|
||||||
>
|
>
|
||||||
<span className="writing-vertical-rl font-medium text-sm">
|
<span className="writing-vertical-rl font-medium text-sm">
|
||||||
@@ -152,7 +154,11 @@ function DesktopPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`}
|
className={cn(
|
||||||
|
"panel-glow fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel",
|
||||||
|
sideClasses,
|
||||||
|
isCollapsed ? collapsedTranslate : "translate-x-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<CollapsedTab
|
<CollapsedTab
|
||||||
@@ -194,7 +200,10 @@ function MobileDrawer({
|
|||||||
aria-label="Close stat block"
|
aria-label="Close stat block"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
className={cn(
|
||||||
|
"panel-glow absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card",
|
||||||
|
!isSwiping && "animate-slide-in-right",
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function PropertyLine({
|
|||||||
|
|
||||||
function SectionDivider() {
|
function SectionDivider() {
|
||||||
return (
|
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">
|
<div className="space-y-1 text-foreground">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<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">
|
<p className="text-muted-foreground text-sm italic">
|
||||||
{creature.size} {creature.type}, {creature.alignment}
|
{creature.size} {creature.type}, {creature.alignment}
|
||||||
</p>
|
</p>
|
||||||
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
{creature.actions && creature.actions.length > 0 && (
|
{creature.actions && creature.actions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<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">
|
<div className="space-y-2">
|
||||||
{creature.actions.map((a) => (
|
{creature.actions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -209,7 +209,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<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">
|
<div className="space-y-2">
|
||||||
{creature.bonusActions.map((a) => (
|
{creature.bonusActions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -224,7 +226,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
{creature.reactions && creature.reactions.length > 0 && (
|
{creature.reactions && creature.reactions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<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">
|
<div className="space-y-2">
|
||||||
{creature.reactions.map((a) => (
|
{creature.reactions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -239,7 +241,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
{!!creature.legendaryActions && (
|
{!!creature.legendaryActions && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="font-bold text-amber-400 text-base">
|
<h3 className="font-bold text-base text-stat-heading">
|
||||||
Legendary Actions
|
Legendary Actions
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground text-sm italic">
|
<p className="text-muted-foreground text-sm italic">
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function Toast({
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed bottom-4 left-4 z-50">
|
<div className="fixed bottom-4 left-4 z-50">
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||||
<span className="text-foreground text-sm">{message}</span>
|
<span className="text-foreground text-sm">{message}</span>
|
||||||
{progress !== undefined && (
|
{progress !== undefined && (
|
||||||
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ export function TurnNavigation({
|
|||||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onRetreatTurn}
|
onClick={onRetreatTurn}
|
||||||
disabled={!hasCombatants || isAtStart}
|
disabled={!hasCombatants || isAtStart}
|
||||||
@@ -53,7 +53,7 @@ export function TurnNavigation({
|
|||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onAdvanceTurn}
|
onClick={onAdvanceTurn}
|
||||||
disabled={!hasCombatants}
|
disabled={!hasCombatants}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const buttonVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
outline:
|
outline:
|
||||||
"border border-border bg-transparent text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
"border border-border bg-background/50 text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||||
ghost:
|
ghost:
|
||||||
"text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
"text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface OverflowMenuItem {
|
|||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly onClick: () => void;
|
readonly onClick: () => void;
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
|
readonly keepOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OverflowMenuProps {
|
interface OverflowMenuProps {
|
||||||
@@ -49,7 +50,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
|||||||
<EllipsisVertical className="h-5 w-5" />
|
<EllipsisVertical className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
{!!open && (
|
{!!open && (
|
||||||
<div className="absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
<div className="card-glow absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-lg border border-border bg-card py-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
@@ -58,7 +59,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
|||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
item.onClick();
|
item.onClick();
|
||||||
setOpen(false);
|
if (!item.keepOpen) setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ describe("useEncounter", () => {
|
|||||||
type: "humanoid",
|
type: "humanoid",
|
||||||
};
|
};
|
||||||
|
|
||||||
act(() => result.current.addFromBestiary(entry));
|
act(() => {
|
||||||
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.hasCreatureCombatants).toBe(true);
|
expect(result.current.hasCreatureCombatants).toBe(true);
|
||||||
expect(result.current.canRollAllInitiative).toBe(true);
|
expect(result.current.canRollAllInitiative).toBe(true);
|
||||||
@@ -158,7 +160,9 @@ describe("useEncounter", () => {
|
|||||||
type: "humanoid",
|
type: "humanoid",
|
||||||
};
|
};
|
||||||
|
|
||||||
act(() => result.current.addFromBestiary(entry));
|
act(() => {
|
||||||
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
|
||||||
const combatant = result.current.encounter.combatants[0];
|
const combatant = result.current.encounter.combatants[0];
|
||||||
expect(combatant.name).toBe("Goblin");
|
expect(combatant.name).toBe("Goblin");
|
||||||
@@ -183,8 +187,12 @@ describe("useEncounter", () => {
|
|||||||
type: "humanoid",
|
type: "humanoid",
|
||||||
};
|
};
|
||||||
|
|
||||||
act(() => result.current.addFromBestiary(entry));
|
act(() => {
|
||||||
act(() => result.current.addFromBestiary(entry));
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
|
||||||
const names = result.current.encounter.combatants.map((c) => c.name);
|
const names = result.current.encounter.combatants.map((c) => c.name);
|
||||||
expect(names).toContain("Goblin 1");
|
expect(names).toContain("Goblin 1");
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
BestiaryIndexEntry,
|
BestiaryIndexEntry,
|
||||||
CombatantId,
|
CombatantId,
|
||||||
ConditionId,
|
ConditionId,
|
||||||
|
CreatureId,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
@@ -265,7 +266,7 @@ export function useEncounter() {
|
|||||||
}, [makeStore]);
|
}, [makeStore]);
|
||||||
|
|
||||||
const addFromBestiary = useCallback(
|
const addFromBestiary = useCallback(
|
||||||
(entry: BestiaryIndexEntry) => {
|
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
const existingNames = store.get().combatants.map((c) => c.name);
|
const existingNames = store.get().combatants.map((c) => c.name);
|
||||||
const { newName, renames } = resolveCreatureName(
|
const { newName, renames } = resolveCreatureName(
|
||||||
@@ -284,7 +285,7 @@ export function useEncounter() {
|
|||||||
// Add combatant with resolved name
|
// Add combatant with resolved name
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||||
if (isDomainError(addResult)) return;
|
if (isDomainError(addResult)) return null;
|
||||||
|
|
||||||
// Set HP
|
// Set HP
|
||||||
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
||||||
@@ -317,6 +318,8 @@ export function useEncounter() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
|
|
||||||
|
return cId;
|
||||||
},
|
},
|
||||||
[makeStore],
|
[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;
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: #0f172a;
|
--color-background: #0e1a2e;
|
||||||
--color-foreground: #e2e8f0;
|
--color-foreground: #e2e8f0;
|
||||||
--color-muted: #64748b;
|
--color-muted: #7a8ba4;
|
||||||
--color-muted-foreground: #94a3b8;
|
--color-muted-foreground: #94a3b8;
|
||||||
--color-card: #1e293b;
|
--color-card: #1a2e4a;
|
||||||
--color-card-foreground: #e2e8f0;
|
--color-card-foreground: #e2e8f0;
|
||||||
--color-border: #334155;
|
--color-border: #2a5088;
|
||||||
--color-input: #334155;
|
--color-input: #2a5088;
|
||||||
--color-primary: #3b82f6;
|
--color-primary: #3b82f6;
|
||||||
--color-primary-foreground: #ffffff;
|
--color-primary-foreground: #ffffff;
|
||||||
--color-accent: #3b82f6;
|
--color-accent: #3b82f6;
|
||||||
@@ -19,12 +19,47 @@
|
|||||||
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
|
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
|
||||||
--color-hover-action-bg: var(--color-muted);
|
--color-hover-action-bg: var(--color-muted);
|
||||||
--color-hover-destructive-bg: transparent;
|
--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-sm: 0.25rem;
|
||||||
--radius-md: 0.375rem;
|
--radius-md: 0.5rem;
|
||||||
--radius-lg: 0.5rem;
|
--radius-lg: 0.75rem;
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--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 {
|
@keyframes concentration-shake {
|
||||||
0% {
|
0% {
|
||||||
translate: 0;
|
translate: 0;
|
||||||
@@ -169,6 +204,38 @@
|
|||||||
concentration-glow 1200ms ease-out;
|
concentration-glow 1200ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility card-glow {
|
||||||
|
background-image: radial-gradient(
|
||||||
|
ellipse at 50% 50%,
|
||||||
|
oklch(0.35 0.05 250 / 0.5) 0%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
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 {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
oklch(0.35 0.05 250 / 0.4) 0%,
|
||||||
|
transparent 40%
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
scrollbar-color: var(--color-border) transparent;
|
scrollbar-color: var(--color-border) transparent;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -176,6 +243,16 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-background);
|
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);
|
color: var(--color-foreground);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] body {
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
||||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && tsc --build && vitest run && jscpd"
|
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||||
|
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && tsc --build && vitest run && jscpd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
scripts/check-cn-classnames.mjs
Normal file
47
scripts/check-cn-classnames.mjs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Ban template-literal classNames in TSX files.
|
||||||
|
*
|
||||||
|
* Tailwind v4's production content extractor does static analysis on source
|
||||||
|
* files to discover utility classes. Template literals like
|
||||||
|
* className={`foo ${bar}`}
|
||||||
|
* can cause the extractor to miss classes adjacent to `${`, leading to
|
||||||
|
* styles that work in dev (JIT) but break in production.
|
||||||
|
*
|
||||||
|
* Rule: always use cn() for dynamic class composition instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const PATTERN = /className\s*=\s*\{`/;
|
||||||
|
|
||||||
|
function findFiles() {
|
||||||
|
return execSync("git ls-files -- '*.tsx'", { encoding: "utf-8" })
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const file of findFiles()) {
|
||||||
|
const lines = readFileSync(file, "utf-8").split("\n");
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (PATTERN.test(lines[i])) {
|
||||||
|
console.error(
|
||||||
|
`${file}:${i + 1}: className uses template literal — use cn() instead`,
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors > 0) {
|
||||||
|
console.error(
|
||||||
|
`\n${errors} template-literal className(s) found. Use cn() for dynamic classes.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log("No template-literal classNames found.");
|
||||||
|
}
|
||||||
@@ -264,7 +264,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
|
|||||||
### Edge Cases
|
### 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).
|
- 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.
|
- 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.
|
- 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.
|
- Panel showing a source fetch prompt: the pin button is hidden.
|
||||||
|
|||||||
Reference in New Issue
Block a user