Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions Claude-notes/code-review-2026-09-02.md
Original file line number Diff line number Diff line change
@@ -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`
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
127 changes: 112 additions & 15 deletions src/components/grid/Cell.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { keyframes } from "@emotion/react";
import {
Box,
PaletteColor,
Expand All @@ -6,14 +7,39 @@ import {
useMediaQuery,
useTheme,
} from "@mui/material";
import { useEffect, useRef, useState } from "react";
import {
ABSENT_TEXT,
CORRECT_TEXT,
PRESENT_TEXT,
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;
Expand All @@ -24,6 +50,7 @@ interface CellProps {
fontColor?: string;
alternateLean?: boolean;
borderColorOverride?: string;
winBounceDelayMs?: number;
}

const Cell = ({
Expand All @@ -35,9 +62,75 @@ 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 [isWaving, setIsWaving] = useState(false);
const [displayStatus, setDisplayStatus] = useState(status);
useEffect(() => {
const wasUnrevealed = !prevHadStatusRef.current;
prevHadStatusRef.current = !!status;

// 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 (!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, winBounceDelayMs]);

const getStatusText = (): string => {
let statusText = "";
Expand Down Expand Up @@ -67,6 +160,20 @@ const Cell = ({
value ? (value === SKIP_LETTER ? SKIPPED_TEXT : value) : "empty"
}${getStatusText()}`;

const animation = isPopping
? `${popKeyframes} ${PULSE_TYPE_MS}ms ease-out`
: [
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 (
<Box
className={value ? "Triviale-filled" : ""}
Expand All @@ -76,36 +183,26 @@ const Cell = ({
justifyContent="center"
alignItems="center"
sx={{
border: status && !borderColorOverride ? "none" : "2px solid",
border: displayStatus && !borderColorOverride ? "none" : "2px solid",
borderColor:
borderColorOverride ||
`${value ? "primary.light" : "primary.darker"}`,
borderRadius: 10,
height: isNotMobile ? "52px" : "48px",
width: "52px",
backgroundColor: status?.main || "info.dark",
backgroundColor: displayStatus?.main || "info.dark",
overflow: "clip",
borderTopLeftRadius: "100px",
borderTopRightRadius: alternateLean ? undefined : "100px",
borderBottomLeftRadius: alternateLean ? "100px" : undefined,
borderBottomRightRadius: "100px",
// "&.Triviale-filled": {
// transitionTimingFunction: "cubic-bezier(.05, 2, 1, 1)",
// transitionDuration: `${REVEAL_TIME_MS}ms`,
// transitionProperty: "background-color",
// animationDelay: `${REVEAL_TIME_MS * nthLetter}ms`,
// },
// transition: () =>
// theme.transitions.create("background-color", {
// duration: REVEAL_TIME_MS,
// delay: REVEAL_TIME_MS * nthLetter,
// }),
animation,
}}
>
<Zoom in={!!value} easing={"cubic-bezier(.05, 2, 1, 1)"}>
<Typography
fontSize={fontSizeOverride ? fontSizeOverride : "1.5em"}
color={fontColor ? fontColor : status?.contrastText}
color={fontColor ? fontColor : displayStatus?.contrastText}
fontWeight={"bold"}
variant={isH3 ? "h3" : "body1"}
>
Expand Down
4 changes: 4 additions & 0 deletions src/components/grid/GameGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -101,6 +102,9 @@ const GameGrid = () => {
hardMode ? getBorderColorOverrides(g) : undefined
}
isPastGuess={gi < guessNumber[i]}
isWinningRow={
questionState[i] === "won" && gi === guessNumber[i] - 1
}
/> // Past guesses
)
)
Expand Down
10 changes: 10 additions & 0 deletions src/components/grid/GameRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ 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[];
statuses?: PaletteColor[];
answerOverride?: string; // Used for the help dialog.
isPastGuess?: boolean;
borderColorOverride?: string;
isWinningRow?: boolean;
}

const GameRow = ({
Expand All @@ -21,6 +24,7 @@ const GameRow = ({
answerOverride,
isPastGuess,
borderColorOverride,
isWinningRow,
}: GameRowProps) => {
const hardMode = useHardModeStore((s) => s.hardMode);
const dailyIndex = useDailyIndex();
Expand Down Expand Up @@ -57,6 +61,7 @@ const GameRow = ({
direction="row"
justifyContent={answerOverride ? "left" : "center"}
alignItems="center"
sx={{ perspective: "300px" }}
>
{guess.map((letter, i) => {
let shouldSkip = false;
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/constants/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading