From 70aae8c20593afd02bcf1d39fa7b9b345742df04 Mon Sep 17 00:00:00 2001 From: dotcomstar Date: Thu, 20 Aug 2026 20:44:48 -0400 Subject: [PATCH 1/2] Add typing pop, submit flip, win wave, and confetti animations Cells pop on typed letters, flip to reveal their scored color on a real submission (not skips), and bounce left-to-right when a question is won. A game-ending win holds the stats dialog behind a confetti burst that plays after the winning row's wave. All three visual animations respect prefers-reduced-motion. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0148VYECYKDyjEqmeNDb9hg6 --- package-lock.json | 19 +++ package.json | 2 + src/components/grid/Cell.tsx | 110 +++++++++++++--- src/components/grid/GameGrid.tsx | 4 + src/components/grid/GameRow.tsx | 10 ++ src/constants/settings.ts | 4 + src/pages/HomePage.tsx | 41 +++++- src/utils/animationTiming.ts | 16 +++ tests/components/grid/Cell.test.tsx | 163 +++++++++++++++++++++++- tests/components/grid/GameGrid.test.tsx | 32 +++++ tests/components/grid/GameRow.test.tsx | 27 ++++ tests/integration/routing.test.tsx | 8 +- tests/pages/HomePage.test.tsx | 74 ++++++++--- 13 files changed, 472 insertions(+), 38 deletions(-) create mode 100644 src/utils/animationTiming.ts diff --git a/package-lock.json b/package-lock.json index e0edaab..05fd696 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@tanstack/react-query-devtools": "^5.13.5", "@vercel/analytics": "^1.1.1", "axios": "^1.6.2", + "canvas-confetti": "^1.9.4", "copy-to-clipboard": "^3.3.3", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -29,6 +30,7 @@ "devDependencies": { "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^20.8.6", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", @@ -2396,6 +2398,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3217,6 +3226,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", diff --git a/package.json b/package.json index 09345b9..ed7284b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@tanstack/react-query-devtools": "^5.13.5", "@vercel/analytics": "^1.1.1", "axios": "^1.6.2", + "canvas-confetti": "^1.9.4", "copy-to-clipboard": "^3.3.3", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -34,6 +35,7 @@ "devDependencies": { "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^20.8.6", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", diff --git a/src/components/grid/Cell.tsx b/src/components/grid/Cell.tsx index d2e9471..b24997c 100644 --- a/src/components/grid/Cell.tsx +++ b/src/components/grid/Cell.tsx @@ -1,3 +1,4 @@ +import { keyframes } from "@emotion/react"; import { Box, PaletteColor, @@ -6,6 +7,7 @@ import { useMediaQuery, useTheme, } from "@mui/material"; +import { useEffect, useRef, useState } from "react"; import { ABSENT_TEXT, CORRECT_TEXT, @@ -13,7 +15,31 @@ import { SKIPPED_TEXT, SKIP_LETTER, } from "../../constants/strings"; -import { MOBILE_SCREEN_CUTOFF } from "../../constants/settings"; +import { + FLIP_ANIMATION_MS, + MOBILE_SCREEN_CUTOFF, + PULSE_TYPE_MS, + REVEAL_TIME_MS, + WAVE_BOUNCE_MS, +} from "../../constants/settings"; + +const popKeyframes = keyframes` + 0% { transform: scale(1); } + 40% { transform: scale(1.12); } + 100% { transform: scale(1); } +`; + +const flipKeyframes = keyframes` + 0% { transform: rotateX(0deg); } + 50% { transform: rotateX(-90deg); } + 100% { transform: rotateX(0deg); } +`; + +const bounceKeyframes = keyframes` + 0% { transform: translateY(0); } + 40% { transform: translateY(-20px); } + 100% { transform: translateY(0); } +`; interface CellProps { nthLetter: number; @@ -24,6 +50,7 @@ interface CellProps { fontColor?: string; alternateLean?: boolean; borderColorOverride?: string; + winBounceDelayMs?: number; } const Cell = ({ @@ -35,9 +62,62 @@ const Cell = ({ fontColor, alternateLean, borderColorOverride, + winBounceDelayMs, }: CellProps) => { const theme = useTheme(); const isNotMobile = useMediaQuery(`(min-width:${MOBILE_SCREEN_CUTOFF})`); + const prefersReducedMotion = useMediaQuery( + "(prefers-reduced-motion: reduce)" + ); + + // Whole-tile "pop" on a freshly typed letter. Retriggers every time this + // slot goes empty -> filled again (type, delete, retype), which a static + // animation value can't do on its own, so isPopping is explicitly flipped + // off and back on to force the browser to restart it. + const prevHadValueRef = useRef(!!value); + const [isPopping, setIsPopping] = useState(false); + useEffect(() => { + const wasEmpty = !prevHadValueRef.current; + prevHadValueRef.current = !!value; + if (!value || !wasEmpty || prefersReducedMotion) { + return; + } + setIsPopping(false); + const restart = setTimeout(() => setIsPopping(true), 0); + return () => clearTimeout(restart); + }, [value, prefersReducedMotion]); + useEffect(() => { + if (!isPopping) { + return; + } + const stop = setTimeout(() => setIsPopping(false), PULSE_TYPE_MS); + return () => clearTimeout(stop); + }, [isPopping]); + + // Flip-and-reveal on a real submission. Only fires the first time `status` + // goes from undefined to defined for this Cell instance (a live guess + // being scored) -- never on mount, so a page load or question-tab switch + // that mounts an already-scored past guess shows its color immediately + // instead of replaying the flip. + const prevHadStatusRef = useRef(!!status); + const [isFlipping, setIsFlipping] = useState(false); + const [displayStatus, setDisplayStatus] = useState(status); + useEffect(() => { + const wasUnrevealed = !prevHadStatusRef.current; + prevHadStatusRef.current = !!status; + if (!status || !wasUnrevealed) { + return; + } + if (prefersReducedMotion) { + setDisplayStatus(status); + return; + } + setIsFlipping(true); + const midpoint = + REVEAL_TIME_MS * (nthLetter - 1) + FLIP_ANIMATION_MS / 2; + const reveal = setTimeout(() => setDisplayStatus(status), midpoint); + return () => clearTimeout(reveal); + }, [status, prefersReducedMotion, nthLetter]); const getStatusText = (): string => { let statusText = ""; @@ -67,6 +147,16 @@ const Cell = ({ value ? (value === SKIP_LETTER ? SKIPPED_TEXT : value) : "empty" }${getStatusText()}`; + const animation = isPopping + ? `${popKeyframes} ${PULSE_TYPE_MS}ms ease-out` + : winBounceDelayMs !== undefined && !prefersReducedMotion + ? `${bounceKeyframes} ${WAVE_BOUNCE_MS}ms ease-out ${winBounceDelayMs}ms` + : isFlipping + ? `${flipKeyframes} ${FLIP_ANIMATION_MS}ms ease-in-out ${ + REVEAL_TIME_MS * (nthLetter - 1) + }ms` + : "none"; + return ( - // theme.transitions.create("background-color", { - // duration: REVEAL_TIME_MS, - // delay: REVEAL_TIME_MS * nthLetter, - // }), + animation, }} > diff --git a/src/components/grid/GameGrid.tsx b/src/components/grid/GameGrid.tsx index 6d5a2be..3a4e3ca 100644 --- a/src/components/grid/GameGrid.tsx +++ b/src/components/grid/GameGrid.tsx @@ -17,6 +17,7 @@ const GameGrid = () => { const answer = answerWithSpaces.replace(/\s+/g, "")!; const currGuess = useCurrGuessStore((s) => s.guess); const guesses = useGameStateStore((s) => s.guesses); + const questionState = useGameStateStore((s) => s.questionState); const hardMode = useHardModeStore((s) => s.hardMode); const theme = useTheme(); @@ -101,6 +102,9 @@ const GameGrid = () => { hardMode ? getBorderColorOverrides(g) : undefined } isPastGuess={gi < guessNumber[i]} + isWinningRow={ + questionState[i] === "won" && gi === guessNumber[i] - 1 + } /> // Past guesses ) ) diff --git a/src/components/grid/GameRow.tsx b/src/components/grid/GameRow.tsx index 7fead67..8719a8f 100644 --- a/src/components/grid/GameRow.tsx +++ b/src/components/grid/GameRow.tsx @@ -6,6 +6,8 @@ import useDailyIndex, { getPositiveIndex } from "../../hooks/useDailyIndex"; import useRetrievedStore from "../../stores/retrievedStore"; import useHardModeStore from "../../stores/hardModeStore"; import React from "react"; +import { WAVE_STEP_MS } from "../../constants/settings"; +import { getFlipTotalMs } from "../../utils/animationTiming"; interface GameRowProps { guess: string[]; @@ -13,6 +15,7 @@ interface GameRowProps { answerOverride?: string; // Used for the help dialog. isPastGuess?: boolean; borderColorOverride?: string; + isWinningRow?: boolean; } const GameRow = ({ @@ -21,6 +24,7 @@ const GameRow = ({ answerOverride, isPastGuess, borderColorOverride, + isWinningRow, }: GameRowProps) => { const hardMode = useHardModeStore((s) => s.hardMode); const dailyIndex = useDailyIndex(); @@ -57,6 +61,7 @@ const GameRow = ({ direction="row" justifyContent={answerOverride ? "left" : "center"} alignItems="center" + sx={{ perspective: "300px" }} > {guess.map((letter, i) => { let shouldSkip = false; @@ -74,6 +79,11 @@ const GameRow = ({ status={statuses[i]} borderColorOverride={borderColorOverride} alternateLean={!hardMode && prevLean === shouldSkip} + winBounceDelayMs={ + isWinningRow + ? getFlipTotalMs(guess.length) + WAVE_STEP_MS * i + : undefined + } /> {(inProgressHardMode || i < Math.max(guess.length - 1, answer.length - 1)) && ( // Prevents hanging box after the last letter diff --git a/src/constants/settings.ts b/src/constants/settings.ts index 36e62f8..164f435 100644 --- a/src/constants/settings.ts +++ b/src/constants/settings.ts @@ -4,6 +4,10 @@ export const ALERT_TIME_MS = 2000; export const LONG_ALERT_TIME_MS = 10000; export const REVEAL_TIME_MS = 100; export const PULSE_TYPE_MS = 150; +export const FLIP_ANIMATION_MS = 500; +export const WAVE_STEP_MS = 100; +export const WAVE_BOUNCE_MS = 350; +export const CONFETTI_LEAD_MS = 900; export const THEME_TRANSITION_TIME_MS = 100; export const MANUAL_OFFSET = -9; // Used to calibrate questions to days mapping export const MOBILE_SCREEN_CUTOFF = "600px"; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 34f3a0f..0fe14f4 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,4 +1,5 @@ import { Alert, Grid, Paper, useMediaQuery } from "@mui/material"; +import confetti from "canvas-confetti"; import { useEffect, useRef } from "react"; import GameGrid from "../components/grid/GameGrid"; import Keyboard from "../components/keyboard/Keyboard"; @@ -7,6 +8,7 @@ import ProgressBar from "../components/progressBar/ProgressBar"; import ExpandableText from "../components/question/ExpandableText"; import CustomizableText from "../components/question/custom/CustomizableText"; import { + CONFETTI_LEAD_MS, MAX_CHALLENGES, MOBILE_SCREEN_CUTOFF, QUESTIONS_PER_DAY, @@ -23,6 +25,7 @@ import useOnscreenKeyboardOnlyStore from "../stores/onscreenKeyboardOnlyStore"; import useStatsStore, { StatsStoreImport } from "../stores/statsStore"; import { safeParse } from "../utils/safeParse"; import { getAcceptableAnswers } from "../utils/acceptableAnswers"; +import { getFlipTotalMs, getWaveTotalMs } from "../utils/animationTiming"; // The shape of the two `localStorage` blobs HomePage persists/restores. // Built on the stores' own GameStateImport/StatsStoreImport (the canonical @@ -53,6 +56,9 @@ const HomePage = () => { } = useGameStateStore(); const isNotMobile = useMediaQuery(`(min-width:${MOBILE_SCREEN_CUTOFF})`); + const prefersReducedMotion = useMediaQuery( + "(prefers-reduced-motion: reduce)" + ); const dailyIndex = useDailyIndex(); const editing = useEditingStore((s) => s.editing); const safeIndex = useSafeQuestionIndex(); @@ -83,6 +89,18 @@ const HomePage = () => { .fill("") .map((_, i) => data[getPositiveIndex(dailyIndex + i)]?.category ?? ""); + // Confetti + the delayed stats-dialog open (on a game-ending win) are + // scheduled via setTimeout so they land after the winning row's flip and + // wave animations finish. Tracked in a ref so a navigation away mid-delay + // doesn't fire a state update on an unmounted component. + const gameEndTimeoutsRef = useRef[]>([]); + useEffect(() => { + const timeouts = gameEndTimeoutsRef.current; + return () => { + timeouts.forEach(clearTimeout); + }; + }, []); + // Save game state to local storage. const handleTabClosing = () => { const persistedGame: PersistedGame = { @@ -336,7 +354,28 @@ const HomePage = () => { questionsGuessedIn: todaysQuestionsGuessedIn, changedToday: todaysQuestionsGuessedIn.map((v) => v > 0), }); - setStatsOpen(true); + // On a win, hold the stats dialog back until the winning + // row's flip + wave animations have played, with a + // confetti burst after the wave and before the dialog. + if (hasOneMoreGuess && won && !prefersReducedMotion) { + const waveStartMs = getFlipTotalMs(guess.length); + const confettiMs = + waveStartMs + getWaveTotalMs(guess.length); + gameEndTimeoutsRef.current.push( + setTimeout(() => { + confetti({ + particleCount: 150, + spread: 70, + origin: { y: 0.6 }, + }); + }, confettiMs), + setTimeout(() => { + setStatsOpen(true); + }, confettiMs + CONFETTI_LEAD_MS) + ); + } else { + setStatsOpen(true); + } return; } if ( diff --git a/src/utils/animationTiming.ts b/src/utils/animationTiming.ts new file mode 100644 index 0000000..50837ce --- /dev/null +++ b/src/utils/animationTiming.ts @@ -0,0 +1,16 @@ +import { + FLIP_ANIMATION_MS, + REVEAL_TIME_MS, + WAVE_BOUNCE_MS, + WAVE_STEP_MS, +} from "../constants/settings"; + +// Total time for a submitted row's flip reveal to finish, including the +// per-letter stagger — the earliest moment it's safe to start anything that +// should wait for every tile to have flipped (e.g. the win wave). +export const getFlipTotalMs = (wordLength: number) => + REVEAL_TIME_MS * Math.max(wordLength - 1, 0) + FLIP_ANIMATION_MS; + +// Total time for a won row's letter-by-letter bounce wave to finish. +export const getWaveTotalMs = (wordLength: number) => + WAVE_STEP_MS * Math.max(wordLength - 1, 0) + WAVE_BOUNCE_MS; diff --git a/tests/components/grid/Cell.test.tsx b/tests/components/grid/Cell.test.tsx index 48b9d9a..9bc9957 100644 --- a/tests/components/grid/Cell.test.tsx +++ b/tests/components/grid/Cell.test.tsx @@ -1,8 +1,14 @@ import { useTheme } from "@mui/material"; -import { render, renderHook } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { act, render, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import Cell from "../../../src/components/grid/Cell"; import { SKIP_LETTER } from "../../../src/constants/strings"; +import { + FLIP_ANIMATION_MS, + PULSE_TYPE_MS, + REVEAL_TIME_MS, + WAVE_BOUNCE_MS, +} from "../../../src/constants/settings"; // Cell/GameRow compare `status === theme.palette.X` by strict reference, and // tests render with no ThemeProvider, so useTheme() resolves to MUI's @@ -12,6 +18,28 @@ import { SKIP_LETTER } from "../../../src/constants/strings"; const { result } = renderHook(() => useTheme()); const theme = result.current; +// tests/setup.ts's matchMedia polyfill always returns matches: false, which +// is what makes every animation test below exercise the "motion allowed" +// path by default. This override lets the reduced-motion tests flip just +// the prefers-reduced-motion query, without disturbing MOBILE_SCREEN_CUTOFF +// (also read via useMediaQuery in the same component). +const mockPrefersReducedMotion = (matches: boolean) => { + const original = window.matchMedia; + window.matchMedia = ((query: string) => ({ + matches: query.includes("prefers-reduced-motion") ? matches : false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; + return () => { + window.matchMedia = original; + }; +}; + describe("Cell", () => { it("renders an empty cell with no status text and no filled class", () => { render(); @@ -96,3 +124,134 @@ describe("Cell", () => { }); }); }); + +describe("Cell typing pop", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("does not pop on initial mount, even when already filled", () => { + render(); + const cell = document.querySelector('[aria-label^="1st letter, A"]'); + expect(getComputedStyle(cell as Element).animation).toBe("none"); + }); + + it("pops when a letter is typed into a previously empty slot", () => { + vi.useFakeTimers(); + const { rerender } = render(); + rerender(); + // The pop is applied via an off -> on state flip (a 0ms timeout) so it + // can restart on every retype, not just the first -- advance past it. + act(() => { + vi.advanceTimersByTime(0); + }); + const cell = document.querySelector('[aria-label^="1st letter, A"]'); + expect(getComputedStyle(cell as Element).animation).toContain( + `${PULSE_TYPE_MS}ms ease-out` + ); + }); + + it("does not pop when prefers-reduced-motion is set", () => { + const restore = mockPrefersReducedMotion(true); + vi.useFakeTimers(); + const { rerender } = render(); + rerender(); + act(() => { + vi.advanceTimersByTime(0); + }); + const cell = document.querySelector('[aria-label^="1st letter, A"]'); + expect(getComputedStyle(cell as Element).animation).toBe("none"); + restore(); + }); +}); + +describe("Cell submit flip + color reveal", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("does not flip when a past guess mounts with a status already set", () => { + render(); + const cell = document.querySelector('[aria-label^="1st letter, A"]'); + expect(getComputedStyle(cell as Element).animation).toBe("none"); + }); + + it("flips on a live status reveal, staggered by nthLetter, and holds the old color until the midpoint", () => { + vi.useFakeTimers(); + const nthLetter = 3; + const { rerender } = render(); + const cell = () => + document.querySelector('[aria-label^="3rd letter, A"]') as Element; + const colorBeforeReveal = getComputedStyle(cell()).backgroundColor; + + rerender( + + ); + expect(getComputedStyle(cell()).animation).toContain( + `${FLIP_ANIMATION_MS}ms ease-in-out ${REVEAL_TIME_MS * (nthLetter - 1)}ms` + ); + // Not revealed yet -- still shows the pre-flip color right up to the + // midpoint of this cell's (staggered) flip. + const midpoint = + REVEAL_TIME_MS * (nthLetter - 1) + FLIP_ANIMATION_MS / 2; + act(() => { + vi.advanceTimersByTime(midpoint - 1); + }); + expect(getComputedStyle(cell()).backgroundColor).toBe(colorBeforeReveal); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(getComputedStyle(cell()).backgroundColor).not.toBe( + colorBeforeReveal + ); + }); + + it("reveals the status immediately, with no flip, under prefers-reduced-motion", () => { + const restore = mockPrefersReducedMotion(true); + const { rerender } = render(); + const cell = () => + document.querySelector('[aria-label^="1st letter, A"]') as Element; + const colorBeforeReveal = getComputedStyle(cell()).backgroundColor; + + rerender(); + expect(getComputedStyle(cell()).animation).toBe("none"); + expect(getComputedStyle(cell()).backgroundColor).not.toBe( + colorBeforeReveal + ); + restore(); + }); + + it("never flips a skipped guess (its status stays undefined)", () => { + // GameGrid's getStatuses returns undefined entirely for a skipped + // guess, so a skipped Cell never receives a defined status prop and + // this component-level behavior alone keeps it flip-free. + render(); + const cell = document.querySelector('[aria-label^="1st letter"]'); + expect(getComputedStyle(cell as Element).animation).toBe("none"); + }); +}); + +describe("Cell win bounce", () => { + it("applies the bounce with the given delay when winBounceDelayMs is set", () => { + render(); + const cell = document.querySelector('[aria-label^="2nd letter, A"]'); + expect(getComputedStyle(cell as Element).animation).toContain( + `${WAVE_BOUNCE_MS}ms ease-out 250ms` + ); + }); + + it("does not bounce when winBounceDelayMs is not set", () => { + render(); + const cell = document.querySelector('[aria-label^="2nd letter, A"]'); + expect(getComputedStyle(cell as Element).animation).toBe("none"); + }); + + it("does not bounce under prefers-reduced-motion, even when winBounceDelayMs is set", () => { + const restore = mockPrefersReducedMotion(true); + render(); + const cell = document.querySelector('[aria-label^="2nd letter, A"]'); + expect(getComputedStyle(cell as Element).animation).toBe("none"); + restore(); + }); +}); diff --git a/tests/components/grid/GameGrid.test.tsx b/tests/components/grid/GameGrid.test.tsx index 9db4e79..b61c9ad 100644 --- a/tests/components/grid/GameGrid.test.tsx +++ b/tests/components/grid/GameGrid.test.tsx @@ -127,4 +127,36 @@ describe("GameGrid", () => { getComputedStyle(divider as Element).borderColor ); }); + + it("only bounces the guess that actually won the question, not an earlier wrong guess", () => { + useRetrievedStore.getState().setRetrieved(true); + // A wrong guess first, then the winning exact-match guess. + useGameStateStore.getState().makeGuess(Array(12).fill("Z")); + useGameStateStore.getState().makeGuess(Array.from("JOHANNESBURG")); + useGameStateStore.getState().winQuestion(0); + + render(); + + const wrongGuessCell = document.querySelector('[aria-label^="1st letter, Z"]'); + const winningGuessCell = document.querySelector( + '[aria-label="1st letter, J, correct"]' + ); + expect(getComputedStyle(wrongGuessCell as Element).animation).toBe("none"); + expect( + getComputedStyle(winningGuessCell as Element).animation + ).not.toBe("none"); + }); + + it("does not bounce any row for a question that hasn't been won", () => { + useRetrievedStore.getState().setRetrieved(true); + useGameStateStore.getState().makeGuess(Array.from("JOHANNESBURG")); + // Not calling winQuestion -- an exact-match letter pattern alone + // shouldn't be enough to trigger the wave without questionState saying + // "won". + + render(); + + const cell = document.querySelector('[aria-label="1st letter, J, correct"]'); + expect(getComputedStyle(cell as Element).animation).toBe("none"); + }); }); diff --git a/tests/components/grid/GameRow.test.tsx b/tests/components/grid/GameRow.test.tsx index d5c70d9..e20b6c1 100644 --- a/tests/components/grid/GameRow.test.tsx +++ b/tests/components/grid/GameRow.test.tsx @@ -1,10 +1,12 @@ import { render } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest"; import GameRow from "../../../src/components/grid/GameRow"; +import { WAVE_STEP_MS } from "../../../src/constants/settings"; import useCurrGuessStore from "../../../src/stores/currGuessStore"; import useGameStateStore from "../../../src/stores/gameStateStore"; import useHardModeStore from "../../../src/stores/hardModeStore"; import useRetrievedStore from "../../../src/stores/retrievedStore"; +import { getFlipTotalMs } from "../../../src/utils/animationTiming"; describe("GameRow", () => { beforeEach(() => { @@ -58,4 +60,29 @@ describe("GameRow", () => { expect(currentGuessEmptyCells).toBe(1); expect(pastGuessEmptyCells).toBe(0); }); + + it("gives each letter of a winning row a bounce delay staggered after the row's total flip time", () => { + render(); + + const cellAnimation = (label: string) => + getComputedStyle( + document.querySelector(`[aria-label^="${label}"]`) as Element + ).animation; + + const flipTotal = getFlipTotalMs(3); + expect(cellAnimation("1st letter, C")).toContain(`${flipTotal}ms`); + expect(cellAnimation("2nd letter, A")).toContain( + `${flipTotal + WAVE_STEP_MS}ms` + ); + expect(cellAnimation("3rd letter, T")).toContain( + `${flipTotal + WAVE_STEP_MS * 2}ms` + ); + }); + + it("does not bounce a non-winning row's letters", () => { + render(); + + const cell = document.querySelector('[aria-label^="1st letter, C"]'); + expect(getComputedStyle(cell as Element).animation).toBe("none"); + }); }); diff --git a/tests/integration/routing.test.tsx b/tests/integration/routing.test.tsx index e15885a..5523152 100644 --- a/tests/integration/routing.test.tsx +++ b/tests/integration/routing.test.tsx @@ -19,7 +19,11 @@ describe("app routing", () => { // Full-router render (now rendering 3 questions' worth of UI instead of // 1) is comfortably under a second alone, but can cross Vitest's default // 5s timeout under the parallel worker contention of a full `npm test` - // run — bump per-test rather than the global default. + // run — bump per-test rather than the global default. Bumped a second + // time after Cell.tsx gained the typing/flip/bounce animation hooks (two + // more useState/useRef/useEffect pairs and a useMediaQuery call per Cell, + // multiplied across every rendered row) pushed this past the first bump's + // 15s under the same full-suite contention. it( "loads the web page and routes to the home page at /", async () => { @@ -47,6 +51,6 @@ describe("app routing", () => { await screen.findByRole("button", { name: "ENTER key" }) ).toBeInTheDocument(); }, - 15000 + 30000 ); }); diff --git a/tests/pages/HomePage.test.tsx b/tests/pages/HomePage.test.tsx index c74ad2e..348a41b 100644 --- a/tests/pages/HomePage.test.tsx +++ b/tests/pages/HomePage.test.tsx @@ -3,7 +3,11 @@ import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import HomePage from "../../src/pages/HomePage"; import ThemedLayout from "../../src/components/ThemedLayout"; -import { MAX_CHALLENGES, QUESTIONS_PER_DAY } from "../../src/constants/settings"; +import { + CONFETTI_LEAD_MS, + MAX_CHALLENGES, + QUESTIONS_PER_DAY, +} from "../../src/constants/settings"; import useCurrGuessStore from "../../src/stores/currGuessStore"; import useDialogStore from "../../src/stores/dialogStore"; import useEditingStore from "../../src/stores/editingStore"; @@ -11,6 +15,13 @@ import useGameStateStore from "../../src/stores/gameStateStore"; import useHardModeStore from "../../src/stores/hardModeStore"; import useRetrievedStore from "../../src/stores/retrievedStore"; import useStatsStore from "../../src/stores/statsStore"; +import { getFlipTotalMs, getWaveTotalMs } from "../../src/utils/animationTiming"; + +// The real canvas-confetti call touches a 2D context jsdom doesn't +// implement -- stubbed the same way Keyboard is below, so the game-end +// timing tests can assert it fired without pulling in real canvas support. +const confettiMock = vi.hoisted(() => vi.fn()); +vi.mock("canvas-confetti", () => ({ default: confettiMock })); // Same Auth0 mock block as tests/integration/routing.test.tsx. HomePage // itself never calls useAuth0, but the NavBar it renders (and @@ -155,6 +166,7 @@ describe("HomePage gameplay", () => { // would otherwise sit on top of the page on every render. useDialogStore.getState().setLandingOpen(false); keyboardHolder.current = undefined; + confettiMock.mockClear(); }); describe("winning and losing guesses", () => { @@ -318,23 +330,49 @@ describe("HomePage gameplay", () => { }); it("ends the game as won once every question is won, and records per-question and per-category stats", () => { - renderHomePage(); - - typeGuess("CAT"); - pressEnter(); // question 0 won - act(() => { - useGameStateStore.getState().moveToQuestion(1); - }); - typeGuess("DOG"); - pressEnter(); // question 1 won - act(() => { - useGameStateStore.getState().moveToQuestion(2); - }); - typeGuess("SUN"); - pressEnter(); // question 2 won -- the final question, triggers game-end - - expect(useGameStateStore.getState().gameState).toBe("won"); - expect(useDialogStore.getState().isStatsOpen).toBe(true); + // A game-ending win holds the stats dialog behind the winning row's + // flip + wave animations and a confetti burst (HomePage.tsx's onEnter + // handler) instead of opening it synchronously -- fake timers let this + // test advance past that delay deterministically instead of racing it. + vi.useFakeTimers(); + try { + renderHomePage(); + + typeGuess("CAT"); + pressEnter(); // question 0 won + act(() => { + useGameStateStore.getState().moveToQuestion(1); + }); + typeGuess("DOG"); + pressEnter(); // question 1 won + act(() => { + useGameStateStore.getState().moveToQuestion(2); + }); + typeGuess("SUN"); + pressEnter(); // question 2 won -- the final question, triggers game-end + + expect(useGameStateStore.getState().gameState).toBe("won"); + // Neither confetti nor the stats dialog have fired yet -- both are + // still waiting on the scheduled delay. + expect(confettiMock).not.toHaveBeenCalled(); + expect(useDialogStore.getState().isStatsOpen).toBe(false); + + const wordLength = "SUN".length; + const confettiDelayMs = + getFlipTotalMs(wordLength) + getWaveTotalMs(wordLength); + act(() => { + vi.advanceTimersByTime(confettiDelayMs); + }); + expect(confettiMock).toHaveBeenCalledTimes(1); + expect(useDialogStore.getState().isStatsOpen).toBe(false); + + act(() => { + vi.advanceTimersByTime(CONFETTI_LEAD_MS); + }); + expect(useDialogStore.getState().isStatsOpen).toBe(true); + } finally { + vi.useRealTimers(); + } const stats = useStatsStore.getState(); expect(stats.numQuestionsAttempted).toBe(QUESTIONS_PER_DAY); From 9effaa28e6a8df2f9fe05ed3434f137a2999e091 Mon Sep 17 00:00:00 2001 From: dotcomstar Date: Wed, 2 Sep 2026 14:21:11 -0400 Subject: [PATCH 2/2] Fix winning row animation sequence --- Claude-notes/code-review-2026-09-02.md | 47 +++++++++++++++++++++++++ src/components/grid/Cell.tsx | 39 ++++++++++++++------ tests/components/grid/Cell.test.tsx | 33 ++++++++++++++--- tests/components/grid/GameGrid.test.tsx | 6 ++-- tests/components/grid/GameRow.test.tsx | 15 +++----- 5 files changed, 109 insertions(+), 31 deletions(-) create mode 100644 Claude-notes/code-review-2026-09-02.md diff --git a/Claude-notes/code-review-2026-09-02.md b/Claude-notes/code-review-2026-09-02.md new file mode 100644 index 0000000..582fff7 --- /dev/null +++ b/Claude-notes/code-review-2026-09-02.md @@ -0,0 +1,47 @@ +# Triviale Code Review — 2026-09-02 + +Review of `70aae8c` (typing, flip, win-wave, and confetti animations), with +follow-up fixes started in the same working session. + +## Findings + +### Resolved — winning rows suppressed the flip animation + +`src/components/grid/Cell.tsx:150-158` (pre-fix) + +On a live winning submission, a Cell received its newly scored `status` and +`winBounceDelayMs` in the same render. The animation conditional selected the +delayed bounce before it considered `isFlipping`, so the effect that later set +`isFlipping` could not make the flip visible. The confetti/dialog delay still +waited for the theoretical flip duration, but the user only saw the wave. + +Fixed by composing the non-overlapping flip and delayed bounce into one CSS +animation value. The regression test now asserts that a live winning score +contains both animations. + +### Resolved — completed rows replayed their win wave on mount + +`src/components/grid/GameGrid.tsx:105-107` and `src/components/grid/Cell.tsx` +(pre-fix) + +Every mounted final row for a won question received `winBounceDelayMs`. That +replayed the wave after restoring a completed game or returning to a completed +question tab, unlike the intended no-replay behavior of score flips. + +Fixed by starting the wave only when the Cell transitions from unscored to +scored. A Cell that mounts with existing scored state stays still. + +### Resolved — reduced-motion changes could leave a score mid-flip + +`src/components/grid/Cell.tsx:108-120` (pre-fix) + +If the system preference changed to reduced motion after a status arrived, the +effect treated the status as no longer new and returned before revealing it or +clearing the active flip. The preference now independently reveals any pending +status and clears both animation states immediately. + +## Verification + +- `npm run lint` +- `npm test -- --run` +- `npm run build` diff --git a/src/components/grid/Cell.tsx b/src/components/grid/Cell.tsx index b24997c..105d414 100644 --- a/src/components/grid/Cell.tsx +++ b/src/components/grid/Cell.tsx @@ -101,23 +101,36 @@ const Cell = ({ // instead of replaying the flip. const prevHadStatusRef = useRef(!!status); const [isFlipping, setIsFlipping] = useState(false); + const [isWaving, setIsWaving] = useState(false); const [displayStatus, setDisplayStatus] = useState(status); useEffect(() => { const wasUnrevealed = !prevHadStatusRef.current; prevHadStatusRef.current = !!status; - if (!status || !wasUnrevealed) { + + // This setting can change while a flip is waiting to reveal its color. + // Honor it immediately instead of requiring the Cell to receive a new + // status prop before it can leave the in-progress animation state. + if (prefersReducedMotion) { + if (status) { + setDisplayStatus(status); + } + setIsFlipping(false); + setIsWaving(false); return; } - if (prefersReducedMotion) { - setDisplayStatus(status); + if (!status || !wasUnrevealed) { return; } setIsFlipping(true); + // A wave is meaningful only for the live transition from an unscored + // Cell. Persisted/revisited winning rows mount with a status already set + // and therefore remain still. + setIsWaving(winBounceDelayMs !== undefined); const midpoint = REVEAL_TIME_MS * (nthLetter - 1) + FLIP_ANIMATION_MS / 2; const reveal = setTimeout(() => setDisplayStatus(status), midpoint); return () => clearTimeout(reveal); - }, [status, prefersReducedMotion, nthLetter]); + }, [status, prefersReducedMotion, nthLetter, winBounceDelayMs]); const getStatusText = (): string => { let statusText = ""; @@ -149,13 +162,17 @@ const Cell = ({ const animation = isPopping ? `${popKeyframes} ${PULSE_TYPE_MS}ms ease-out` - : winBounceDelayMs !== undefined && !prefersReducedMotion - ? `${bounceKeyframes} ${WAVE_BOUNCE_MS}ms ease-out ${winBounceDelayMs}ms` - : isFlipping - ? `${flipKeyframes} ${FLIP_ANIMATION_MS}ms ease-in-out ${ - REVEAL_TIME_MS * (nthLetter - 1) - }ms` - : "none"; + : [ + isFlipping && + `${flipKeyframes} ${FLIP_ANIMATION_MS}ms ease-in-out ${ + REVEAL_TIME_MS * (nthLetter - 1) + }ms`, + isWaving && + !prefersReducedMotion && + `${bounceKeyframes} ${WAVE_BOUNCE_MS}ms ease-out ${winBounceDelayMs}ms`, + ] + .filter(Boolean) + .join(", ") || "none"; return ( { }); describe("Cell win bounce", () => { - it("applies the bounce with the given delay when winBounceDelayMs is set", () => { - render(); + it("plays a live winning reveal as a flip followed by a delayed bounce", () => { + const { rerender } = render( + + ); const cell = document.querySelector('[aria-label^="2nd letter, A"]'); - expect(getComputedStyle(cell as Element).animation).toContain( + expect(getComputedStyle(cell as Element).animation).toBe("none"); + + rerender( + + ); + const animation = getComputedStyle(cell as Element).animation; + expect(animation).toContain( + `${FLIP_ANIMATION_MS}ms ease-in-out ${REVEAL_TIME_MS}ms` + ); + expect(animation).toContain( `${WAVE_BOUNCE_MS}ms ease-out 250ms` ); }); - it("does not bounce when winBounceDelayMs is not set", () => { - render(); + it("does not replay a bounce when a scored winning cell mounts", () => { + render( + + ); const cell = document.querySelector('[aria-label^="2nd letter, A"]'); expect(getComputedStyle(cell as Element).animation).toBe("none"); }); diff --git a/tests/components/grid/GameGrid.test.tsx b/tests/components/grid/GameGrid.test.tsx index b61c9ad..1179011 100644 --- a/tests/components/grid/GameGrid.test.tsx +++ b/tests/components/grid/GameGrid.test.tsx @@ -128,7 +128,7 @@ describe("GameGrid", () => { ); }); - it("only bounces the guess that actually won the question, not an earlier wrong guess", () => { + it("does not replay a winning-row bounce when a completed question is mounted", () => { useRetrievedStore.getState().setRetrieved(true); // A wrong guess first, then the winning exact-match guess. useGameStateStore.getState().makeGuess(Array(12).fill("Z")); @@ -142,9 +142,7 @@ describe("GameGrid", () => { '[aria-label="1st letter, J, correct"]' ); expect(getComputedStyle(wrongGuessCell as Element).animation).toBe("none"); - expect( - getComputedStyle(winningGuessCell as Element).animation - ).not.toBe("none"); + expect(getComputedStyle(winningGuessCell as Element).animation).toBe("none"); }); it("does not bounce any row for a question that hasn't been won", () => { diff --git a/tests/components/grid/GameRow.test.tsx b/tests/components/grid/GameRow.test.tsx index e20b6c1..d1ba88d 100644 --- a/tests/components/grid/GameRow.test.tsx +++ b/tests/components/grid/GameRow.test.tsx @@ -1,12 +1,10 @@ import { render } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest"; import GameRow from "../../../src/components/grid/GameRow"; -import { WAVE_STEP_MS } from "../../../src/constants/settings"; import useCurrGuessStore from "../../../src/stores/currGuessStore"; import useGameStateStore from "../../../src/stores/gameStateStore"; import useHardModeStore from "../../../src/stores/hardModeStore"; import useRetrievedStore from "../../../src/stores/retrievedStore"; -import { getFlipTotalMs } from "../../../src/utils/animationTiming"; describe("GameRow", () => { beforeEach(() => { @@ -61,7 +59,7 @@ describe("GameRow", () => { expect(pastGuessEmptyCells).toBe(0); }); - it("gives each letter of a winning row a bounce delay staggered after the row's total flip time", () => { + it("does not replay a wave when a winning row mounts from existing state", () => { render(); const cellAnimation = (label: string) => @@ -69,14 +67,9 @@ describe("GameRow", () => { document.querySelector(`[aria-label^="${label}"]`) as Element ).animation; - const flipTotal = getFlipTotalMs(3); - expect(cellAnimation("1st letter, C")).toContain(`${flipTotal}ms`); - expect(cellAnimation("2nd letter, A")).toContain( - `${flipTotal + WAVE_STEP_MS}ms` - ); - expect(cellAnimation("3rd letter, T")).toContain( - `${flipTotal + WAVE_STEP_MS * 2}ms` - ); + expect(cellAnimation("1st letter, C")).toBe("none"); + expect(cellAnimation("2nd letter, A")).toBe("none"); + expect(cellAnimation("3rd letter, T")).toBe("none"); }); it("does not bounce a non-winning row's letters", () => {