diff --git a/app/layout.tsx b/app/layout.tsx index 19e0796f..6b2e1237 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { Inter, Space_Grotesk } from "next/font/google"; import "./globals.css"; import "./styles/patterns.css"; +import "./styles/motion.css"; import { Providers } from "@/components/providers"; const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); diff --git a/app/styles/motion.css b/app/styles/motion.css new file mode 100644 index 00000000..63e6cb30 --- /dev/null +++ b/app/styles/motion.css @@ -0,0 +1,102 @@ +/** + * Success confetti motion tokens. + * Animated pieces are disabled under prefers-reduced-motion (see globals.css + .success-confetti-static). + */ + +@keyframes confetti-fall { + 0% { + transform: translate3d(0, -10%, 0) rotate(0deg); + opacity: 1; + } + 100% { + transform: translate3d(var(--confetti-drift, 0px), 120%, 0) rotate(var(--confetti-spin, 360deg)); + opacity: 0; + } +} + +.success-confetti { + pointer-events: none; + position: absolute; + inset: 0; + overflow: hidden; + z-index: 10; +} + +.success-confetti-piece { + position: absolute; + top: -8%; + width: 8px; + height: 14px; + border-radius: 2px; + opacity: 0.9; + animation: confetti-fall var(--confetti-duration, 1.4s) ease-out forwards; + will-change: transform, opacity; +} + +.success-confetti-piece--cyan { + background: #29fcf3; + left: 12%; + --confetti-drift: -24px; + --confetti-spin: 420deg; + animation-delay: 0ms; +} + +.success-confetti-piece--green { + background: #4ade80; + left: 28%; + --confetti-drift: 18px; + --confetti-spin: -300deg; + animation-delay: 80ms; +} + +.success-confetti-piece--violet { + background: #a78bfa; + left: 44%; + --confetti-drift: -12px; + --confetti-spin: 540deg; + animation-delay: 40ms; +} + +.success-confetti-piece--amber { + background: #fbbf24; + left: 60%; + --confetti-drift: 28px; + --confetti-spin: -420deg; + animation-delay: 120ms; +} + +.success-confetti-piece--pink { + background: #f472b6; + left: 76%; + --confetti-drift: -32px; + --confetti-spin: 480deg; + animation-delay: 60ms; +} + +.success-confetti-piece--blue { + background: #69daff; + left: 88%; + --confetti-drift: 14px; + --confetti-spin: -360deg; + animation-delay: 100ms; +} + +/* Static success badge shown when motion is reduced */ +.success-confetti-static { + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem; +} + +.success-confetti-static__icon { + color: #4ade80; + filter: drop-shadow(0 0 8px rgba(74, 222, 128, 0.35)); +} + +@media (prefers-reduced-motion: reduce) { + .success-confetti-piece { + animation: none; + opacity: 0; + } +} diff --git a/components/patterns/BetConfirmPattern.tsx b/components/patterns/BetConfirmPattern.tsx index 75a56956..199f90c7 100644 --- a/components/patterns/BetConfirmPattern.tsx +++ b/components/patterns/BetConfirmPattern.tsx @@ -30,6 +30,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Kbd } from "@/components/ui/kbd" import { playSound } from "@/lib/audio/play-sound" import { LiveRegion } from "@/components/ui/live-region" +import { SuccessConfetti } from "@/components/success/SuccessConfetti" /** Each step in the bet placement flow */ type BetStep = "review" | "sign" | "submit" | "confirm" @@ -148,14 +149,17 @@ export function BetConfirmPattern() { ) : ( - +
+ + +
)} @@ -200,7 +204,8 @@ export function BetConfirmPattern() { ) : ( -
+
+ { + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)") + setPrefersReducedMotion(mediaQuery.matches) + + const onChange = (event: MediaQueryListEvent) => { + setPrefersReducedMotion(event.matches) + } + + mediaQuery.addEventListener("change", onChange) + return () => mediaQuery.removeEventListener("change", onChange) + }, []) + + if (!active) { + return null + } + + if (prefersReducedMotion) { + return ( +
+
+ ) + } + + return ( + + ) +} diff --git a/components/success/__tests__/SuccessConfetti.test.tsx b/components/success/__tests__/SuccessConfetti.test.tsx new file mode 100644 index 00000000..19fa12f8 --- /dev/null +++ b/components/success/__tests__/SuccessConfetti.test.tsx @@ -0,0 +1,41 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import { SuccessConfetti } from "../SuccessConfetti" + +function mockReducedMotion(reduced: boolean) { + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: reduced && query.includes("prefers-reduced-motion"), + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })) +} + +describe("SuccessConfetti", () => { + beforeEach(() => { + mockReducedMotion(false) + }) + + it("renders animated confetti pieces when motion is allowed", () => { + const { container } = render() + expect(container.querySelectorAll(".success-confetti-piece").length).toBeGreaterThan(0) + expect(container.querySelector(".success-confetti-static")).not.toBeInTheDocument() + }) + + it("renders a static success icon under prefers-reduced-motion", () => { + mockReducedMotion(true) + render() + + expect(screen.getByRole("img", { name: "Prediction confirmed" })).toBeInTheDocument() + expect(document.querySelector(".success-confetti-piece")).not.toBeInTheDocument() + }) + + it("renders nothing when inactive", () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) +})