# Anime.js Animation Integration — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Replace the current mixed CSS keyframes / inline-style animation system with Anime.js v4 for all card motion — pickup morph, drag tracking, spring snap-back, dismissals, bucket grid rise, and badge pop. **Architecture:** A new `useCardAnimation` composable owns all Anime.js calls imperatively against DOM refs. Vue reactive state (`isHeld`, `deltaX`, `deltaY`, `dismissType`) is unchanged. `cardStyle` computed and `dismissClass` computed are deleted; Anime.js writes to the element directly. **Tech Stack:** Anime.js v4 (`animejs`), Vue 3 Composition API, `@vue/test-utils` + Vitest for tests. --- ## Task 1: Install Anime.js **Files:** - Modify: `web/package.json` **Step 1: Install the package** ```bash cd /Library/Development/CircuitForge/avocet/web npm install animejs ``` **Step 2: Verify the import resolves** Create a throwaway check — open `web/src/main.ts` briefly and confirm: ```ts import { animate, spring } from 'animejs' ``` resolves without error in the editor (TypeScript types ship with animejs v4). Remove the import immediately after verifying — do not commit it. **Step 3: Commit** ```bash cd /Library/Development/CircuitForge/avocet/web git add package.json package-lock.json git commit -m "feat(avocet): add animejs v4 dependency" ``` --- ## Task 2: Create `useCardAnimation` composable **Files:** - Create: `web/src/composables/useCardAnimation.ts` - Create: `web/src/composables/useCardAnimation.test.ts` **Background — Anime.js v4 transform model:** Anime.js v4 tracks `x`, `y`, `scale`, `rotate`, etc. as separate transform components internally. Use `utils.set(el, props)` for instant (no-animation) property updates — this keeps the internal cache consistent. Never mix direct `el.style.transform = "..."` with Anime.js on the same element, or the cache desyncs. **Step 1: Write the failing tests** `web/src/composables/useCardAnimation.test.ts`: ```ts import { ref, nextTick } from 'vue' import { describe, it, expect, vi, beforeEach } from 'vitest' // Mock animejs before importing the composable vi.mock('animejs', () => ({ animate: vi.fn(), spring: vi.fn(() => 'mock-spring'), utils: { set: vi.fn() }, })) import { useCardAnimation } from './useCardAnimation' import { animate, utils } from 'animejs' const mockAnimate = animate as ReturnType const mockSet = utils.set as ReturnType function makeEl() { return document.createElement('div') } describe('useCardAnimation', () => { beforeEach(() => { vi.clearAllMocks() }) it('pickup() calls animate with ball shape', () => { const el = makeEl() const cardEl = ref(el) const motion = { rich: ref(true) } const { pickup } = useCardAnimation(cardEl, motion) pickup() expect(mockAnimate).toHaveBeenCalledWith( el, expect.objectContaining({ scale: 0.55, borderRadius: '50%' }), expect.anything(), ) }) it('pickup() is a no-op when motion.rich is false', () => { const el = makeEl() const cardEl = ref(el) const motion = { rich: ref(false) } const { pickup } = useCardAnimation(cardEl, motion) pickup() expect(mockAnimate).not.toHaveBeenCalled() }) it('setDragPosition() calls utils.set with translated coords', () => { const el = makeEl() const cardEl = ref(el) const motion = { rich: ref(true) } const { setDragPosition } = useCardAnimation(cardEl, motion) setDragPosition(50, 30) expect(mockSet).toHaveBeenCalledWith(el, expect.objectContaining({ x: 50, y: -50 })) // y = deltaY - 80 = 30 - 80 = -50 }) it('snapBack() calls animate returning to card shape', () => { const el = makeEl() const cardEl = ref(el) const motion = { rich: ref(true) } const { snapBack } = useCardAnimation(cardEl, motion) snapBack() expect(mockAnimate).toHaveBeenCalledWith( el, expect.objectContaining({ x: 0, y: 0, scale: 1 }), expect.anything(), ) }) it('animateDismiss("label") calls animate', () => { const el = makeEl() const cardEl = ref(el) const motion = { rich: ref(true) } const { animateDismiss } = useCardAnimation(cardEl, motion) animateDismiss('label') expect(mockAnimate).toHaveBeenCalled() }) it('animateDismiss("discard") calls animate', () => { const el = makeEl() const cardEl = ref(el) const motion = { rich: ref(true) } const { animateDismiss } = useCardAnimation(cardEl, motion) animateDismiss('discard') expect(mockAnimate).toHaveBeenCalled() }) it('animateDismiss("skip") calls animate', () => { const el = makeEl() const cardEl = ref(el) const motion = { rich: ref(true) } const { animateDismiss } = useCardAnimation(cardEl, motion) animateDismiss('skip') expect(mockAnimate).toHaveBeenCalled() }) it('animateDismiss is a no-op when motion.rich is false', () => { const el = makeEl() const cardEl = ref(el) const motion = { rich: ref(false) } const { animateDismiss } = useCardAnimation(cardEl, motion) animateDismiss('label') expect(mockAnimate).not.toHaveBeenCalled() }) }) ``` **Step 2: Run tests to confirm they fail** ```bash cd /Library/Development/CircuitForge/avocet/web npm test -- useCardAnimation ``` Expected: FAIL — "Cannot find module './useCardAnimation'" **Step 3: Implement the composable** `web/src/composables/useCardAnimation.ts`: ```ts import { type Ref } from 'vue' import { animate, spring, utils } from 'animejs' const BALL_SCALE = 0.55 const BALL_RADIUS = '50%' const CARD_RADIUS = '1rem' const PICKUP_Y_OFFSET = 80 // px above finger const PICKUP_DURATION = 200 // NOTE: animejs v4 — spring() takes an object, not positional args const SNAP_SPRING = spring({ mass: 1, stiffness: 80, damping: 10 }) interface Motion { rich: Ref } export function useCardAnimation( cardEl: Ref, motion: Motion, ) { function pickup() { if (!motion.rich.value || !cardEl.value) return // NOTE: animejs v4 — animate() is 2-arg; timing options merge into the params object animate(cardEl.value, { scale: BALL_SCALE, borderRadius: BALL_RADIUS, y: -PICKUP_Y_OFFSET, duration: PICKUP_DURATION, ease: SNAP_SPRING, }) } function setDragPosition(dx: number, dy: number) { if (!cardEl.value) return utils.set(cardEl.value, { x: dx, y: dy - PICKUP_Y_OFFSET }) } function snapBack() { if (!motion.rich.value || !cardEl.value) return // No duration — spring physics determines settling time animate(cardEl.value, { x: 0, y: 0, scale: 1, borderRadius: CARD_RADIUS, ease: SNAP_SPRING, }) } function animateDismiss(type: 'label' | 'skip' | 'discard') { if (!motion.rich.value || !cardEl.value) return const el = cardEl.value if (type === 'label') { animate(el, { y: '-120%', scale: 0.85, opacity: 0, duration: 280, ease: 'out(3)' }) } else if (type === 'discard') { // Two-step: crumple then shrink (keyframes array in params object) animate(el, { keyframes: [ { scale: 0.95, rotate: 2, filter: 'brightness(0.6) sepia(1) hue-rotate(-20deg)', duration: 140 }, { scale: 0, rotate: 8, opacity: 0, duration: 210 }, ]) } else if (type === 'skip') { animate(el, { x: '110%', rotate: 5, opacity: 0 }, { duration: 260, ease: 'out(2)' }) } } return { pickup, setDragPosition, snapBack, animateDismiss } } ``` **Step 4: Run tests — expect pass** ```bash npm test -- useCardAnimation ``` Expected: All 8 tests PASS. **Step 5: Commit** ```bash git add web/src/composables/useCardAnimation.ts web/src/composables/useCardAnimation.test.ts git commit -m "feat(avocet): add useCardAnimation composable with Anime.js" ``` --- ## Task 3: Wire `useCardAnimation` into `EmailCardStack.vue` **Files:** - Modify: `web/src/components/EmailCardStack.vue` - Modify: `web/src/components/EmailCardStack.test.ts` **What changes:** - Remove `cardStyle` computed and `:style="cardStyle"` binding - Remove `dismissClass` computed and `:class="[dismissClass, ...]"` binding (keep `is-held`) - Remove `deltaX`, `deltaY` reactive refs (position now owned by Anime.js) - Call `pickup()` in `onPointerDown`, `setDragPosition()` in `onPointerMove`, `snapBack()` in `onPointerUp` (no-target path) - Watch `props.dismissType` and call `animateDismiss()` - Remove CSS `@keyframes fileAway`, `crumple`, `slideUnder` and their `.dismiss-*` rule blocks from `