From f67b39f315444a68e0eb61f8819966fa9b9b508c Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sun, 18 Jan 2026 06:19:34 -0600 Subject: [PATCH] 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 --- .../flowchart/AnimatedMathDisplay.tsx | 90 +++++++++++++++++++ .../flowchart/FlowchartProblemInput.tsx | 26 +++--- 2 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/components/flowchart/AnimatedMathDisplay.tsx diff --git a/apps/web/src/components/flowchart/AnimatedMathDisplay.tsx b/apps/web/src/components/flowchart/AnimatedMathDisplay.tsx new file mode 100644 index 00000000..733a75c4 --- /dev/null +++ b/apps/web/src/components/flowchart/AnimatedMathDisplay.tsx @@ -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(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 ( + + {layers.map((layer) => ( + + + + ))} + + ) +} diff --git a/apps/web/src/components/flowchart/FlowchartProblemInput.tsx b/apps/web/src/components/flowchart/FlowchartProblemInput.tsx index e27758f3..ca47adda 100644 --- a/apps/web/src/components/flowchart/FlowchartProblemInput.tsx +++ b/apps/web/src/components/flowchart/FlowchartProblemInput.tsx @@ -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({ })} >
- @@ -1202,7 +1200,7 @@ export function FlowchartProblemInput({ })} >
- @@ -1306,7 +1304,7 @@ export function FlowchartProblemInput({ })} >
-