diff --git a/ANIMATION_GUIDE.md b/ANIMATION_GUIDE.md new file mode 100644 index 0000000..356c36a --- /dev/null +++ b/ANIMATION_GUIDE.md @@ -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). diff --git a/package-lock.json b/package-lock.json index 9534b9b..a101ce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "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", @@ -4845,6 +4846,29 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.0.tgz", + "integrity": "sha512-QSZrF0Id3QGuHJ+OL+9PSY9pk86C8ERFalwAGSchzTm65+ZoGH/RM26lmEARLljcHj2lqhv0jZOOks+EI3COOw==", + "license": "MIT", + "dependencies": { + "motion-dom": "^13.0.0", + "motion-utils": "^13.0.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -6294,6 +6318,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.0.0.tgz", + "integrity": "sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==", + "license": "MIT", + "dependencies": { + "motion-utils": "^13.0.0" + } + }, + "node_modules/motion-utils": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz", + "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index b89d51b..23b2837 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index 44e3f72..211ec11 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -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
; -} +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); @@ -34,9 +45,9 @@ export default function OverviewPage() { if (isLoading) { return (
- - - + + +
); } @@ -93,11 +104,18 @@ export default function OverviewPage() { } /> ) : ( -
+ {projects.map((project) => ( - + + + ))} -
+ )} @@ -110,11 +128,18 @@ export default function OverviewPage() { description="Payments from milestone releases, deposits, and withdrawals will appear here." /> ) : ( -
+ {recentPayments.map((payment) => ( - + + + ))} -
+ )}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 34418cb..d113066 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -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"; @@ -21,15 +27,21 @@ const SIZE_CLASSES: Record = { 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 { +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 ( - + +
{children}
+ + + )} + + ); +} diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..07e792f --- /dev/null +++ b/src/components/ui/Skeleton.tsx @@ -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 ( + + ); +} diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 0000000..8b598af --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react"; +import { useReducedMotion } from "@/hooks/useReducedMotion"; +import { reducedMotionTransition, springs } from "@/lib/animations"; +import { cn } from "@/lib/utils"; + +export type ToastType = "success" | "error" | "warning" | "info"; + +interface ToastProps { + message: string; + type: ToastType; + isVisible: boolean; + onClose: () => void; + /** Milliseconds before auto-dismissing. Defaults to 3000. */ + duration?: number; +} + +const ICONS: Record = { + success: CheckCircle2, + error: XCircle, + warning: AlertTriangle, + info: Info, +}; + +const TYPE_CLASSES: Record = { + success: "bg-success text-white", + error: "bg-danger text-white", + warning: "bg-warning text-white", + info: "bg-primary text-white", +}; + +export function Toast({ message, type, isVisible, onClose, duration = 3000 }: ToastProps) { + const prefersReducedMotion = useReducedMotion(); + const Icon = ICONS[type]; + + useEffect(() => { + if (!isVisible) return; + const timer = setTimeout(onClose, duration); + return () => clearTimeout(timer); + }, [isVisible, duration, onClose]); + + return ( + + {isVisible && ( + + + )} + + ); +} diff --git a/src/hooks/useReducedMotion.ts b/src/hooks/useReducedMotion.ts new file mode 100644 index 0000000..14a482b --- /dev/null +++ b/src/hooks/useReducedMotion.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const QUERY = "(prefers-reduced-motion: reduce)"; + +/** Tracks the user's OS-level reduced-motion preference. */ +export function useReducedMotion(): boolean { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia(QUERY); + setPrefersReducedMotion(mediaQuery.matches); + + const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches); + mediaQuery.addEventListener("change", handler); + return () => mediaQuery.removeEventListener("change", handler); + }, []); + + return prefersReducedMotion; +} diff --git a/src/lib/animations.ts b/src/lib/animations.ts new file mode 100644 index 0000000..9719199 --- /dev/null +++ b/src/lib/animations.ts @@ -0,0 +1,39 @@ +import type { Transition } from "framer-motion"; + +/** Spring presets matching Apple's HIG motion values. */ +export const springs = { + /** Smooth, no bounce — default UI transitions. */ + default: { + type: "spring", + damping: 10, + stiffness: 100, + mass: 1, + }, + + /** Quick & snappy — buttons, small interactions. */ + snappy: { + type: "spring", + damping: 12, + stiffness: 200, + mass: 1, + }, + + /** Bouncy, playful — momentum/flick interactions. */ + bouncy: { + type: "spring", + damping: 8, + stiffness: 100, + mass: 1, + }, + + /** Slow & smooth — large elements, modals, drawers. */ + smooth: { + type: "spring", + damping: 15, + stiffness: 60, + mass: 1, + }, +} satisfies Record; + +/** Near-instant transition used when the user prefers reduced motion. */ +export const reducedMotionTransition: Transition = { duration: 0.01 }; diff --git a/src/test/setup.ts b/src/test/setup.ts index f149f27..510d260 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,14 @@ import "@testing-library/jest-dom/vitest"; + +if (typeof window !== "undefined" && !window.matchMedia) { + window.matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as unknown as MediaQueryList; +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 4fa096e..1e5fd7c 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -33,6 +33,16 @@ const config: Config = { fontFamily: { sans: ["var(--font-inter)", "system-ui", "sans-serif"], }, + fontSize: { + "display-lg": ["clamp(2rem, 5vw, 3rem)", { lineHeight: "1.2", letterSpacing: "-0.02em", fontWeight: "700" }], + "display-md": ["clamp(1.5rem, 4vw, 2rem)", { lineHeight: "1.3", letterSpacing: "-0.01em", fontWeight: "600" }], + "heading-lg": ["1.5rem", { lineHeight: "1.4", fontWeight: "600" }], + "heading-md": ["1.25rem", { lineHeight: "1.4", fontWeight: "600" }], + "heading-sm": ["1rem", { lineHeight: "1.5", fontWeight: "600" }], + "body-lg": ["1.125rem", { lineHeight: "1.6", fontWeight: "400" }], + "label-caps": ["0.75rem", { lineHeight: "1.3", letterSpacing: "0.08em", fontWeight: "600" }], + caption: ["0.75rem", { lineHeight: "1.4", fontWeight: "400" }], + }, borderRadius: { xl: "0.875rem", "2xl": "1.25rem",