feat: Add page-level entrance animations using motion library

Subtle fade-in + y-offset animations on mount for all main pages
(initiatives list, initiative detail, agents, inbox) and staggered
card animations for initiative and agent lists.
This commit is contained in:
Lukas May
2026-03-04 07:28:53 +01:00
parent af092ba16a
commit dd86f12057
5 changed files with 173 additions and 96 deletions

View File

@@ -1,4 +1,5 @@
import { AlertCircle, Plus } from "lucide-react"; import { AlertCircle, Plus } from "lucide-react";
import { motion } from "motion/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/Skeleton"; import { Skeleton } from "@/components/Skeleton";
@@ -83,12 +84,22 @@ export function InitiativeList({
// Populated state // Populated state
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{initiatives.map((initiative) => ( {initiatives.map((initiative, i) => (
<InitiativeCard <motion.div
key={initiative.id} key={initiative.id}
initiative={initiative} initial={{ opacity: 0, y: 12 }}
onClick={() => onViewInitiative(initiative.id)} animate={{ opacity: 1, y: 0 }}
/> transition={{
duration: 0.3,
delay: Math.min(i * 0.05, 0.3),
ease: [0, 0, 0.2, 1],
}}
>
<InitiativeCard
initiative={initiative}
onClick={() => onViewInitiative(initiative.id)}
/>
</motion.div>
))} ))}
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router"; import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle, RefreshCw } from "lucide-react"; import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -182,7 +183,12 @@ function AgentsPage() {
]; ];
return ( return (
<div className="flex h-full flex-col gap-4"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="flex h-full flex-col gap-4"
>
{/* Header + Filters */} {/* Header + Filters */}
<div className="shrink-0 space-y-3"> <div className="shrink-0 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -195,7 +201,12 @@ function AgentsPage() {
Refresh Refresh
</Button> </Button>
</div> </div>
<div className="flex items-center gap-1"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
className="flex items-center gap-1"
>
{filterOptions.map((opt) => ( {filterOptions.map((opt) => (
<Button <Button
key={opt.value} key={opt.value}
@@ -213,11 +224,16 @@ function AgentsPage() {
</Badge> </Badge>
</Button> </Button>
))} ))}
</div> </motion.div>
</div> </div>
{/* Two-panel layout */} {/* Two-panel layout */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1"> <motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: [0, 0, 0.2, 1] }}
className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1"
>
{/* Left: Agent List */} {/* Left: Agent List */}
<div className="overflow-y-auto min-h-0 space-y-2"> <div className="overflow-y-auto min-h-0 space-y-2">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
@@ -227,60 +243,70 @@ function AgentsPage() {
</p> </p>
</div> </div>
) : ( ) : (
filtered.map((agent) => ( filtered.map((agent, i) => (
<Card <motion.div
key={agent.id} key={agent.id}
className={cn( initial={{ opacity: 0, y: 12 }}
"cursor-pointer p-3 transition-colors hover:bg-muted/50", animate={{ opacity: 1, y: 0 }}
selectedAgentId === agent.id && "bg-muted" transition={{
)} duration: 0.3,
onClick={() => setSelectedAgentId(agent.id)} delay: Math.min(i * 0.05, 0.3),
ease: [0, 0, 0.2, 1],
}}
> >
<div className="flex items-center justify-between gap-2"> <Card
<div className="flex items-center gap-2 min-w-0"> className={cn(
<StatusDot status={agent.status} size="sm" /> "cursor-pointer p-3 transition-colors hover:bg-muted/50",
<span className="truncate text-sm font-medium"> selectedAgentId === agent.id && "bg-muted"
{agent.name} )}
</span> onClick={() => setSelectedAgentId(agent.id)}
</div> >
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center justify-between gap-2">
<Badge variant="outline" className="text-xs"> <div className="flex items-center gap-2 min-w-0">
{agent.provider} <StatusDot status={agent.status} size="sm" />
</Badge> <span className="truncate text-sm font-medium">
<Badge variant="secondary" className="text-xs"> {agent.name}
{modeLabel(agent.mode)} </span>
</Badge> </div>
{/* Action dropdown */} <div className="flex items-center gap-1.5 shrink-0">
<div onClick={(e) => e.stopPropagation()}> <Badge variant="outline" className="text-xs">
<AgentActions {agent.provider}
agentId={agent.id} </Badge>
status={agent.status} <Badge variant="secondary" className="text-xs">
isDismissed={!!agent.userDismissedAt} {modeLabel(agent.mode)}
onStop={handleStop} </Badge>
onDelete={handleDelete} {/* Action dropdown */}
onDismiss={handleDismiss} <div onClick={(e) => e.stopPropagation()}>
onGoToInbox={handleGoToInbox} <AgentActions
/> agentId={agent.id}
status={agent.status}
isDismissed={!!agent.userDismissedAt}
onStop={handleStop}
onDelete={handleDelete}
onDismiss={handleDismiss}
onGoToInbox={handleGoToInbox}
/>
</div>
</div> </div>
</div> </div>
</div> <div className="mt-1 flex items-center justify-between">
<div className="mt-1 flex items-center justify-between"> <span className="text-xs text-muted-foreground">
<span className="text-xs text-muted-foreground"> {formatRelativeTime(String(agent.updatedAt))}
{formatRelativeTime(String(agent.updatedAt))}
</span>
{agent.status === "waiting_for_input" && (
<span
className="text-xs text-status-warning-fg hover:underline cursor-pointer"
onClick={(e) => {
e.stopPropagation();
handleGoToInbox();
}}
>
Answer questions &rarr;
</span> </span>
)} {agent.status === "waiting_for_input" && (
</div> <span
</Card> className="text-xs text-status-warning-fg hover:underline cursor-pointer"
onClick={(e) => {
e.stopPropagation();
handleGoToInbox();
}}
>
Answer questions &rarr;
</span>
)}
</div>
</Card>
</motion.div>
)) ))
)} )}
</div> </div>
@@ -302,7 +328,7 @@ function AgentsPage() {
</div> </div>
)} )}
</div> </div>
</div> </motion.div>
</div> </motion.div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -176,10 +177,20 @@ function InboxPage() {
})); }));
return ( return (
<div className="space-y-6"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="space-y-6"
>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
{/* Left: Inbox List -- hidden on mobile when agent selected */} {/* Left: Inbox List -- hidden on mobile when agent selected */}
<div className={selectedAgent ? "hidden lg:block" : undefined}> <motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
className={selectedAgent ? "hidden lg:block" : undefined}
>
<InboxList <InboxList
agents={serializedAgents} agents={serializedAgents}
messages={serializedMessages} messages={serializedMessages}
@@ -187,7 +198,7 @@ function InboxPage() {
onSelectAgent={setSelectedAgentId} onSelectAgent={setSelectedAgentId}
onRefresh={handleRefresh} onRefresh={handleRefresh}
/> />
</div> </motion.div>
{/* Right: Detail Panel */} {/* Right: Detail Panel */}
{selectedAgent && ( {selectedAgent && (
@@ -251,6 +262,6 @@ function InboxPage() {
</div> </div>
)} )}
</div> </div>
</div> </motion.div>
); );
} }

View File

@@ -1,4 +1,5 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/Skeleton"; import { Skeleton } from "@/components/Skeleton";
@@ -99,7 +100,12 @@ function InitiativeDetailPage() {
const phases = phasesQuery.data ?? []; const phases = phasesQuery.data ?? [];
return ( return (
<div className="space-y-3"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="space-y-3"
>
{/* Header */} {/* Header */}
<InitiativeHeader <InitiativeHeader
initiative={serializedInitiative} initiative={serializedInitiative}
@@ -108,7 +114,12 @@ function InitiativeDetailPage() {
/> />
{/* Tab bar */} {/* Tab bar */}
<div className="flex gap-1 border-b border-border"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
className="flex gap-1 border-b border-border"
>
{TABS.map((tab) => ( {TABS.map((tab) => (
<button <button
key={tab} key={tab}
@@ -127,28 +138,34 @@ function InitiativeDetailPage() {
{tab} {tab}
</button> </button>
))} ))}
</div> </motion.div>
{/* Tab content */} {/* Tab content */}
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />} <motion.div
{activeTab === "plan" && ( initial={{ opacity: 0, y: 12 }}
<ExecutionTab animate={{ opacity: 1, y: 0 }}
initiativeId={id} transition={{ duration: 0.3, delay: 0.1, ease: [0, 0, 0.2, 1] }}
phases={phases} >
phasesLoading={phasesQuery.isLoading} {activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
phasesLoaded={phasesQuery.isSuccess} {activeTab === "plan" && (
dependencyEdges={depsQuery.data ?? []} <ExecutionTab
branch={serializedInitiative.branch} initiativeId={id}
/> phases={phases}
)} phasesLoading={phasesQuery.isLoading}
{activeTab === "execution" && ( phasesLoaded={phasesQuery.isSuccess}
<PipelineTab dependencyEdges={depsQuery.data ?? []}
initiativeId={id} branch={serializedInitiative.branch}
phases={phases} />
phasesLoading={phasesQuery.isLoading} )}
/> {activeTab === "execution" && (
)} <PipelineTab
{activeTab === "review" && <ReviewTab initiativeId={id} />} initiativeId={id}
</div> phases={phases}
phasesLoading={phasesQuery.isLoading}
/>
)}
{activeTab === "review" && <ReviewTab initiativeId={id} />}
</motion.div>
</motion.div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InitiativeList } from "@/components/InitiativeList"; import { InitiativeList } from "@/components/InitiativeList";
@@ -31,7 +32,12 @@ function DashboardPage() {
]); ]);
return ( return (
<div className="mx-auto max-w-6xl space-y-6"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="mx-auto max-w-6xl space-y-6"
>
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Initiatives</h1> <h1 className="text-2xl font-bold">Initiatives</h1>
@@ -57,19 +63,25 @@ function DashboardPage() {
</div> </div>
{/* Initiative list */} {/* Initiative list */}
<InitiativeList <motion.div
statusFilter={statusFilter} initial={{ opacity: 0, y: 12 }}
onCreateNew={() => setCreateDialogOpen(true)} animate={{ opacity: 1, y: 0 }}
onViewInitiative={(id) => transition={{ duration: 0.3, delay: 0.1, ease: [0, 0, 0.2, 1] }}
navigate({ to: "/initiatives/$id", params: { id } }) >
} <InitiativeList
/> statusFilter={statusFilter}
onCreateNew={() => setCreateDialogOpen(true)}
onViewInitiative={(id) =>
navigate({ to: "/initiatives/$id", params: { id } })
}
/>
</motion.div>
{/* Create initiative dialog */} {/* Create initiative dialog */}
<CreateInitiativeDialog <CreateInitiativeDialog
open={createDialogOpen} open={createDialogOpen}
onOpenChange={setCreateDialogOpen} onOpenChange={setCreateDialogOpen}
/> />
</div> </motion.div>
); );
} }