diff --git a/apps/blocks/README.md b/apps/blocks/README.md index 5b89ae9..852ed8d 100644 --- a/apps/blocks/README.md +++ b/apps/blocks/README.md @@ -18,6 +18,9 @@ pnpm --filter blocks test pnpm --filter blocks build:pages ``` +The development command builds the local package outputs before Next.js starts, +so it also works from a fresh worktree. + `src/lib/base-primitives.ts`, `src/lib/billing-blocks.ts`, and `src/lib/feature-packs.ts` are the documentation catalogs. `pnpm gen:check` regenerates (or checks) generated UI demo source used by the docs app. diff --git a/apps/blocks/package.json b/apps/blocks/package.json index 2a0855f..c35a9c5 100644 --- a/apps/blocks/package.json +++ b/apps/blocks/package.json @@ -8,7 +8,7 @@ "build:registry": "pnpm --workspace-root build:registry", "check:generated": "pnpm gen:check", "check:selections": "tsx scripts/check-mutation-selections.ts", - "predev": "pnpm gen:check", + "predev": "pnpm --workspace-root build:packages && pnpm gen:check", "prebuild": "pnpm gen:check", "dev": "next dev --turbopack --port 3005", "build": "next build", diff --git a/packages/ui/registry.json b/packages/ui/registry.json index f62d8f5..8a7311f 100644 --- a/packages/ui/registry.json +++ b/packages/ui/registry.json @@ -7,7 +7,7 @@ "name": "constructive-theme", "type": "registry:theme", "title": "Constructive Theme", - "description": "Constructive design system theme \u2014 OKLCH colors, z-index layers, shadow utilities, and animations.", + "description": "Constructive design system theme — OKLCH colors, z-index layers, shadow utilities, and animations.", "cssVars": { "theme": { "--color-background": "var(--background)", @@ -102,14 +102,14 @@ "radius": "0.5rem", "shadow-border": "0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 1px -0.5px rgb(35 35 35 / 0.08), 0 1px 2px -1px rgb(35 35 35 / 0.04), 0 2px 4px -1px rgb(35 35 35 / 0.03)", "shadow-border-hover": "0 0 0 1px oklch(0 0 0 / 0.08), 0 1px 2px -1px rgb(35 35 35 / 0.08), 0 2px 4px -2px rgb(35 35 35 / 0.06), 0 4px 8px -2px rgb(35 35 35 / 0.04), 0 8px 16px -4px rgb(35 35 35 / 0.03)", - "shadow-2xs": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-xs": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-sm": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-md": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-lg": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-xl": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-2xl": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0)", + "shadow-2xs": "0 1px rgb(0 0 0 / 0.05)", + "shadow-xs": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "shadow-sm": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "shadow": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "shadow-md": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + "shadow-lg": "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", + "shadow-xl": "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", + "shadow-2xl": "0 25px 50px -12px rgb(0 0 0 / 0.25)", "z-layer-portal-root": "9999", "z-layer-floating": "1000", "z-layer-modal-backdrop": "2000", @@ -162,14 +162,14 @@ "radius": "0.5rem", "shadow-border": "0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 1px -0.5px rgb(0 0 0 / 0.25), 0 1px 2px -1px rgb(0 0 0 / 0.15), 0 2px 4px -1px rgb(0 0 0 / 0.10)", "shadow-border-hover": "0 0 0 1px oklch(1 0 0 / 0.13), 0 1px 2px -1px rgb(0 0 0 / 0.30), 0 2px 4px -2px rgb(0 0 0 / 0.20), 0 4px 8px -2px rgb(0 0 0 / 0.15), 0 8px 16px -4px rgb(0 0 0 / 0.10)", - "shadow-2xs": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-xs": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-sm": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-md": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-lg": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-xl": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0)", - "shadow-2xl": "0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0)", + "shadow-2xs": "0 1px rgb(0 0 0 / 0.2)", + "shadow-xs": "0 1px 2px 0 rgb(0 0 0 / 0.25)", + "shadow-sm": "0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.25)", + "shadow": "0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.25)", + "shadow-md": "0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.3)", + "shadow-lg": "0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)", + "shadow-xl": "0 20px 25px -5px rgb(0 0 0 / 0.45), 0 8px 10px -6px rgb(0 0 0 / 0.35)", + "shadow-2xl": "0 25px 50px -12px rgb(0 0 0 / 0.55)", "z-layer-portal-root": "9999", "z-layer-floating": "1000", "z-layer-modal-backdrop": "2000", diff --git a/packages/ui/src/components/stack/index.ts b/packages/ui/src/components/stack/index.ts index 48bfd13..a941383 100644 --- a/packages/ui/src/components/stack/index.ts +++ b/packages/ui/src/components/stack/index.ts @@ -49,6 +49,8 @@ export { export { DEFAULT_ANIMATION_CONFIG, createCardVariants, + createEnterTransition, + createExitTransition, createOffsetTransition, backdropVariants, snapBackSpring, diff --git a/packages/ui/src/components/stack/stack-animations.ts b/packages/ui/src/components/stack/stack-animations.ts index d6d8f28..f6264ec 100644 --- a/packages/ui/src/components/stack/stack-animations.ts +++ b/packages/ui/src/components/stack/stack-animations.ts @@ -62,6 +62,39 @@ export function createOffsetTransition(_config: AnimationConfig = {}): Transitio }; } +/** + * Enter transition for card slide-in (x: 100% → offset). + * Tween + snappy ease — distinct from offset springs used after settle. + */ +export function createEnterTransition(config: AnimationConfig = {}): Transition { + const { duration, enterEase } = { + ...DEFAULT_ANIMATION_CONFIG, + ...config, + }; + + return { + type: 'tween', + duration, + ease: enterEase as [number, number, number, number], + }; +} + +/** + * Exit transition for card slide-out (offset → x: 100%). + */ +export function createExitTransition(config: AnimationConfig = {}): Transition { + const { duration, exitEase } = { + ...DEFAULT_ANIMATION_CONFIG, + ...config, + }; + + return { + type: 'tween', + duration, + ease: exitEase as [number, number, number, number], + }; +} + // ============================================================================= // Backdrop Variants // ============================================================================= diff --git a/packages/ui/src/components/stack/stack-card.tsx b/packages/ui/src/components/stack/stack-card.tsx index 5d5ba29..a8c16fa 100644 --- a/packages/ui/src/components/stack/stack-card.tsx +++ b/packages/ui/src/components/stack/stack-card.tsx @@ -6,7 +6,13 @@ import { motion, useReducedMotion } from 'motion/react'; import { cn } from '../../lib/utils'; -import { createOffsetTransition, peekHoverSpring, peekDragSpring, lastCardExitTransition } from './stack-animations'; +import { + createEnterTransition, + createExitTransition, + createOffsetTransition, + peekHoverSpring, + peekDragSpring, +} from './stack-animations'; import { CardReadyProvider, useCardInjectedProps, useStackContext } from './stack-context'; import { StackHeader } from './stack-header'; import type { AnimationConfig, CardSpec, CardStackApi, GestureConfig, PeekGestureConfig } from './stack.types'; @@ -16,6 +22,7 @@ import type { StackGestureBind } from './use-stack-gestures'; /** Default hover expansion in pixels */ const DEFAULT_HOVER_EXPANSION = 48; +const reducedMotionTransition = { duration: 0 } as const; export type StackCardProps = { /** The card specification */ @@ -131,8 +138,10 @@ function StackCardInner({ // Card width const cardWidth = isMobile ? '100%' : normalizeWidth(card.width, config.defaultWidth); - // Animation transition + // Animation transitions: enter/exit tweens, offset spring after settle const offsetTransition = createOffsetTransition(animation); + const enterTransition = createEnterTransition(animation); + const exitTransition = createExitTransition(animation); // Close handler const handleClose = useCallback(() => { @@ -216,18 +225,27 @@ function StackCardInner({ // Last card fades out as it slides for smoother exit when backdrop is also fading const exitOpacity = prefersReducedMotion ? 0 : (isLastCard ? 0 : 1); - // Choose transition based on interaction state - let activeTransition = offsetTransition; - if (peekGestures.isDragging || peekDragOffset > 0) { - activeTransition = peekDragSpring; - } else if (isPeekHovered) { - activeTransition = peekHoverSpring; + // Enter uses snappy tween until first settle; then spring for offset/peek. + // Peek/drag always win while interacting. + let activeTransition = prefersReducedMotion + ? reducedMotionTransition + : isAnimationComplete + ? offsetTransition + : enterTransition; + if (!prefersReducedMotion) { + if (peekGestures.isDragging || peekDragOffset > 0) { + activeTransition = peekDragSpring; + } else if (isPeekHovered) { + activeTransition = peekHoverSpring; + } } - // Build exit animation object - last card uses tween for smooth fade, others use spring - const exitAnimation = isLastCard && !prefersReducedMotion - ? { x: exitX, opacity: exitOpacity, transition: lastCardExitTransition } - : { x: exitX, opacity: exitOpacity }; + // Exit always honors the configured tween; reduced motion settles immediately. + const exitAnimation = { + x: exitX, + opacity: exitOpacity, + transition: prefersReducedMotion ? reducedMotionTransition : exitTransition, + }; return ( - + {/* initial must stay true: viewport unmounts when empty, so first open remounts + AnimatePresence with cards already present. initial={false} skips enter. */} + {/* Backdrop - only when cards exist and first card wants it */} {shouldShowBackdrop && hasCards && renderBackdropNode(resolvedBackdrop.config)} diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index fa5906f..b3eaf7a 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -51,14 +51,14 @@ --radius: 0.5rem; --shadow-border: 0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 1px -0.5px rgb(35 35 35 / 0.08), 0 1px 2px -1px rgb(35 35 35 / 0.04), 0 2px 4px -1px rgb(35 35 35 / 0.03); --shadow-border-hover: 0 0 0 1px oklch(0 0 0 / 0.08), 0 1px 2px -1px rgb(35 35 35 / 0.08), 0 2px 4px -2px rgb(35 35 35 / 0.06), 0 4px 8px -2px rgb(35 35 35 / 0.04), 0 8px 16px -4px rgb(35 35 35 / 0.03); - --shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0); + --shadow-2xs: 0 1px rgb(0 0 0 / 0.05); + --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); --z-layer-portal-root: 9999; --z-layer-floating: 1000; --z-layer-modal-backdrop: 2000; @@ -112,14 +112,14 @@ --radius: 0.5rem; --shadow-border: 0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 1px -0.5px rgb(0 0 0 / 0.25), 0 1px 2px -1px rgb(0 0 0 / 0.15), 0 2px 4px -1px rgb(0 0 0 / 0.10); --shadow-border-hover: 0 0 0 1px oklch(1 0 0 / 0.13), 0 1px 2px -1px rgb(0 0 0 / 0.30), 0 2px 4px -2px rgb(0 0 0 / 0.20), 0 4px 8px -2px rgb(0 0 0 / 0.15), 0 8px 16px -4px rgb(0 0 0 / 0.10); - --shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0); - --shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0); + --shadow-2xs: 0 1px rgb(0 0 0 / 0.2); + --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.25); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.25); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.25); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.3); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.45), 0 8px 10px -6px rgb(0 0 0 / 0.35); + --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.55); } @theme inline { diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index c90770d..503981d 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -24,17 +24,27 @@ const sharedTypographyAndShape = { radius: '0.5rem', } as const; -const transparentShadow = '0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0)'; +/** Elevation shadows — were fully transparent (`/ 0`), so shadow-* utilities did nothing. */ +const lightElevationShadows = { + 'shadow-2xs': '0 1px rgb(0 0 0 / 0.05)', + 'shadow-xs': '0 1px 2px 0 rgb(0 0 0 / 0.05)', + 'shadow-sm': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + shadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + 'shadow-md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + 'shadow-lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', + 'shadow-xl': '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)', + 'shadow-2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)', +} as const; -const sharedShadows = { - 'shadow-2xs': transparentShadow, - 'shadow-xs': transparentShadow, - 'shadow-sm': `${transparentShadow}, 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0)`, - shadow: `${transparentShadow}, 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0)`, - 'shadow-md': `${transparentShadow}, 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0)`, - 'shadow-lg': `${transparentShadow}, 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0)`, - 'shadow-xl': `${transparentShadow}, 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0)`, - 'shadow-2xl': transparentShadow, +const darkElevationShadows = { + 'shadow-2xs': '0 1px rgb(0 0 0 / 0.2)', + 'shadow-xs': '0 1px 2px 0 rgb(0 0 0 / 0.25)', + 'shadow-sm': '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.25)', + shadow: '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.25)', + 'shadow-md': '0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.3)', + 'shadow-lg': '0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)', + 'shadow-xl': '0 20px 25px -5px rgb(0 0 0 / 0.45), 0 8px 10px -6px rgb(0 0 0 / 0.35)', + 'shadow-2xl': '0 25px 50px -12px rgb(0 0 0 / 0.55)', } as const; export const constructiveTheme = { @@ -85,7 +95,7 @@ export const constructiveTheme = { '0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 1px -0.5px rgb(35 35 35 / 0.08), 0 1px 2px -1px rgb(35 35 35 / 0.04), 0 2px 4px -1px rgb(35 35 35 / 0.03)', 'shadow-border-hover': '0 0 0 1px oklch(0 0 0 / 0.08), 0 1px 2px -1px rgb(35 35 35 / 0.08), 0 2px 4px -2px rgb(35 35 35 / 0.06), 0 4px 8px -2px rgb(35 35 35 / 0.04), 0 8px 16px -4px rgb(35 35 35 / 0.03)', - ...sharedShadows, + ...lightElevationShadows, }, dark: { background: 'oklch(0.21 0.006 285.885)', @@ -131,7 +141,7 @@ export const constructiveTheme = { '0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 1px -0.5px rgb(0 0 0 / 0.25), 0 1px 2px -1px rgb(0 0 0 / 0.15), 0 2px 4px -1px rgb(0 0 0 / 0.10)', 'shadow-border-hover': '0 0 0 1px oklch(1 0 0 / 0.13), 0 1px 2px -1px rgb(0 0 0 / 0.30), 0 2px 4px -2px rgb(0 0 0 / 0.20), 0 4px 8px -2px rgb(0 0 0 / 0.15), 0 8px 16px -4px rgb(0 0 0 / 0.10)', - ...sharedShadows, + ...darkElevationShadows, }, tailwind: { '--color-background': 'var(--background)', diff --git a/packages/ui/test/stack-animation.test.tsx b/packages/ui/test/stack-animation.test.tsx new file mode 100644 index 0000000..3ddb8b1 --- /dev/null +++ b/packages/ui/test/stack-animation.test.tsx @@ -0,0 +1,153 @@ +import * as React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const motionState = vi.hoisted(() => ({ + prefersReducedMotion: false, + cardProps: [] as Array>, +})); + +vi.mock('motion/react', async () => { + const React = await vi.importActual('react'); + + return { + motion: { + div: React.forwardRef>(function MotionDiv(props, ref) { + const { + initial, + animate, + exit, + transition, + onAnimationComplete, + ...elementProps + } = props; + + motionState.cardProps.push({ + initial, + animate, + exit, + transition, + onAnimationComplete, + }); + + return
; + }), + }, + useReducedMotion: () => motionState.prefersReducedMotion, + }; +}); + +import { StackCard } from '../src/components/stack/stack-card'; +import { CardStackProvider } from '../src/components/stack/stack-context'; +import type { AnimationConfig, CardSpec } from '../src/components/stack/stack.types'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const activeRoots = new Set(); + +function TestCard() { + return null; +} + +const card: CardSpec = { + id: 'settings', + title: 'Settings', + Component: TestCard, +}; + +async function renderCard(animation?: AnimationConfig) { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + activeRoots.add(root); + + await act(async () => { + root.render( + + + , + ); + }); +} + +function latestMotionProps() { + const props = motionState.cardProps.at(-1); + expect(props).toBeDefined(); + return props!; +} + +beforeEach(() => { + motionState.prefersReducedMotion = false; + motionState.cardProps = []; +}); + +afterEach(async () => { + for (const root of activeRoots) { + await act(async () => root.unmount()); + } + activeRoots.clear(); + document.body.replaceChildren(); +}); + +describe('StackCard motion', () => { + it('uses configured enter and exit tweens before settling onto the offset spring', async () => { + const enterEase = [0.1, 0.2, 0.3, 1]; + const exitEase = [0.4, 0.3, 0.2, 1]; + + await renderCard({ duration: 0.12, enterEase, exitEase }); + + let props = latestMotionProps(); + expect(props.initial).toEqual({ x: '100%', opacity: 1 }); + expect(props.animate).toEqual({ x: 0, opacity: 1 }); + expect(props.transition).toEqual({ + type: 'tween', + duration: 0.12, + ease: enterEase, + }); + expect(props.exit).toEqual({ + x: '100%', + opacity: 0, + transition: { + type: 'tween', + duration: 0.12, + ease: exitEase, + }, + }); + + await act(async () => { + (props.onAnimationComplete as (() => void) | undefined)?.(); + }); + + props = latestMotionProps(); + expect(props.transition).toEqual({ + type: 'spring', + stiffness: 300, + damping: 30, + mass: 0.8, + }); + }); + + it('settles entry and exit immediately when reduced motion is requested', async () => { + motionState.prefersReducedMotion = true; + + await renderCard({ duration: 0.2 }); + + const props = latestMotionProps(); + expect(props.initial).toEqual({ x: 0, opacity: 1 }); + expect(props.animate).toEqual({ x: 0, opacity: 1 }); + expect(props.transition).toEqual({ duration: 0 }); + expect(props.exit).toEqual({ + x: 0, + opacity: 0, + transition: { duration: 0 }, + }); + }); +});