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 { motion } from "motion/react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/Skeleton";
@@ -83,12 +84,22 @@ export function InitiativeList({
// Populated state
return (
<div className="space-y-3">
{initiatives.map((initiative) => (
<InitiativeCard
{initiatives.map((initiative, i) => (
<motion.div
key={initiative.id}
initiative={initiative}
onClick={() => onViewInitiative(initiative.id)}
/>
initial={{ opacity: 0, y: 12 }}
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>
);

View File

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

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -176,10 +177,20 @@ function InboxPage() {
}));
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]">
{/* 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
agents={serializedAgents}
messages={serializedMessages}
@@ -187,7 +198,7 @@ function InboxPage() {
onSelectAgent={setSelectedAgentId}
onRefresh={handleRefresh}
/>
</div>
</motion.div>
{/* Right: Detail Panel */}
{selectedAgent && (
@@ -251,6 +262,6 @@ function InboxPage() {
</div>
)}
</div>
</div>
</motion.div>
);
}

View File

@@ -1,4 +1,5 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/Skeleton";
@@ -99,7 +100,12 @@ function InitiativeDetailPage() {
const phases = phasesQuery.data ?? [];
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 */}
<InitiativeHeader
initiative={serializedInitiative}
@@ -108,7 +114,12 @@ function InitiativeDetailPage() {
/>
{/* 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) => (
<button
key={tab}
@@ -127,28 +138,34 @@ function InitiativeDetailPage() {
{tab}
</button>
))}
</div>
</motion.div>
{/* Tab content */}
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
{activeTab === "plan" && (
<ExecutionTab
initiativeId={id}
phases={phases}
phasesLoading={phasesQuery.isLoading}
phasesLoaded={phasesQuery.isSuccess}
dependencyEdges={depsQuery.data ?? []}
branch={serializedInitiative.branch}
/>
)}
{activeTab === "execution" && (
<PipelineTab
initiativeId={id}
phases={phases}
phasesLoading={phasesQuery.isLoading}
/>
)}
{activeTab === "review" && <ReviewTab initiativeId={id} />}
</div>
<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] }}
>
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
{activeTab === "plan" && (
<ExecutionTab
initiativeId={id}
phases={phases}
phasesLoading={phasesQuery.isLoading}
phasesLoaded={phasesQuery.isSuccess}
dependencyEdges={depsQuery.data ?? []}
branch={serializedInitiative.branch}
/>
)}
{activeTab === "execution" && (
<PipelineTab
initiativeId={id}
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 { createFileRoute, useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { InitiativeList } from "@/components/InitiativeList";
@@ -31,7 +32,12 @@ function DashboardPage() {
]);
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 */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Initiatives</h1>
@@ -57,19 +63,25 @@ function DashboardPage() {
</div>
{/* Initiative list */}
<InitiativeList
statusFilter={statusFilter}
onCreateNew={() => setCreateDialogOpen(true)}
onViewInitiative={(id) =>
navigate({ to: "/initiatives/$id", params: { id } })
}
/>
<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] }}
>
<InitiativeList
statusFilter={statusFilter}
onCreateNew={() => setCreateDialogOpen(true)}
onViewInitiative={(id) =>
navigate({ to: "/initiatives/$id", params: { id } })
}
/>
</motion.div>
{/* Create initiative dialog */}
<CreateInitiativeDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</div>
</motion.div>
);
}