Animate Try it and Pass exits and deck promotion
Add web/src/discover/motion.ts with the handoff Motion §2, §3, §5 and §6
values over the Web Animations API: the button and Quest log pill bump,
the "Try it!" stamp pop, wiggle and throw right, the Pass page fold with
its shade, the 300 ms deck promotion and the 200 ms reduced-motion fade.
Each exit takes an optional starting transform for the swipe gesture and
returns { finished, cancel }.
DiscoverPage now runs the exit, sends the decision once the card is gone
and keeps input locked until it settles; a refused decision cancels the
exit so the card comes back, and leaving the page mid-exit sends nothing.
The shade uses --color-text (#201e1d).
Maestro task cb7da499-ffda-441d-8574-82e57b332fb6.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ export type DeckCardProps = {
|
||||
// DeckCard is one Side quest in the deck. Only the top card is read out and
|
||||
// reachable; the cards behind it are the stack's edge, kept rendered so they
|
||||
// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a
|
||||
// decision shows them.
|
||||
// decision shows them, and its shade until Pass folds it (see motion.ts).
|
||||
export function DeckCard({ quest, depth }: DeckCardProps) {
|
||||
const top = depth === 0
|
||||
const titleId = `side-quest-${quest.id}`
|
||||
@@ -38,12 +38,13 @@ export function DeckCard({ quest, depth }: DeckCardProps) {
|
||||
<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">
|
||||
Pass
|
||||
</span>
|
||||
<span className={styles.shade} data-part="shade" />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -206,6 +206,16 @@
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
/* The shade Pass darkens the card with as it folds. */
|
||||
.shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--color-text);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Pass and Try it: IconButtons with their 11 px labels shown. */
|
||||
.actions {
|
||||
display: flex;
|
||||
|
||||
@@ -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,168 @@ describe('DiscoverPage', () => {
|
||||
expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('motion', () => {
|
||||
const accepted = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'accepted' }, replenish_job_id: null })
|
||||
const declined = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: null })
|
||||
const on = (animations: FakeAnimation[], target: Element) => animations.filter((a) => a.target === target)
|
||||
|
||||
it('Try it bumps the button, pops the stamp, wiggles and throws the card, then records it, bumps the Quest log pill and promotes the deck', async () => {
|
||||
const server = stubServer([BOWLS, TIKKA, ORZO], {
|
||||
'POST /api/v1/suggestions/s1/accept': () => {
|
||||
server.deck = [TIKKA, ORZO]
|
||||
return accepted(BOWLS)
|
||||
},
|
||||
})
|
||||
const animations = stubAnimate({ autoFinish: false })
|
||||
await renderPage()
|
||||
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||
const pill = await screen.findByText('Quest log · 6')
|
||||
const tryButton = screen.getByRole('button', { name: 'Try it' })
|
||||
|
||||
fireEvent.click(tryButton)
|
||||
expect(animations.map((a) => a.target)).toEqual([tryButton, within(card).getByText('Try it!'), card])
|
||||
const [bumped, pop, wiggle] = animations
|
||||
expect(bumped!.keyframes.map((k) => k.transform)).toEqual(['scale(1)', 'scale(.88)', 'scale(1.06)', 'scale(1)'])
|
||||
expect(bumped!.options).toEqual({ duration: 300, easing: 'ease-out' })
|
||||
expect(pop!.options).toEqual({ duration: 220, easing: 'cubic-bezier(.34,1.4,.64,1)', fill: 'forwards' })
|
||||
expect(wiggle!.keyframes.map((k) => k.transform)).toEqual(['rotate(0deg)', 'rotate(-4deg)', 'rotate(4deg)', 'rotate(-3deg)', 'rotate(2deg)', 'rotate(0deg)'])
|
||||
expect(wiggle!.options).toEqual({ duration: 420, easing: 'ease-in-out' })
|
||||
|
||||
// Input is locked while the card leaves, and nothing is sent yet.
|
||||
await waitFor(() => expect(tryButton).toBeDisabled())
|
||||
expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled()
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
|
||||
await finishRunning(animations)
|
||||
const fly = animations[3]!
|
||||
expect(fly.target).toBe(card)
|
||||
expect(fly.keyframes).toEqual([
|
||||
{ transform: 'translate(0px,0px) rotate(0deg)' },
|
||||
{ transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 },
|
||||
{ transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 },
|
||||
])
|
||||
expect(fly.options).toEqual({ duration: 480, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
|
||||
expect(animations).toHaveLength(4)
|
||||
expect(sent(server.fetchMock)).toEqual([])
|
||||
|
||||
// Once it is gone: the decision, the pill bump, then the deck moves up.
|
||||
await finishRunning(animations)
|
||||
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
|
||||
expect(on(animations, pill).map((a) => a.options)).toEqual([{ duration: 300, easing: 'ease-out' }])
|
||||
expect(on(animations, pill)[0]!.keyframes.map((k) => k.transform)).toEqual(['scale(1)', 'scale(.88)', 'scale(1.06)', 'scale(1)'])
|
||||
const next = await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
|
||||
const behind = next.parentElement!.querySelectorAll('article')[1]!
|
||||
expect(on(animations, next).map((a) => [a.keyframes, a.options])).toEqual([
|
||||
[[{ transform: 'translateY(8px) scale(0.95)' }, { transform: 'translateY(0px) scale(1)' }], { duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' }],
|
||||
])
|
||||
expect(on(animations, behind).map((a) => a.keyframes)).toEqual([
|
||||
[{ transform: 'translateY(16px) scale(0.9)' }, { transform: 'translateY(8px) scale(0.95)' }],
|
||||
])
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled())
|
||||
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
|
||||
})
|
||||
|
||||
it('Pass bumps the button and folds the card under its shade, then records it and promotes the deck', async () => {
|
||||
const server = stubServer([BOWLS, TIKKA], {
|
||||
'POST /api/v1/suggestions/s1/decline': () => {
|
||||
server.deck = [TIKKA]
|
||||
return declined(BOWLS)
|
||||
},
|
||||
})
|
||||
const animations = stubAnimate({ autoFinish: false })
|
||||
const { container } = await renderPage()
|
||||
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||
const pill = await screen.findByText('Quest log · 6')
|
||||
const passButton = screen.getByRole('button', { name: 'Pass' })
|
||||
const shade = card.querySelector('[data-part="shade"]')!
|
||||
expect(declaredStyle(shade).background).toBe('var(--color-text)')
|
||||
expect(getComputedStyle(shade).opacity).toBe('0')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
expect(animations.map((a) => a.target)).toEqual([passButton, shade, card])
|
||||
const [bumped, darken, fold] = animations
|
||||
expect(bumped!.options).toEqual({ duration: 300, easing: 'ease-out' })
|
||||
expect(darken!.keyframes).toEqual([{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }])
|
||||
expect(darken!.options).toEqual({ duration: 720, fill: 'forwards' })
|
||||
expect(fold!.keyframes).toEqual([
|
||||
{ transformOrigin: '0% 50%', transform: 'translate(0px,0px) rotate(0deg) rotateY(0deg)', easing: 'cubic-bezier(.45,0,.85,.55)' },
|
||||
{ transformOrigin: '0% 50%', transform: 'translate(0px,0px) rotate(0deg) rotateY(-80deg)', offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' },
|
||||
{ transformOrigin: '0% 50%', transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 },
|
||||
])
|
||||
expect(fold!.options).toEqual({ duration: 720, fill: 'forwards' })
|
||||
await waitFor(() => expect(passButton).toBeDisabled())
|
||||
expect(sent(server.fetchMock)).toEqual([])
|
||||
|
||||
await finishRunning(animations)
|
||||
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])
|
||||
const next = await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
|
||||
expect(on(animations, next).map((a) => a.options)).toEqual([{ duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' }])
|
||||
expect(on(animations, pill)).toEqual([])
|
||||
expect(container.querySelectorAll('article')).toHaveLength(1)
|
||||
await waitFor(() => expect(passButton).toBeEnabled())
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Try it', 'ArrowRight', 'accept'],
|
||||
['Pass', 'ArrowLeft', 'decline'],
|
||||
] as const)('%s with reduced motion is a 200 ms fade, with no bump, wiggle, fold or promotion', async (_, key, action) => {
|
||||
mockViewport(390, { reducedMotion: true })
|
||||
const server = stubServer([BOWLS, TIKKA, ORZO], {
|
||||
[`POST /api/v1/suggestions/s1/${action}`]: () => {
|
||||
server.deck = [TIKKA, ORZO]
|
||||
return action === 'accept' ? accepted(BOWLS) : declined(BOWLS)
|
||||
},
|
||||
})
|
||||
const animations = stubAnimate({ autoFinish: false })
|
||||
await renderPage()
|
||||
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||
await screen.findByText('Quest log · 6')
|
||||
|
||||
fireEvent.keyDown(window, { key })
|
||||
expect(animations).toHaveLength(1)
|
||||
expect(animations[0]!.target).toBe(card)
|
||||
expect(animations[0]!.keyframes).toEqual([{ opacity: 1 }, { opacity: 0 }])
|
||||
expect(animations[0]!.options).toEqual({ duration: 200, fill: 'forwards' })
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled())
|
||||
expect(sent(server.fetchMock)).toEqual([])
|
||||
|
||||
await finishRunning(animations)
|
||||
expect(sent(server.fetchMock)).toEqual([`POST /api/v1/suggestions/s1/${action}`])
|
||||
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled())
|
||||
expect(animations).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('puts the card back when its decision is refused, and unlocks', async () => {
|
||||
stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => errorResponse(500, 'internal') })
|
||||
const animations = stubAnimate({ autoFinish: false })
|
||||
await renderPage()
|
||||
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
|
||||
await finishRunning(animations)
|
||||
await finishRunning(animations)
|
||||
await screen.findByRole('alert')
|
||||
// The stamp, wiggle and throw are undone; the bump had ended.
|
||||
expect(animations.slice(1, 4).map((a) => a.playState)).toEqual(['idle', 'idle', 'idle'])
|
||||
expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('stops the exit and sends nothing when the page goes away mid-exit', async () => {
|
||||
const server = stubServer([BOWLS, TIKKA])
|
||||
const animations = stubAnimate({ autoFinish: false })
|
||||
const { unmount } = await renderPage()
|
||||
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
|
||||
unmount()
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(animations.slice(1).map((a) => a.playState)).toEqual(['idle', 'idle'])
|
||||
expect(sent(server.fetchMock)).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Heart, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type Ref } from 'react'
|
||||
import { useJob } from '../api/index.ts'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type Choice,
|
||||
} from './api.ts'
|
||||
import { DeckCard, DECK_DEPTH } from './DeckCard.tsx'
|
||||
import { bump, fadeOut, isAbort, pass, prefersReducedMotion, promote, tryIt, type Exit } from './motion.ts'
|
||||
import styles from './DiscoverPage.module.css'
|
||||
|
||||
// isEditable says whether a key press belongs to a field being typed in,
|
||||
@@ -32,7 +33,8 @@ function isEditable(target: EventTarget | null): boolean {
|
||||
|
||||
// QuestLogPill is the "Quest log · N" pill. It shows and hides the names
|
||||
// of the dinners in the Quest log; the Quest log sheet replaces this list.
|
||||
function QuestLogPill({ dinners }: { dinners: { id: string; name: string }[] }) {
|
||||
// ref is the visible pill, which Try it bumps.
|
||||
function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[]; ref: Ref<HTMLSpanElement> }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const listId = useId()
|
||||
return (
|
||||
@@ -44,7 +46,7 @@ function QuestLogPill({ dinners }: { dinners: { id: string; name: string }[] })
|
||||
aria-controls={open ? listId : undefined}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<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 +66,12 @@ function QuestLogPill({ dinners }: { dinners: { id: string; name: string }[] })
|
||||
}
|
||||
|
||||
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
|
||||
// at a time from the top card. Every decision — the buttons, the ← / →
|
||||
// keys, Retry — goes through submit, which does nothing while one is in
|
||||
// flight. The lock is a ref, set before the decision is sent, so a second
|
||||
// press before the next render cannot send another.
|
||||
// at a time from the top card. A decision from the buttons or the ← / → keys
|
||||
// throws the top card out (motion.ts) and sends the decision once it is
|
||||
// gone; Retry sends a refused one again. Input is locked from the press
|
||||
// until the decision settles: the lock is a ref, set before anything
|
||||
// starts, so a second press before the next render does nothing, and busy
|
||||
// mirrors it to disable the buttons.
|
||||
export function DiscoverPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const deck = useDeck()
|
||||
@@ -77,7 +81,7 @@ export function DiscoverPage() {
|
||||
const job = useJob<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,27 +98,90 @@ 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 { mutate: mutateDecision } = decision
|
||||
const deciding = useRef(false)
|
||||
const submit = useCallback(
|
||||
const lock = useCallback(() => {
|
||||
if (deciding.current) return false
|
||||
deciding.current = true
|
||||
setBusy(true)
|
||||
return true
|
||||
}, [])
|
||||
// send records a decision. A refused one puts the card back.
|
||||
const send = useCallback(
|
||||
(variables: { id: string; choice: Choice }) => {
|
||||
if (deciding.current) return
|
||||
deciding.current = true
|
||||
leaving.current = variables.id
|
||||
mutateDecision(variables, {
|
||||
onError: () => {
|
||||
exit.current?.cancel()
|
||||
exit.current = null
|
||||
leaving.current = null
|
||||
},
|
||||
onSettled: () => {
|
||||
deciding.current = false
|
||||
setBusy(false)
|
||||
},
|
||||
})
|
||||
},
|
||||
[mutateDecision],
|
||||
)
|
||||
const submit = useCallback(
|
||||
(variables: { id: string; choice: Choice }) => {
|
||||
if (lock()) send(variables)
|
||||
},
|
||||
[lock, send],
|
||||
)
|
||||
// decide bumps the button, throws the top card out — or, motion reduced,
|
||||
// fades it — then sends the decision; Try it bumps the Quest log pill too.
|
||||
const decide = useCallback(
|
||||
(choice: Choice) => {
|
||||
if (top !== undefined) submit({ id: top.id, choice })
|
||||
if (top === undefined || !lock()) return
|
||||
const card = deckRef.current!.querySelector('article')!
|
||||
const reduced = prefersReducedMotion()
|
||||
let run: Exit
|
||||
if (reduced) {
|
||||
run = fadeOut(card)
|
||||
} else {
|
||||
bump((choice === 'try' ? tryRef : passRef).current!)
|
||||
run = choice === 'try' ? tryIt(card) : pass(card)
|
||||
}
|
||||
exit.current = run
|
||||
run.finished.then(
|
||||
() => {
|
||||
// The Quest log pill is missing while the Quest log fails to load.
|
||||
if (choice === 'try' && !reduced && questLogRef.current !== null) bump(questLogRef.current)
|
||||
send({ id: top.id, choice })
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!isAbort(error)) throw error
|
||||
},
|
||||
)
|
||||
},
|
||||
[top, submit],
|
||||
[top, lock, send],
|
||||
)
|
||||
|
||||
// Leaving the page stops an exit before its decision is sent.
|
||||
useEffect(() => () => exit.current?.cancel(), [])
|
||||
|
||||
// Once the deck moves past the card a decision was sent for, the cards
|
||||
// behind it move up a depth.
|
||||
const topId = top?.id
|
||||
useLayoutEffect(() => {
|
||||
if (leaving.current === null || leaving.current === topId) return
|
||||
leaving.current = null
|
||||
exit.current = null
|
||||
if (topId === undefined || prefersReducedMotion()) return
|
||||
promote([...deckRef.current!.querySelectorAll<HTMLElement>(':scope > article')])
|
||||
}, [topId])
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
@@ -170,16 +237,16 @@ export function DiscoverPage() {
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
<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} />
|
||||
))}
|
||||
</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 +266,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>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
|
||||
import { bump, fadeOut, isAbort, pass, promote, tryIt } from './motion.ts'
|
||||
|
||||
let animations: FakeAnimation[]
|
||||
|
||||
beforeEach(() => {
|
||||
animations = stubAnimate({ autoFinish: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(Element.prototype, 'animate')
|
||||
})
|
||||
|
||||
// card builds a deck card with the parts DeckCard marks.
|
||||
function card() {
|
||||
const article = document.createElement('article')
|
||||
article.innerHTML = '<span data-part="try-stamp"></span><span data-part="shade"></span>'
|
||||
return {
|
||||
article,
|
||||
stamp: article.querySelector('[data-part="try-stamp"]')!,
|
||||
shade: article.querySelector('[data-part="shade"]')!,
|
||||
}
|
||||
}
|
||||
|
||||
// settled says whether a promise has settled after pending callbacks run.
|
||||
async function settled(promise: Promise<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('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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
// The deck's motion, from the handoff's Motion §2 (Try it), §3 (Pass),
|
||||
// §5 (deck advance) and §6 (reduced motion), with the Web Animations API.
|
||||
// The exits take the card's starting transform so the swipe gesture can
|
||||
// throw a dragged card from where it was let go.
|
||||
|
||||
const SPRING = 'cubic-bezier(.34,1.4,.64,1)'
|
||||
const THROW = 'cubic-bezier(.55,0,.8,.4)'
|
||||
const SOFT = 'cubic-bezier(.2,.8,.2,1)'
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
|
||||
|
||||
// REST is the top card's transform when nothing moves it, written as the
|
||||
// translate + rotate a drag gives it so the exits interpolate function by
|
||||
// function.
|
||||
export const REST = 'translate(0px,0px) rotate(0deg)'
|
||||
|
||||
// DEPTH_TRANSFORM is each deck depth's transform, as the stylesheet places it.
|
||||
export const DEPTH_TRANSFORM = ['translateY(0px) scale(1)', 'translateY(8px) scale(0.95)', 'translateY(16px) scale(0.9)'] as const
|
||||
|
||||
const FLY_OFF = 'translate(150%,-60px) rotate(28deg)'
|
||||
const PASS_ORIGIN = '0% 50%'
|
||||
|
||||
// An Exit is a card leaving the deck: finished resolves once it is off
|
||||
// screen and rejects with an AbortError if cancel stops it first. cancel
|
||||
// also undoes a finished exit, putting the card back.
|
||||
export type Exit = {
|
||||
finished: Promise<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)
|
||||
}
|
||||
|
||||
// fadeOut is both exits under prefers-reduced-motion: reduce.
|
||||
export function fadeOut(card: HTMLElement): Exit {
|
||||
const exit = recorder()
|
||||
return exit.done(exit.run(card, [{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }).finished)
|
||||
}
|
||||
|
||||
// promote moves the cards left after an exit, top first, from the depth
|
||||
// behind to their own.
|
||||
export function promote(cards: HTMLElement[]): Animation[] {
|
||||
return cards.slice(0, DEPTH_TRANSFORM.length - 1).map((card, depth) =>
|
||||
card.animate([{ transform: DEPTH_TRANSFORM[depth + 1] }, { transform: DEPTH_TRANSFORM[depth] }], {
|
||||
duration: 300,
|
||||
easing: SOFT,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// part is the card's element marked data-part="name", which DeckCard renders.
|
||||
function part(card: HTMLElement, name: string): HTMLElement {
|
||||
const element = card.querySelector<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())
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user