Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 aaa771e23b Let one pointer drag the Discover top card at a time
CI / ci (push) Successful in 2m53s
CI / ci (pull_request) Successful in 2m38s
A second primary pointer pressing mid-drag replaced the drag record, so
neither release cleared the first pointer's transform and the card stayed
displaced. A press while a drag is in progress is now ignored, and a new
top card ends a drag left on the old one so it cannot block the next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:12:39 -04:00
dev_agentandClaude Opus 5.5 f3483b1d4d Decide a swipe from the release point and stop stray snap-backs
CI / ci (push) Successful in 2m57s
CI / ci (pull_request) Successful in 2m39s
- Track the pointerup position before choosing Try it, Pass or snap-back,
  so the outcome follows where the card was let go (SQ-107-01).
- Keep the snap-back animation and cancel it when a new drag or a
  decision starts (SQ-107-02).
- Treat losing pointer capture before the release as a cancel, springing
  the card back and resetting its stamps (SQ-107-03).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:05:08 -04:00
dev_agentandClaude Opus 5.5 78b0c7b84c Add the swipe gesture to the Discover deck's top card
CI / ci (push) Successful in 2m53s
CI / ci (pull_request) Successful in 2m39s
The top card follows a pointer drag per handoff Motion §4: captured with
setPointerCapture under touch-action none, it moves to translate(dx, dy*0.3)
rotate(dx/18 deg) with the "Try it!" and "Pass" stamps at clamp(±dx/100).
A release past 110 px right throws it from where it was let go (340 ms, no
wiggle); past 110 px left folds it from the drag transform; anything shorter
springs back over 420 ms and resets the stamps. Swipe decisions go through
the same decide handler as the buttons and ← / → keys, so the busy lock and
reduced-motion fade apply unchanged; only the top card is draggable and
input is ignored while busy.

motion.ts gains dragTransform, follow, unfollow and snapBack beside the
exits; DeckCard marks its Pass stamp as a part.

Maestro task 54c12e9e-5f36-4595-babb-e54107bb473b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:54:58 -04:00
orchestrator-agent c0ea1b9323 Try it and Pass animations (Discover deck) (#106)
CI / ci (push) Successful in 2m41s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 01:44:21 -04:00
7 changed files with 1355 additions and 28 deletions
+17 -5
View File
@@ -1,3 +1,4 @@
import type { DOMAttributes } from 'react'
import { DIFFICULTIES, type Difficulty } from '../dinners/api.ts'
import { Photo } from '../ui/Photo.tsx'
import type { SideQuest } from './api.ts'
@@ -8,25 +9,35 @@ export const DECK_DEPTH = [0, 1, 2] as const
const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// SwipeHandlers are the pointer handlers that drag the top card.
export type SwipeHandlers = Pick<
DOMAttributes<HTMLElement>,
'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' | 'onLostPointerCapture'
>
export type DeckCardProps = {
quest: SideQuest
// 0 is the top card; 1 and 2 stack behind it.
depth: number
// swipe makes the card draggable; only the top card gets it.
swipe?: SwipeHandlers
}
// 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.
export function DeckCard({ quest, depth }: DeckCardProps) {
// decision or a drag shows them, and its shade until Pass folds it (see
// motion.ts).
export function DeckCard({ quest, depth, swipe }: DeckCardProps) {
const top = depth === 0
const titleId = `side-quest-${quest.id}`
return (
<article
className={`${styles.card} ${styles[`depth${depth}`]}`}
className={`${styles.card} ${styles[`depth${depth}`]}${swipe === undefined ? '' : ` ${styles.draggable}`}`}
aria-labelledby={top ? titleId : undefined}
aria-hidden={top ? undefined : true}
inert={!top}
{...swipe}
>
<Photo id={quest.photo_id} name={quest.name} radius="24px" inset="10px" sizes="400px" className={styles.photo} />
<div className={styles.content}>
@@ -38,12 +49,13 @@ export function DeckCard({ quest, depth }: DeckCardProps) {
<strong>Why this?</strong> {quest.reason}
</p>
</div>
<span className={`${styles.stamp} ${styles.tryStamp}`} aria-hidden="true">
<span className={`${styles.stamp} ${styles.tryStamp}`} data-part="try-stamp" aria-hidden="true">
Try it!
</span>
<span className={`${styles.stamp} ${styles.passStamp}`} aria-hidden="true">
<span className={`${styles.stamp} ${styles.passStamp}`} data-part="pass-stamp" aria-hidden="true">
Pass
</span>
<span className={styles.shade} data-part="shade" />
</article>
)
}
+16
View File
@@ -104,6 +104,12 @@
box-shadow: var(--shadow-lg);
}
/* The top card follows a drag instead of scrolling the page. */
.draggable {
touch-action: none;
cursor: grab;
}
.depth1 {
z-index: 2;
transform: translateY(8px) scale(0.95);
@@ -206,6 +212,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;
+682 -2
View File
@@ -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<typeof fetch>) {
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,665 @@ 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([])
})
})
describe('swipe', () => {
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)
const SPRING = 'cubic-bezier(.34,1.4,.64,1)'
let capture: Mock<(pointerId: number) => void>
// jsdom has no pointer capture.
beforeEach(() => {
capture = vi.fn()
Element.prototype.setPointerCapture = capture
})
afterEach(() => {
Reflect.deleteProperty(Element.prototype, 'setPointerCapture')
})
// press, drag and release put a pointer down on the card at (200, 300),
// move it by dx, dy, and let go where it is, or dx, dy from the press.
let pointer = { dx: 0, dy: 0 }
function press(card: Element) {
pointer = { dx: 0, dy: 0 }
fireEvent.pointerDown(card, { pointerId: 7, isPrimary: true, button: 0, clientX: 200, clientY: 300 })
}
function drag(card: Element, dx: number, dy: number) {
pointer = { dx, dy }
fireEvent.pointerMove(card, { pointerId: 7, isPrimary: true, clientX: 200 + dx, clientY: 300 + dy })
}
function release(card: Element, dx = pointer.dx, dy = pointer.dy) {
fireEvent.pointerUp(card, { pointerId: 7, isPrimary: true, button: 0, clientX: 200 + dx, clientY: 300 + dy })
}
const stamps = (card: HTMLElement) => [within(card).getByText('Try it!'), within(card).getByText('Pass')]
it('makes the top card follow the pointer, captured, with the stamps showing how far it has gone', async () => {
stubServer([BOWLS, TIKKA])
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
const [tryStamp, passStamp] = stamps(card)
expect(declaredStyle(card).getPropertyValue('touch-action')).toBe('none')
press(card)
expect(capture).toHaveBeenCalledExactlyOnceWith(7)
expect(capture.mock.contexts[0]).toBe(card)
drag(card, 54, 20)
expect(card.style.transform).toBe('translate(54px,6px) rotate(3deg)')
expect(tryStamp!.style.opacity).toBe('0.54')
expect(passStamp!.style.opacity).toBe('0')
drag(card, -36, -10)
expect(card.style.transform).toBe('translate(-36px,-3px) rotate(-2deg)')
expect(tryStamp!.style.opacity).toBe('0')
expect(passStamp!.style.opacity).toBe('0.36')
drag(card, -180, 0)
expect(tryStamp!.style.opacity).toBe('0')
expect(passStamp!.style.opacity).toBe('1')
expect(animations).toEqual([])
})
it('Try it on a release past 110 px right: throws the card from where it was let go, in 340 ms with no wiggle, then records it', 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' })
press(card)
drag(card, 126, 30)
release(card)
// No button bump, no wiggle: the stamp pops and the card flies.
expect(animations.map((a) => a.target)).toEqual([within(card).getByText('Try it!')])
await waitFor(() => expect(tryButton).toBeDisabled())
expect(animations).toHaveLength(2)
const fly = animations[1]!
expect(fly.target).toBe(card)
expect(fly.keyframes).toEqual([
{ transform: 'translate(126px,9px) rotate(7deg)' },
{ transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 },
])
expect(fly.options).toEqual({ duration: 340, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
expect(sent(server.fetchMock)).toEqual([])
await finishRunning(animations)
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
expect(on(animations, pill)).toHaveLength(1)
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)' }])
await waitFor(() => expect(tryButton).toBeEnabled())
expect(next.style.transform).toBe('')
})
it('Pass on a release past 110 px left: folds the card from where it was let go, then records it', async () => {
const server = stubServer([BOWLS, TIKKA], {
'POST /api/v1/suggestions/s1/decline': () => {
server.deck = [TIKKA]
return declined(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 shade = card.querySelector('[data-part="shade"]')!
press(card)
drag(card, -144, 20)
release(card)
expect(animations.map((a) => a.target)).toEqual([shade, card])
expect(animations[1]!.keyframes).toEqual([
{ transformOrigin: '0% 50%', transform: 'translate(-144px,6px) rotate(-8deg) rotateY(0deg)', easing: 'cubic-bezier(.45,0,.85,.55)' },
{ transformOrigin: '0% 50%', transform: 'translate(-144px,6px) rotate(-8deg) 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(animations[1]!.options).toEqual({ duration: 720, fill: 'forwards' })
await waitFor(() => expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled())
expect(sent(server.fetchMock)).toEqual([])
await finishRunning(animations)
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
expect(on(animations, pill)).toEqual([])
})
it.each([
['right', 110],
['left', -110],
])('springs the card back from a release of 110 px %s, resetting the stamps and deciding nothing', async (_, dx) => {
const server = stubServer([BOWLS, TIKKA])
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
const [tryStamp, passStamp] = stamps(card)
press(card)
drag(card, dx, 0)
expect([tryStamp!.style.opacity, passStamp!.style.opacity]).toContain('1')
release(card)
expect(animations).toHaveLength(1)
expect(animations[0]!.target).toBe(card)
expect(animations[0]!.keyframes).toEqual([{ transform: `translate(${dx}px,0px) rotate(${dx / 18}deg)` }, { transform: 'translate(0px,0px) rotate(0deg)' }])
expect(animations[0]!.options).toEqual({ duration: 420, easing: SPRING })
expect(card.style.transform).toBe('')
expect(tryStamp!.style.opacity).toBe('')
expect(passStamp!.style.opacity).toBe('')
expect(getComputedStyle(tryStamp!).opacity).toBe('0')
expect(getComputedStyle(passStamp!).opacity).toBe('0')
// A later move with the pointer up does nothing.
drag(card, 200, 0)
expect(card.style.transform).toBe('')
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()
expect(sent(server.fetchMock)).toEqual([])
})
it.each([
['Try it', 60, 130, 'accept'],
['Pass', -60, -130, 'decline'],
] as const)('%s from where a release past 110 px lets go, though the last move was short of it', async (_, moved, at, action) => {
const server = stubServer([BOWLS, TIKKA], {
[`POST /api/v1/suggestions/s1/${action}`]: () => {
server.deck = [TIKKA]
return action === 'accept' ? accepted(BOWLS) : declined(BOWLS)
},
})
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
press(card)
drag(card, moved, 0)
release(card, at, 20)
const from = `translate(${at}px,6px) rotate(${at / 18}deg)`
await waitFor(() => expect(on(animations, card)).toHaveLength(1))
expect(on(animations, card).map((a) => a.keyframes[0]!.transform)).toEqual([action === 'accept' ? from : `${from} rotateY(0deg)`])
await finishRunning(animations)
expect(sent(server.fetchMock)).toEqual([`POST /api/v1/suggestions/s1/${action}`])
})
it.each([
['right', 150, 40],
['left', -150, -40],
])('springs the card back from where a release short of 110 px %s lets go, though the last move was past it', async (_, moved, at) => {
const server = stubServer([BOWLS, TIKKA])
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
press(card)
drag(card, moved, 0)
release(card, at, 0)
expect(animations.map((a) => [a.target, a.keyframes, a.options])).toEqual([
[card, [{ transform: `translate(${at}px,0px) rotate(${at / 18}deg)` }, { transform: 'translate(0px,0px) rotate(0deg)' }], { duration: 420, easing: SPRING }],
])
expect(card.style.transform).toBe('')
await act(async () => {
await Promise.resolve()
})
expect(sent(server.fetchMock)).toEqual([])
})
it('stops a snap-back when the card is grabbed again, so it follows the new drag', async () => {
stubServer([BOWLS, TIKKA])
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
press(card)
drag(card, 80, 0)
release(card)
const [spring] = animations
expect(spring!.playState).toBe('running')
press(card)
expect(spring!.playState).toBe('idle')
drag(card, 36, 0)
expect(card.style.transform).toBe('translate(36px,0px) rotate(2deg)')
expect(animations).toHaveLength(1)
})
it('stops a snap-back when a decision starts', async () => {
stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/decline': () => declined(BOWLS) })
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
press(card)
drag(card, -80, 0)
release(card)
const [spring] = animations
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
expect(spring!.playState).toBe('idle')
expect(on(animations, card)).toHaveLength(2)
})
it('springs the card back when it loses the pointer capture before the release, deciding nothing', async () => {
const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accepted(BOWLS) })
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
const [tryStamp, passStamp] = stamps(card)
press(card)
drag(card, 150, 0)
fireEvent.lostPointerCapture(card, { pointerId: 7, isPrimary: true })
expect(animations.map((a) => [a.target, a.options])).toEqual([[card, { duration: 420, easing: SPRING }]])
expect(card.style.transform).toBe('')
expect(tryStamp!.style.opacity).toBe('')
expect(passStamp!.style.opacity).toBe('')
// The drag is over: later moves and the release do nothing.
drag(card, 180, 0)
release(card)
expect(card.style.transform).toBe('')
expect(animations).toHaveLength(1)
await act(async () => {
await Promise.resolve()
})
expect(sent(server.fetchMock)).toEqual([])
// The capture going after a release leaves its decision alone.
press(card)
drag(card, 140, 0)
release(card)
await waitFor(() => expect(on(animations, card)).toHaveLength(2))
fireEvent.lostPointerCapture(card, { pointerId: 7, isPrimary: true })
expect(animations).toHaveLength(3)
expect(card.style.transform).toBe('translate(140px,0px) rotate(7.777777777777778deg)')
await finishRunning(animations)
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
})
it('springs the card back when the pointer is canceled, however far it went', async () => {
const server = stubServer([BOWLS])
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
press(card)
drag(card, 180, 0)
fireEvent.pointerCancel(card, { pointerId: 7, isPrimary: true })
expect(animations.map((a) => a.options)).toEqual([{ duration: 420, easing: SPRING }])
expect(card.style.transform).toBe('')
expect(sent(server.fetchMock)).toEqual([])
})
it('ignores a drag while busy', async () => {
const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/decline': () => declined(BOWLS) })
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
const started = animations.length
press(card)
drag(card, 160, 0)
release(card)
expect(capture).not.toHaveBeenCalled()
expect(card.style.transform).toBe('')
expect(animations).toHaveLength(started)
await finishRunning(animations)
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])
})
it('puts a drag held while a Retry goes out back at once on release, deciding nothing', async () => {
const retry = deferred()
let calls = 0
const server = stubServer([BOWLS, TIKKA], {
'POST /api/v1/suggestions/s1/accept': () => (++calls === 1 ? errorResponse(500, 'internal') : retry.promise),
})
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
const alert = await screen.findByRole('alert')
press(card)
drag(card, 60, 0)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled())
drag(card, -160, 0)
expect(card.style.transform).toBe('translate(60px,0px) rotate(3.3333333333333335deg)')
const started = vi.mocked(Element.prototype.animate).mock.calls.length
release(card)
expect(card.style.transform).toBe('')
expect(within(card).getByText('Try it!').style.opacity).toBe('')
expect(vi.mocked(Element.prototype.animate).mock.calls.length).toBe(started)
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept'])
retry.resolve(accepted(BOWLS))
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled())
})
it('drags only the top card', async () => {
const server = stubServer([BOWLS, TIKKA, ORZO])
const animations = stubAnimate({ autoFinish: false })
const { container } = await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
const [, second, third] = container.querySelectorAll<HTMLElement>('article')
for (const card of [second!, third!]) {
expect(declaredStyle(card).getPropertyValue('touch-action')).toBe('')
press(card)
drag(card, 160, 0)
release(card)
expect(card.style.transform).toBe('')
}
expect(capture).not.toHaveBeenCalled()
expect(animations).toEqual([])
expect(sent(server.fetchMock)).toEqual([])
})
it('ignores a press that is not the primary button', async () => {
stubServer([BOWLS])
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.pointerDown(card, { pointerId: 7, isPrimary: true, button: 2, clientX: 200, clientY: 300 })
drag(card, 160, 0)
expect(capture).not.toHaveBeenCalled()
expect(card.style.transform).toBe('')
})
it.each([
['without moving', null],
['after moving', 160],
])('ignores a second primary pointer pressing and releasing %s mid-drag, then springs the card back clean', async (_, moves) => {
const server = stubServer([BOWLS, TIKKA])
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
const [tryStamp, passStamp] = stamps(card)
// A touch (id 7) drags; a mouse (id 1), also primary, comes and goes.
const mouse = (x: number) => ({ pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, clientX: 400 + x, clientY: 300 })
press(card)
drag(card, 72, 0)
fireEvent.pointerDown(card, mouse(0))
if (moves !== null) fireEvent.pointerMove(card, mouse(moves))
fireEvent.pointerUp(card, mouse(moves ?? 0))
fireEvent.lostPointerCapture(card, mouse(moves ?? 0))
expect(capture).toHaveBeenCalledExactlyOnceWith(7)
expect(card.style.transform).toBe('translate(72px,0px) rotate(4deg)')
expect(animations).toEqual([])
release(card)
expect(animations.map((a) => [a.target, a.options])).toEqual([[card, { duration: 420, easing: SPRING }]])
expect(card.style.transform).toBe('')
expect(tryStamp!.style.opacity).toBe('')
expect(passStamp!.style.opacity).toBe('')
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()
expect(sent(server.fetchMock)).toEqual([])
// The card can be dragged again.
press(card)
drag(card, 36, 0)
expect(card.style.transform).toBe('translate(36px,0px) rotate(2deg)')
})
it('a key decision mid-drag ends the drag and throws the card from rest', async () => {
const server = stubServer([BOWLS, TIKKA], {
'POST /api/v1/suggestions/s1/accept': () => {
server.deck = [TIKKA]
return accepted(BOWLS)
},
})
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
const [tryStamp] = stamps(card)
press(card)
drag(card, -60, 0)
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(card.style.transform).toBe('')
expect(tryStamp!.style.opacity).toBe('')
// The button bump, stamp pop and wiggle of a Try it from rest.
expect(animations.map((a) => a.target)).toEqual([screen.getByRole('button', { name: 'Try it' }), tryStamp, card])
release(card)
expect(animations).toHaveLength(3)
await finishRunning(animations)
await finishRunning(animations)
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
})
it('puts a swiped card back at rest when its decision is refused', async () => {
stubServer([BOWLS], { 'POST /api/v1/suggestions/s1/decline': () => errorResponse(500, 'internal') })
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
const [tryStamp, passStamp] = stamps(card)
press(card)
drag(card, -150, 0)
release(card)
await finishRunning(animations)
await screen.findByRole('alert')
expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle'])
expect(card.style.transform).toBe('')
expect(tryStamp!.style.opacity).toBe('')
expect(passStamp!.style.opacity).toBe('')
expect(screen.getByRole('button', { name: 'Pass' })).toBeEnabled()
})
it.each([
['Try it', 140, 'accept'],
['Pass', -140, 'decline'],
] as const)('%s by swipe with reduced motion fades the card where it was let go', async (_, dx, action) => {
mockViewport(390, { reducedMotion: true })
const server = stubServer([BOWLS, TIKKA], {
[`POST /api/v1/suggestions/s1/${action}`]: () => {
server.deck = [TIKKA]
return action === 'accept' ? accepted(BOWLS) : declined(BOWLS)
},
})
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
press(card)
drag(card, dx, 0)
release(card)
expect(animations.map((a) => [a.target, a.keyframes, a.options])).toEqual([[card, [{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }]])
expect(card.style.transform).toBe(`translate(${dx}px,0px) rotate(${dx / 18}deg)`)
await finishRunning(animations)
expect(sent(server.fetchMock)).toEqual([`POST /api/v1/suggestions/s1/${action}`])
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
expect(animations).toHaveLength(1)
})
it('puts the card back at once, without the spring, from a short swipe with reduced motion', async () => {
mockViewport(390, { reducedMotion: true })
stubServer([BOWLS])
const animations = stubAnimate({ autoFinish: false })
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
press(card)
drag(card, 80, 0)
release(card)
expect(animations).toEqual([])
expect(card.style.transform).toBe('')
expect(within(card).getByText('Try it!').style.opacity).toBe('')
})
})
})
+189 -21
View File
@@ -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 PointerEvent, type Ref } from 'react'
import { useJob } from '../api/index.ts'
import { Card } from '../ui/Card.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
@@ -18,9 +18,30 @@ import {
useStartBatch,
type Choice,
} from './api.ts'
import { DeckCard, DECK_DEPTH } from './DeckCard.tsx'
import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx'
import {
bump,
fadeOut,
follow,
isAbort,
pass,
prefersReducedMotion,
promote,
snapBack,
tryIt,
unfollow,
type Exit,
} from './motion.ts'
import styles from './DiscoverPage.module.css'
// SWIPE_THRESHOLD is how far, in px, the top card must be dragged left or
// right for its release to Pass or Try it.
const SWIPE_THRESHOLD = 110
// A Drag is the pointer dragging the top card: where it went down, and the
// card's drag transform so far.
type Drag = { pointerId: number; x: number; y: number; dx: number; transform: string | null }
// isEditable says whether a key press belongs to a field being typed in,
// which the deck's ← / → keys leave alone.
function isEditable(target: EventTarget | null): boolean {
@@ -32,7 +53,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<HTMLSpanElement> }) {
const [open, setOpen] = useState(false)
const listId = useId()
return (
@@ -44,7 +66,7 @@ function QuestLogPill({ dinners }: { dinners: { id: string; name: string }[] })
aria-controls={open ? listId : undefined}
onClick={() => setOpen(!open)}
>
<span className={styles.questLog}>Quest log · {dinners.length}</span>
<span ref={ref} className={styles.questLog}>Quest log · {dinners.length}</span>
</button>
{open && (
<Card id={listId} tone="muted" className={styles.questLogList}>
@@ -64,10 +86,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, the ← / → keys
// or a swipe of the top card throws it 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 +101,7 @@ export function DiscoverPage() {
const job = useJob<unknown>(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,26 +118,170 @@ export function DiscoverPage() {
// batch's job, or an unreadable result.
const batchError = job.error ?? batch.error
const deckRef = useRef<HTMLDivElement>(null)
const passRef = useRef<HTMLButtonElement>(null)
const tryRef = useRef<HTMLButtonElement>(null)
const questLogRef = useRef<HTMLSpanElement>(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<Exit | null>(null)
const leaving = useRef<string | null>(null)
const drag = useRef<Drag | null>(null)
// The top card's snap-back while it may still run; a new drag or a
// decision stops it so it does not fight the card's new motion.
const springing = useRef<Animation | null>(null)
const stopSpring = useCallback(() => {
springing.current?.cancel()
springing.current = 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 decide = useCallback(
(choice: Choice) => {
if (top !== undefined) submit({ id: top.id, choice })
const submit = useCallback(
(variables: { id: string; choice: Choice }) => {
if (lock()) send(variables)
},
[top, submit],
[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.
// A swipe's decision comes from the card's drag transform: it is thrown
// from there without a bump or wiggle. A button or key decision ends a
// drag in progress, putting the card back first.
const decide = useCallback(
(choice: Choice, from?: string) => {
if (top === undefined || !lock()) return
stopSpring()
const card = deckRef.current!.querySelector('article')!
if (from === undefined && drag.current !== null) {
drag.current = null
unfollow(card)
}
const reduced = prefersReducedMotion()
let run: Exit
if (reduced) {
run = fadeOut(card)
} else if (from === undefined) {
bump((choice === 'try' ? tryRef : passRef).current!)
run = choice === 'try' ? tryIt(card) : pass(card)
} else {
run = choice === 'try' ? tryIt(card, from) : pass(card, from)
}
if (from !== undefined) {
// Putting a swiped card back puts it back at rest.
const thrown = run
run = {
finished: thrown.finished,
cancel: () => {
thrown.cancel()
unfollow(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, lock, send, stopSpring],
)
// swipe drags the top card: it follows the pointer, captured from the
// press, until the release decides past the threshold or springs it back
// short of it. The release decides from where the pointer let go, which
// may differ from its last move. A cancel, or losing the capture before
// the release, springs it back from its last move. One pointer drags at
// a time: a press while a drag is in progress is ignored, as is input
// while busy, and a drag held into a busy spell (a Retry) is put back on
// release.
const swipe = useMemo<SwipeHandlers>(() => {
function track(card: HTMLElement, current: Drag, event: PointerEvent<HTMLElement>) {
current.dx = event.clientX - current.x
current.transform = follow(card, current.dx, event.clientY - current.y)
}
function release(event: PointerEvent<HTMLElement>, decides: boolean) {
const card = event.currentTarget
const current = drag.current
if (current === null || current.pointerId !== event.pointerId) return
drag.current = null
const moved = current.transform !== null || event.clientX !== current.x || event.clientY !== current.y
if (decides && moved && !deciding.current) track(card, current, event)
if (current.transform === null) return
if (deciding.current) unfollow(card)
else if (decides && current.dx > SWIPE_THRESHOLD) decide('try', current.transform)
else if (decides && current.dx < -SWIPE_THRESHOLD) decide('pass', current.transform)
else if (prefersReducedMotion()) unfollow(card)
else springing.current = snapBack(card, current.transform)
}
return {
onPointerDown: (event) => {
if (drag.current !== null || deciding.current || !event.isPrimary || event.button !== 0) return
stopSpring()
event.currentTarget.setPointerCapture(event.pointerId)
drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dx: 0, transform: null }
},
onPointerMove: (event) => {
const current = drag.current
if (current === null || current.pointerId !== event.pointerId || deciding.current) return
track(event.currentTarget, current, event)
},
onPointerUp: (event) => release(event, true),
onPointerCancel: (event) => release(event, false),
// After a release the drag is already over, so this only ends a drag
// whose capture was lost before it.
onLostPointerCapture: (event) => release(event, false),
}
}, [decide, stopSpring])
// Leaving the page stops an exit before its decision is sent.
useEffect(() => () => exit.current?.cancel(), [])
const topId = top?.id
// A drag belongs to the card it started on, which a new top card ends.
useLayoutEffect(() => {
drag.current = null
}, [topId])
// Once the deck moves past the card a decision was sent for, the cards
// behind it move up a depth.
useLayoutEffect(() => {
if (leaving.current === null || leaving.current === topId) return
leaving.current = null
exit.current = null
if (topId === undefined || prefersReducedMotion()) return
promote([...deckRef.current!.querySelectorAll<HTMLElement>(':scope > article')])
}, [topId])
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
@@ -170,16 +338,16 @@ export function DiscoverPage() {
} else {
body = (
<>
<div className={styles.deck}>
<div ref={deckRef} className={styles.deck}>
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
<DeckCard key={quest.id} quest={quest} depth={depth} />
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
))}
</div>
<div className={styles.actions}>
<IconButton label="Pass" size={62} tone="neutral" showLabel disabled={busy} onClick={() => decide('pass')}>
<IconButton ref={passRef} label="Pass" size={62} tone="neutral" showLabel disabled={busy} onClick={() => decide('pass')}>
<Icon icon={X} size="nav" />
</IconButton>
<IconButton label="Try it" size={62} tone="teal" showLabel disabled={busy} onClick={() => decide('try')}>
<IconButton ref={tryRef} label="Try it" size={62} tone="teal" showLabel disabled={busy} onClick={() => decide('try')}>
<Icon icon={Heart} size="nav" />
</IconButton>
</div>
@@ -199,7 +367,7 @@ export function DiscoverPage() {
<p className={styles.kicker}>Discover</p>
<h1 className={styles.title}>Side quests</h1>
</div>
{questLog.isSuccess && <QuestLogPill dinners={questLog.data} />}
{questLog.isSuccess && <QuestLogPill ref={questLogRef} dinners={questLog.data} />}
</header>
{questLog.isError && (
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
+221
View File
@@ -0,0 +1,221 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
import { bump, dragTransform, fadeOut, follow, isAbort, pass, promote, snapBack, tryIt, unfollow } 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 = '<span data-part="try-stamp"></span><span data-part="pass-stamp"></span><span data-part="shade"></span>'
return {
article,
stamp: article.querySelector<HTMLElement>('[data-part="try-stamp"]')!,
passStamp: article.querySelector<HTMLElement>('[data-part="pass-stamp"]')!,
shade: article.querySelector('[data-part="shade"]')!,
}
}
// settled says whether a promise has settled after pending callbacks run.
async function settled(promise: Promise<unknown>) {
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('drags: translate(dx, dy·0.3) rotate(dx/18 deg)', () => {
expect(dragTransform(90, 20)).toBe('translate(90px,6px) rotate(5deg)')
expect(dragTransform(-36, -10)).toBe('translate(-36px,-3px) rotate(-2deg)')
})
it.each([
[50, '0.5', '0'],
[-50, '0', '0.5'],
[160, '1', '0'],
[-160, '0', '1'],
[0, '0', '0'],
])('follows a drag of %i px with the stamps at Try it! %s, Pass %s', (dx, tryOpacity, passOpacity) => {
const { article, stamp, passStamp } = card()
expect(follow(article, dx, 0)).toBe(dragTransform(dx, 0))
expect(article.style.transform).toBe(dragTransform(dx, 0))
expect(stamp.style.opacity).toBe(tryOpacity)
expect(passStamp.style.opacity).toBe(passOpacity)
expect(animations).toEqual([])
})
it('unfollows at once, back to the stylesheet', () => {
const { article, stamp, passStamp } = card()
follow(article, -80, 12)
unfollow(article)
expect(article.getAttribute('style')).toBe('')
expect(stamp.getAttribute('style')).toBe('')
expect(passStamp.getAttribute('style')).toBe('')
expect(animations).toEqual([])
})
it('snaps back from the drag transform to rest, 420 ms spring, and hides the stamps', () => {
const { article, stamp, passStamp } = card()
const from = follow(article, 70, 30)
snapBack(article, from)
expect(article.style.transform).toBe('')
expect(stamp.style.opacity).toBe('')
expect(passStamp.style.opacity).toBe('')
expect(animations).toHaveLength(1)
expect(animations[0]!.target).toBe(article)
expect(animations[0]!.keyframes).toEqual([{ transform: 'translate(70px,9px) rotate(3.888888888888889deg)' }, { transform: 'translate(0px,0px) rotate(0deg)' }])
expect(animations[0]!.options).toEqual({ duration: 420, easing: 'cubic-bezier(.34,1.4,.64,1)' })
})
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')
expect(() => follow(document.createElement('article'), 10, 0)).toThrow('deck card has no try-stamp')
})
})
+178
View File
@@ -0,0 +1,178 @@
// The deck's motion, from the handoff's Motion §2 (Try it), §3 (Pass),
// §4 (swipe), §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<void>
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)
}
// dragTransform is the top card's transform dragged dx, dy from where the
// pointer went down.
export function dragTransform(dx: number, dy: number): string {
return `translate(${dx}px,${dy * 0.3}px) rotate(${dx / 18}deg)`
}
// follow moves a dragged card to its drag transform and shows each stamp as
// far as the drag has gone toward its side, fully at 100 px. It returns the
// transform, for the exit or snap-back the release starts.
export function follow(card: HTMLElement, dx: number, dy: number): string {
const transform = dragTransform(dx, dy)
card.style.transform = transform
part(card, 'try-stamp').style.opacity = String(clamp(dx / 100))
part(card, 'pass-stamp').style.opacity = String(clamp(-dx / 100))
return transform
}
// unfollow puts a dragged card and its stamps back where the stylesheet
// places them, at once.
export function unfollow(card: HTMLElement) {
card.style.removeProperty('transform')
part(card, 'try-stamp').style.removeProperty('opacity')
part(card, 'pass-stamp').style.removeProperty('opacity')
}
// snapBack springs a card let go short of a decision back to rest from its
// drag transform, and hides its stamps.
export function snapBack(card: HTMLElement, from: string): Animation {
unfollow(card)
return card.animate([{ transform: from }, { transform: REST }], { duration: 420, easing: SPRING })
}
// 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,
}),
)
}
function clamp(value: number): number {
return Math.min(1, Math.max(0, value))
}
// part is the card's element marked data-part="name", which DeckCard renders.
function part(card: HTMLElement, name: string): HTMLElement {
const element = card.querySelector<HTMLElement>(`[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<unknown>): Exit {
return {
finished: finished.then(() => undefined),
cancel: () => {
canceled = true
animations.forEach((animation) => animation.cancel())
},
}
},
}
}
+52
View File
@@ -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<FakeAnimation>
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
}