Add crossfade animation to example problems synced with dice roll

- Create AnimatedMathDisplay component with opacity crossfade transitions
- Use CSS Grid to stack old/new expressions independently (no layout shift)
- Update examples at roll start (handleRoll) instead of roll complete
- Set crossfade duration to 700ms to match dice animation timing
- Both animations now end together for smooth visual experience

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-18 06:19:34 -06:00
parent bb192c5e20
commit f67b39f315
2 changed files with 102 additions and 14 deletions

View File

@ -0,0 +1,90 @@
'use client'
import { useRef, useEffect, useState } from 'react'
import { css } from '../../../styled-system/css'
import { MathDisplay } from './MathDisplay'
interface AnimatedMathDisplayProps {
/** Math expression string to render (e.g., "52 37" or "3 2/9 1 1/2") */
expression: string
/** Font size for the display */
size?: 'sm' | 'md' | 'lg' | 'xl'
/** Animation duration in ms (default: 700 to match dice animation) */
duration?: number
}
/**
* AnimatedMathDisplay - MathML display with crossfade transitions
*
* When the expression changes, the old expression fades out while
* the new expression fades in (inverse opacity). Uses CSS Grid to
* stack layers independently.
*/
export function AnimatedMathDisplay({
expression,
size = 'lg',
duration = 700,
}: AnimatedMathDisplayProps) {
const prevExpressionRef = useRef<string>(expression)
const [layers, setLayers] = useState<
Array<{ expression: string; opacity: number; id: number }>
>([{ expression, opacity: 1, id: 0 }])
const idCounter = useRef(1)
useEffect(() => {
if (expression !== prevExpressionRef.current) {
const oldExpression = prevExpressionRef.current
prevExpressionRef.current = expression
const newId = idCounter.current++
// Add new layer at opacity 0, keep old layer at opacity 1
setLayers([
{ expression: oldExpression, opacity: 1, id: newId - 1 },
{ expression, opacity: 0, id: newId },
])
// After a frame, trigger the crossfade
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setLayers([
{ expression: oldExpression, opacity: 0, id: newId - 1 },
{ expression, opacity: 1, id: newId },
])
})
})
// After animation, remove the old layer
const timeout = setTimeout(() => {
setLayers([{ expression, opacity: 1, id: newId }])
}, duration + 50)
return () => clearTimeout(timeout)
}
}, [expression, duration])
return (
<span
data-testid="animated-math-display"
data-expression={expression}
className={css({
display: 'inline-grid',
placeItems: 'center',
})}
>
{layers.map((layer) => (
<span
key={layer.id}
style={{
gridArea: '1 / 1',
opacity: layer.opacity,
transition: `opacity ${duration}ms ease-in-out`,
}}
>
<MathDisplay expression={layer.expression} size={size} />
</span>
))}
</span>
)
}

View File

@ -28,7 +28,7 @@ import { TeacherConfigPanel } from './TeacherConfigPanel'
import { useVisualDebugSafe } from '@/contexts/VisualDebugContext'
import { css } from '../../../styled-system/css'
import { vstack, hstack } from '../../../styled-system/patterns'
import { MathDisplay } from './MathDisplay'
import { AnimatedMathDisplay } from './AnimatedMathDisplay'
/** Difficulty tier for filtering examples */
type DifficultyTier = 'easy' | 'medium' | 'hard' | 'all'
@ -352,8 +352,13 @@ export function FlowchartProblemInput({
pendingExamplesRef.current = generateExamplesAsync(flowchart, exampleCount, constraints)
}, [flowchart, exampleCount, constraints])
// Show new examples after the dice roll animation completes
const handleRollComplete = useCallback(async () => {
// Dice roll animation complete - examples already updated in handleRoll
const handleRollComplete = useCallback(() => {
// No-op: examples are now updated at roll start (handleRoll) so crossfade syncs with dice
}, [])
// Handler for dice roll - update examples immediately so crossfade syncs with dice animation
const handleRoll = useCallback(async () => {
let newExamples: GeneratedExample[] | null = null
// If we have pre-computed examples from drag (promise), await them
@ -365,7 +370,7 @@ export function FlowchartProblemInput({
}
pendingExamplesRef.current = null
} else if (flowchart) {
// Click-only roll - compute via worker (animation masked the compute time)
// Click-only roll - compute via worker
try {
newExamples = await generateExamplesAsync(flowchart, exampleCount, constraints)
} catch (e) {
@ -386,13 +391,6 @@ export function FlowchartProblemInput({
}
}, [flowchart, exampleCount, constraints, storageKey])
// Handler for dice roll - we don't update examples here anymore
// (examples update on completion via handleRollComplete)
const handleRoll = useCallback(() => {
// The actual example update happens in handleRollComplete
// This is called immediately when rolled - could add a spinning indicator here
}, [])
// Handler for instant roll (shift+click) - bypasses animation
const handleInstantRoll = useCallback(async () => {
if (!flowchart) return
@ -1066,7 +1064,7 @@ export function FlowchartProblemInput({
})}
>
<div className={css({ color: { base: 'gray.900', _dark: 'white' } })}>
<MathDisplay
<AnimatedMathDisplay
expression={formatExampleDisplay(schema.schema, example.values)}
size="md"
/>
@ -1202,7 +1200,7 @@ export function FlowchartProblemInput({
})}
>
<div className={css({ color: { base: 'gray.900', _dark: 'white' } })}>
<MathDisplay
<AnimatedMathDisplay
expression={formatExampleDisplay(schema.schema, example.values)}
size="md"
/>
@ -1306,7 +1304,7 @@ export function FlowchartProblemInput({
})}
>
<div className={css({ color: { base: 'gray.900', _dark: 'white' } })}>
<MathDisplay
<AnimatedMathDisplay
expression={formatExampleDisplay(schema.schema, example.values)}
size="md"
/>