From 72d4f30e6078cbc7d8754e772e28f998e780ef0f Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 13 Mar 2026 14:29:51 +0100 Subject: [PATCH] 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 --- apps/web/src/App.tsx | 161 ++++++++++++++++--------- apps/web/src/components/action-bar.tsx | 3 + apps/web/src/index.css | 38 ++++-- 3 files changed, 136 insertions(+), 66 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a5e76c3..ff5037d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,7 +3,6 @@ import { rollInitiativeUseCase, } from "@initiative/application"; import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; -import { Plus } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { ActionBar } from "./components/action-bar"; import { CombatantRow } from "./components/combatant-row"; @@ -22,6 +21,29 @@ function rollDice(): number { 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); + + useEffect(() => { + const nowEmpty = combatantCount === 0; + if (wasEmptyRef.current && !nowEmpty) { + setSettling(true); + } else if (!wasEmptyRef.current && nowEmpty) { + setRising(true); + } + wasEmptyRef.current = nowEmpty; + }, [combatantCount]); + + return { + settling, + rising, + onSettleEnd: () => setSettling(false), + onRiseEnd: () => setRising(false), + }; +} + export function App() { const { encounter, @@ -171,6 +193,7 @@ export function App() { }, []); const actionBarInputRef = useRef(null); + const actionBarAnim = useActionBarAnimation(encounter.combatants.length); // Auto-scroll to the active combatant when the turn changes const activeRowRef = useRef(null); @@ -194,6 +217,12 @@ export function App() { setSelectedCreatureId(active.creatureId as CreatureId); }, [encounter.activeIndex, encounter.combatants, isLoaded]); + const isEmpty = encounter.combatants.length === 0; + const risingClass = actionBarAnim.rising ? " animate-rise-to-center" : ""; + const settlingClass = actionBarAnim.settling + ? " animate-settle-to-bottom" + : ""; + return (
@@ -215,64 +244,82 @@ export function App() {
)} - {/* Scrollable area — combatant list */} -
-
- {encounter.combatants.length === 0 ? ( - - ) : ( - encounter.combatants.map((c, i) => ( - handleCombatantStatBlock(c.creatureId as string) - : undefined - } - onRollInitiative={ - c.creatureId ? handleRollInitiative : undefined - } - /> - )) - )} + {isEmpty ? ( + /* Empty state — ActionBar centered */ +
+
+ setManagementOpen(true)} + autoFocus + /> +
-
+ ) : ( + <> + {/* Scrollable area — combatant list */} +
+
+ {encounter.combatants.map((c, i) => ( + handleCombatantStatBlock(c.creatureId as string) + : undefined + } + onRollInitiative={ + c.creatureId ? handleRollInitiative : undefined + } + /> + ))} +
+
- {/* Action Bar — fixed at bottom */} -
- setManagementOpen(true)} - /> -
+ {/* Action Bar — fixed at bottom */} +
+ setManagementOpen(true)} + /> +
+ + )}
{/* Pinned Stat Block Panel (left) */} diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index 1b2cdd8..7c98450 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -32,6 +32,7 @@ interface ActionBarProps { playerCharacters?: readonly PlayerCharacter[]; onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void; onManagePlayers?: () => void; + autoFocus?: boolean; } function creatureKey(r: SearchResult): string { @@ -50,6 +51,7 @@ export function ActionBar({ playerCharacters, onAddFromPlayerCharacter, onManagePlayers, + autoFocus, }: ActionBarProps) { const [nameInput, setNameInput] = useState(""); const [suggestions, setSuggestions] = useState([]); @@ -260,6 +262,7 @@ export function ActionBar({ onKeyDown={handleKeyDown} placeholder="+ Add combatants" className="max-w-xs" + autoFocus={autoFocus} /> {hasSuggestions && (
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 1f61fea..4314f8e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -80,20 +80,40 @@ } } -@keyframes breathe { - 0%, - 100% { - opacity: 0.4; - scale: 0.9; +@keyframes settle-to-bottom { + from { + transform: translateY(-40vh); + opacity: 0; } - 50% { + 40% { + opacity: 1; + } + to { + transform: translateY(0); opacity: 1; - scale: 1.1; } } -@utility animate-breathe { - animation: breathe 3s ease-in-out infinite; +@utility animate-settle-to-bottom { + animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1); +} + +@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); } @custom-variant pointer-coarse (@media (pointer: coarse));