10 Commits
0.6.0 ... 0.7.2

Author SHA1 Message Date
Lukas
63e233bd8d Switch side panel to stat block when advancing turns
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
Previously the import/sources view would stay open when navigating to
the next combatant. Now advancing turns clears those modes so the active
creature's stat block is shown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:03:11 +01:00
Lukas
8c62ec28f2 Rename "Bulk Import" to "Import All Sources" and remove file size mention
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:01:03 +01:00
Lukas
72195e90f6 Show toast when roll-all-initiative skips combatants without loaded sources
Previously the button silently did nothing for creatures whose bestiary
source wasn't loaded. Now it reports how many were skipped and why. Also
keeps the roll-all button visible (but disabled) when there's nothing
left to roll, and moves toasts to the bottom-left corner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:58:25 +01:00
Lukas
6ac8e67970 Move source manager from combatant area to side panel
Source management now opens in the right side panel (like bulk import
and stat blocks) instead of rendering inline above the combatant list.
All three panel modes properly clear each other on activation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:40:28 +01:00
Lukas
a4797d5b15 Unfold side panel when triggering bulk import from overflow menu
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:33:05 +01:00
Lukas
d48e39ced4 Fix input not clearing after adding player character from suggestions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:30:24 +01:00
Lukas
b7406c4b54 Make player character color and icon optional
Clicking an already-selected color or icon in the create/edit form now
deselects it. PCs without a color use the default combatant styling;
PCs without an icon show no icon. Domain, application, persistence,
and display layers all updated to handle the optional fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:01:20 +01:00
Lukas
07cdd4867a Fix custom combatant form disappearing when dismissing suggestions
All checks were successful
CI / check (push) Successful in 46s
CI / build-image (push) Successful in 18s
The "Add as custom" button and Escape key were clearing the name input
along with the suggestions, preventing the custom fields (Init, AC,
MaxHP) from ever appearing. Now only the suggestions are dismissed,
keeping the typed name intact so the custom combatant form renders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:30:17 +01:00
Lukas
85acb5c185 Migrate icon buttons to Button component and simplify size variants
All checks were successful
CI / check (push) Successful in 48s
CI / build-image (push) Successful in 18s
Replace raw <button> elements with Button variant="ghost" in stat-block
panel, toast, player modals. Add icon-sm size variant (h-6 w-6) for
compact contexts. Consolidate text button sizes into a single default
(h-8 px-3), removing the redundant sm variant. Add size prop to
ConfirmButton for consistent sizing.

Button now has three sizes: default (text), icon, icon-sm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:22:00 +01:00
Lukas
f9ef64bb00 Unify hover effects via semantic theme tokens
Replace one-off hover colors with hover-neutral/hover-destructive tokens
so all interactive elements respond consistently to theme changes. Fix
hover-neutral-bg token value (was identical to card surface, making hover
invisible on card backgrounds) to a semi-transparent primary tint. Switch
turn nav buttons to outline variant for visible hover feedback. Convert HP
popover damage/heal to plain buttons to avoid ghost variant hover conflict
with tailwind-merge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:58:01 +01:00
29 changed files with 474 additions and 147 deletions

View File

@@ -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);

View File

@@ -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"
> >

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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>
); );

View File

@@ -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

View File

@@ -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>

View File

@@ -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"}

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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,

View File

@@ -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}

View File

@@ -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: {

View File

@@ -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

View File

@@ -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();

View File

@@ -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(),

View File

@@ -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;

View File

@@ -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),

View 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`)?

View File

@@ -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(

View File

@@ -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(

View File

@@ -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";

View File

@@ -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 };
} }

View File

@@ -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);

View File

@@ -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);

View File

@@ -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",

View File

@@ -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,
}; };
} }

View File

@@ -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 {