diff --git a/apps/web/src/components/decomposition/DecompositionDisplay.tsx b/apps/web/src/components/decomposition/DecompositionDisplay.tsx index a4b96e1a..f8f7e48a 100644 --- a/apps/web/src/components/decomposition/DecompositionDisplay.tsx +++ b/apps/web/src/components/decomposition/DecompositionDisplay.tsx @@ -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(null) + const measureRef = useRef(null) + const [needsMultiLine, setNeedsMultiLine] = useState(false) + // Build a quick lookup: termIndex -> segment const termIndexToSegment = useMemo(() => { const map = new Map() @@ -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( - {fullDecomposition.slice(cursor, startIndex)} + + {fullDecomposition.slice(connectorStart, connectorEnd)} + ) } @@ -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( - - {fullDecomposition.slice(segmentCursor, segPos.startIndex)} + + {fullDecomposition.slice(segConnectorStart, segConnectorEnd)} ) } - 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( - - ) + if (segText) { + segmentElements.push( + + ) + } segmentCursor = segPos.endIndex } - elements.push( - - {segmentElements} - - ) + if (segmentElements.length > 0) { + elements.push( + + {segmentElements} + + ) + } // 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( - - ) - cursor = endIndex + const termStart = Math.max(startIndex, rangeStart) + const termEnd = Math.min(endIndex, rangeEnd) + const termText = fullDecomposition.slice(termStart, termEnd) + + if (termText) { + elements.push( + + ) + } + 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({fullDecomposition.slice(cursor)}) + // Add trailing text within range + if (cursor < rangeEnd) { + elements.push( + {fullDecomposition.slice(cursor, rangeEnd)} + ) } 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( +
+ {renderElementsForRange(0, firstEqualPos, 'line-0')} +
+ ) + } + + // 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( +
+ {renderElementsForRange(lineStart, lineEnd, `line-${i + 1}`)} +
+ ) + } + + return lines + } + return ( -
- {renderElements()} +
+ {/* Hidden measurement element - always renders single-line to measure true width */} + + {/* Visible content - may be multi-line if overflow detected */} +
{renderElements()}
) } + +/** + * 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 ( +
+
+ {label} +
+
+ +
+
+ ) +} diff --git a/apps/web/src/components/decomposition/index.ts b/apps/web/src/components/decomposition/index.ts index 0032d931..2d8c2870 100644 --- a/apps/web/src/components/decomposition/index.ts +++ b/apps/web/src/components/decomposition/index.ts @@ -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' diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index ea35a521..b8bc8c2e 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -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(null) + // Problem transition animation state + const [outgoingProblem, setOutgoingProblem] = useState(null) + const [isTransitioning, setIsTransitioning] = useState(false) + + // Refs for measuring problem widths during animation + const outgoingRef = useRef(null) + const activeRef = useRef(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}
- {/* Problem display - horizontal layout with help panel on right when in help mode */} + {/* Problem display - centered, with help panel positioned outside */}
- {/* Center: Problem display */} - {currentPart.format === 'vertical' ? ( - - ) : undefined - } - /> - ) : ( - = 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 && ( -
- {/* Coach hint */} - {(() => { - const hint = generateCoachHint(helpContext.currentValue, helpContext.targetValue) - if (!hint) return null - return ( -
-

- {hint} -

-
- ) - })()} - - {/* Decomposition display */} -
`translateX(${x}px)`), + }} + > + {/* Outgoing problem (slides left during transition) */} + {outgoingProblem && ( + + + {/* Feedback stays with outgoing problem */}
- Step-by-Step + Correct!
+
+ )} + + {/* Problem container - relative positioning for help panel */} + + {/* Problem display */} + {currentPart.format === 'vertical' ? ( + + ) : undefined + } + /> + ) : ( + = 0 && matchedPrefixIndex < prefixSums.length - 1 + ? matchedPrefixIndex + : undefined + } + /> + )} + + {/* Help panel - absolutely positioned to the right of the problem */} + {!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && (
+ {/* Coach hint */} + {(() => { + const hint = generateCoachHint( + helpContext.currentValue, + helpContext.targetValue + ) + if (!hint) return null + return ( +
+

+ {hint} +

+
+ ) + })()} + + {/* Decomposition display - hides when not meaningful */} - +
-
-
- )} + )} + +
{/* Feedback message */} diff --git a/apps/web/src/components/practice/HelpAbacus.tsx b/apps/web/src/components/practice/HelpAbacus.tsx index b1eb38d6..cb0815df 100644 --- a/apps/web/src/components/practice/HelpAbacus.tsx +++ b/apps/web/src/components/practice/HelpAbacus.tsx @@ -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 (