Files
ReadMeABook/documentation/features/bookdate-animations.md
T
kikootwo ac2ad8aac2 Add BookDate card stack animations and thumbnail caching
Implements pure CSS card stack animations for BookDate recommendations, including smooth exit and advance transitions. Adds local caching of library cover thumbnails during scans, updates database schema and API to serve cached covers, and enhances BookDate to support 'favorites' scope with a book picker modal. Updates admin settings validation logic for Prowlarr, improves indexer state management, and documents new features and backend changes.
2026-01-28 11:41:59 -05:00

7.9 KiB

BookDate Card Stack Animations

Status: Implemented | Pure CSS card stack with smooth exit/advance animations

Overview

Visual card stack (3 visible cards) with GPU-accelerated animations. Top card swipes away, remaining cards advance forward smoothly.

Key Components

CardStack.tsx

  • Location: src/components/bookdate/CardStack.tsx
  • Purpose: Orchestrates 3-card stack rendering and animation lifecycle
  • Props:
    • recommendations: any[] - Full recommendations array
    • currentIndex: number - Index of current top card
    • onSwipe: (action, markedAsKnown?) => void - Swipe handler (API call)
    • onSwipeComplete: () => void - Called after animations finish

Animation Flow:

  1. User swipes → handleSwipeStart triggered
  2. Exit animation starts (400ms) → API call
  3. Exit completes → visibleCards array updated to exclude exited card
  4. Advance animation starts (350ms) → Cards move from positions 1,2,3 to 0,1,2
  5. Advance completes → onSwipeComplete called → currentIndex incremented

State Management:

  • isExiting: boolean - Exit animation in progress
  • exitDirection: 'left' | 'right' | 'up' - Which exit animation to play
  • isAdvancing: boolean - Advance animation in progress

Visible Cards Logic:

  • Normal: Shows cards at [currentIndex, currentIndex+1, currentIndex+2] with stackPosition 0, 1, 2
  • During Advance: Shows cards at [currentIndex+1, currentIndex+2, currentIndex+3] with stackPosition 0, 1, 2 and fromPosition 1, 2, 3
    • This excludes the exited card and prevents snapping
    • fromPosition determines which advance animation to apply

RecommendationCard.tsx Updates

  • New Props:
    • stackPosition?: number - 0=top, 1=middle, 2=bottom (default: 0)
    • isAnimating?: boolean - Disables gestures during animations (default: false)
    • isDraggable?: boolean - Only top card accepts input (default: true)

Behavior:

  • Swipe handlers disabled when !isDraggable || isAnimating
  • Desktop buttons hidden when stackPosition !== 0
  • Drag offset only updates for top card

page.tsx Updates

  • Changed: Import CardStack instead of RecommendationCard
  • Added: handleSwipeComplete() callback
  • Modified: handleSwipe() no longer increments currentIndex (delegated to handleSwipeComplete)

CSS Animations

Location: src/app/globals.css

Exit Animations (400ms, ease-in-out)

.animate-exit-left   /* translate(-150%, 50px) rotate(-25deg) */
.animate-exit-right  /* translate(150%, 50px) rotate(25deg) */
.animate-exit-up     /* translate(0, -120%) scale(0.8) */

Advance Animations (350ms, bounce easing)

.animate-advance-to-top     /* scale(0.95→1.0), translateY(-12px→0) */
.animate-advance-to-middle  /* scale(0.90→0.95), translateY(-24px→-12px) */
.animate-enter              /* scale(0.85→0.90), translateY(-36px→-24px) */

Stack Position Classes (Static)

.card-stack-position-0  /* z-50, scale(1.0), translateY(0), opacity(1.0) */
.card-stack-position-1  /* z-40, scale(0.95), translateY(-12px), opacity(0.95) */
.card-stack-position-2  /* z-30, scale(0.90), translateY(-24px), opacity(0.90) */

Performance Optimizations

.card-stack-container  /* perspective: 1000px, preserve-3d */
.card-stack-item       /* will-change: transform, opacity */

Animation Timing

Phase Duration Easing Description
Exit 400ms ease-in-out Top card swipes away
Advance 350ms cubic-bezier(0.34, 1.56, 0.64, 1) Cards move forward (slight bounce)
Total 750ms - Full swipe cycle

Staggering: Advance animations start after exit completes (sequential, not overlapping).

Edge Cases Handled

Rapid Swipes

  • Problem: User swipes again during animation
  • Solution: isAnimating flag blocks gestures and button clicks
  • Code: CardStack.handleSwipeStart() checks isExiting || isAdvancing

<3 Cards Remaining

  • Problem: Not enough cards to fill stack
  • Solution: CardStack renders only available cards (0-3)
  • Behavior: Stack naturally shrinks as user approaches end

Undo Functionality

  • Problem: Undo reverses card to top, but animations may be in progress
  • Solution: useEffect in CardStack resets animation states when currentIndex changes externally
  • Code: useEffect(() => { setIsExiting(false); ... }, [currentIndex])

Empty State

  • Problem: No cards to render
  • Solution: CardStack returns null, page.tsx shows empty state UI
  • Trigger: currentIndex >= recommendations.length

Mobile Performance

Target: 60fps on mobile devices

Optimizations:

  • GPU-accelerated properties only (transform, opacity, not left/top/width)
  • will-change: transform, opacity hints browser to optimize
  • backface-visibility: hidden prevents rendering artifacts
  • No layout shift (cards positioned absolutely)

Tested On:

  • Chrome (desktop + mobile)
  • Safari (iOS + macOS)
  • Firefox

User Experience

Visual Hierarchy:

  • Top card: Full size, interactive, clear visuals
  • Card 2: 95% scale, 95% opacity, visible but de-emphasized
  • Card 3: 90% scale, 90% opacity, subtle depth cue

Swipe Directions:

  • Left: Reject (red overlay, rotate left)
  • Right: Request (green overlay, confirm toast, rotate right)
  • Up: Dismiss (blue overlay, shrink up)

Toast Confirmation:

  • Right swipe triggers toast modal
  • User chooses: "Request" or "Mark as Liked"
  • Card exit animation plays after choice

Integration with Existing Features

Settings Widget

  • Status: No changes required
  • Behavior: Opens over card stack, gestures disabled when modal open

Undo Button

  • Status: Works with stack
  • Behavior: Triggers loadRecommendations() → Cards re-render from API
  • Animation: No special animation (instant reset to fresh state)

Progress Indicator

  • Status: No changes required
  • Display: Shows currentIndex + 1 / recommendations.length

Desktop Buttons

  • Status: Updated to disable during animations
  • Code: disabled={isAnimating} in RecommendationCard.tsx:217-234

Troubleshooting

Cards Not Stacking

  • Check: CSS classes applied correctly in CardStack.tsx
  • Verify: card-stack-position-{0,1,2} classes present in globals.css
  • Debug: Inspect z-index values (50, 40, 30)

Cards Snapping Instead of Animating

  • Root Cause: Exited card still in visibleCards array during advance phase
  • Fix: During isAdvancing, visibleCards starts from currentIndex + 1 (skips exited card)
  • Verify: Check CardStack.tsx:71-97 - advance branch excludes card at currentIndex

Animations Not Playing

  • Check: Exit/advance animation classes applied during state transitions
  • Verify: animationClass computed correctly based on card.fromPosition during advance
  • Debug: Console log isExiting, exitDirection, isAdvancing, visibleCards

Gestures Not Working

  • Check: isDraggable prop passed correctly (only true for top card)
  • Verify: isAnimating not stuck in true state
  • Debug: Check CardStack animation state machine

Performance Issues

  • Check: Animations targeting only transform and opacity
  • Verify: will-change applied to .card-stack-item
  • Test: Chrome DevTools Performance tab (60fps target)
  • Documentation: documentation/features/bookdate-prd.md (BookDate feature spec)
  • Components: src/components/bookdate/LoadingScreen.tsx, SettingsWidget.tsx
  • API: src/app/api/bookdate/swipe/route.ts

Future Enhancements (Not Implemented)

  • Preload Card 4: Load image for 4th card in stack (currently loads on-demand)
  • Spring Physics: Replace CSS easing with spring animations for more natural feel
  • Haptic Feedback: Vibrate on swipe (requires Web Vibration API)
  • Parallax Effect: Cards shift slightly on device tilt (requires DeviceOrientation API)