Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 7e2bf897a2 Validate the job envelope and make Retry poll the batch's job
CI / ci (push) Successful in 2m54s
CI / ci (pull_request) Successful in 2m37s
useJob now takes the job kind and checks each GET /api/v1/jobs/{id}
answer: the requested id, the expected kind, a known status, a result
and a text or null error. An answer that is not the job is exposed as the
error and stops polling, so an unknown status no longer leaves the
Discover skeleton cards running with no error or Retry. useJob also
returns poll, which asks for the job at once and restarts the backoff;
the Discover page's "Couldn't check on new side quests" Retry uses it
instead of posting another batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:22:17 -04:00
dev_agentandClaude Opus 5.5 fe87968ac9 Fix the Discover deck review findings
CI / ci (pull_request) Successful in 2m39s
CI / ci (push) Successful in 3m10s
The Quest log pill is now a button that shows and hides the names of
the dinners in the Quest log, read from GET
/api/v1/dinners?status=quest_log; the designed Quest log sheet replaces
this list in its own task.

A batch whose job cannot be polled, or whose result cannot be read,
now shows an error with a Retry that asks for the batch again, instead
of leaving skeleton cards on screen with no way out.

Decisions go through one submit guarded by a ref set before the request
is sent, so presses before the next render cannot send a second,
conflicting decision. A decision counts as saved only when the answer
is that suggestion, accepted or declined as chosen; anything else shows
in the "Couldn't save that choice" banner.

Pass and Try it are IconButtons: IconButton gains a neutral tone and
showLabel, which shows the label as an 11 px caption under the circle.

Maestro task 1ecc121c-86eb-4784-a9fb-1314709b4a59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:13:00 -04:00
dev_agentandClaude Opus 5.5 38fec723f6 Add the Discover deck screen (mock 1a)
CI / ci (push) Successful in 2m53s
CI / ci (pull_request) Successful in 2m37s
/discover now shows the Side quests deck: the "Discover" kicker and
"Side quests" title, the teal "Quest log · N" pill, and the three
stacked cards in a 440 px deck with perspective 1000px. The top card
shows the washed photo, the name, the difficulty dots and the
"Why this?" box, with hidden "Try it!" and "Pass" stamps. The 62 px
Pass and Try it buttons and the ←/→ keys all go through one decide
handler that ignores input while a decision is in flight.

An empty deck shows "All caught up" with "Get more now", which starts a
suggestion batch and shows skeleton cards while its job runs, reading
the deck again as the batch stores Side quests.

Maestro task 1ecc121c-86eb-4784-a9fb-1314709b4a59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:03:48 -04:00
7 changed files with 27 additions and 1354 deletions
+5 -17
View File
@@ -1,4 +1,3 @@
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'
@@ -9,35 +8,25 @@ 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 or a drag shows them, and its shade until Pass folds it (see
// motion.ts).
export function DeckCard({ quest, depth, swipe }: DeckCardProps) {
// decision shows them.
export function DeckCard({ quest, depth }: DeckCardProps) {
const top = depth === 0
const titleId = `side-quest-${quest.id}`
return (
<article
className={`${styles.card} ${styles[`depth${depth}`]}${swipe === undefined ? '' : ` ${styles.draggable}`}`}
className={`${styles.card} ${styles[`depth${depth}`]}`}
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}>
@@ -49,13 +38,12 @@ export function DeckCard({ quest, depth, swipe }: DeckCardProps) {
<strong>Why this?</strong> {quest.reason}
</p>
</div>
<span className={`${styles.stamp} ${styles.tryStamp}`} data-part="try-stamp" aria-hidden="true">
<span className={`${styles.stamp} ${styles.tryStamp}`} aria-hidden="true">
Try it!
</span>
<span className={`${styles.stamp} ${styles.passStamp}`} data-part="pass-stamp" aria-hidden="true">
<span className={`${styles.stamp} ${styles.passStamp}`} aria-hidden="true">
Pass
</span>
<span className={styles.shade} data-part="shade" />
</article>
)
}
-16
View File
@@ -104,12 +104,6 @@
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);
@@ -212,16 +206,6 @@
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;
+2 -682
View File
@@ -1,13 +1,11 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, 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'
@@ -75,27 +73,10 @@ 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() {
@@ -504,665 +485,4 @@ 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('')
})
})
})
+20 -188
View File
@@ -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 PointerEvent, type Ref } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useJob } from '../api/index.ts'
import { Card } from '../ui/Card.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
@@ -18,30 +18,9 @@ import {
useStartBatch,
type Choice,
} from './api.ts'
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 { DeckCard, DECK_DEPTH } from './DeckCard.tsx'
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 {
@@ -53,8 +32,7 @@ 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.
// ref is the visible pill, which Try it bumps.
function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[]; ref: Ref<HTMLSpanElement> }) {
function QuestLogPill({ dinners }: { dinners: { id: string; name: string }[] }) {
const [open, setOpen] = useState(false)
const listId = useId()
return (
@@ -66,7 +44,7 @@ function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[
aria-controls={open ? listId : undefined}
onClick={() => setOpen(!open)}
>
<span ref={ref} className={styles.questLog}>Quest log · {dinners.length}</span>
<span className={styles.questLog}>Quest log · {dinners.length}</span>
</button>
{open && (
<Card id={listId} tone="muted" className={styles.questLogList}>
@@ -86,12 +64,10 @@ function QuestLogPill({ dinners, ref }: { 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. 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.
// 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.
export function DiscoverPage() {
const queryClient = useQueryClient()
const deck = useDeck()
@@ -101,7 +77,7 @@ export function DiscoverPage() {
const job = useJob<unknown>(BATCH_JOB_KIND, startBatch.data?.job_id ?? null)
const top = deck.data?.[0]
const [busy, setBusy] = useState(false)
const busy = decision.isPending
const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed'
const finding = startBatch.isPending || jobRunning
@@ -118,171 +94,27 @@ 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 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(
const submit = useCallback(
(variables: { id: string; choice: Choice }) => {
leaving.current = variables.id
if (deciding.current) return
deciding.current = true
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.
// 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
},
)
(choice: Choice) => {
if (top !== undefined) submit({ id: top.id, choice })
},
[top, lock, send, stopSpring],
[top, submit],
)
// 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) {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
@@ -338,16 +170,16 @@ export function DiscoverPage() {
} else {
body = (
<>
<div ref={deckRef} className={styles.deck}>
<div className={styles.deck}>
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
<DeckCard key={quest.id} quest={quest} depth={depth} />
))}
</div>
<div className={styles.actions}>
<IconButton ref={passRef} label="Pass" size={62} tone="neutral" showLabel disabled={busy} onClick={() => decide('pass')}>
<IconButton label="Pass" size={62} tone="neutral" showLabel disabled={busy} onClick={() => decide('pass')}>
<Icon icon={X} size="nav" />
</IconButton>
<IconButton ref={tryRef} label="Try it" size={62} tone="teal" showLabel disabled={busy} onClick={() => decide('try')}>
<IconButton label="Try it" size={62} tone="teal" showLabel disabled={busy} onClick={() => decide('try')}>
<Icon icon={Heart} size="nav" />
</IconButton>
</div>
@@ -367,7 +199,7 @@ export function DiscoverPage() {
<p className={styles.kicker}>Discover</p>
<h1 className={styles.title}>Side quests</h1>
</div>
{questLog.isSuccess && <QuestLogPill ref={questLogRef} dinners={questLog.data} />}
{questLog.isSuccess && <QuestLogPill dinners={questLog.data} />}
</header>
{questLog.isError && (
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
-221
View File
@@ -1,221 +0,0 @@
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
@@ -1,178 +0,0 @@
// 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
@@ -1,52 +0,0 @@
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
}