From 144a9b29b31fb5c76e998412fa0fdb47db8fd90b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 8 Mar 2026 06:38:16 -0700 Subject: [PATCH] docs: add Anime.js animation implementation plan --- docs/plans/2026-03-08-anime-animation-plan.md | 572 ++++++++++++++++++ 1 file changed, 572 insertions(+) create mode 100644 docs/plans/2026-03-08-anime-animation-plan.md diff --git a/docs/plans/2026-03-08-anime-animation-plan.md b/docs/plans/2026-03-08-anime-animation-plan.md new file mode 100644 index 0000000..bafed0f --- /dev/null +++ b/docs/plans/2026-03-08-anime-animation-plan.md @@ -0,0 +1,572 @@ +# 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 +const SNAP_SPRING = spring(1, 80, 10) + +interface Motion { rich: Ref } + +export function useCardAnimation( + cardEl: Ref, + motion: Motion, +) { + function pickup() { + if (!motion.rich.value || !cardEl.value) return + 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 + 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 + animate(el, [ + { 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 `