diff --git a/web/src/discover/DeckCard.tsx b/web/src/discover/DeckCard.tsx
index 7d3df64..6c2cb2d 100644
--- a/web/src/discover/DeckCard.tsx
+++ b/web/src/discover/DeckCard.tsx
@@ -17,7 +17,7 @@ export type DeckCardProps = {
// DeckCard is one Side quest in the deck. Only the top card is read out and
// reachable; the cards behind it are the stack's edge, kept rendered so they
// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a
-// decision shows them.
+// decision shows them, and its shade until Pass folds it (see motion.ts).
export function DeckCard({ quest, depth }: DeckCardProps) {
const top = depth === 0
const titleId = `side-quest-${quest.id}`
@@ -38,12 +38,13 @@ export function DeckCard({ quest, depth }: DeckCardProps) {
Why this? {quest.reason}
-
+
Try it!
Pass
+
)
}
diff --git a/web/src/discover/DiscoverPage.module.css b/web/src/discover/DiscoverPage.module.css
index bcae6e1..723e734 100644
--- a/web/src/discover/DiscoverPage.module.css
+++ b/web/src/discover/DiscoverPage.module.css
@@ -206,6 +206,16 @@
color: var(--color-neutral-900);
}
+/* The shade Pass darkens the card with as it folds. */
+.shade {
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: var(--color-text);
+ opacity: 0;
+ pointer-events: none;
+}
+
/* Pass and Try it: IconButtons with their 11 px labels shown. */
.actions {
display: flex;
diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx
index a147537..7df51d0 100644
--- a/web/src/discover/DiscoverPage.test.tsx
+++ b/web/src/discover/DiscoverPage.test.tsx
@@ -1,11 +1,13 @@
import { QueryClientProvider } from '@tanstack/react-query'
-import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
+import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
+import { mockViewport } from '../test/matchMedia.ts'
import { DiscoverPage } from './DiscoverPage.tsx'
const DECK = '/api/v1/suggestions/deck'
@@ -73,10 +75,27 @@ function jobPolls(fetchMock: Mock) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === '/api/v1/jobs/j1').length
}
+// Every animation finishes as it starts unless a test stubs animate again.
+beforeEach(() => {
+ mockViewport(390)
+ stubAnimate({ autoFinish: true })
+})
+
afterEach(() => {
vi.unstubAllGlobals()
+ Reflect.deleteProperty(Element.prototype, 'animate')
+ Reflect.deleteProperty(window, 'matchMedia')
})
+// finishRunning finishes the animations running now, then lets what they
+// start begin.
+async function finishRunning(animations: FakeAnimation[]) {
+ await act(async () => {
+ animations.filter((a) => a.playState === 'running').forEach((a) => a.finish())
+ await Promise.resolve()
+ })
+}
+
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Discover page.
async function renderPage() {
@@ -485,4 +504,168 @@ describe('DiscoverPage', () => {
expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
})
+
+ describe('motion', () => {
+ const accepted = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'accepted' }, replenish_job_id: null })
+ const declined = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: null })
+ const on = (animations: FakeAnimation[], target: Element) => animations.filter((a) => a.target === target)
+
+ it('Try it bumps the button, pops the stamp, wiggles and throws the card, then records it, bumps the Quest log pill and promotes the deck', async () => {
+ const server = stubServer([BOWLS, TIKKA, ORZO], {
+ 'POST /api/v1/suggestions/s1/accept': () => {
+ server.deck = [TIKKA, ORZO]
+ return accepted(BOWLS)
+ },
+ })
+ const animations = stubAnimate({ autoFinish: false })
+ await renderPage()
+ const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
+ const pill = await screen.findByText('Quest log · 6')
+ const tryButton = screen.getByRole('button', { name: 'Try it' })
+
+ fireEvent.click(tryButton)
+ expect(animations.map((a) => a.target)).toEqual([tryButton, within(card).getByText('Try it!'), card])
+ const [bumped, pop, wiggle] = animations
+ expect(bumped!.keyframes.map((k) => k.transform)).toEqual(['scale(1)', 'scale(.88)', 'scale(1.06)', 'scale(1)'])
+ expect(bumped!.options).toEqual({ duration: 300, easing: 'ease-out' })
+ expect(pop!.options).toEqual({ duration: 220, easing: 'cubic-bezier(.34,1.4,.64,1)', fill: 'forwards' })
+ expect(wiggle!.keyframes.map((k) => k.transform)).toEqual(['rotate(0deg)', 'rotate(-4deg)', 'rotate(4deg)', 'rotate(-3deg)', 'rotate(2deg)', 'rotate(0deg)'])
+ expect(wiggle!.options).toEqual({ duration: 420, easing: 'ease-in-out' })
+
+ // Input is locked while the card leaves, and nothing is sent yet.
+ await waitFor(() => expect(tryButton).toBeDisabled())
+ expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled()
+ fireEvent.keyDown(window, { key: 'ArrowLeft' })
+ fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
+ await finishRunning(animations)
+ const fly = animations[3]!
+ expect(fly.target).toBe(card)
+ expect(fly.keyframes).toEqual([
+ { transform: 'translate(0px,0px) rotate(0deg)' },
+ { transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 },
+ { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 },
+ ])
+ expect(fly.options).toEqual({ duration: 480, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
+ expect(animations).toHaveLength(4)
+ expect(sent(server.fetchMock)).toEqual([])
+
+ // Once it is gone: the decision, the pill bump, then the deck moves up.
+ await finishRunning(animations)
+ expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
+ expect(on(animations, pill).map((a) => a.options)).toEqual([{ duration: 300, easing: 'ease-out' }])
+ expect(on(animations, pill)[0]!.keyframes.map((k) => k.transform)).toEqual(['scale(1)', 'scale(.88)', 'scale(1.06)', 'scale(1)'])
+ const next = await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
+ const behind = next.parentElement!.querySelectorAll('article')[1]!
+ expect(on(animations, next).map((a) => [a.keyframes, a.options])).toEqual([
+ [[{ transform: 'translateY(8px) scale(0.95)' }, { transform: 'translateY(0px) scale(1)' }], { duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' }],
+ ])
+ expect(on(animations, behind).map((a) => a.keyframes)).toEqual([
+ [{ transform: 'translateY(16px) scale(0.9)' }, { transform: 'translateY(8px) scale(0.95)' }],
+ ])
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled())
+ expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
+ })
+
+ it('Pass bumps the button and folds the card under its shade, then records it and promotes the deck', async () => {
+ const server = stubServer([BOWLS, TIKKA], {
+ 'POST /api/v1/suggestions/s1/decline': () => {
+ server.deck = [TIKKA]
+ return declined(BOWLS)
+ },
+ })
+ const animations = stubAnimate({ autoFinish: false })
+ const { container } = await renderPage()
+ const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
+ const pill = await screen.findByText('Quest log · 6')
+ const passButton = screen.getByRole('button', { name: 'Pass' })
+ const shade = card.querySelector('[data-part="shade"]')!
+ expect(declaredStyle(shade).background).toBe('var(--color-text)')
+ expect(getComputedStyle(shade).opacity).toBe('0')
+
+ fireEvent.keyDown(window, { key: 'ArrowLeft' })
+ expect(animations.map((a) => a.target)).toEqual([passButton, shade, card])
+ const [bumped, darken, fold] = animations
+ expect(bumped!.options).toEqual({ duration: 300, easing: 'ease-out' })
+ expect(darken!.keyframes).toEqual([{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }])
+ expect(darken!.options).toEqual({ duration: 720, fill: 'forwards' })
+ expect(fold!.keyframes).toEqual([
+ { transformOrigin: '0% 50%', transform: 'translate(0px,0px) rotate(0deg) rotateY(0deg)', easing: 'cubic-bezier(.45,0,.85,.55)' },
+ { transformOrigin: '0% 50%', transform: 'translate(0px,0px) rotate(0deg) rotateY(-80deg)', offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' },
+ { transformOrigin: '0% 50%', transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 },
+ ])
+ expect(fold!.options).toEqual({ duration: 720, fill: 'forwards' })
+ await waitFor(() => expect(passButton).toBeDisabled())
+ expect(sent(server.fetchMock)).toEqual([])
+
+ await finishRunning(animations)
+ expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])
+ const next = await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
+ expect(on(animations, next).map((a) => a.options)).toEqual([{ duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' }])
+ expect(on(animations, pill)).toEqual([])
+ expect(container.querySelectorAll('article')).toHaveLength(1)
+ await waitFor(() => expect(passButton).toBeEnabled())
+ })
+
+ it.each([
+ ['Try it', 'ArrowRight', 'accept'],
+ ['Pass', 'ArrowLeft', 'decline'],
+ ] as const)('%s with reduced motion is a 200 ms fade, with no bump, wiggle, fold or promotion', async (_, key, action) => {
+ mockViewport(390, { reducedMotion: true })
+ const server = stubServer([BOWLS, TIKKA, ORZO], {
+ [`POST /api/v1/suggestions/s1/${action}`]: () => {
+ server.deck = [TIKKA, ORZO]
+ return action === 'accept' ? accepted(BOWLS) : declined(BOWLS)
+ },
+ })
+ const animations = stubAnimate({ autoFinish: false })
+ await renderPage()
+ const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
+ await screen.findByText('Quest log · 6')
+
+ fireEvent.keyDown(window, { key })
+ expect(animations).toHaveLength(1)
+ expect(animations[0]!.target).toBe(card)
+ expect(animations[0]!.keyframes).toEqual([{ opacity: 1 }, { opacity: 0 }])
+ expect(animations[0]!.options).toEqual({ duration: 200, fill: 'forwards' })
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled())
+ expect(sent(server.fetchMock)).toEqual([])
+
+ await finishRunning(animations)
+ expect(sent(server.fetchMock)).toEqual([`POST /api/v1/suggestions/s1/${action}`])
+ await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled())
+ expect(animations).toHaveLength(1)
+ })
+
+ it('puts the card back when its decision is refused, and unlocks', async () => {
+ stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => errorResponse(500, 'internal') })
+ const animations = stubAnimate({ autoFinish: false })
+ await renderPage()
+ await screen.findByRole('article', { name: 'Korean beef bowls' })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
+ await finishRunning(animations)
+ await finishRunning(animations)
+ await screen.findByRole('alert')
+ // The stamp, wiggle and throw are undone; the bump had ended.
+ expect(animations.slice(1, 4).map((a) => a.playState)).toEqual(['idle', 'idle', 'idle'])
+ expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()
+ })
+
+ it('stops the exit and sends nothing when the page goes away mid-exit', async () => {
+ const server = stubServer([BOWLS, TIKKA])
+ const animations = stubAnimate({ autoFinish: false })
+ const { unmount } = await renderPage()
+ await screen.findByRole('article', { name: 'Korean beef bowls' })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
+ unmount()
+ await act(async () => {
+ await Promise.resolve()
+ })
+ expect(animations.slice(1).map((a) => a.playState)).toEqual(['idle', 'idle'])
+ expect(sent(server.fetchMock)).toEqual([])
+ })
+ })
})
diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx
index 77a4337..ad5d15c 100644
--- a/web/src/discover/DiscoverPage.tsx
+++ b/web/src/discover/DiscoverPage.tsx
@@ -1,6 +1,6 @@
import { useQueryClient } from '@tanstack/react-query'
import { Heart, X } from 'lucide-react'
-import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type Ref } from 'react'
import { useJob } from '../api/index.ts'
import { Card } from '../ui/Card.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
@@ -19,6 +19,7 @@ import {
type Choice,
} from './api.ts'
import { DeckCard, DECK_DEPTH } from './DeckCard.tsx'
+import { bump, fadeOut, isAbort, pass, prefersReducedMotion, promote, tryIt, type Exit } from './motion.ts'
import styles from './DiscoverPage.module.css'
// isEditable says whether a key press belongs to a field being typed in,
@@ -32,7 +33,8 @@ function isEditable(target: EventTarget | null): boolean {
// QuestLogPill is the "Quest log · N" pill. It shows and hides the names
// of the dinners in the Quest log; the Quest log sheet replaces this list.
-function QuestLogPill({ dinners }: { dinners: { id: string; name: string }[] }) {
+// ref is the visible pill, which Try it bumps.
+function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[]; ref: Ref }) {
const [open, setOpen] = useState(false)
const listId = useId()
return (
@@ -44,7 +46,7 @@ function QuestLogPill({ dinners }: { dinners: { id: string; name: string }[] })
aria-controls={open ? listId : undefined}
onClick={() => setOpen(!open)}
>
- Quest log · {dinners.length}
+ Quest log · {dinners.length}
{open && (
@@ -64,10 +66,12 @@ function QuestLogPill({ dinners }: { dinners: { id: string; name: string }[] })
}
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
-// at a time from the top card. Every decision — the buttons, the ← / →
-// keys, Retry — goes through submit, which does nothing while one is in
-// flight. The lock is a ref, set before the decision is sent, so a second
-// press before the next render cannot send another.
+// at a time from the top card. A decision from the buttons or the ← / → keys
+// throws the top card out (motion.ts) and sends the decision once it is
+// gone; Retry sends a refused one again. Input is locked from the press
+// until the decision settles: the lock is a ref, set before anything
+// starts, so a second press before the next render does nothing, and busy
+// mirrors it to disable the buttons.
export function DiscoverPage() {
const queryClient = useQueryClient()
const deck = useDeck()
@@ -77,7 +81,7 @@ export function DiscoverPage() {
const job = useJob(BATCH_JOB_KIND, startBatch.data?.job_id ?? null)
const top = deck.data?.[0]
- const busy = decision.isPending
+ const [busy, setBusy] = useState(false)
const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed'
const finding = startBatch.isPending || jobRunning
@@ -94,27 +98,90 @@ export function DiscoverPage() {
// batch's job, or an unreadable result.
const batchError = job.error ?? batch.error
+ const deckRef = useRef(null)
+ const passRef = useRef(null)
+ const tryRef = useRef(null)
+ const questLogRef = useRef(null)
+ // The top card's exit while it runs or its decision is unsettled, and the
+ // Side quest the last decision sent was for, until the deck moves past it.
+ const exit = useRef(null)
+ const leaving = useRef(null)
+
const { mutate: mutateDecision } = decision
const deciding = useRef(false)
- const submit = useCallback(
+ const lock = useCallback(() => {
+ if (deciding.current) return false
+ deciding.current = true
+ setBusy(true)
+ return true
+ }, [])
+ // send records a decision. A refused one puts the card back.
+ const send = useCallback(
(variables: { id: string; choice: Choice }) => {
- if (deciding.current) return
- deciding.current = true
+ leaving.current = variables.id
mutateDecision(variables, {
+ onError: () => {
+ exit.current?.cancel()
+ exit.current = null
+ leaving.current = null
+ },
onSettled: () => {
deciding.current = false
+ setBusy(false)
},
})
},
[mutateDecision],
)
+ const submit = useCallback(
+ (variables: { id: string; choice: Choice }) => {
+ if (lock()) send(variables)
+ },
+ [lock, send],
+ )
+ // decide bumps the button, throws the top card out — or, motion reduced,
+ // fades it — then sends the decision; Try it bumps the Quest log pill too.
const decide = useCallback(
(choice: Choice) => {
- if (top !== undefined) submit({ id: top.id, choice })
+ if (top === undefined || !lock()) return
+ const card = deckRef.current!.querySelector('article')!
+ const reduced = prefersReducedMotion()
+ let run: Exit
+ if (reduced) {
+ run = fadeOut(card)
+ } else {
+ bump((choice === 'try' ? tryRef : passRef).current!)
+ run = choice === 'try' ? tryIt(card) : pass(card)
+ }
+ exit.current = run
+ run.finished.then(
+ () => {
+ // The Quest log pill is missing while the Quest log fails to load.
+ if (choice === 'try' && !reduced && questLogRef.current !== null) bump(questLogRef.current)
+ send({ id: top.id, choice })
+ },
+ (error: unknown) => {
+ if (!isAbort(error)) throw error
+ },
+ )
},
- [top, submit],
+ [top, lock, send],
)
+ // Leaving the page stops an exit before its decision is sent.
+ useEffect(() => () => exit.current?.cancel(), [])
+
+ // Once the deck moves past the card a decision was sent for, the cards
+ // behind it move up a depth.
+ const topId = top?.id
+ useLayoutEffect(() => {
+ if (leaving.current === null || leaving.current === topId) return
+ leaving.current = null
+ exit.current = null
+ if (topId === undefined || prefersReducedMotion()) return
+ promote([...deckRef.current!.querySelectorAll(':scope > article')])
+ }, [topId])
+
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
@@ -170,16 +237,16 @@ export function DiscoverPage() {
} else {
body = (
<>
-
+
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
))}
- decide('pass')}>
+ decide('pass')}>
- decide('try')}>
+ decide('try')}>
@@ -199,7 +266,7 @@ export function DiscoverPage() {
Discover
Side quests
- {questLog.isSuccess && }
+ {questLog.isSuccess && }
{questLog.isError && (
void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}
diff --git a/web/src/discover/motion.test.ts b/web/src/discover/motion.test.ts
new file mode 100644
index 0000000..34baaf9
--- /dev/null
+++ b/web/src/discover/motion.test.ts
@@ -0,0 +1,176 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
+import { bump, fadeOut, isAbort, pass, promote, tryIt } from './motion.ts'
+
+let animations: FakeAnimation[]
+
+beforeEach(() => {
+ animations = stubAnimate({ autoFinish: false })
+})
+
+afterEach(() => {
+ Reflect.deleteProperty(Element.prototype, 'animate')
+})
+
+// card builds a deck card with the parts DeckCard marks.
+function card() {
+ const article = document.createElement('article')
+ article.innerHTML = ''
+ return {
+ article,
+ stamp: article.querySelector('[data-part="try-stamp"]')!,
+ shade: article.querySelector('[data-part="shade"]')!,
+ }
+}
+
+// settled says whether a promise has settled after pending callbacks run.
+async function settled(promise: Promise) {
+ let done = false
+ void promise.then(
+ () => (done = true),
+ () => (done = true),
+ )
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ return done
+}
+
+const FLY_OFF = { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 }
+
+describe('motion', () => {
+ it('bumps: scale 1 → .88 → 1.06 → 1, 300 ms ease-out', () => {
+ const button = document.createElement('button')
+ bump(button)
+ expect(animations).toHaveLength(1)
+ expect(animations[0]!.target).toBe(button)
+ expect(animations[0]!.keyframes).toEqual([
+ { transform: 'scale(1)' },
+ { transform: 'scale(.88)' },
+ { transform: 'scale(1.06)' },
+ { transform: 'scale(1)' },
+ ])
+ expect(animations[0]!.options).toEqual({ duration: 300, easing: 'ease-out' })
+ })
+
+ it('Try it pops the stamp and wiggles, then throws the card right', async () => {
+ const { article, stamp } = card()
+ const exit = tryIt(article)
+
+ const [pop, wiggle] = animations
+ expect(pop!.target).toBe(stamp)
+ expect(pop!.keyframes).toEqual([
+ { opacity: 0, transform: 'rotate(-12deg) scale(1.5)' },
+ { opacity: 1, transform: 'rotate(-12deg) scale(1)' },
+ ])
+ expect(pop!.options).toEqual({ duration: 220, easing: 'cubic-bezier(.34,1.4,.64,1)', fill: 'forwards' })
+ expect(wiggle!.target).toBe(article)
+ expect(wiggle!.keyframes).toEqual(['0deg', '-4deg', '4deg', '-3deg', '2deg', '0deg'].map((a) => ({ transform: `rotate(${a})` })))
+ expect(wiggle!.options).toEqual({ duration: 420, easing: 'ease-in-out' })
+ expect(animations).toHaveLength(2)
+
+ // The throw waits for the wiggle.
+ pop!.finish()
+ wiggle!.finish()
+ await expect.poll(() => animations.length).toBe(3)
+ const fly = animations[2]!
+ expect(fly.target).toBe(article)
+ expect(fly.keyframes).toEqual([
+ { transform: 'translate(0px,0px) rotate(0deg)' },
+ { transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 },
+ FLY_OFF,
+ ])
+ expect(fly.options).toEqual({ duration: 480, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
+
+ expect(await settled(exit.finished)).toBe(false)
+ fly.finish()
+ await expect(exit.finished).resolves.toBeUndefined()
+ })
+
+ it('Try it from a dragged transform skips the wiggle and throws in 340 ms', async () => {
+ const { article } = card()
+ const exit = tryIt(article, 'translate(120px,9px) rotate(6.7deg)')
+ await expect.poll(() => animations.length).toBe(2)
+ const fly = animations[1]!
+ expect(fly.target).toBe(article)
+ expect(fly.keyframes).toEqual([{ transform: 'translate(120px,9px) rotate(6.7deg)' }, FLY_OFF])
+ expect(fly.options).toEqual({ duration: 340, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
+ fly.finish()
+ await expect(exit.finished).resolves.toBeUndefined()
+ })
+
+ it('Pass folds the card over its left edge under a darkening shade', async () => {
+ const { article, shade } = card()
+ const exit = pass(article)
+
+ const [darken, fold] = animations
+ expect(animations).toHaveLength(2)
+ expect(darken!.target).toBe(shade)
+ expect(darken!.keyframes).toEqual([{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }])
+ expect(darken!.options).toEqual({ duration: 720, fill: 'forwards' })
+ expect(fold!.target).toBe(article)
+ expect(fold!.keyframes).toEqual([
+ { transformOrigin: '0% 50%', transform: 'translate(0px,0px) rotate(0deg) rotateY(0deg)', easing: 'cubic-bezier(.45,0,.85,.55)' },
+ {
+ transformOrigin: '0% 50%',
+ transform: 'translate(0px,0px) rotate(0deg) rotateY(-80deg)',
+ offset: 0.45,
+ easing: 'cubic-bezier(.4,0,.9,.6)',
+ },
+ { transformOrigin: '0% 50%', transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 },
+ ])
+ expect(fold!.options).toEqual({ duration: 720, fill: 'forwards' })
+
+ darken!.finish()
+ expect(await settled(exit.finished)).toBe(false)
+ fold!.finish()
+ await expect(exit.finished).resolves.toBeUndefined()
+ })
+
+ it('Pass from a dragged transform folds from there', () => {
+ const { article } = card()
+ pass(article, 'translate(-130px,6px) rotate(-7deg)')
+ const fold = animations[1]!
+ expect(fold.keyframes.map((k) => k.transform)).toEqual([
+ 'translate(-130px,6px) rotate(-7deg) rotateY(0deg)',
+ 'translate(-130px,6px) rotate(-7deg) rotateY(-80deg)',
+ 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)',
+ ])
+ })
+
+ it('fades out in 200 ms with reduced motion', async () => {
+ const { article } = card()
+ const exit = fadeOut(article)
+ expect(animations).toHaveLength(1)
+ expect(animations[0]!.target).toBe(article)
+ expect(animations[0]!.keyframes).toEqual([{ opacity: 1 }, { opacity: 0 }])
+ expect(animations[0]!.options).toEqual({ duration: 200, fill: 'forwards' })
+ animations[0]!.finish()
+ await expect(exit.finished).resolves.toBeUndefined()
+ })
+
+ it('promotes the top two cards from the depth behind, 300 ms', () => {
+ const cards = [card().article, card().article, card().article]
+ promote(cards)
+ expect(animations.map((a) => a.target)).toEqual([cards[0], cards[1]])
+ expect(animations.map((a) => a.keyframes)).toEqual([
+ [{ transform: 'translateY(8px) scale(0.95)' }, { transform: 'translateY(0px) scale(1)' }],
+ [{ transform: 'translateY(16px) scale(0.9)' }, { transform: 'translateY(8px) scale(0.95)' }],
+ ])
+ for (const a of animations) expect(a.options).toEqual({ duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' })
+ })
+
+ it('cancel stops an exit and never starts its next step', async () => {
+ const { article } = card()
+ const exit = tryIt(article)
+ animations[1]!.finish()
+ exit.cancel()
+ const error: unknown = await exit.finished.catch((e: unknown) => e)
+ expect(isAbort(error)).toBe(true)
+ expect(animations).toHaveLength(2)
+ expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle'])
+ })
+
+ it('fails fast on a card without its parts', () => {
+ expect(() => tryIt(document.createElement('article'))).toThrow('deck card has no try-stamp')
+ expect(() => pass(document.createElement('article'))).toThrow('deck card has no shade')
+ })
+})
diff --git a/web/src/discover/motion.ts b/web/src/discover/motion.ts
new file mode 100644
index 0000000..a144ae0
--- /dev/null
+++ b/web/src/discover/motion.ts
@@ -0,0 +1,142 @@
+// The deck's motion, from the handoff's Motion §2 (Try it), §3 (Pass),
+// §5 (deck advance) and §6 (reduced motion), with the Web Animations API.
+// The exits take the card's starting transform so the swipe gesture can
+// throw a dragged card from where it was let go.
+
+const SPRING = 'cubic-bezier(.34,1.4,.64,1)'
+const THROW = 'cubic-bezier(.55,0,.8,.4)'
+const SOFT = 'cubic-bezier(.2,.8,.2,1)'
+const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
+
+// REST is the top card's transform when nothing moves it, written as the
+// translate + rotate a drag gives it so the exits interpolate function by
+// function.
+export const REST = 'translate(0px,0px) rotate(0deg)'
+
+// DEPTH_TRANSFORM is each deck depth's transform, as the stylesheet places it.
+export const DEPTH_TRANSFORM = ['translateY(0px) scale(1)', 'translateY(8px) scale(0.95)', 'translateY(16px) scale(0.9)'] as const
+
+const FLY_OFF = 'translate(150%,-60px) rotate(28deg)'
+const PASS_ORIGIN = '0% 50%'
+
+// An Exit is a card leaving the deck: finished resolves once it is off
+// screen and rejects with an AbortError if cancel stops it first. cancel
+// also undoes a finished exit, putting the card back.
+export type Exit = {
+ finished: Promise
+ cancel: () => void
+}
+
+export function prefersReducedMotion(): boolean {
+ return window.matchMedia(REDUCED_MOTION_QUERY).matches
+}
+
+// isAbort says whether an exit's finished rejected because it was canceled.
+export function isAbort(error: unknown): boolean {
+ return error instanceof DOMException && error.name === 'AbortError'
+}
+
+// bump is the press bump of the Try it and Pass buttons and the Quest log pill.
+export function bump(element: Element): Animation {
+ return element.animate(
+ [{ transform: 'scale(1)' }, { transform: 'scale(.88)' }, { transform: 'scale(1.06)' }, { transform: 'scale(1)' }],
+ { duration: 300, easing: 'ease-out' },
+ )
+}
+
+// tryIt pops the "Try it!" stamp and throws the card off to the right. From
+// rest it wiggles first and winds up before the throw; from a dragged
+// transform it skips both and throws faster.
+export function tryIt(card: HTMLElement, from?: string): Exit {
+ const exit = recorder()
+ exit.run(part(card, 'try-stamp'), [
+ { opacity: 0, transform: 'rotate(-12deg) scale(1.5)' },
+ { opacity: 1, transform: 'rotate(-12deg) scale(1)' },
+ ], { duration: 220, easing: SPRING, fill: 'forwards' })
+ const wiggled =
+ from === undefined
+ ? exit.run(card, [
+ { transform: 'rotate(0deg)' },
+ { transform: 'rotate(-4deg)' },
+ { transform: 'rotate(4deg)' },
+ { transform: 'rotate(-3deg)' },
+ { transform: 'rotate(2deg)' },
+ { transform: 'rotate(0deg)' },
+ ], { duration: 420, easing: 'ease-in-out' }).finished
+ : Promise.resolve()
+ const finished = wiggled.then(() =>
+ from === undefined
+ ? exit.run(card, [
+ { transform: REST },
+ { transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 },
+ { transform: FLY_OFF, opacity: 0 },
+ ], { duration: 480, easing: THROW, fill: 'forwards' }).finished
+ : exit.run(card, [{ transform: from }, { transform: FLY_OFF, opacity: 0 }], { duration: 340, easing: THROW, fill: 'forwards' })
+ .finished,
+ )
+ return exit.done(finished)
+}
+
+// pass folds the card over its left edge like a page, darkening it, then
+// drops it off to the left. A dragged card folds from where it was let go.
+export function pass(card: HTMLElement, from: string = REST): Exit {
+ const exit = recorder()
+ exit.run(part(card, 'shade'), [{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }], {
+ duration: 720,
+ fill: 'forwards',
+ })
+ const finished = exit.run(card, [
+ { transformOrigin: PASS_ORIGIN, transform: `${from} rotateY(0deg)`, easing: 'cubic-bezier(.45,0,.85,.55)' },
+ { transformOrigin: PASS_ORIGIN, transform: `${from} rotateY(-80deg)`, offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' },
+ { transformOrigin: PASS_ORIGIN, transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 },
+ ], { duration: 720, fill: 'forwards' }).finished
+ return exit.done(finished)
+}
+
+// fadeOut is both exits under prefers-reduced-motion: reduce.
+export function fadeOut(card: HTMLElement): Exit {
+ const exit = recorder()
+ return exit.done(exit.run(card, [{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }).finished)
+}
+
+// promote moves the cards left after an exit, top first, from the depth
+// behind to their own.
+export function promote(cards: HTMLElement[]): Animation[] {
+ return cards.slice(0, DEPTH_TRANSFORM.length - 1).map((card, depth) =>
+ card.animate([{ transform: DEPTH_TRANSFORM[depth + 1] }, { transform: DEPTH_TRANSFORM[depth] }], {
+ duration: 300,
+ easing: SOFT,
+ }),
+ )
+}
+
+// part is the card's element marked data-part="name", which DeckCard renders.
+function part(card: HTMLElement, name: string): HTMLElement {
+ const element = card.querySelector(`[data-part="${name}"]`)
+ if (element === null) throw new Error(`deck card has no ${name}`)
+ return element
+}
+
+// recorder keeps every animation an exit starts, so cancel can stop them all.
+// A step that would start after cancel aborts instead.
+function recorder() {
+ const animations: Animation[] = []
+ let canceled = false
+ return {
+ run(element: Element, keyframes: Keyframe[], options: KeyframeAnimationOptions): Animation {
+ if (canceled) throw new DOMException('The exit was canceled.', 'AbortError')
+ const animation = element.animate(keyframes, options)
+ animations.push(animation)
+ return animation
+ },
+ done(finished: Promise): Exit {
+ return {
+ finished: finished.then(() => undefined),
+ cancel: () => {
+ canceled = true
+ animations.forEach((animation) => animation.cancel())
+ },
+ }
+ },
+ }
+}
diff --git a/web/src/test/animate.ts b/web/src/test/animate.ts
new file mode 100644
index 0000000..e3e5f08
--- /dev/null
+++ b/web/src/test/animate.ts
@@ -0,0 +1,52 @@
+import { vi } from 'vitest'
+
+// A FakeAnimation is one Element.animate call: its target and arguments, and
+// a finished promise the test settles with finish (or cancel, which rejects
+// it with an AbortError while it runs).
+export type FakeAnimation = {
+ target: Element
+ keyframes: Keyframe[]
+ options: KeyframeAnimationOptions
+ finished: Promise
+ finish: () => void
+ cancel: () => void
+ playState: 'running' | 'finished' | 'idle'
+}
+
+// stubAnimate replaces Element.prototype.animate (jsdom has no Web Animations
+// API) and returns every animation it starts, in order. With autoFinish each
+// animation finishes as it starts; otherwise the test finishes it. Remove the
+// stub with Reflect.deleteProperty(Element.prototype, 'animate').
+export function stubAnimate({ autoFinish }: { autoFinish: boolean }): FakeAnimation[] {
+ const animations: FakeAnimation[] = []
+ Element.prototype.animate = vi.fn(function (this: Element, keyframes, options) {
+ let resolve!: (animation: FakeAnimation) => void
+ let reject!: (error: DOMException) => void
+ const animation: FakeAnimation = {
+ target: this,
+ keyframes: keyframes as Keyframe[],
+ options: options as KeyframeAnimationOptions,
+ finished: new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ }),
+ playState: 'running',
+ finish() {
+ if (animation.playState !== 'running') throw new Error('finish on an animation that is not running')
+ animation.playState = 'finished'
+ resolve(animation)
+ },
+ cancel() {
+ if (animation.playState === 'running') reject(new DOMException('The animation was canceled.', 'AbortError'))
+ animation.playState = 'idle'
+ },
+ }
+ // Browsers mark finished as handled, so a canceled animation nobody waits
+ // on is not an unhandled rejection.
+ animation.finished.catch(() => {})
+ animations.push(animation)
+ if (autoFinish) animation.finish()
+ return animation as unknown as Animation
+ }) as Element['animate']
+ return animations
+}