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:
parent
52ea3f10fa
commit
b12112e8da
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue