feat(practice): add smooth problem transition animation

- Add react-spring animation for transitioning between problems
- New problem fades in to the right, then track slides left to center it
- Outgoing problem fades out during the slide
- Use useLayoutEffect to prevent layout jank from flexbox recentering
- Use flushSync for smooth cleanup when removing outgoing problem
- Hide decomposition section when not meaningful (e.g., "5 = 5")
- Add DecompositionSection component for self-contained display
- Simplify HelpAbacus visibility with opacity + pointer-events

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-08 09:56:51 -06:00
parent 52ea3f10fa
commit b12112e8da
4 changed files with 597 additions and 180 deletions

View File

@ -1,7 +1,7 @@
'use client'
import type React from 'react'
import { createContext, useContext, useMemo } from 'react'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useDecomposition } from '@/contexts/DecompositionContext'
import type { PedagogicalSegment, UnifiedStepData } from '@/utils/unifiedStepGenerator'
import { ReasonTooltip } from './ReasonTooltip'
@ -150,8 +150,19 @@ export function DecompositionDisplay() {
setActiveTermIndices,
setActiveIndividualTermIndex,
getGroupTermIndicesFromTermIndex,
isMeaningfulDecomposition,
} = useDecomposition()
// Don't render if decomposition is not meaningful (e.g., "5 = 5")
if (!isMeaningfulDecomposition) {
return null
}
// Refs for overflow detection
const containerRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const [needsMultiLine, setNeedsMultiLine] = useState(false)
// Build a quick lookup: termIndex -> segment
const termIndexToSegment = useMemo(() => {
const map = new Map<number, PedagogicalSegment>()
@ -173,6 +184,33 @@ export function DecompositionDisplay() {
return null
}, [activeTermIndices, termIndexToSegment])
// Detect overflow and enable multi-line mode if needed
// Uses a hidden measurement element that always renders in single-line mode
useEffect(() => {
const container = containerRef.current
const measure = measureRef.current
if (!container || !measure) return
const checkOverflow = () => {
// measureRef always contains the single-line version, so we can
// reliably compare its width to the container width
const singleLineWidth = measure.scrollWidth
const containerWidth = container.clientWidth
// Add a small buffer (2px) to avoid edge cases
setNeedsMultiLine(singleLineWidth > containerWidth + 2)
}
// Check on mount and content changes
checkOverflow()
// Use ResizeObserver to detect container size changes
const resizeObserver = new ResizeObserver(checkOverflow)
resizeObserver.observe(container)
return () => resizeObserver.disconnect()
}, [fullDecomposition])
// Term hover handlers
const addActiveTerm = (termIndex: number, _segmentId?: string) => {
// Set individual term highlight (orange glow)
@ -199,19 +237,38 @@ export function DecompositionDisplay() {
setActiveTermIndices(new Set())
}
// Render elements with segment groupings
const renderElements = () => {
// Find positions of '=' signs in fullDecomposition for line breaking
const equalSignPositions = useMemo(() => {
const positions: number[] = []
for (let i = 0; i < fullDecomposition.length; i++) {
if (fullDecomposition[i] === '=') {
positions.push(i)
}
}
return positions
}, [fullDecomposition])
// Render elements for a given range of the decomposition string
const renderElementsForRange = (rangeStart: number, rangeEnd: number, keyPrefix: string) => {
const elements: React.ReactNode[] = []
let cursor = 0
let cursor = rangeStart
for (let termIndex = 0; termIndex < termPositions.length; termIndex++) {
const { startIndex, endIndex } = termPositions[termIndex]
// Skip terms outside our range
if (endIndex <= rangeStart || startIndex >= rangeEnd) continue
const segment = termIndexToSegment.get(termIndex)
// Add connector text before this term
if (cursor < startIndex) {
// Add connector text before this term (within range)
const connectorStart = Math.max(cursor, rangeStart)
const connectorEnd = Math.min(startIndex, rangeEnd)
if (connectorStart < connectorEnd) {
elements.push(
<span key={`connector-${cursor}`}>{fullDecomposition.slice(cursor, startIndex)}</span>
<span key={`${keyPrefix}-connector-${connectorStart}`}>
{fullDecomposition.slice(connectorStart, connectorEnd)}
</span>
)
}
@ -219,72 +276,131 @@ export function DecompositionDisplay() {
if (segment && segment.termIndices[0] === termIndex) {
// This is the first term of a segment - wrap all segment terms
const segmentElements: React.ReactNode[] = []
let segmentCursor = startIndex
let segmentCursor = Math.max(startIndex, rangeStart)
for (const segTermIndex of segment.termIndices) {
const segPos = termPositions[segTermIndex]
if (!segPos) continue
// Add connector within segment
if (segmentCursor < segPos.startIndex) {
// Skip segment terms outside our range
if (segPos.endIndex <= rangeStart || segPos.startIndex >= rangeEnd) continue
// Add connector within segment (within range)
const segConnectorStart = Math.max(segmentCursor, rangeStart)
const segConnectorEnd = Math.min(segPos.startIndex, rangeEnd)
if (segConnectorStart < segConnectorEnd) {
segmentElements.push(
<span key={`seg-connector-${segmentCursor}`}>
{fullDecomposition.slice(segmentCursor, segPos.startIndex)}
<span key={`${keyPrefix}-seg-connector-${segConnectorStart}`}>
{fullDecomposition.slice(segConnectorStart, segConnectorEnd)}
</span>
)
}
const segText = fullDecomposition.slice(segPos.startIndex, segPos.endIndex)
const termStart = Math.max(segPos.startIndex, rangeStart)
const termEnd = Math.min(segPos.endIndex, rangeEnd)
const segText = fullDecomposition.slice(termStart, termEnd)
segmentElements.push(
<TermSpan
key={`seg-term-${segTermIndex}`}
termIndex={segTermIndex}
text={segText}
segment={segment}
isCurrentStep={segTermIndex === currentStepIndex}
/>
)
if (segText) {
segmentElements.push(
<TermSpan
key={`${keyPrefix}-seg-term-${segTermIndex}`}
termIndex={segTermIndex}
text={segText}
segment={segment}
isCurrentStep={segTermIndex === currentStepIndex}
/>
)
}
segmentCursor = segPos.endIndex
}
elements.push(
<SegmentGroup key={`segment-${segment.id}`} segment={segment} steps={steps}>
{segmentElements}
</SegmentGroup>
)
if (segmentElements.length > 0) {
elements.push(
<SegmentGroup
key={`${keyPrefix}-segment-${segment.id}`}
segment={segment}
steps={steps}
>
{segmentElements}
</SegmentGroup>
)
}
// Skip ahead past all terms in this segment
const lastSegTermIndex = segment.termIndices[segment.termIndices.length - 1]
const lastSegPos = termPositions[lastSegTermIndex]
cursor = lastSegPos?.endIndex ?? endIndex
cursor = Math.min(lastSegPos?.endIndex ?? endIndex, rangeEnd)
termIndex = lastSegTermIndex // Will be incremented by for loop
} else if (!segment) {
// Regular term not in a segment
const termText = fullDecomposition.slice(startIndex, endIndex)
elements.push(
<TermSpan
key={`term-${termIndex}`}
termIndex={termIndex}
text={termText}
segment={segment}
isCurrentStep={termIndex === currentStepIndex}
/>
)
cursor = endIndex
const termStart = Math.max(startIndex, rangeStart)
const termEnd = Math.min(endIndex, rangeEnd)
const termText = fullDecomposition.slice(termStart, termEnd)
if (termText) {
elements.push(
<TermSpan
key={`${keyPrefix}-term-${termIndex}`}
termIndex={termIndex}
text={termText}
segment={segment}
isCurrentStep={termIndex === currentStepIndex}
/>
)
}
cursor = termEnd
}
// If this term is part of a segment but not the first, it was already handled above
}
// Add trailing text
if (cursor < fullDecomposition.length) {
elements.push(<span key="trailing">{fullDecomposition.slice(cursor)}</span>)
// Add trailing text within range
if (cursor < rangeEnd) {
elements.push(
<span key={`${keyPrefix}-trailing`}>{fullDecomposition.slice(cursor, rangeEnd)}</span>
)
}
return elements
}
// Render elements - either single line or multi-line split on '='
const renderElements = () => {
if (!needsMultiLine || equalSignPositions.length === 0) {
// Single line mode
return renderElementsForRange(0, fullDecomposition.length, 'single')
}
// Multi-line mode: split on '=' signs
// First line: everything before first '='
// Subsequent lines: start with '=' and go to next '=' (or end)
const lines: React.ReactNode[] = []
// First line: from start to first '='
const firstEqualPos = equalSignPositions[0]
if (firstEqualPos > 0) {
lines.push(
<div key="line-0" className="decomposition-line">
{renderElementsForRange(0, firstEqualPos, 'line-0')}
</div>
)
}
// Subsequent lines: each starts with '=' and goes to next '=' or end
for (let i = 0; i < equalSignPositions.length; i++) {
const lineStart = equalSignPositions[i]
const lineEnd = equalSignPositions[i + 1] ?? fullDecomposition.length
lines.push(
<div key={`line-${i + 1}`} className="decomposition-line">
{renderElementsForRange(lineStart, lineEnd, `line-${i + 1}`)}
</div>
)
}
return lines
}
return (
<InternalDecompositionContext.Provider
value={{
@ -294,9 +410,55 @@ export function DecompositionDisplay() {
removeActiveTerm,
}}
>
<div data-element="decomposition-display" className="decomposition">
{renderElements()}
<div
ref={containerRef}
data-element="decomposition-display"
className={`decomposition ${needsMultiLine ? 'decomposition--multiline' : ''}`}
>
{/* Hidden measurement element - always renders single-line to measure true width */}
<div ref={measureRef} className="decomposition-measure" aria-hidden="true">
{renderElementsForRange(0, fullDecomposition.length, 'measure')}
</div>
{/* Visible content - may be multi-line if overflow detected */}
<div className="decomposition-content">{renderElements()}</div>
</div>
</InternalDecompositionContext.Provider>
)
}
/**
* DecompositionSection - A self-contained section with label that hides when decomposition is not meaningful
*
* Use this when you want the entire section (including "Step-by-Step" label) to disappear
* when the decomposition is trivial (e.g., "5 = 5").
*
* Must be used inside a DecompositionProvider.
*/
export function DecompositionSection({
label = 'Step-by-Step',
className,
labelClassName,
contentClassName,
}: {
label?: string
className?: string
labelClassName?: string
contentClassName?: string
}) {
const { isMeaningfulDecomposition } = useDecomposition()
if (!isMeaningfulDecomposition) {
return null
}
return (
<div data-element="decomposition-section" className={className}>
<div data-element="decomposition-label" className={labelClassName}>
{label}
</div>
<div data-element="decomposition-content" className={contentClassName}>
<DecompositionDisplay />
</div>
</div>
)
}

View File

@ -1,7 +1,7 @@
// Decomposition Display Components
// Standalone decomposition visualization that works anywhere in the app
export { DecompositionDisplay } from './DecompositionDisplay'
export { DecompositionDisplay, DecompositionSection } from './DecompositionDisplay'
export { ReasonTooltip } from './ReasonTooltip'
export type { PedagogicalRule, PedagogicalSegment, TermReason } from './ReasonTooltip'

View File

@ -1,6 +1,8 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { animated, useSpring } from '@react-spring/web'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useTheme } from '@/contexts/ThemeContext'
import type {
GeneratedProblem,
@ -18,7 +20,7 @@ import {
generateSingleProblem,
} from '@/utils/problemGenerator'
import { css } from '../../../styled-system/css'
import { DecompositionDisplay, DecompositionProvider } from '../decomposition'
import { DecompositionProvider, DecompositionSection } from '../decomposition'
import { generateCoachHint } from './coachHintGenerator'
import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection'
import { NumericKeypad } from './NumericKeypad'
@ -47,6 +49,14 @@ interface CurrentProblem {
startTime: number
}
/** Snapshot of a problem that's animating out */
interface OutgoingProblem {
key: string
problem: GeneratedProblem
userAnswer: string
isCorrect: true
}
/**
* Get the part type description for display
*/
@ -216,6 +226,77 @@ export function ActiveSession({
// Track rejected digit for red X animation (null = no rejection, string = the rejected digit)
const [rejectedDigit, setRejectedDigit] = useState<string | null>(null)
// Problem transition animation state
const [outgoingProblem, setOutgoingProblem] = useState<OutgoingProblem | null>(null)
const [isTransitioning, setIsTransitioning] = useState(false)
// Refs for measuring problem widths during animation
const outgoingRef = useRef<HTMLDivElement>(null)
const activeRef = useRef<HTMLDivElement>(null)
// Track if we need to apply centering offset (set true when transition starts)
const needsCenteringOffsetRef = useRef(false)
// Store the centering offset value for the animation end
const centeringOffsetRef = useRef(0)
// Spring for problem transition animation
const [trackSpring, trackApi] = useSpring(() => ({
x: 0,
outgoingOpacity: 1,
activeOpacity: 1,
config: { tension: 200, friction: 26 },
}))
// Apply centering offset before paint to prevent jank
useLayoutEffect(() => {
if (needsCenteringOffsetRef.current && outgoingRef.current) {
const outgoingWidth = outgoingRef.current.offsetWidth
const gap = 32 // 2rem gap
const centeringOffset = (outgoingWidth + gap) / 2
centeringOffsetRef.current = centeringOffset
// Set initial position to compensate for flexbox centering
trackApi.set({
x: centeringOffset,
outgoingOpacity: 1,
activeOpacity: 0,
})
needsCenteringOffsetRef.current = false
// Start fade-in of new problem
trackApi.start({
activeOpacity: 1,
config: { tension: 200, friction: 26 },
})
// Start slide after a brief moment (150ms) - don't wait for fade-in to complete
// This eliminates the jarring pause between phases
setTimeout(() => {
trackApi.start({
x: -centeringOffset,
outgoingOpacity: 0,
config: { tension: 170, friction: 22 },
onRest: () => {
// Outgoing is now invisible (opacity 0).
// Remove it and reset X to 0 in the same synchronous batch
// so flexbox recentering and track reset happen together.
flushSync(() => {
setOutgoingProblem(null)
setIsTransitioning(false)
setFeedback('none')
setIsSubmitting(false)
setIncorrectAttempts(0)
setConfirmedTermCount(0)
})
// Reset spring immediately after DOM update
trackApi.set({ x: 0, outgoingOpacity: 1, activeOpacity: 1 })
},
})
}, 150)
}
}, [outgoingProblem, trackApi])
const hasPhysicalKeyboard = useHasPhysicalKeyboard()
// Compute all prefix sums for the current problem
@ -311,7 +392,8 @@ export function ActiveSession({
// Initialize or advance to current problem
useEffect(() => {
if (currentPart && currentSlot && !currentProblem) {
// Don't auto-load during transitions - startTransition handles this
if (currentPart && currentSlot && !currentProblem && !isTransitioning) {
// Generate problem from slot constraints (simplified for now)
const problem = currentSlot.problem || generateProblemFromConstraints(currentSlot.constraints)
setCurrentProblem({
@ -323,7 +405,14 @@ export function ActiveSession({
setUserAnswer('')
setFeedback('none')
}
}, [currentPart, currentSlot, currentPartIndex, currentSlotIndex, currentProblem])
}, [
currentPart,
currentSlot,
currentPartIndex,
currentSlotIndex,
currentProblem,
isTransitioning,
])
// Check if adding a digit would be consistent with any prefix sum
const isDigitConsistent = useCallback(
@ -388,6 +477,39 @@ export function ActiveSession({
}, 800) // 800ms delay to show "Perfect!" feedback
}, [helpTermIndex, currentProblem])
// Start transition animation to next problem
const startTransition = useCallback(
(nextProblem: GeneratedProblem, nextSlotIndex: number) => {
if (!currentProblem) return
// Mark that we need to apply centering offset in useLayoutEffect
needsCenteringOffsetRef.current = true
// Capture outgoing problem state
setOutgoingProblem({
key: `${currentProblem.partIndex}-${currentProblem.slotIndex}`,
problem: currentProblem.problem,
userAnswer: userAnswer,
isCorrect: true,
})
// Set up next problem immediately (it fades in on right side)
setCurrentProblem({
partIndex: currentPartIndex,
slotIndex: nextSlotIndex,
problem: nextProblem,
startTime: Date.now(),
})
setUserAnswer('')
setHelpTermIndex(null)
setCorrectionCount(0)
setAutoSubmitTriggered(false)
setIsTransitioning(true)
// Animation is triggered by useLayoutEffect when outgoingProblem changes
},
[currentProblem, userAnswer, currentPartIndex]
)
const handleSubmit = useCallback(async () => {
if (!currentProblem || isSubmitting || !userAnswer) return
@ -416,6 +538,8 @@ export function ActiveSession({
skillsExercised: currentProblem.problem.skillsRequired,
usedOnScreenAbacus: confirmedTermCount > 0 || helpTermIndex !== null,
incorrectAttempts,
// Help level: 1 if any abacus help was used, 0 otherwise (simplified from multi-level system)
helpLevelUsed: helpTermIndex !== null ? 1 : 0,
}
await onAnswer(result)
@ -423,17 +547,41 @@ export function ActiveSession({
// Wait for feedback display then advance
setTimeout(
() => {
setCurrentProblem(null)
setIncorrectAttempts(0)
setConfirmedTermCount(0)
setHelpTermIndex(null)
setIsSubmitting(false)
setCorrectionCount(0)
setAutoSubmitTriggered(false)
// Check if there's a next problem in this part
const nextSlotIndex = currentSlotIndex + 1
const nextSlot = currentPart?.slots[nextSlotIndex]
if (nextSlot && currentPart && isCorrect) {
// Has next problem - animate transition
const nextProblem =
nextSlot.problem || generateProblemFromConstraints(nextSlot.constraints)
startTransition(nextProblem, nextSlotIndex)
} else {
// End of part or incorrect - no animation, just clean up
setCurrentProblem(null)
setIncorrectAttempts(0)
setConfirmedTermCount(0)
setHelpTermIndex(null)
setIsSubmitting(false)
setCorrectionCount(0)
setAutoSubmitTriggered(false)
setFeedback('none')
}
},
isCorrect ? 500 : 1500
)
}, [currentProblem, isSubmitting, userAnswer, confirmedTermCount, helpTermIndex, onAnswer, incorrectAttempts])
}, [
currentProblem,
isSubmitting,
userAnswer,
confirmedTermCount,
helpTermIndex,
onAnswer,
incorrectAttempts,
currentSlotIndex,
currentPart,
startTransition,
])
// Auto-submit when correct answer is entered on first attempt (allow minor corrections)
useEffect(() => {
@ -458,7 +606,9 @@ export function ActiveSession({
// Handle keyboard input (placed after handleSubmit to avoid temporal dead zone)
useEffect(() => {
if (!hasPhysicalKeyboard || isPaused || !currentProblem || isSubmitting) return
// Block input during transitions
if (!hasPhysicalKeyboard || isPaused || !currentProblem || isSubmitting || isTransitioning)
return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
@ -480,6 +630,7 @@ export function ActiveSession({
isPaused,
currentProblem,
isSubmitting,
isTransitioning,
handleSubmit,
handleDigit,
handleBackspace,
@ -823,135 +974,175 @@ export function ActiveSession({
{currentSlot?.purpose}
</div>
{/* Problem display - horizontal layout with help panel on right when in help mode */}
{/* Problem display - centered, with help panel positioned outside */}
<div
data-element="problem-with-help"
className={css({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
gap: '1.5rem',
width: '100%',
overflow: 'hidden', // Clip during transition animation
})}
>
{/* Center: Problem display */}
{currentPart.format === 'vertical' ? (
<VerticalProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
size="large"
currentHelpTermIndex={helpTermIndex ?? undefined}
autoSubmitPending={autoSubmitTriggered}
rejectedDigit={rejectedDigit}
helpOverlay={
!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext ? (
<PracticeHelpOverlay
currentValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
columns={Math.max(
1,
Math.max(helpContext.currentValue, helpContext.targetValue).toString().length
)}
onTargetReached={handleTargetReached}
/>
) : undefined
}
/>
) : (
<LinearProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
isDark={isDark}
detectedPrefixIndex={
matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1
? matchedPrefixIndex
: undefined
}
/>
)}
{/* Right: Help panel with coach hint and decomposition (only in help mode) */}
{!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && (
<div
data-element="help-panel"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
padding: '1rem',
backgroundColor: isDark ? 'blue.900' : 'blue.50',
borderRadius: '12px',
border: '2px solid',
borderColor: isDark ? 'blue.700' : 'blue.200',
minWidth: '200px',
maxWidth: '280px',
})}
>
{/* Coach hint */}
{(() => {
const hint = generateCoachHint(helpContext.currentValue, helpContext.targetValue)
if (!hint) return null
return (
<div
data-element="coach-hint"
className={css({
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
})}
>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.700',
lineHeight: '1.4',
margin: 0,
})}
>
{hint}
</p>
</div>
)
})()}
{/* Decomposition display */}
<div
data-element="decomposition-display"
className={css({
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
whiteSpace: 'nowrap',
})}
{/* Animated track for problem transitions */}
<animated.div
data-element="problem-track"
style={{
display: 'flex',
alignItems: 'flex-start',
transform: trackSpring.x.to((x) => `translateX(${x}px)`),
}}
>
{/* Outgoing problem (slides left during transition) */}
{outgoingProblem && (
<animated.div
ref={outgoingRef}
data-element="outgoing-problem"
style={{
opacity: trackSpring.outgoingOpacity,
marginRight: '2rem',
position: 'relative' as const,
}}
>
<VerticalProblem
terms={outgoingProblem.problem.terms}
userAnswer={outgoingProblem.userAnswer}
isCompleted={true}
correctAnswer={outgoingProblem.problem.answer}
size="large"
/>
{/* Feedback stays with outgoing problem */}
<div
data-element="outgoing-feedback"
className={css({
fontSize: '0.625rem',
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '0.5rem',
padding: '0.5rem 1rem',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 'bold',
color: isDark ? 'blue.300' : 'blue.600',
marginBottom: '0.25rem',
textTransform: 'uppercase',
backgroundColor: isDark ? 'green.900' : 'green.100',
color: isDark ? 'green.200' : 'green.700',
whiteSpace: 'nowrap',
})}
>
Step-by-Step
Correct!
</div>
</animated.div>
)}
{/* Problem container - relative positioning for help panel */}
<animated.div
ref={activeRef}
data-element="problem-container"
style={{
opacity: trackSpring.activeOpacity,
position: 'relative' as const,
}}
>
{/* Problem display */}
{currentPart.format === 'vertical' ? (
<VerticalProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
size="large"
currentHelpTermIndex={helpTermIndex ?? undefined}
autoSubmitPending={autoSubmitTriggered}
rejectedDigit={rejectedDigit}
helpOverlay={
!isSubmitting &&
feedback === 'none' &&
helpTermIndex !== null &&
helpContext ? (
<PracticeHelpOverlay
currentValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
columns={Math.max(
1,
Math.max(helpContext.currentValue, helpContext.targetValue).toString()
.length
)}
onTargetReached={handleTargetReached}
/>
) : undefined
}
/>
) : (
<LinearProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
isDark={isDark}
detectedPrefixIndex={
matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1
? matchedPrefixIndex
: undefined
}
/>
)}
{/* Help panel - absolutely positioned to the right of the problem */}
{!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && (
<div
data-element="help-panel"
className={css({
fontFamily: 'monospace',
fontSize: '0.875rem',
color: isDark ? 'gray.100' : 'gray.800',
position: 'absolute',
left: '100%',
top: 0,
marginLeft: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
padding: '1rem',
backgroundColor: isDark ? 'blue.900' : 'blue.50',
borderRadius: '12px',
border: '2px solid',
borderColor: isDark ? 'blue.700' : 'blue.200',
minWidth: '200px',
maxWidth: '280px',
})}
>
{/* Coach hint */}
{(() => {
const hint = generateCoachHint(
helpContext.currentValue,
helpContext.targetValue
)
if (!hint) return null
return (
<div
data-element="coach-hint"
className={css({
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
})}
>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.700',
lineHeight: '1.4',
margin: 0,
})}
>
{hint}
</p>
</div>
)
})()}
{/* Decomposition display - hides when not meaningful */}
<DecompositionProvider
startValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
@ -961,12 +1152,33 @@ export function ActiveSession({
Math.max(helpContext.currentValue, helpContext.targetValue).toString().length
)}
>
<DecompositionDisplay />
<DecompositionSection
className={css({
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
whiteSpace: 'nowrap',
})}
labelClassName={css({
fontSize: '0.625rem',
fontWeight: 'bold',
color: isDark ? 'blue.300' : 'blue.600',
marginBottom: '0.25rem',
textTransform: 'uppercase',
})}
contentClassName={css({
fontFamily: 'monospace',
fontSize: '0.875rem',
color: isDark ? 'gray.100' : 'gray.800',
})}
/>
</DecompositionProvider>
</div>
</div>
</div>
)}
)}
</animated.div>
</animated.div>
</div>
{/* Feedback message */}

View File

@ -78,6 +78,22 @@ export function HelpAbacus({
// This is updated via onValueChange from AbacusReact
const [displayedValue, setDisplayedValue] = useState(currentValue)
// Track visibility state for animations
// 'entering' = fade in, 'visible' = fully shown, 'waiting' = target reached, waiting to exit
// 'exiting' = fade out, 'hidden' = removed from DOM
const [visibilityState, setVisibilityState] = useState<
'entering' | 'visible' | 'waiting' | 'exiting' | 'hidden'
>('entering')
// After mount, transition to visible
useEffect(() => {
if (visibilityState === 'entering') {
// Small delay to ensure CSS transition triggers
const timer = setTimeout(() => setVisibilityState('visible'), 50)
return () => clearTimeout(timer)
}
}, [visibilityState])
// Handle value changes from user interaction
const handleValueChange = useCallback(
(newValue: number | bigint) => {
@ -85,12 +101,31 @@ export function HelpAbacus({
setDisplayedValue(numValue)
onValueChange?.(numValue)
// Check if target reached
// If we just reached the target, mark that we're waiting for animation to complete
if (numValue === targetValue) {
onTargetReached?.()
setVisibilityState('waiting')
}
},
[targetValue, onTargetReached, onValueChange]
[onValueChange, targetValue]
)
// Handle value change complete (called after animations settle)
const handleValueChangeComplete = useCallback(
(newValue: number | bigint) => {
const numValue = typeof newValue === 'bigint' ? Number(newValue) : newValue
// Only trigger dismissal after animation completes
if (numValue === targetValue) {
// Wait a moment to let user see the completed state, then start exit animation
setTimeout(() => {
setVisibilityState('exiting')
// After exit animation completes, notify parent
setTimeout(() => {
onTargetReached?.()
}, 300) // Match CSS transition duration
}, 600) // Delay before starting exit
}
},
[targetValue, onTargetReached]
)
// Check if currently at target (for showing success state)
@ -148,20 +183,27 @@ export function HelpAbacus({
}
}, [isDark])
// When there are no changes left (target reached), return null
// The success feedback is shown via showTargetReached prop below
if (!hasChanges) {
return null
}
// Compute visibility - hidden when no changes and not animating
const isHidden = !hasChanges && visibilityState !== 'waiting' && visibilityState !== 'exiting'
const isEntering = visibilityState === 'entering'
const isExiting = visibilityState === 'exiting'
const shouldHide = isEntering || isExiting || isHidden
return (
<div
data-component="help-abacus"
data-visibility={visibilityState}
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.75rem',
// Animation properties
transition: 'opacity 0.3s ease-out, transform 0.3s ease-out',
opacity: shouldHide ? 0 : 1,
transform: shouldHide ? 'translateY(-10px)' : 'translateY(0)',
// Disable interaction when hidden/transitioning
pointerEvents: shouldHide ? 'none' : 'auto',
})}
>
{/* Summary instruction */}
@ -209,6 +251,7 @@ export function HelpAbacus({
showDirectionIndicators={!isAtTarget}
customStyles={customStyles}
onValueChange={handleValueChange}
onValueChangeComplete={handleValueChangeComplete}
overlays={overlays}
/>
</div>