Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions ANIMATION_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# EscrowFlow Animation Guide

Framer Motion, applied on top of the existing Tailwind design system — no
parallel styling system, no duplicate components.

## Spring presets (`src/lib/animations.ts`)

| Preset | Use case |
| --------- | -------------------------------------- |
| `default` | General-purpose UI transitions |
| `snappy` | Buttons, taps, small interactions |
| `bouncy` | Momentum / flick interactions |
| `smooth` | Large elements — modals, drawers |

`reducedMotionTransition` (a near-instant `{ duration: 0.01 }`) is swapped in
whenever `useReducedMotion()` reports the user prefers reduced motion.

## Components

### `Button` / `LinkButton` (`src/components/ui/Button.tsx`)
Existing button API unchanged (`variant`, `size`, `disabled`, ...props).
Now scales to 1.02 on hover / 0.98 on tap with the `snappy` spring.
`LinkButton` wraps `next/link` via `motion.create(Link)` so the same feedback
applies to link-styled buttons.

### `Modal` (`src/components/ui/Modal.tsx`)
`isOpen`, `onClose`, `title`, `children`. Backdrop fades in/out; panel enters
with a `smooth` spring (slide + fade), fully interruptible via
`AnimatePresence`.

### `Toast` (`src/components/ui/Toast.tsx`)
`message`, `type` (`success` | `error` | `warning` | `info`), `isVisible`,
`onClose`, optional `duration` (defaults to 3000ms). Auto-dismisses via a
`useEffect` timer keyed on `isVisible` — not on animation completion, so it
won't re-fire on exit.

### `Skeleton` (`src/components/ui/Skeleton.tsx`)
`className` for sizing. Shimmering opacity pulse; static when reduced motion
is preferred. Used for the dashboard's loading state.

## Typography (`tailwind.config.ts`)
Added `text-display-lg`, `text-display-md`, `text-heading-lg/md/sm`,
`text-body-lg`, `text-label-caps`, `text-caption` to the existing Tailwind
`fontSize` scale. `text-base` / `text-sm` were left as-is since they already
matched the intended body sizes — no need to duplicate them.

## Reduced motion (`src/hooks/useReducedMotion.ts`)
Tracks `(prefers-reduced-motion: reduce)` live via `matchMedia`. Every
animated component checks it and either skips the animation or uses
`reducedMotionTransition`.

## Dashboard (`src/app/app/page.tsx`)
Loading state now uses the shared `Skeleton`. The projects grid and recent
payments list stagger in with the `default` spring (0.06s stagger), skipped
entirely under reduced motion.

## Testing
1. Hover/tap a button — should feel snappy, no lag.
2. Open/close a `Modal` — slide + fade, interruptible mid-animation.
3. Trigger a `Toast` — enters top-left-ish offset, auto-dismisses once
after ~3s, no repeat-fire on close.
4. Enable OS-level reduced motion — all of the above should become
near-instant with no scale/slide/stagger.
5. Keyboard: buttons and the modal close button remain focusable and
operable via keyboard.

## Notes
- `window.matchMedia` is polyfilled in `src/test/setup.ts` since jsdom
doesn't implement it — needed once any component uses
`useReducedMotion`.
- `Button.tsx` and `useReducedMotion.ts` are marked `"use client"` since
they're pulled in by Server Components (e.g. the landing page hero).
39 changes: 39 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
"dependencies": {
"@stellar/stellar-sdk": "^16.2.0",
"framer-motion": "^13.1.0",
"jose": "^6.2.8",
"lucide-react": "^0.427.0",
"next": "^14.2.5",
Expand Down
49 changes: 37 additions & 12 deletions src/app/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,35 @@
"use client";

import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { Download, FolderKanban, Plus, Upload } from "lucide-react";
import { BalanceCard } from "@/components/dashboard/BalanceCard";
import { ProjectListItem } from "@/components/dashboard/ProjectListItem";
import { PaymentListItem } from "@/components/dashboard/PaymentListItem";
import { EmptyState } from "@/components/ui/EmptyState";
import { LinkButton } from "@/components/ui/Button";
import { Skeleton } from "@/components/ui/Skeleton";
import { useReducedMotion } from "@/hooks/useReducedMotion";
import { springs } from "@/lib/animations";
import { projectsForRole, useAppStore } from "@/lib/store";
import { useAuthStore } from "@/store/auth.store";
import { computeWalletSummary, paymentDirection } from "@/lib/mock/service";

function SkeletonBlock({ className }: { className?: string }) {
return <div className={`animate-pulse rounded-xl bg-line/60 ${className ?? ""}`} />;
}
const LIST_CONTAINER = {
hidden: {},
show: { transition: { staggerChildren: 0.06 } },
};

const LIST_ITEM = {
hidden: { opacity: 0, y: 12 },
show: { opacity: 1, y: 0 },
};

export default function OverviewPage() {
const [isLoading, setIsLoading] = useState(true);
const state = useAppStore((s) => s.state);
const userRole = useAuthStore((s) => s.userRole);
const prefersReducedMotion = useReducedMotion();

useEffect(() => {
const timer = setTimeout(() => setIsLoading(false), 350);
Expand All @@ -34,9 +45,9 @@ export default function OverviewPage() {
if (isLoading) {
return (
<div className="space-y-6">
<SkeletonBlock className="h-40" />
<SkeletonBlock className="h-24" />
<SkeletonBlock className="h-64" />
<Skeleton className="h-40" />
<Skeleton className="h-24" />
<Skeleton className="h-64" />
</div>
);
}
Expand Down Expand Up @@ -93,11 +104,18 @@ export default function OverviewPage() {
}
/>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<motion.div
className="grid gap-4 sm:grid-cols-2"
variants={prefersReducedMotion ? undefined : LIST_CONTAINER}
initial={prefersReducedMotion ? undefined : "hidden"}
animate={prefersReducedMotion ? undefined : "show"}
>
{projects.map((project) => (
<ProjectListItem key={project.id} project={project} role={userRole} />
<motion.div key={project.id} variants={prefersReducedMotion ? undefined : LIST_ITEM} transition={springs.default}>
<ProjectListItem project={project} role={userRole} />
</motion.div>
))}
</div>
</motion.div>
)}
</section>

Expand All @@ -110,11 +128,18 @@ export default function OverviewPage() {
description="Payments from milestone releases, deposits, and withdrawals will appear here."
/>
) : (
<div className="space-y-3">
<motion.div
className="space-y-3"
variants={prefersReducedMotion ? undefined : LIST_CONTAINER}
initial={prefersReducedMotion ? undefined : "hidden"}
animate={prefersReducedMotion ? undefined : "show"}
>
{recentPayments.map((payment) => (
<PaymentListItem key={payment.id} payment={payment} direction={paymentDirection(payment, state)} />
<motion.div key={payment.id} variants={prefersReducedMotion ? undefined : LIST_ITEM} transition={springs.default}>
<PaymentListItem payment={payment} direction={paymentDirection(payment, state)} />
</motion.div>
))}
</div>
</motion.div>
)}
</section>
</div>
Expand Down
32 changes: 26 additions & 6 deletions src/components/ui/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
"use client";

import { cn } from "@/lib/utils";
import { reducedMotionTransition, springs } from "@/lib/animations";
import { useReducedMotion } from "@/hooks/useReducedMotion";
import { motion, type HTMLMotionProps } from "framer-motion";
import Link from "next/link";
import type { ButtonHTMLAttributes } from "react";

const MotionLink = motion.create(Link);

type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";
Expand All @@ -21,15 +27,21 @@ const SIZE_CLASSES: Record<Size, string> = {
const BASE =
"inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
interface ButtonProps extends HTMLMotionProps<"button"> {
variant?: Variant;
size?: Size;
}

export function Button({ variant = "primary", size = "md", className, ...props }: ButtonProps) {
export function Button({ variant = "primary", size = "md", className, disabled, ...props }: ButtonProps) {
const prefersReducedMotion = useReducedMotion();

return (
<button
<motion.button
className={cn(BASE, VARIANT_CLASSES[variant], SIZE_CLASSES[size], className)}
disabled={disabled}
whileHover={!disabled && !prefersReducedMotion ? { scale: 1.02 } : undefined}
whileTap={!disabled && !prefersReducedMotion ? { scale: 0.98 } : undefined}
transition={prefersReducedMotion ? reducedMotionTransition : springs.snappy}
{...props}
/>
);
Expand All @@ -44,9 +56,17 @@ interface LinkButtonProps {
}

export function LinkButton({ href, variant = "primary", size = "md", className, children }: LinkButtonProps) {
const prefersReducedMotion = useReducedMotion();

return (
<Link href={href} className={cn(BASE, VARIANT_CLASSES[variant], SIZE_CLASSES[size], className)}>
<MotionLink
href={href}
className={cn(BASE, VARIANT_CLASSES[variant], SIZE_CLASSES[size], className)}
whileHover={prefersReducedMotion ? undefined : { scale: 1.02 }}
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
transition={prefersReducedMotion ? reducedMotionTransition : springs.snappy}
>
{children}
</Link>
</MotionLink>
);
}
55 changes: 55 additions & 0 deletions src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client";

import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import { useReducedMotion } from "@/hooks/useReducedMotion";
import { reducedMotionTransition, springs } from "@/lib/animations";

interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const prefersReducedMotion = useReducedMotion();
const panelTransition = prefersReducedMotion ? reducedMotionTransition : springs.smooth;

return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label={title}>
<motion.div
className="absolute inset-0 bg-ink/50"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>

<motion.div
className="relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-line bg-white shadow-xl"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
transition={panelTransition}
>
<div className="flex items-center justify-between border-b border-line px-6 py-4">
<h2 className="text-lg font-semibold text-ink">{title}</h2>
<button
onClick={onClose}
aria-label="Close"
className="rounded-lg p-1 text-ink-secondary hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<X className="h-5 w-5" aria-hidden="true" />
</button>
</div>
<div className="p-6">{children}</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}
21 changes: 21 additions & 0 deletions src/components/ui/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import { motion } from "framer-motion";
import { useReducedMotion } from "@/hooks/useReducedMotion";
import { cn } from "@/lib/utils";

interface SkeletonProps {
className?: string;
}

export function Skeleton({ className }: SkeletonProps) {
const prefersReducedMotion = useReducedMotion();

return (
<motion.div
className={cn("rounded-xl bg-line/60", className)}
animate={prefersReducedMotion ? undefined : { opacity: [0.6, 1, 0.6] }}
transition={prefersReducedMotion ? undefined : { duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
/>
);
}
Loading
Loading