2 Commits
0.5.1 ... 0.5.2

Author SHA1 Message Date
Lukas
75778884bd Hide top bar in empty state and animate it in with first combatant
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
The turn navigation bar is now hidden when no combatants exist, keeping
the empty state clean. It slides down from above when the first
combatant is added, synchronized with the action bar settling animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:43:41 +01:00
Lukas
72d4f30e60 Center action bar in empty state for better onboarding UX
Replace the abstract + icon with the actual input field centered at the
optical center when no combatants exist. Animate the transition in both
directions: settling down when the first combatant is added, rising up
when all combatants are removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:29:51 +01:00
3 changed files with 207 additions and 83 deletions

View File

@@ -3,8 +3,13 @@ import {
rollInitiativeUseCase, rollInitiativeUseCase,
} from "@initiative/application"; } from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { Plus } from "lucide-react"; import {
import { useCallback, useEffect, useRef, useState } from "react"; useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { ActionBar } from "./components/action-bar"; 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";
@@ -22,6 +27,44 @@ function rollDice(): number {
return Math.floor(Math.random() * 20) + 1; return Math.floor(Math.random() * 20) + 1;
} }
function useActionBarAnimation(combatantCount: number) {
const wasEmptyRef = useRef(combatantCount === 0);
const [settling, setSettling] = useState(false);
const [rising, setRising] = useState(false);
const [topBarExiting, setTopBarExiting] = useState(false);
useLayoutEffect(() => {
const nowEmpty = combatantCount === 0;
if (wasEmptyRef.current && !nowEmpty) {
setSettling(true);
} else if (!wasEmptyRef.current && nowEmpty) {
setRising(true);
setTopBarExiting(true);
}
wasEmptyRef.current = nowEmpty;
}, [combatantCount]);
const empty = combatantCount === 0;
const risingClass = rising ? " animate-rise-to-center" : "";
const settlingClass = settling ? " animate-settle-to-bottom" : "";
const topBarClass = settling
? " animate-slide-down-in"
: topBarExiting
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
: "";
const showTopBar = !empty || topBarExiting;
return {
risingClass,
settlingClass,
topBarClass,
showTopBar,
onSettleEnd: () => setSettling(false),
onRiseEnd: () => setRising(false),
onTopBarExitEnd: () => setTopBarExiting(false),
};
}
export function App() { export function App() {
const { const {
encounter, encounter,
@@ -171,6 +214,7 @@ export function App() {
}, []); }, []);
const actionBarInputRef = useRef<HTMLInputElement>(null); const actionBarInputRef = useRef<HTMLInputElement>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-scroll to the active combatant when the turn changes // Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null); const activeRowRef = useRef<HTMLDivElement>(null);
@@ -194,85 +238,109 @@ export function App() {
setSelectedCreatureId(active.creatureId as CreatureId); setSelectedCreatureId(active.creatureId as CreatureId);
}, [encounter.activeIndex, encounter.combatants, isLoaded]); }, [encounter.activeIndex, encounter.combatants, isLoaded]);
const isEmpty = encounter.combatants.length === 0;
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0"> <div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{/* Turn Navigation — fixed at top */} {actionBarAnim.showTopBar && (
<div className="shrink-0 pt-8"> <div
<TurnNavigation className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
encounter={encounter} onAnimationEnd={actionBarAnim.onTopBarExitEnd}
onAdvanceTurn={advanceTurn} >
onRetreatTurn={retreatTurn} <TurnNavigation
onClearEncounter={clearEncounter} encounter={encounter}
onRollAllInitiative={handleRollAllInitiative} onAdvanceTurn={advanceTurn}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} onRetreatTurn={retreatTurn}
/> onClearEncounter={clearEncounter}
</div> onRollAllInitiative={handleRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
{sourceManagerOpen && ( />
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div> </div>
)} )}
{/* Scrollable area — combatant list */} {isEmpty ? (
<div className="flex-1 overflow-y-auto min-h-0"> /* Empty state — ActionBar centered */
<div <div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
className={`flex flex-col px-2 py-2${encounter.combatants.length === 0 ? " h-full items-center justify-center" : ""}`} <div
> className={`w-full${actionBarAnim.risingClass}`}
{encounter.combatants.length === 0 ? ( onAnimationEnd={actionBarAnim.onRiseEnd}
<button >
type="button" <ActionBar
onClick={() => actionBarInputRef.current?.focus()} onAddCombatant={addCombatant}
className="animate-breathe cursor-pointer text-muted-foreground transition-colors hover:text-primary" onAddFromBestiary={handleAddFromBestiary}
> bestiarySearch={search}
<Plus className="size-14" /> bestiaryLoaded={isLoaded}
</button> onViewStatBlock={handleViewStatBlock}
) : ( onBulkImport={handleBulkImport}
encounter.combatants.map((c, i) => ( bulkImportDisabled={bulkImport.state.status === "loading"}
<CombatantRow inputRef={actionBarInputRef}
key={c.id} playerCharacters={playerCharacters}
ref={i === encounter.activeIndex ? activeRowRef : null} onAddFromPlayerCharacter={addFromPlayerCharacter}
combatant={c} onManagePlayers={() => setManagementOpen(true)}
isActive={i === encounter.activeIndex} autoFocus
onRename={editCombatant} />
onSetInitiative={setInitiative} </div>
onRemove={removeCombatant}
onSetHp={setHp}
onAdjustHp={adjustHp}
onSetAc={setAc}
onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
onShowStatBlock={
c.creatureId
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
/>
))
)}
</div> </div>
</div> ) : (
<>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)}
{/* Action Bar — fixed at bottom */} {/* Scrollable area — combatant list */}
<div className="shrink-0 pb-8"> <div className="flex-1 overflow-y-auto min-h-0">
<ActionBar <div className="flex flex-col px-2 py-2">
onAddCombatant={addCombatant} {encounter.combatants.map((c, i) => (
onAddFromBestiary={handleAddFromBestiary} <CombatantRow
bestiarySearch={search} key={c.id}
bestiaryLoaded={isLoaded} ref={i === encounter.activeIndex ? activeRowRef : null}
onViewStatBlock={handleViewStatBlock} combatant={c}
onBulkImport={handleBulkImport} isActive={i === encounter.activeIndex}
bulkImportDisabled={bulkImport.state.status === "loading"} onRename={editCombatant}
inputRef={actionBarInputRef} onSetInitiative={setInitiative}
playerCharacters={playerCharacters} onRemove={removeCombatant}
onAddFromPlayerCharacter={addFromPlayerCharacter} onSetHp={setHp}
onManagePlayers={() => setManagementOpen(true)} onAdjustHp={adjustHp}
/> onSetAc={setAc}
</div> onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
onShowStatBlock={
c.creatureId
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
/>
))}
</div>
</div>
{/* Action Bar — fixed at bottom */}
<div
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
onAnimationEnd={actionBarAnim.onSettleEnd}
>
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)}
/>
</div>
</>
)}
</div> </div>
{/* Pinned Stat Block Panel (left) */} {/* Pinned Stat Block Panel (left) */}

View File

@@ -32,6 +32,7 @@ interface ActionBarProps {
playerCharacters?: readonly PlayerCharacter[]; playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void; onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void; onManagePlayers?: () => void;
autoFocus?: boolean;
} }
function creatureKey(r: SearchResult): string { function creatureKey(r: SearchResult): string {
@@ -50,6 +51,7 @@ export function ActionBar({
playerCharacters, playerCharacters,
onAddFromPlayerCharacter, onAddFromPlayerCharacter,
onManagePlayers, onManagePlayers,
autoFocus,
}: ActionBarProps) { }: ActionBarProps) {
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
@@ -260,6 +262,7 @@ export function ActionBar({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="+ Add combatants" placeholder="+ Add combatants"
className="max-w-xs" className="max-w-xs"
autoFocus={autoFocus}
/> />
{hasSuggestions && ( {hasSuggestions && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg"> <div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">

View File

@@ -80,20 +80,73 @@
} }
} }
@keyframes breathe { @keyframes settle-to-bottom {
0%, from {
100% { transform: translateY(-40vh);
opacity: 0.4; opacity: 0;
scale: 0.9;
} }
50% { 40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1; opacity: 1;
scale: 1.1;
} }
} }
@utility animate-breathe { @utility animate-settle-to-bottom {
animation: breathe 3s ease-in-out infinite; animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes rise-to-center {
from {
transform: translateY(40vh);
opacity: 0;
}
40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-rise-to-center {
animation: rise-to-center 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-down-in {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-slide-down-in {
animation: slide-down-in 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-up-out {
from {
transform: translateY(0);
opacity: 1;
}
60% {
opacity: 0;
}
to {
transform: translateY(-100%);
opacity: 0;
}
}
@utility animate-slide-up-out {
animation: slide-up-out 700ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
} }
@custom-variant pointer-coarse (@media (pointer: coarse)); @custom-variant pointer-coarse (@media (pointer: coarse));