diff --git a/web/src/index.css b/web/src/index.css index d0713e5..df5ba47 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -25,11 +25,11 @@ --spacing-3xl: 64px; --spacing-4xl: 96px; - /* Elevation Layers - Carbon-inspired */ + /* Elevation Layers */ --layer-00: #161616; - --layer-01: #1E1E1E; + --layer-01: #1e1e1e; --layer-02: #262626; - --layer-03: #2C2C2C; + --layer-03: #2c2c2c; --layer-04: #323232; /* Elevation Shadows - Subtle depth */ @@ -45,13 +45,12 @@ --duration-slow: 350ms; --duration-slower: 500ms; - --easing-standard: cubic-bezier(0.4, 0.0, 0.2, 1); - --easing-accelerate: cubic-bezier(0.4, 0.0, 1, 1); - --easing-decelerate: cubic-bezier(0.0, 0.0, 0.2, 1); - --easing-sharp: cubic-bezier(0.4, 0.0, 0.6, 1); + --easing-standard: cubic-bezier(0.4, 0, 0.2, 1); + --easing-accelerate: cubic-bezier(0.4, 0, 1, 1); + --easing-decelerate: cubic-bezier(0, 0, 0.2, 1); + --easing-sharp: cubic-bezier(0.4, 0, 0.6, 1); - /* Density Multiplier - Set by context provider */ - --density-multiplier: 1.0; + --density-multiplier: 1; } * { @@ -108,14 +107,13 @@ h6 { } .density-comfortable { - --density-multiplier: 1.0; + --density-multiplier: 1; } .density-spacious { --density-multiplier: 1.25; } -/* Transition Utilities - Consistent motion */ .transition-standard { transition-duration: var(--duration-normal); transition-timing-function: var(--easing-standard); @@ -130,3 +128,56 @@ h6 { transition-duration: var(--duration-slow); transition-timing-function: var(--easing-decelerate); } + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@keyframes breathe { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.02); + } +} + +.animate-pulse { + animation: pulse 2s ease-in-out infinite; +} + +.animate-shimmer { + background: linear-gradient( + 90deg, + var(--layer-01) 0%, + var(--layer-02) 50%, + var(--layer-01) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} + +.animate-breathe { + animation: breathe 3s ease-in-out infinite; +} + +.press-effect:active { + transform: scale(0.97); + transition: transform var(--duration-instant) var(--easing-sharp); +} diff --git a/web/src/lib/animations.ts b/web/src/lib/animations.ts index f412dc0..b0ed434 100644 --- a/web/src/lib/animations.ts +++ b/web/src/lib/animations.ts @@ -1,4 +1,5 @@ import type { Options as MotionOptions } from "solid-motionone"; +import { motion } from "./design-tokens"; /** Spring animation config for natural bounce */ export const springConfig = { stiffness: 300, damping: 24 }; @@ -6,68 +7,160 @@ export const springConfig = { stiffness: 300, damping: 24 }; /** Standard easing for UI animations */ export const easeOut = [0.22, 1, 0.36, 1] as const; +/** Bounce easing for playful animations */ +export const easeBounce = [0.34, 1.56, 0.64, 1] as const; + /** Fade in animation */ export const fadeIn: MotionOptions = { initial: { opacity: 0 }, animate: { opacity: 1 }, - transition: { duration: 0.2, easing: easeOut }, + transition: { duration: motion.duration.fast / 1000, easing: easeOut }, }; -/** Fade out animation */ -export const fadeOut: MotionOptions = { - initial: { opacity: 1 }, - animate: { opacity: 0 }, - transition: { duration: 0.15 }, +/** Fade in while sliding up 20px */ +export const fadeInUp: MotionOptions = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + transition: { duration: motion.duration.normal / 1000, easing: easeOut }, }; /** Slide in from right */ export const slideInRight: MotionOptions = { initial: { opacity: 0, x: 20 }, animate: { opacity: 1, x: 0 }, - transition: { duration: 0.25, easing: easeOut }, + transition: { duration: motion.duration.normal / 1000, easing: easeOut }, +}; + +/** Slide in from left */ +export const slideInLeft: MotionOptions = { + initial: { opacity: 0, x: -20 }, + animate: { opacity: 1, x: 0 }, + transition: { duration: motion.duration.slow / 1000, easing: easeOut }, }; /** Slide in from bottom */ export const slideInUp: MotionOptions = { initial: { opacity: 0, y: 10 }, animate: { opacity: 1, y: 0 }, - transition: { duration: 0.2, easing: easeOut }, + transition: { duration: motion.duration.fast / 1000, easing: easeOut }, }; /** Scale in (pop) */ export const scaleIn: MotionOptions = { initial: { opacity: 0, scale: 0.95 }, animate: { opacity: 1, scale: 1 }, - transition: { duration: 0.2, easing: easeOut }, + transition: { duration: motion.duration.fast / 1000, easing: easeOut }, +}; + +/** Scale in with bounce effect */ +export const scaleInBounce: MotionOptions = { + initial: { opacity: 0, scale: 0.8 }, + animate: { opacity: 1, scale: 1 }, + transition: { duration: motion.duration.slow / 1000, easing: easeBounce }, +}; + +/** Fade out animation */ +export const fadeOut: MotionOptions = { + initial: { opacity: 1 }, + animate: { opacity: 0 }, + transition: { duration: motion.duration.fast / 1000 }, +}; + +/** Fade out while sliding down */ +export const fadeOutDown: MotionOptions = { + initial: { opacity: 1, y: 0 }, + animate: { opacity: 0, y: 20 }, + transition: { duration: motion.duration.fast / 1000 }, }; /** Scale out */ export const scaleOut: MotionOptions = { initial: { opacity: 1, scale: 1 }, animate: { opacity: 0, scale: 0.95 }, - transition: { duration: 0.15 }, + transition: { duration: motion.duration.fast / 1000 }, }; -/** Stagger delay for list items */ -export const staggerDelay = (index: number, baseDelay = 0.05) => index * baseDelay; - -/** Card flip animation (3D) */ -export const cardFlip: MotionOptions = { - initial: { rotateY: 0 }, - animate: { rotateY: 180 }, - transition: { duration: 0.4, easing: easeOut }, +/** Quick scale out to zero */ +export const scaleOutFast: MotionOptions = { + initial: { opacity: 1, scale: 1 }, + animate: { opacity: 0, scale: 0 }, + transition: { duration: motion.duration.instant / 1000 }, }; /** Slide out left (for card dismissal) */ export const slideOutLeft: MotionOptions = { initial: { opacity: 1, x: 0 }, animate: { opacity: 0, x: -100 }, - transition: { duration: 0.25, easing: easeOut }, + transition: { duration: motion.duration.normal / 1000, easing: easeOut }, +}; + +/** Slide out right */ +export const slideOutRight: MotionOptions = { + initial: { opacity: 1, x: 0 }, + animate: { opacity: 0, x: 100 }, + transition: { duration: motion.duration.normal / 1000, easing: easeOut }, +}; + +/** Card flip animation (3D) */ +export const cardFlip: MotionOptions = { + initial: { rotateY: 0 }, + animate: { rotateY: 180 }, + transition: { duration: 0.4, easing: easeOut }, }; /** Bounce in (for success feedback) */ export const bounceIn: MotionOptions = { initial: { opacity: 0, scale: 0.8 }, animate: { opacity: 1, scale: 1 }, - transition: { duration: 0.3, easing: [0.34, 1.56, 0.64, 1] }, + transition: { duration: motion.duration.slow / 1000, easing: easeBounce }, +}; + +/** Button press feedback - scale down slightly */ +export const pressDown: MotionOptions = { + initial: { scale: 1 }, + animate: { scale: 0.97 }, + transition: { duration: motion.duration.instant / 1000 }, }; + +/** Modal backdrop fade animation */ +export const modalBackdrop: MotionOptions = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { duration: motion.duration.normal / 1000 }, +}; + +/** Modal content scale + fade animation */ +export const modalContent: MotionOptions = { + initial: { opacity: 0, scale: 0.9 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0.9 }, + transition: { duration: motion.duration.slow / 1000, easing: easeOut }, +}; + +/** Stagger delay for list items */ +export const staggerDelay = (index: number, baseDelay = 0.05) => index * baseDelay; + +/** + * Create staggered animation options for a list of items + */ +export const createStaggeredList = ( + count: number, + baseAnimation: MotionOptions = fadeInUp, + staggerMs = 50, +): MotionOptions[] => { + return Array.from( + { length: count }, + (_, index) => ({ + ...baseAnimation, + transition: { ...baseAnimation.transition, delay: (index * staggerMs) / 1000 }, + }), + ); +}; + +/** CSS class names for keyframe animations */ +export const cssAnimations = { + pulse: "animate-pulse", + shimmer: "animate-shimmer", + breathe: "animate-breathe", +} as const; diff --git a/web/src/lib/tests/animations.test.ts b/web/src/lib/tests/animations.test.ts new file mode 100644 index 0000000..8ff3db0 --- /dev/null +++ b/web/src/lib/tests/animations.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + bounceIn, + cardFlip, + createStaggeredList, + cssAnimations, + easeBounce, + easeOut, + fadeIn, + fadeInUp, + fadeOut, + fadeOutDown, + modalBackdrop, + modalContent, + pressDown, + scaleIn, + scaleInBounce, + scaleOut, + scaleOutFast, + slideInLeft, + slideInRight, + slideInUp, + slideOutLeft, + slideOutRight, + springConfig, + staggerDelay, +} from "../animations"; + +describe("animations", () => { + describe("config", () => { + it("exports spring config with stiffness and damping", () => { + expect(springConfig.stiffness).toBe(300); + expect(springConfig.damping).toBe(24); + }); + + it("exports easing curves as tuples", () => { + expect(easeOut).toHaveLength(4); + expect(easeBounce).toHaveLength(4); + }); + }); + + describe("entrance animations", () => { + it.each([ + ["fadeIn", fadeIn], + ["fadeInUp", fadeInUp], + ["slideInRight", slideInRight], + ["slideInLeft", slideInLeft], + ["slideInUp", slideInUp], + ["scaleIn", scaleIn], + ["scaleInBounce", scaleInBounce], + ])("%s has required properties", (_, preset) => { + expect(preset).toHaveProperty("initial"); + expect(preset).toHaveProperty("animate"); + expect(preset).toHaveProperty("transition"); + }); + + it("fadeInUp starts from y: 20", () => { + expect(fadeInUp.initial).toEqual({ opacity: 0, y: 20 }); + expect(fadeInUp.animate).toEqual({ opacity: 1, y: 0 }); + }); + + it("slideInLeft starts from x: -20", () => { + expect(slideInLeft.initial).toEqual({ opacity: 0, x: -20 }); + }); + }); + + describe("exit animations", () => { + it.each([ + ["fadeOut", fadeOut], + ["fadeOutDown", fadeOutDown], + ["scaleOut", scaleOut], + ["scaleOutFast", scaleOutFast], + ["slideOutLeft", slideOutLeft], + ["slideOutRight", slideOutRight], + ])("%s has required properties", (_, preset) => { + expect(preset).toHaveProperty("initial"); + expect(preset).toHaveProperty("animate"); + expect(preset).toHaveProperty("transition"); + }); + + it("fadeOutDown animates to y: 20", () => { + expect(fadeOutDown.animate).toEqual({ opacity: 0, y: 20 }); + }); + + it("slideOutRight animates to x: 100", () => { + expect(slideOutRight.animate).toEqual({ opacity: 0, x: 100 }); + }); + }); + + describe("interactive animations", () => { + it("cardFlip rotates 180 degrees", () => { + expect(cardFlip.animate).toEqual({ rotateY: 180 }); + }); + + it("bounceIn uses bounce easing", () => { + expect(bounceIn.transition?.easing).toEqual(easeBounce); + }); + + it("pressDown scales to 0.97", () => { + expect(pressDown.animate).toEqual({ scale: 0.97 }); + }); + }); + + describe("modal animations", () => { + it("modalBackdrop has exit property", () => { + expect(modalBackdrop).toHaveProperty("exit"); + expect(modalBackdrop.exit).toEqual({ opacity: 0 }); + }); + + it("modalContent has scale and fade", () => { + expect(modalContent.initial).toEqual({ opacity: 0, scale: 0.9 }); + expect(modalContent.animate).toEqual({ opacity: 1, scale: 1 }); + expect(modalContent.exit).toEqual({ opacity: 0, scale: 0.9 }); + }); + }); + + describe("staggerDelay", () => { + it("calculates delay based on index", () => { + expect(staggerDelay(0)).toBe(0); + expect(staggerDelay(1)).toBe(0.05); + expect(staggerDelay(5)).toBe(0.25); + }); + + it("accepts custom base delay", () => { + expect(staggerDelay(2, 0.1)).toBe(0.2); + expect(staggerDelay(3, 0.02)).toBe(0.06); + }); + }); + + describe("createStaggeredList", () => { + it("creates array of motion options with staggered delays", () => { + const list = createStaggeredList(3); + + expect(list).toHaveLength(3); + expect(list[0].transition?.delay).toBe(0); + expect(list[1].transition?.delay).toBe(0.05); + expect(list[2].transition?.delay).toBe(0.1); + }); + + it("uses provided base animation", () => { + const list = createStaggeredList(2, fadeIn); + + expect(list[0].initial).toEqual(fadeIn.initial); + expect(list[0].animate).toEqual(fadeIn.animate); + }); + + it("accepts custom stagger duration", () => { + const list = createStaggeredList(3, fadeInUp, 100); + + expect(list[0].transition?.delay).toBe(0); + expect(list[1].transition?.delay).toBe(0.1); + expect(list[2].transition?.delay).toBe(0.2); + }); + }); + + describe("cssAnimations", () => { + it("exports CSS class names for keyframe animations", () => { + expect(cssAnimations.pulse).toBe("animate-pulse"); + expect(cssAnimations.shimmer).toBe("animate-shimmer"); + expect(cssAnimations.breathe).toBe("animate-breathe"); + }); + }); +});