Compare commits
10 Commits
bd39808000
...
0.7.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e233bd8d | ||
|
|
8c62ec28f2 | ||
|
|
72195e90f6 | ||
|
|
6ac8e67970 | ||
|
|
a4797d5b15 | ||
|
|
d48e39ced4 | ||
|
|
b7406c4b54 | ||
|
|
07cdd4867a | ||
|
|
85acb5c185 | ||
|
|
f9ef64bb00 |
@@ -2,7 +2,12 @@ import {
|
|||||||
rollAllInitiativeUseCase,
|
rollAllInitiativeUseCase,
|
||||||
rollInitiativeUseCase,
|
rollInitiativeUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type Creature,
|
||||||
|
type CreatureId,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -14,7 +19,6 @@ import { ActionBar } from "./components/action-bar";
|
|||||||
import { CombatantRow } from "./components/combatant-row";
|
import { CombatantRow } from "./components/combatant-row";
|
||||||
import { CreatePlayerModal } from "./components/create-player-modal";
|
import { CreatePlayerModal } from "./components/create-player-modal";
|
||||||
import { PlayerManagement } from "./components/player-management";
|
import { PlayerManagement } from "./components/player-management";
|
||||||
import { SourceManager } from "./components/source-manager";
|
|
||||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||||
import { Toast } from "./components/toast";
|
import { Toast } from "./components/toast";
|
||||||
import { TurnNavigation } from "./components/turn-navigation";
|
import { TurnNavigation } from "./components/turn-navigation";
|
||||||
@@ -110,10 +114,12 @@ export function App() {
|
|||||||
|
|
||||||
const bulkImport = useBulkImport();
|
const bulkImport = useBulkImport();
|
||||||
|
|
||||||
|
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||||
|
|
||||||
const [selectedCreatureId, setSelectedCreatureId] =
|
const [selectedCreatureId, setSelectedCreatureId] =
|
||||||
useState<CreatureId | null>(null);
|
useState<CreatureId | null>(null);
|
||||||
const [bulkImportMode, setBulkImportMode] = useState(false);
|
const [bulkImportMode, setBulkImportMode] = useState(false);
|
||||||
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
const [sourceManagerMode, setSourceManagerMode] = useState(false);
|
||||||
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
||||||
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
||||||
null,
|
null,
|
||||||
@@ -146,6 +152,8 @@ export function App() {
|
|||||||
|
|
||||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
||||||
setSelectedCreatureId(creatureId as CreatureId);
|
setSelectedCreatureId(creatureId as CreatureId);
|
||||||
|
setBulkImportMode(false);
|
||||||
|
setSourceManagerMode(false);
|
||||||
setIsRightPanelFolded(false);
|
setIsRightPanelFolded(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -157,7 +165,10 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRollAllInitiative = useCallback(() => {
|
const handleRollAllInitiative = useCallback(() => {
|
||||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||||
|
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||||
|
setRollSkippedCount(result.skippedNoSource);
|
||||||
|
}
|
||||||
}, [makeStore, getCreature]);
|
}, [makeStore, getCreature]);
|
||||||
|
|
||||||
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
||||||
@@ -167,12 +178,23 @@ export function App() {
|
|||||||
.replace(/(^-|-$)/g, "");
|
.replace(/(^-|-$)/g, "");
|
||||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||||
setSelectedCreatureId(cId);
|
setSelectedCreatureId(cId);
|
||||||
|
setBulkImportMode(false);
|
||||||
|
setSourceManagerMode(false);
|
||||||
setIsRightPanelFolded(false);
|
setIsRightPanelFolded(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleBulkImport = useCallback(() => {
|
const handleBulkImport = useCallback(() => {
|
||||||
setBulkImportMode(true);
|
setBulkImportMode(true);
|
||||||
|
setSourceManagerMode(false);
|
||||||
setSelectedCreatureId(null);
|
setSelectedCreatureId(null);
|
||||||
|
setIsRightPanelFolded(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenSourceManager = useCallback(() => {
|
||||||
|
setSourceManagerMode(true);
|
||||||
|
setBulkImportMode(false);
|
||||||
|
setSelectedCreatureId(null);
|
||||||
|
setIsRightPanelFolded(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStartBulkImport = useCallback(
|
const handleStartBulkImport = useCallback(
|
||||||
@@ -195,6 +217,7 @@ export function App() {
|
|||||||
const handleDismissBrowsePanel = useCallback(() => {
|
const handleDismissBrowsePanel = useCallback(() => {
|
||||||
setSelectedCreatureId(null);
|
setSelectedCreatureId(null);
|
||||||
setBulkImportMode(false);
|
setBulkImportMode(false);
|
||||||
|
setSourceManagerMode(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleFold = useCallback(() => {
|
const handleToggleFold = useCallback(() => {
|
||||||
@@ -236,10 +259,15 @@ export function App() {
|
|||||||
const active = encounter.combatants[encounter.activeIndex];
|
const active = encounter.combatants[encounter.activeIndex];
|
||||||
if (!active?.creatureId || !isLoaded) return;
|
if (!active?.creatureId || !isLoaded) return;
|
||||||
setSelectedCreatureId(active.creatureId as CreatureId);
|
setSelectedCreatureId(active.creatureId as CreatureId);
|
||||||
|
setBulkImportMode(false);
|
||||||
|
setSourceManagerMode(false);
|
||||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
||||||
|
|
||||||
const isEmpty = encounter.combatants.length === 0;
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
const showRollAllInitiative = encounter.combatants.some(
|
const hasCreatureCombatants = encounter.combatants.some(
|
||||||
|
(c) => c.creatureId != null,
|
||||||
|
);
|
||||||
|
const canRollAllInitiative = encounter.combatants.some(
|
||||||
(c) => c.creatureId != null && c.initiative == null,
|
(c) => c.creatureId != null && c.initiative == null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -280,20 +308,15 @@ export function App() {
|
|||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
onManagePlayers={() => setManagementOpen(true)}
|
onManagePlayers={() => setManagementOpen(true)}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
|
onOpenSourceManager={handleOpenSourceManager}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{sourceManagerOpen && (
|
|
||||||
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
|
||||||
<SourceManager onCacheCleared={refreshCache} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scrollable area — combatant list */}
|
{/* Scrollable area — combatant list */}
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
<div className="flex flex-col px-2 py-2">
|
<div className="flex flex-col px-2 py-2">
|
||||||
@@ -342,8 +365,9 @@ export function App() {
|
|||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
onManagePlayers={() => setManagementOpen(true)}
|
onManagePlayers={() => setManagementOpen(true)}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
|
onOpenSourceManager={handleOpenSourceManager}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -390,6 +414,7 @@ export function App() {
|
|||||||
bulkImportState={bulkImport.state}
|
bulkImportState={bulkImport.state}
|
||||||
onStartBulkImport={handleStartBulkImport}
|
onStartBulkImport={handleStartBulkImport}
|
||||||
onBulkImportDone={handleBulkImportDone}
|
onBulkImportDone={handleBulkImportDone}
|
||||||
|
sourceManagerMode={sourceManagerMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Toast for bulk import progress when panel is closed */}
|
{/* Toast for bulk import progress when panel is closed */}
|
||||||
@@ -419,6 +444,14 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rollSkippedCount > 0 && (
|
||||||
|
<Toast
|
||||||
|
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||||
|
onDismiss={() => setRollSkippedCount(0)}
|
||||||
|
autoDismissMs={4000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<CreatePlayerModal
|
<CreatePlayerModal
|
||||||
open={createPlayerOpen}
|
open={createPlayerOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -431,8 +464,8 @@ export function App() {
|
|||||||
name,
|
name,
|
||||||
ac,
|
ac,
|
||||||
maxHp,
|
maxHp,
|
||||||
color,
|
color: color ?? null,
|
||||||
icon,
|
icon: icon ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
createPlayerCharacter(name, ac, maxHp, color, icon);
|
createPlayerCharacter(name, ac, maxHp, color, icon);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ interface ActionBarProps {
|
|||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
onRollAllInitiative?: () => void;
|
onRollAllInitiative?: () => void;
|
||||||
showRollAllInitiative?: boolean;
|
showRollAllInitiative?: boolean;
|
||||||
|
rollAllInitiativeDisabled?: boolean;
|
||||||
onOpenSourceManager?: () => void;
|
onOpenSourceManager?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
@@ -60,6 +61,7 @@ function AddModeSuggestions({
|
|||||||
onSetQueued,
|
onSetQueued,
|
||||||
onConfirmQueued,
|
onConfirmQueued,
|
||||||
onAddFromPlayerCharacter,
|
onAddFromPlayerCharacter,
|
||||||
|
onClear,
|
||||||
}: {
|
}: {
|
||||||
nameInput: string;
|
nameInput: string;
|
||||||
suggestions: SearchResult[];
|
suggestions: SearchResult[];
|
||||||
@@ -67,6 +69,7 @@ function AddModeSuggestions({
|
|||||||
suggestionIndex: number;
|
suggestionIndex: number;
|
||||||
queued: QueuedCreature | null;
|
queued: QueuedCreature | null;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
|
onClear: () => void;
|
||||||
onClickSuggestion: (result: SearchResult) => void;
|
onClickSuggestion: (result: SearchResult) => void;
|
||||||
onSetSuggestionIndex: (i: number) => void;
|
onSetSuggestionIndex: (i: number) => void;
|
||||||
onSetQueued: (q: QueuedCreature | null) => void;
|
onSetQueued: (q: QueuedCreature | null) => void;
|
||||||
@@ -95,9 +98,12 @@ function AddModeSuggestions({
|
|||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{pcMatches.map((pc) => {
|
{pcMatches.map((pc) => {
|
||||||
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
const PcIcon = pc.icon
|
||||||
const pcColor =
|
? PLAYER_ICON_MAP[pc.icon as PlayerIcon]
|
||||||
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
|
: undefined;
|
||||||
|
const pcColor = pc.color
|
||||||
|
? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<li key={pc.id}>
|
<li key={pc.id}>
|
||||||
<button
|
<button
|
||||||
@@ -106,7 +112,7 @@ function AddModeSuggestions({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAddFromPlayerCharacter?.(pc);
|
onAddFromPlayerCharacter?.(pc);
|
||||||
onDismiss();
|
onClear();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PcIcon && (
|
{PcIcon && (
|
||||||
@@ -235,7 +241,7 @@ function buildOverflowItems(opts: {
|
|||||||
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <Import className="h-4 w-4" />,
|
icon: <Import className="h-4 w-4" />,
|
||||||
label: "Bulk Import",
|
label: "Import All Sources",
|
||||||
onClick: opts.onBulkImport,
|
onClick: opts.onBulkImport,
|
||||||
disabled: opts.bulkImportDisabled,
|
disabled: opts.bulkImportDisabled,
|
||||||
});
|
});
|
||||||
@@ -257,6 +263,7 @@ export function ActionBar({
|
|||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
onRollAllInitiative,
|
onRollAllInitiative,
|
||||||
showRollAllInitiative,
|
showRollAllInitiative,
|
||||||
|
rollAllInitiativeDisabled,
|
||||||
onOpenSourceManager,
|
onOpenSourceManager,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
}: ActionBarProps) {
|
}: ActionBarProps) {
|
||||||
@@ -284,6 +291,13 @@ export function ActionBar({
|
|||||||
setSuggestionIndex(-1);
|
setSuggestionIndex(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dismissSuggestions = () => {
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
setQueued(null);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
const confirmQueued = () => {
|
const confirmQueued = () => {
|
||||||
if (!queued) return;
|
if (!queued) return;
|
||||||
for (let i = 0; i < queued.count; i++) {
|
for (let i = 0; i < queued.count; i++) {
|
||||||
@@ -395,7 +409,7 @@ export function ActionBar({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleEnter();
|
handleEnter();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
clearInput();
|
dismissSuggestions();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -514,7 +528,8 @@ export function ActionBar({
|
|||||||
pcMatches={pcMatches}
|
pcMatches={pcMatches}
|
||||||
suggestionIndex={suggestionIndex}
|
suggestionIndex={suggestionIndex}
|
||||||
queued={queued}
|
queued={queued}
|
||||||
onDismiss={clearInput}
|
onDismiss={dismissSuggestions}
|
||||||
|
onClear={clearInput}
|
||||||
onClickSuggestion={handleClickSuggestion}
|
onClickSuggestion={handleClickSuggestion}
|
||||||
onSetSuggestionIndex={setSuggestionIndex}
|
onSetSuggestionIndex={setSuggestionIndex}
|
||||||
onSetQueued={setQueued}
|
onSetQueued={setQueued}
|
||||||
@@ -553,9 +568,7 @@ export function ActionBar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<Button type="submit" size="sm">
|
<Button type="submit">Add</Button>
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
{showRollAllInitiative && onRollAllInitiative && (
|
{showRollAllInitiative && onRollAllInitiative && (
|
||||||
<Button
|
<Button
|
||||||
@@ -564,6 +577,7 @@ export function ActionBar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
onClick={onRollAllInitiative}
|
onClick={onRollAllInitiative}
|
||||||
|
disabled={rollAllInitiativeDisabled}
|
||||||
title="Roll all initiative"
|
title="Roll all initiative"
|
||||||
aria-label="Roll all initiative"
|
aria-label="Roll all initiative"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ export function BulkImportPrompt({
|
|||||||
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
|
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
|
||||||
All sources loaded
|
All sources loaded
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={onDone}>
|
<Button onClick={onDone}>Done</Button>
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,9 +40,7 @@ export function BulkImportPrompt({
|
|||||||
Loaded {importState.completed}/{importState.total} sources (
|
Loaded {importState.completed}/{importState.total} sources (
|
||||||
{importState.failed} failed)
|
{importState.failed} failed)
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={onDone}>
|
<Button onClick={onDone}>Done</Button>
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,11 +75,10 @@ export function BulkImportPrompt({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
Bulk Import Sources
|
Import All Sources
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Load stat block data for all {totalSources} sources at once. This will
|
Load stat block data for all {totalSources} sources at once.
|
||||||
download approximately 12.5 MB of data.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,11 +98,7 @@ export function BulkImportPrompt({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
|
||||||
size="sm"
|
|
||||||
onClick={() => onStartImport(baseUrl)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
Load All
|
Load All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function ColorPalette({ value, onChange }: ColorPaletteProps) {
|
|||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(color)}
|
onClick={() => onChange(value === color ? "" : color)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 rounded-full transition-all",
|
"h-8 w-8 rounded-full transition-all",
|
||||||
value === color
|
value === color
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ interface CreatePlayerModalProps {
|
|||||||
name: string,
|
name: string,
|
||||||
ac: number,
|
ac: number,
|
||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string,
|
color: string | undefined,
|
||||||
icon: string,
|
icon: string | undefined,
|
||||||
) => void;
|
) => void;
|
||||||
playerCharacter?: PlayerCharacter;
|
playerCharacter?: PlayerCharacter;
|
||||||
}
|
}
|
||||||
@@ -40,14 +40,14 @@ export function CreatePlayerModal({
|
|||||||
setName(playerCharacter.name);
|
setName(playerCharacter.name);
|
||||||
setAc(String(playerCharacter.ac));
|
setAc(String(playerCharacter.ac));
|
||||||
setMaxHp(String(playerCharacter.maxHp));
|
setMaxHp(String(playerCharacter.maxHp));
|
||||||
setColor(playerCharacter.color);
|
setColor(playerCharacter.color ?? "");
|
||||||
setIcon(playerCharacter.icon);
|
setIcon(playerCharacter.icon ?? "");
|
||||||
} else {
|
} else {
|
||||||
setName("");
|
setName("");
|
||||||
setAc("10");
|
setAc("10");
|
||||||
setMaxHp("10");
|
setMaxHp("10");
|
||||||
setColor("blue");
|
setColor("");
|
||||||
setIcon("sword");
|
setIcon("");
|
||||||
}
|
}
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ export function CreatePlayerModal({
|
|||||||
setError("Max HP must be at least 1");
|
setError("Max HP must be at least 1");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave(trimmed, acNum, hpNum, color, icon);
|
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,13 +100,14 @@ export function CreatePlayerModal({
|
|||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
{isEdit ? "Edit Player" : "Create Player"}
|
{isEdit ? "Edit Player" : "Create Player"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
interface HpAdjustPopoverProps {
|
interface HpAdjustPopoverProps {
|
||||||
@@ -109,30 +108,26 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
className="h-7 w-7 shrink-0 text-red-400 hover:bg-red-950 hover:text-red-300"
|
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"
|
||||||
onClick={() => applyDelta(-1)}
|
onClick={() => applyDelta(-1)}
|
||||||
title="Apply damage"
|
title="Apply damage"
|
||||||
aria-label="Apply damage"
|
aria-label="Apply damage"
|
||||||
>
|
>
|
||||||
<Sword size={14} />
|
<Sword size={14} />
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
className="h-7 w-7 shrink-0 text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300"
|
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"
|
||||||
onClick={() => applyDelta(1)}
|
onClick={() => applyDelta(1)}
|
||||||
title="Apply healing"
|
title="Apply healing"
|
||||||
aria-label="Apply healing"
|
aria-label="Apply healing"
|
||||||
>
|
>
|
||||||
<Heart size={14} />
|
<Heart size={14} />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function IconGrid({ value, onChange }: IconGridProps) {
|
|||||||
<button
|
<button
|
||||||
key={iconId}
|
key={iconId}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(iconId)}
|
onClick={() => onChange(value === iconId ? "" : iconId)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
||||||
value === iconId
|
value === iconId
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import type {
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
PlayerCharacter,
|
|
||||||
PlayerCharacterId,
|
|
||||||
PlayerIcon,
|
|
||||||
} from "@initiative/domain";
|
|
||||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
@@ -52,19 +48,20 @@ export function PlayerManagement({
|
|||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
Player Characters
|
Player Characters
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{characters.length === 0 ? (
|
{characters.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
<p className="text-muted-foreground">No player characters yet</p>
|
<p className="text-muted-foreground">No player characters yet</p>
|
||||||
<Button onClick={onCreate} size="sm">
|
<Button onClick={onCreate}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Create your first player character
|
Create your first player character
|
||||||
</Button>
|
</Button>
|
||||||
@@ -72,13 +69,12 @@ export function PlayerManagement({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{characters.map((pc) => {
|
{characters.map((pc) => {
|
||||||
const Icon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
const color =
|
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||||
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pc.id}
|
key={pc.id}
|
||||||
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-background/50"
|
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
||||||
>
|
>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<Icon size={18} style={{ color }} className="shrink-0" />
|
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||||
@@ -92,25 +88,27 @@ export function PlayerManagement({
|
|||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
HP {pc.maxHp}
|
HP {pc.maxHp}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={() => onEdit(pc)}
|
onClick={() => onEdit(pc)}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground"
|
||||||
title="Edit"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</Button>
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<Trash2 size={14} />}
|
icon={<Trash2 size={14} />}
|
||||||
label="Delete player character"
|
label="Delete player character"
|
||||||
onConfirm={() => onDelete(pc.id)}
|
onConfirm={() => onDelete(pc.id)}
|
||||||
className="h-6 w-6 text-muted-foreground"
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="mt-2 flex justify-end">
|
<div className="mt-2 flex justify-end">
|
||||||
<Button onClick={onCreate} size="sm" variant="ghost">
|
<Button onClick={onCreate} variant="ghost">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -88,11 +88,7 @@ export function SourceFetchPrompt({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button onClick={handleFetch} disabled={status === "fetching" || !url}>
|
||||||
size="sm"
|
|
||||||
onClick={handleFetch}
|
|
||||||
disabled={status === "fetching" || !url}
|
|
||||||
>
|
|
||||||
{status === "fetching" ? (
|
{status === "fetching" ? (
|
||||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -104,7 +100,6 @@ export function SourceFetchPrompt({
|
|||||||
<span className="text-xs text-muted-foreground">or</span>
|
<span className="text-xs text-muted-foreground">or</span>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={status === "fetching"}
|
disabled={status === "fetching"}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
Cached Sources
|
Cached Sources
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="hover:text-hover-destructive hover:border-hover-destructive"
|
className="hover:text-hover-destructive hover:border-hover-destructive"
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ 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 { 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 { StatBlock } from "./stat-block.js";
|
import { StatBlock } from "./stat-block.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
interface StatBlockPanelProps {
|
interface StatBlockPanelProps {
|
||||||
creatureId: CreatureId | null;
|
creatureId: CreatureId | null;
|
||||||
@@ -31,6 +33,7 @@ interface StatBlockPanelProps {
|
|||||||
bulkImportState?: BulkImportState;
|
bulkImportState?: BulkImportState;
|
||||||
onStartBulkImport?: (baseUrl: string) => void;
|
onStartBulkImport?: (baseUrl: string) => void;
|
||||||
onBulkImportDone?: () => void;
|
onBulkImportDone?: () => void;
|
||||||
|
sourceManagerMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSourceCode(cId: CreatureId): string {
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
@@ -81,36 +84,39 @@ function PanelHeader({
|
|||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{panelRole === "browse" && (
|
{panelRole === "browse" && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onToggleFold}
|
onClick={onToggleFold}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
aria-label="Fold stat block panel"
|
aria-label="Fold stat block panel"
|
||||||
>
|
>
|
||||||
<PanelRightClose className="h-4 w-4" />
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{panelRole === "browse" && showPinButton && (
|
{panelRole === "browse" && showPinButton && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onPin}
|
onClick={onPin}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
aria-label="Pin creature"
|
aria-label="Pin creature"
|
||||||
>
|
>
|
||||||
<Pin className="h-4 w-4" />
|
<Pin className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{panelRole === "pinned" && (
|
{panelRole === "pinned" && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onUnpin}
|
onClick={onUnpin}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
aria-label="Unpin creature"
|
aria-label="Unpin creature"
|
||||||
>
|
>
|
||||||
<PinOff className="h-4 w-4" />
|
<PinOff className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,14 +201,15 @@ function MobileDrawer({
|
|||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
aria-label="Fold stat block panel"
|
aria-label="Fold stat block panel"
|
||||||
>
|
>
|
||||||
<PanelRightClose className="h-4 w-4" />
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||||
{children}
|
{children}
|
||||||
@@ -231,6 +238,7 @@ export function StatBlockPanel({
|
|||||||
bulkImportState,
|
bulkImportState,
|
||||||
onStartBulkImport,
|
onStartBulkImport,
|
||||||
onBulkImportDone,
|
onBulkImportDone,
|
||||||
|
sourceManagerMode,
|
||||||
}: StatBlockPanelProps) {
|
}: StatBlockPanelProps) {
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||||
@@ -264,7 +272,7 @@ export function StatBlockPanel({
|
|||||||
});
|
});
|
||||||
}, [creatureId, creature, isSourceCached]);
|
}, [creatureId, creature, isSourceCached]);
|
||||||
|
|
||||||
if (!creatureId && !bulkImportMode) return null;
|
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
||||||
|
|
||||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||||
|
|
||||||
@@ -274,6 +282,10 @@ export function StatBlockPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
if (sourceManagerMode) {
|
||||||
|
return <SourceManager onCacheCleared={refreshCache} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
bulkImportMode &&
|
bulkImportMode &&
|
||||||
bulkImportState &&
|
bulkImportState &&
|
||||||
@@ -319,7 +331,12 @@ export function StatBlockPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const creatureName =
|
const creatureName =
|
||||||
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
|
creature?.name ??
|
||||||
|
(sourceManagerMode
|
||||||
|
? "Sources"
|
||||||
|
: bulkImportMode
|
||||||
|
? "Import All Sources"
|
||||||
|
: "Creature");
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
interface ToastProps {
|
interface ToastProps {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -22,7 +23,7 @@ export function Toast({
|
|||||||
}, [autoDismissMs, onDismiss]);
|
}, [autoDismissMs, onDismiss]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
|
<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="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
||||||
<span className="text-sm text-foreground">{message}</span>
|
<span className="text-sm text-foreground">{message}</span>
|
||||||
{progress !== undefined && (
|
{progress !== undefined && (
|
||||||
@@ -33,13 +34,14 @@ export function Toast({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function TurnNavigation({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onRetreatTurn}
|
onClick={onRetreatTurn}
|
||||||
disabled={!hasCombatants || isAtStart}
|
disabled={!hasCombatants || isAtStart}
|
||||||
@@ -52,6 +53,7 @@ export function TurnNavigation({
|
|||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onAdvanceTurn}
|
onClick={onAdvanceTurn}
|
||||||
disabled={!hasCombatants}
|
disabled={!hasCombatants}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ const buttonVariants = cva(
|
|||||||
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2",
|
default: "h-8 px-3 text-xs",
|
||||||
sm: "h-8 px-3 text-xs",
|
|
||||||
icon: "h-8 w-8",
|
icon: "h-8 w-8",
|
||||||
|
"icon-sm": "h-6 w-6",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface ConfirmButtonProps {
|
|||||||
readonly onConfirm: () => void;
|
readonly onConfirm: () => void;
|
||||||
readonly icon: ReactElement;
|
readonly icon: ReactElement;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
readonly size?: "icon" | "icon-sm";
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,7 @@ export function ConfirmButton({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
size = "icon",
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
}: ConfirmButtonProps) {
|
}: ConfirmButtonProps) {
|
||||||
@@ -94,7 +96,7 @@ export function ConfirmButton({
|
|||||||
<div ref={wrapperRef} className="inline-flex">
|
<div ref={wrapperRef} className="inline-flex">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
className,
|
className,
|
||||||
isConfirming
|
isConfirming
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
|||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-muted/20 disabled:pointer-events-none disabled:opacity-50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
item.onClick();
|
item.onClick();
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ interface EditFields {
|
|||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string;
|
readonly color?: string | null;
|
||||||
readonly icon?: string;
|
readonly icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlayerCharacters() {
|
export function usePlayerCharacters() {
|
||||||
@@ -51,7 +51,13 @@ export function usePlayerCharacters() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createCharacter = useCallback(
|
const createCharacter = useCallback(
|
||||||
(name: string, ac: number, maxHp: number, color: string, icon: string) => {
|
(
|
||||||
|
name: string,
|
||||||
|
ac: number,
|
||||||
|
maxHp: number,
|
||||||
|
color: string | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
) => {
|
||||||
const id = generatePcId();
|
const id = generatePcId();
|
||||||
const result = createPlayerCharacterUseCase(
|
const result = createPlayerCharacterUseCase(
|
||||||
makeStore(),
|
makeStore(),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
--color-hover-neutral: var(--color-primary);
|
--color-hover-neutral: var(--color-primary);
|
||||||
--color-hover-action: var(--color-primary);
|
--color-hover-action: var(--color-primary);
|
||||||
--color-hover-destructive: var(--color-destructive);
|
--color-hover-destructive: var(--color-destructive);
|
||||||
--color-hover-neutral-bg: var(--color-card);
|
--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;
|
||||||
--radius-sm: 0.25rem;
|
--radius-sm: 0.25rem;
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidOptionalMember(
|
||||||
|
value: unknown,
|
||||||
|
valid: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||||
return null;
|
return null;
|
||||||
@@ -35,10 +42,8 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
|||||||
entry.maxHp < 1
|
entry.maxHp < 1
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
|
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||||
return null;
|
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||||
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: playerCharacterId(entry.id),
|
id: playerCharacterId(entry.id),
|
||||||
|
|||||||
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal file
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
---
|
||||||
|
date: "2026-03-13T15:35:07.699570+00:00"
|
||||||
|
git_commit: bd398080008349b47726d0016f4b03587f453833
|
||||||
|
branch: main
|
||||||
|
topic: "CSS class usage, button categorization, and hover effects across all components"
|
||||||
|
tags: [research, codebase, css, tailwind, buttons, hover, ui]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: CSS Class Usage, Button Categorization, and Hover Effects
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
How are CSS classes used across all components? How are buttons categorized — are there primary and secondary buttons? What hover effects exist, and are they unified?
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The project uses **Tailwind CSS v4** with a custom dark theme defined in `index.css` via `@theme`. All class merging goes through a `cn()` utility (clsx + tailwind-merge). Buttons are built on a shared `Button` component using **class-variance-authority (CVA)** with three variants: **default** (primary), **outline**, and **ghost**. Hover effects are partially unified through semantic color tokens (`hover-neutral`, `hover-action`, `hover-destructive`) defined in the theme, but several components use **one-off hardcoded hover colors** that bypass the token system.
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### Theme System (`index.css`)
|
||||||
|
|
||||||
|
All colors are defined as CSS custom properties via Tailwind v4's `@theme` directive (`index.css:3-26`):
|
||||||
|
|
||||||
|
| Token | Value | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-background` | `#0f172a` | Page background |
|
||||||
|
| `--color-foreground` | `#e2e8f0` | Default text |
|
||||||
|
| `--color-muted` | `#64748b` | Subdued elements |
|
||||||
|
| `--color-muted-foreground` | `#94a3b8` | Secondary text |
|
||||||
|
| `--color-card` | `#1e293b` | Card/panel surfaces |
|
||||||
|
| `--color-border` | `#334155` | Borders |
|
||||||
|
| `--color-primary` | `#3b82f6` | Primary actions (blue) |
|
||||||
|
| `--color-accent` | `#3b82f6` | Accent (same as primary) |
|
||||||
|
| `--color-destructive` | `#ef4444` | Destructive actions (red) |
|
||||||
|
|
||||||
|
**Hover tokens** (semantic layer for hover states):
|
||||||
|
|
||||||
|
| Token | Resolves to | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover-neutral` | `primary` (blue) | Text color on neutral hover |
|
||||||
|
| `hover-action` | `primary` (blue) | Text color on action hover |
|
||||||
|
| `hover-destructive` | `destructive` (red) | Text color on destructive hover |
|
||||||
|
| `hover-neutral-bg` | `card` (slate) | Background on neutral hover |
|
||||||
|
| `hover-action-bg` | `muted` | Background on action hover |
|
||||||
|
| `hover-destructive-bg` | `transparent` | Background on destructive hover |
|
||||||
|
|
||||||
|
### Button Component (`components/ui/button.tsx`)
|
||||||
|
|
||||||
|
Uses CVA with three variants and three sizes:
|
||||||
|
|
||||||
|
**Variants:**
|
||||||
|
|
||||||
|
| Variant | Base styles | Hover |
|
||||||
|
|---|---|---|
|
||||||
|
| `default` (primary) | `bg-primary text-primary-foreground` | `hover:bg-primary/90` |
|
||||||
|
| `outline` | `border border-border bg-transparent` | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
|
||||||
|
| `ghost` | (no background/border) | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
|
||||||
|
|
||||||
|
**Sizes:**
|
||||||
|
|
||||||
|
| Size | Classes |
|
||||||
|
|---|---|
|
||||||
|
| `default` | `h-9 px-4 py-2` |
|
||||||
|
| `sm` | `h-8 px-3 text-xs` |
|
||||||
|
| `icon` | `h-8 w-8` |
|
||||||
|
|
||||||
|
All variants share: `rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50`.
|
||||||
|
|
||||||
|
There is **no "secondary" variant** — the outline variant is the closest equivalent.
|
||||||
|
|
||||||
|
### Composite Button Components
|
||||||
|
|
||||||
|
**ConfirmButton** (`components/ui/confirm-button.tsx`):
|
||||||
|
- Wraps `Button variant="ghost" size="icon"`
|
||||||
|
- Default state: `hover:text-hover-destructive` (uses token)
|
||||||
|
- Confirming state: `bg-destructive text-primary-foreground animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground`
|
||||||
|
|
||||||
|
**OverflowMenu** (`components/ui/overflow-menu.tsx`):
|
||||||
|
- Trigger: `Button variant="ghost" size="icon"` with `text-muted-foreground hover:text-hover-neutral`
|
||||||
|
- Menu items: raw `<button>` elements with `hover:bg-muted/20` (**not using the token system**)
|
||||||
|
|
||||||
|
### Button Usage Across Components
|
||||||
|
|
||||||
|
| Component | Button type | Variant/Style |
|
||||||
|
|---|---|---|
|
||||||
|
| `action-bar.tsx:556` | `<Button type="submit">` | default (primary) — "Add" |
|
||||||
|
| `action-bar.tsx:561` | `<Button type="button">` | default (primary) — "Roll all" |
|
||||||
|
| `turn-navigation.tsx:25,54` | `<Button size="icon">` | default — prev/next turn |
|
||||||
|
| `turn-navigation.tsx:47` | `<ConfirmButton>` | ghost+destructive — clear encounter |
|
||||||
|
| `source-fetch-prompt.tsx:91` | `<Button size="sm">` | default — "Load" |
|
||||||
|
| `source-fetch-prompt.tsx:106` | `<Button size="sm" variant="outline">` | outline — "Upload file" |
|
||||||
|
| `bulk-import-prompt.tsx:31,45,106` | `<Button size="sm">` | default — "Done"/"Load All" |
|
||||||
|
| `source-manager.tsx:50` | `<Button size="sm" variant="outline">` | outline — "Clear all" |
|
||||||
|
| `hp-adjust-popover.tsx:112` | `<Button variant="ghost" size="icon">` | ghost + custom red — damage |
|
||||||
|
| `hp-adjust-popover.tsx:124` | `<Button variant="ghost" size="icon">` | ghost + custom green — heal |
|
||||||
|
| `player-management.tsx:67` | `<Button>` | default — "Create first player" |
|
||||||
|
| `player-management.tsx:113` | `<Button variant="ghost">` | ghost — "Add player" |
|
||||||
|
| `create-player-modal.tsx:177` | `<Button variant="ghost">` | ghost — "Cancel" |
|
||||||
|
| `create-player-modal.tsx:180` | `<Button type="submit">` | default — "Save"/"Create" |
|
||||||
|
| `combatant-row.tsx:625` | `<ConfirmButton>` | ghost+destructive — remove combatant |
|
||||||
|
|
||||||
|
**Raw `<button>` elements** (not using the Button component):
|
||||||
|
- `action-bar.tsx` — suggestion items, count increment/decrement, browse toggle, custom add (all inline-styled)
|
||||||
|
- `combatant-row.tsx` — editable name, HP display, AC, initiative, concentration toggle
|
||||||
|
- `stat-block-panel.tsx` — fold/close/pin/unpin buttons
|
||||||
|
- `condition-picker.tsx` — condition items
|
||||||
|
- `condition-tags.tsx` — condition tags, add condition button
|
||||||
|
- `toast.tsx` — dismiss button
|
||||||
|
- `player-management.tsx` — close modal, edit player
|
||||||
|
- `create-player-modal.tsx` — close modal
|
||||||
|
- `color-palette.tsx` — color swatches
|
||||||
|
- `icon-grid.tsx` — icon options
|
||||||
|
|
||||||
|
### Hover Effects Inventory
|
||||||
|
|
||||||
|
**Using semantic tokens (unified):**
|
||||||
|
|
||||||
|
| Hover class | Meaning | Used in |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover:bg-hover-neutral-bg` | Neutral background highlight | button.tsx (outline/ghost), action-bar.tsx, condition-picker.tsx, condition-tags.tsx |
|
||||||
|
| `hover:text-hover-neutral` | Text turns primary blue | button.tsx (outline/ghost), action-bar.tsx, combatant-row.tsx, stat-block-panel.tsx, ac-shield.tsx, toast.tsx, overflow-menu.tsx, condition-tags.tsx |
|
||||||
|
| `hover:text-hover-action` | Action text (same as neutral) | action-bar.tsx (overflow trigger) |
|
||||||
|
| `hover:text-hover-destructive` | Destructive text turns red | confirm-button.tsx, source-manager.tsx |
|
||||||
|
| `hover:bg-hover-destructive-bg` | Destructive background (transparent) | source-manager.tsx |
|
||||||
|
|
||||||
|
**One-off / hardcoded hover colors (NOT using tokens):**
|
||||||
|
|
||||||
|
| Hover class | Used in | Context |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover:bg-primary/90` | button.tsx (default variant) | Primary button darken |
|
||||||
|
| `hover:bg-accent/20` | action-bar.tsx | Suggestion highlight, custom add |
|
||||||
|
| `hover:bg-accent/40` | action-bar.tsx | Count +/- buttons, confirm queued |
|
||||||
|
| `hover:bg-muted/20` | overflow-menu.tsx | Menu item highlight |
|
||||||
|
| `hover:bg-red-950` | hp-adjust-popover.tsx | Damage button |
|
||||||
|
| `hover:text-red-300` | hp-adjust-popover.tsx | Damage button text |
|
||||||
|
| `hover:bg-emerald-950` | hp-adjust-popover.tsx | Heal button |
|
||||||
|
| `hover:text-emerald-300` | hp-adjust-popover.tsx | Heal button text |
|
||||||
|
| `hover:text-foreground` | player-management.tsx, create-player-modal.tsx, icon-grid.tsx | Close/edit buttons |
|
||||||
|
| `hover:bg-background/50` | player-management.tsx | Player row hover |
|
||||||
|
| `hover:bg-card` | icon-grid.tsx | Icon option hover |
|
||||||
|
| `hover:border-hover-destructive` | source-manager.tsx | Clear all button border |
|
||||||
|
| `hover:scale-110` | color-palette.tsx | Color swatch enlarge |
|
||||||
|
| `hover:bg-destructive` | confirm-button.tsx (confirming state) | Maintain red bg on hover |
|
||||||
|
| `hover:text-primary-foreground` | confirm-button.tsx (confirming state) | Maintain white text on hover |
|
||||||
|
|
||||||
|
### Hover unification assessment
|
||||||
|
|
||||||
|
The hover token system (`hover-neutral`, `hover-action`, `hover-destructive`) provides a consistent pattern for the most common interactions. The `Button` component's outline and ghost variants use these tokens, and many inline buttons in action-bar, combatant-row, stat-block-panel, and condition components also use them.
|
||||||
|
|
||||||
|
However, there are notable gaps:
|
||||||
|
1. **HP adjust popover** uses hardcoded red/green colors (`red-950`, `emerald-950`) instead of tokens
|
||||||
|
2. **Overflow menu items** use `hover:bg-muted/20` instead of `hover:bg-hover-neutral-bg`
|
||||||
|
3. **Player management modals** use `hover:text-foreground` and `hover:bg-background/50` instead of the semantic tokens
|
||||||
|
4. **Action-bar suggestion items** use `hover:bg-accent/20` and `hover:bg-accent/40` — accent-specific patterns not in the token system
|
||||||
|
5. **Icon grid** and **color palette** use their own hover patterns (`hover:bg-card`, `hover:scale-110`)
|
||||||
|
|
||||||
|
## Code References
|
||||||
|
|
||||||
|
- `apps/web/src/index.css:3-26` — Theme color definitions including hover tokens
|
||||||
|
- `apps/web/src/components/ui/button.tsx:1-38` — Button component with CVA variants
|
||||||
|
- `apps/web/src/components/ui/confirm-button.tsx:93-115` — ConfirmButton with destructive hover states
|
||||||
|
- `apps/web/src/components/ui/overflow-menu.tsx:38-72` — OverflowMenu with non-token hover
|
||||||
|
- `apps/web/src/components/hp-adjust-popover.tsx:117-129` — Hardcoded red/green hover colors
|
||||||
|
- `apps/web/src/components/action-bar.tsx:80-188` — Mixed token and accent-based hovers
|
||||||
|
- `apps/web/src/components/combatant-row.tsx:147-629` — Inline buttons with token hovers
|
||||||
|
- `apps/web/src/components/player-management.tsx:58-98` — Non-token hover patterns
|
||||||
|
- `apps/web/src/components/stat-block-panel.tsx:55-109` — Consistent token usage
|
||||||
|
- `apps/web/src/lib/utils.ts:1-5` — `cn()` utility (clsx + twMerge)
|
||||||
|
|
||||||
|
## Architecture Documentation
|
||||||
|
|
||||||
|
The styling architecture follows this pattern:
|
||||||
|
|
||||||
|
1. **Theme layer**: `index.css` defines all color tokens via `@theme`, including semantic hover tokens
|
||||||
|
2. **Component layer**: `Button` (CVA) provides the shared button abstraction with three variants
|
||||||
|
3. **Composite layer**: `ConfirmButton` and `OverflowMenu` wrap `Button` with additional behavior
|
||||||
|
4. **Usage layer**: Components use either `Button` component or raw `<button>` elements with inline Tailwind classes
|
||||||
|
|
||||||
|
The `cn()` utility from `lib/utils.ts` is used in 9+ components for conditional class merging.
|
||||||
|
|
||||||
|
Custom animations are defined in `index.css` via `@keyframes` + `@utility` pairs: slide-in-right, confirm-pulse, settle-to-bottom, rise-to-center, slide-down-in, slide-up-out, concentration-pulse.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. The `hover-action` and `hover-action-bg` tokens are defined but rarely used — `hover-action` appears only once in `action-bar.tsx:565`. Is this intentional or an incomplete migration?
|
||||||
|
2. The `accent` color (`#3b82f6`) is identical to `primary` — are they intended to diverge in the future, or is this redundancy?
|
||||||
|
3. Should the hardcoded HP adjust colors (red/emerald) be promoted to theme tokens (e.g., `hover-damage`, `hover-heal`)?
|
||||||
@@ -13,8 +13,8 @@ export function createPlayerCharacterUseCase(
|
|||||||
name: string,
|
name: string,
|
||||||
ac: number,
|
ac: number,
|
||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string,
|
color: string | undefined,
|
||||||
icon: string,
|
icon: string | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const characters = store.getAll();
|
const characters = store.getAll();
|
||||||
const result = createPlayerCharacter(
|
const result = createPlayerCharacter(
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ interface EditFields {
|
|||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string;
|
readonly color?: string | null;
|
||||||
readonly icon?: string;
|
readonly icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editPlayerCharacterUseCase(
|
export function editPlayerCharacterUseCase(
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export type {
|
|||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||||
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
|
export {
|
||||||
|
type RollAllResult,
|
||||||
|
rollAllInitiativeUseCase,
|
||||||
|
} from "./roll-all-initiative-use-case.js";
|
||||||
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
|
|||||||
@@ -10,20 +10,29 @@ import {
|
|||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export interface RollAllResult {
|
||||||
|
events: DomainEvent[];
|
||||||
|
skippedNoSource: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function rollAllInitiativeUseCase(
|
export function rollAllInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
rollDice: () => number,
|
rollDice: () => number,
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): RollAllResult | DomainError {
|
||||||
let encounter = store.get();
|
let encounter = store.get();
|
||||||
const allEvents: DomainEvent[] = [];
|
const allEvents: DomainEvent[] = [];
|
||||||
|
let skippedNoSource = 0;
|
||||||
|
|
||||||
for (const combatant of encounter.combatants) {
|
for (const combatant of encounter.combatants) {
|
||||||
if (!combatant.creatureId) continue;
|
if (!combatant.creatureId) continue;
|
||||||
if (combatant.initiative !== undefined) continue;
|
if (combatant.initiative !== undefined) continue;
|
||||||
|
|
||||||
const creature = getCreature(combatant.creatureId);
|
const creature = getCreature(combatant.creatureId);
|
||||||
if (!creature) continue;
|
if (!creature) {
|
||||||
|
skippedNoSource++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const { modifier } = calculateInitiative({
|
const { modifier } = calculateInitiative({
|
||||||
dexScore: creature.abilities.dex,
|
dexScore: creature.abilities.dex,
|
||||||
@@ -47,5 +56,5 @@ export function rollAllInitiativeUseCase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
store.save(encounter);
|
store.save(encounter);
|
||||||
return allEvents;
|
return { events: allEvents, skippedNoSource };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,49 @@ describe("createPlayerCharacter", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows undefined color", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
undefined,
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows undefined icon", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows both color and icon undefined", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("emits exactly one event on success", () => {
|
it("emits exactly one event on success", () => {
|
||||||
const { events } = success([], "Test", 10, 50);
|
const { events } = success([], "Test", 10, 50);
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
|
|||||||
@@ -106,6 +106,22 @@ describe("editPlayerCharacter", () => {
|
|||||||
expect(result.events).toHaveLength(1);
|
expect(result.events).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears color when set to null", () => {
|
||||||
|
const result = editPlayerCharacter([makePC({ color: "green" })], id, {
|
||||||
|
color: null,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears icon when set to null", () => {
|
||||||
|
const result = editPlayerCharacter([makePC({ icon: "sword" })], id, {
|
||||||
|
icon: null,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("event includes old and new name", () => {
|
it("event includes old and new name", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||||
if (isDomainError(result)) throw new Error(result.message);
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export function createPlayerCharacter(
|
|||||||
name: string,
|
name: string,
|
||||||
ac: number,
|
ac: number,
|
||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string,
|
color: string | undefined,
|
||||||
icon: string,
|
icon: string | undefined,
|
||||||
): CreatePlayerCharacterSuccess | DomainError {
|
): CreatePlayerCharacterSuccess | DomainError {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export function createPlayerCharacter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_PLAYER_COLORS.has(color)) {
|
if (color !== undefined && !VALID_PLAYER_COLORS.has(color)) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-color",
|
code: "invalid-color",
|
||||||
@@ -57,7 +57,7 @@ export function createPlayerCharacter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_PLAYER_ICONS.has(icon)) {
|
if (icon !== undefined && !VALID_PLAYER_ICONS.has(icon)) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-icon",
|
code: "invalid-icon",
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ interface EditFields {
|
|||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string;
|
readonly color?: string | null;
|
||||||
readonly icon?: string;
|
readonly icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateFields(fields: EditFields): DomainError | null {
|
function validateFields(fields: EditFields): DomainError | null {
|
||||||
@@ -50,14 +50,22 @@ function validateFields(fields: EditFields): DomainError | null {
|
|||||||
message: "Max HP must be a positive integer",
|
message: "Max HP must be a positive integer",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) {
|
if (
|
||||||
|
fields.color !== undefined &&
|
||||||
|
fields.color !== null &&
|
||||||
|
!VALID_PLAYER_COLORS.has(fields.color)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-color",
|
code: "invalid-color",
|
||||||
message: `Invalid color: ${fields.color}`,
|
message: `Invalid color: ${fields.color}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (fields.icon !== undefined && !VALID_PLAYER_ICONS.has(fields.icon)) {
|
if (
|
||||||
|
fields.icon !== undefined &&
|
||||||
|
fields.icon !== null &&
|
||||||
|
!VALID_PLAYER_ICONS.has(fields.icon)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-icon",
|
code: "invalid-icon",
|
||||||
@@ -78,11 +86,11 @@ function applyFields(
|
|||||||
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
|
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
|
||||||
color:
|
color:
|
||||||
fields.color !== undefined
|
fields.color !== undefined
|
||||||
? (fields.color as PlayerCharacter["color"])
|
? ((fields.color as PlayerCharacter["color"]) ?? undefined)
|
||||||
: existing.color,
|
: existing.color,
|
||||||
icon:
|
icon:
|
||||||
fields.icon !== undefined
|
fields.icon !== undefined
|
||||||
? (fields.icon as PlayerCharacter["icon"])
|
? ((fields.icon as PlayerCharacter["icon"]) ?? undefined)
|
||||||
: existing.icon,
|
: existing.icon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ export interface PlayerCharacter {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly ac: number;
|
readonly ac: number;
|
||||||
readonly maxHp: number;
|
readonly maxHp: number;
|
||||||
readonly color: PlayerColor;
|
readonly color?: PlayerColor;
|
||||||
readonly icon: PlayerIcon;
|
readonly icon?: PlayerIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerCharacterList {
|
export interface PlayerCharacterList {
|
||||||
|
|||||||
Reference in New Issue
Block a user