From f3483b1d4d1540d7485ec84d58932a65a5a5ff91 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 02:05:08 -0400 Subject: [PATCH] Decide a swipe from the release point and stop stray snap-backs - 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 --- web/src/discover/DeckCard.tsx | 5 +- web/src/discover/DiscoverPage.test.tsx | 126 ++++++++++++++++++++++++- web/src/discover/DiscoverPage.tsx | 45 ++++++--- 3 files changed, 160 insertions(+), 16 deletions(-) diff --git a/web/src/discover/DeckCard.tsx b/web/src/discover/DeckCard.tsx index 3c6dbbc..9c4f119 100644 --- a/web/src/discover/DeckCard.tsx +++ b/web/src/discover/DeckCard.tsx @@ -10,7 +10,10 @@ export const DECK_DEPTH = [0, 1, 2] as const const DIFFICULTY_LABEL: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } // SwipeHandlers are the pointer handlers that drag the top card. -export type SwipeHandlers = Pick, 'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel'> +export type SwipeHandlers = Pick< + DOMAttributes, + 'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' | 'onLostPointerCapture' +> export type DeckCardProps = { quest: SideQuest diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx index f19f6d9..43be0a5 100644 --- a/web/src/discover/DiscoverPage.test.tsx +++ b/web/src/discover/DiscoverPage.test.tsx @@ -687,15 +687,18 @@ describe('DiscoverPage', () => { }) // press, drag and release put a pointer down on the card at (200, 300), - // move it by dx, dy, and let go. + // 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) { - fireEvent.pointerUp(card, { pointerId: 7, isPrimary: true, button: 0 }) + 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')] @@ -831,6 +834,123 @@ describe('DiscoverPage', () => { 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 }) diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx index 22e3e0a..dd5eb3a 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, useLayoutEffect, useMemo, useRef, useState, type Ref } 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' @@ -127,6 +127,13 @@ export function DiscoverPage() { const exit = useRef(null) const leaving = useRef(null) const drag = useRef(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(null) + const stopSpring = useCallback(() => { + springing.current?.cancel() + springing.current = null + }, []) const { mutate: mutateDecision } = decision const deciding = useRef(false) @@ -168,6 +175,7 @@ export function DiscoverPage() { 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 @@ -206,41 +214,54 @@ export function DiscoverPage() { }, ) }, - [top, lock, send], + [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. Input while busy is ignored, and a drag held into a busy - // spell (a Retry) is put back on release. + // 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. Input while busy is + // ignored, and a drag held into a busy spell (a Retry) is put back on + // release. const swipe = useMemo(() => { - function release(card: HTMLElement, pointerId: number, decides: boolean) { + function track(card: HTMLElement, current: Drag, event: PointerEvent) { + current.dx = event.clientX - current.x + current.transform = follow(card, current.dx, event.clientY - current.y) + } + function release(event: PointerEvent, decides: boolean) { + const card = event.currentTarget const current = drag.current - if (current === null || current.pointerId !== pointerId) return + 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 snapBack(card, current.transform) + else springing.current = snapBack(card, current.transform) } return { onPointerDown: (event) => { if (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 - current.dx = event.clientX - current.x - current.transform = follow(event.currentTarget, current.dx, event.clientY - current.y) + track(event.currentTarget, current, event) }, - onPointerUp: (event) => release(event.currentTarget, event.pointerId, true), - onPointerCancel: (event) => release(event.currentTarget, event.pointerId, false), + 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]) + }, [decide, stopSpring]) // Leaving the page stops an exit before its decision is sent. useEffect(() => () => exit.current?.cancel(), [])