feat(practice): improve help mode UX with crossfade and dismiss behaviors

- Add crossfade animation between answer boxes and help abacus (1s enter, 300ms dismiss)
- Preserve user's prefix sum in answer boxes during fade-out transition
- Clear answer boxes after help abacus entrance transition completes
- Add dismiss button to help abacus with tooltip suppression on dismiss
- Add keyboard shortcuts (Escape/Delete/Backspace) to exit help mode
- Typing while in help mode dismisses help and starts fresh input
- Add independent dismiss controls for help abacus and help panel
- Fix tooltip remaining visible when help abacus is dismissed

🤖 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-11 06:06:57 -06:00
parent a27fb0c9a4
commit bcb1c7a173
5 changed files with 218 additions and 33 deletions

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { animated, useSpring } from '@react-spring/web' import { animated, useSpring } from '@react-spring/web'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import { useTheme } from '@/contexts/ThemeContext' import { useTheme } from '@/contexts/ThemeContext'
import type { import type {
@ -236,6 +236,7 @@ export function ActiveSession({
handleBackspace, handleBackspace,
enterHelpMode, enterHelpMode,
exitHelpMode, exitHelpMode,
clearAnswer,
startSubmit, startSubmit,
completeSubmit, completeSubmit,
startTransition, startTransition,
@ -248,6 +249,19 @@ export function ActiveSession({
onManualSubmitRequired: () => playSound('womp_womp'), onManualSubmitRequired: () => playSound('womp_womp'),
}) })
// Track which help elements have been individually dismissed
// These reset when entering a new help session (helpContext changes)
const [helpAbacusDismissed, setHelpAbacusDismissed] = useState(false)
const [helpPanelDismissed, setHelpPanelDismissed] = useState(false)
// Reset dismissed states when help context changes (new help session)
useEffect(() => {
if (helpContext) {
setHelpAbacusDismissed(false)
setHelpPanelDismissed(false)
}
}, [helpContext])
// Refs for measuring problem widths during animation // Refs for measuring problem widths during animation
const outgoingRef = useRef<HTMLDivElement>(null) const outgoingRef = useRef<HTMLDivElement>(null)
const activeRef = useRef<HTMLDivElement>(null) const activeRef = useRef<HTMLDivElement>(null)
@ -471,10 +485,24 @@ export function ActiveSession({
if (!hasPhysicalKeyboard || !canAcceptInput) return if (!hasPhysicalKeyboard || !canAcceptInput) return
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Escape or Delete/Backspace exits help mode when in help mode
if (e.key === 'Escape') {
e.preventDefault()
if (showHelpOverlay) {
exitHelpMode()
}
return
}
if (e.key === 'Backspace' || e.key === 'Delete') { if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault() e.preventDefault()
handleBackspace() if (showHelpOverlay) {
} else if (e.key === 'Enter') { exitHelpMode()
} else {
handleBackspace()
}
return
}
if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
handleSubmit() handleSubmit()
} else if (/^[0-9]$/.test(e.key)) { } else if (/^[0-9]$/.test(e.key)) {
@ -484,7 +512,15 @@ export function ActiveSession({
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown)
}, [hasPhysicalKeyboard, canAcceptInput, handleSubmit, handleDigit, handleBackspace]) }, [
hasPhysicalKeyboard,
canAcceptInput,
handleSubmit,
handleDigit,
handleBackspace,
showHelpOverlay,
exitHelpMode,
])
const handlePause = useCallback(() => { const handlePause = useCallback(() => {
pause() pause()
@ -559,7 +595,6 @@ export function ActiveSession({
padding: '1rem', padding: '1rem',
maxWidth: '600px', maxWidth: '600px',
margin: '0 auto', margin: '0 auto',
minHeight: '100vh',
})} })}
> >
{/* Practice Session HUD - Control bar with session info and tape-deck controls */} {/* Practice Session HUD - Control bar with session info and tape-deck controls */}
@ -914,6 +949,7 @@ export function ActiveSession({
} }
rejectedDigit={attempt.rejectedDigit} rejectedDigit={attempt.rejectedDigit}
helpOverlay={ helpOverlay={
// Always render overlay when in help mode (for exit transition)
showHelpOverlay && helpContext ? ( showHelpOverlay && helpContext ? (
<PracticeHelpOverlay <PracticeHelpOverlay
currentValue={helpContext.currentValue} currentValue={helpContext.currentValue}
@ -924,9 +960,17 @@ export function ActiveSession({
.length .length
)} )}
onTargetReached={handleTargetReached} onTargetReached={handleTargetReached}
onDismiss={() => {
setHelpAbacusDismissed(true)
clearAnswer()
}}
visible={!helpAbacusDismissed}
/> />
) : undefined ) : undefined
} }
helpOverlayVisible={showHelpOverlay && !helpAbacusDismissed}
helpOverlayTransitionMs={helpAbacusDismissed ? 300 : 1000}
onHelpOverlayTransitionEnd={clearAnswer}
generationTrace={attempt.problem.generationTrace} generationTrace={attempt.problem.generationTrace}
complexityBudget={currentSlot?.constraints?.maxComplexityBudgetPerTerm} complexityBudget={currentSlot?.constraints?.maxComplexityBudgetPerTerm}
/> />
@ -947,7 +991,7 @@ export function ActiveSession({
)} )}
{/* Help panel - absolutely positioned to the right of the problem */} {/* Help panel - absolutely positioned to the right of the problem */}
{showHelpOverlay && helpContext && ( {showHelpOverlay && helpContext && !helpPanelDismissed && (
<div <div
data-element="help-panel" data-element="help-panel"
className={css({ className={css({
@ -967,6 +1011,39 @@ export function ActiveSession({
maxWidth: '280px', maxWidth: '280px',
})} })}
> >
{/* Close button for help panel */}
<button
type="button"
data-action="close-help-panel"
onClick={() => setHelpPanelDismissed(true)}
className={css({
position: 'absolute',
top: '-8px',
right: '-8px',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'gray.400' : 'gray.500',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
border: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '50%',
cursor: 'pointer',
zIndex: 10,
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.300',
color: isDark ? 'gray.200' : 'gray.700',
},
})}
aria-label="Close help panel"
>
×
</button>
{/* Coach hint */} {/* Coach hint */}
{(() => { {(() => {
const hint = generateCoachHint( const hint = generateCoachHint(

View File

@ -198,10 +198,9 @@ export function HelpAbacus({
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: '0.75rem', gap: '0.75rem',
// Animation properties // Animation properties - fade only, no transform (crossfade handled by parent)
transition: 'opacity 0.3s ease-out, transform 0.3s ease-out', transition: 'opacity 1s ease-out',
opacity: shouldHide ? 0 : 1, opacity: shouldHide ? 0 : 1,
transform: shouldHide ? 'translateY(-10px)' : 'translateY(0)',
// Disable interaction when hidden/transitioning // Disable interaction when hidden/transitioning
pointerEvents: shouldHide ? 'none' : 'auto', pointerEvents: shouldHide ? 'none' : 'auto',
})} })}

View File

@ -14,6 +14,7 @@
import type { AbacusOverlay, StepBeadHighlight } from '@soroban/abacus-react' import type { AbacusOverlay, StepBeadHighlight } from '@soroban/abacus-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { css } from '../../../styled-system/css'
import { useTheme } from '@/contexts/ThemeContext' import { useTheme } from '@/contexts/ThemeContext'
import { getHelpTiming, shouldUseDebugTiming } from '@/constants/helpTiming' import { getHelpTiming, shouldUseDebugTiming } from '@/constants/helpTiming'
import { generateUnifiedInstructionSequence } from '@/utils/unifiedStepGenerator' import { generateUnifiedInstructionSequence } from '@/utils/unifiedStepGenerator'
@ -33,8 +34,12 @@ export interface PracticeHelpOverlayProps {
onTargetReached?: () => void onTargetReached?: () => void
/** Called when abacus value changes */ /** Called when abacus value changes */
onValueChange?: (value: number) => void onValueChange?: (value: number) => void
/** Called when user dismisses the help abacus */
onDismiss?: () => void
/** Whether to show debug timing */ /** Whether to show debug timing */
debugTiming?: boolean debugTiming?: boolean
/** Whether the overlay is visible (false = being dismissed, suppress tooltip) */
visible?: boolean
} }
/** /**
@ -54,7 +59,9 @@ export function PracticeHelpOverlay({
columns = 3, columns = 3,
onTargetReached, onTargetReached,
onValueChange, onValueChange,
onDismiss,
debugTiming, debugTiming,
visible = true,
}: PracticeHelpOverlayProps) { }: PracticeHelpOverlayProps) {
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const theme = resolvedTheme === 'dark' ? 'dark' : 'light' const theme = resolvedTheme === 'dark' ? 'dark' : 'light'
@ -187,6 +194,9 @@ export function PracticeHelpOverlay({
// Create tooltip overlay for HelpAbacus using shared BeadTooltipContent // Create tooltip overlay for HelpAbacus using shared BeadTooltipContent
const tooltipOverlay: AbacusOverlay | undefined = useMemo(() => { const tooltipOverlay: AbacusOverlay | undefined = useMemo(() => {
// Suppress tooltip when overlay is being dismissed (visible=false)
if (!visible) return undefined
// Show tooltip in bead-tooltip phase with instructions, or when celebrating // Show tooltip in bead-tooltip phase with instructions, or when celebrating
const showCelebration = isAtTarget const showCelebration = isAtTarget
const showInstructions = const showInstructions =
@ -222,6 +232,7 @@ export function PracticeHelpOverlay({
visible: true, visible: true,
} }
}, [ }, [
visible,
tooltipPositioning, tooltipPositioning,
isAtTarget, isAtTarget,
currentPhase, currentPhase,
@ -268,11 +279,16 @@ export function PracticeHelpOverlay({
setBeadHighlights(highlights) setBeadHighlights(highlights)
}, []) }, [])
const isDark = theme === 'dark'
return ( return (
<div <div
data-component="practice-help-overlay" data-component="practice-help-overlay"
data-phase={currentPhase} data-phase={currentPhase}
data-at-target={isAtTarget} data-at-target={isAtTarget}
className={css({
position: 'relative',
})}
> >
{/* Interactive abacus with bead arrows - just the abacus, no extra UI */} {/* Interactive abacus with bead arrows - just the abacus, no extra UI */}
<HelpAbacus <HelpAbacus
@ -289,6 +305,41 @@ export function PracticeHelpOverlay({
showValueLabels={false} showValueLabels={false}
showTargetReached={false} showTargetReached={false}
/> />
{/* Dismiss button - small X in top-right corner */}
{onDismiss && (
<button
type="button"
data-action="dismiss-help-abacus"
onClick={onDismiss}
className={css({
position: 'absolute',
top: '-8px',
right: '-8px',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'gray.400' : 'gray.500',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
border: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '50%',
cursor: 'pointer',
zIndex: 10,
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.300',
color: isDark ? 'gray.200' : 'gray.700',
},
})}
aria-label="Dismiss help abacus"
>
×
</button>
)}
</div> </div>
) )
} }

View File

@ -28,6 +28,12 @@ interface VerticalProblemProps {
rejectedDigit?: string | null rejectedDigit?: string | null
/** Help overlay to render in place of answer boxes when in help mode */ /** Help overlay to render in place of answer boxes when in help mode */
helpOverlay?: ReactNode helpOverlay?: ReactNode
/** Whether the help overlay should be visible (controls opacity for crossfade) */
helpOverlayVisible?: boolean
/** Duration for help overlay transition in ms (default 1000 for enter, use 300 for dismiss) */
helpOverlayTransitionMs?: number
/** Called when help overlay transition completes (useful for clearing answer after fade-in) */
onHelpOverlayTransitionEnd?: () => void
/** Generation trace with per-term skills and complexity (for debug overlay) */ /** Generation trace with per-term skills and complexity (for debug overlay) */
generationTrace?: GenerationTrace generationTrace?: GenerationTrace
/** Complexity budget constraint (for debug overlay) */ /** Complexity budget constraint (for debug overlay) */
@ -54,6 +60,9 @@ export function VerticalProblem({
needHelpTermIndex, needHelpTermIndex,
rejectedDigit = null, rejectedDigit = null,
helpOverlay, helpOverlay,
helpOverlayVisible = false,
helpOverlayTransitionMs = 1000,
onHelpOverlayTransitionEnd,
generationTrace, generationTrace,
complexityBudget, complexityBudget,
}: VerticalProblemProps) { }: VerticalProblemProps) {
@ -338,30 +347,55 @@ export function VerticalProblem({
})} })}
/> />
{/* Answer row - shows help abacus when in help mode, otherwise answer cells */} {/* Answer row container - both help overlay and answer cells rendered for crossfade */}
{currentHelpTermIndex !== undefined && helpOverlay ? ( <div
// Help mode: show the help abacus in place of answer boxes data-element="answer-area-container"
<div className={css({
data-element="help-area" position: 'relative',
data-help-term-index={currentHelpTermIndex} minHeight: '50px', // Ensure container has height for absolute positioning
className={css({ })}
display: 'flex', >
alignItems: 'center', {/* Help overlay layer - fades in when in help mode */}
justifyContent: 'center', {helpOverlay && (
padding: '0.5rem 0', <div
})} data-element="help-area"
> data-help-term-index={currentHelpTermIndex}
{helpOverlay} className={css({
</div> display: 'flex',
) : ( alignItems: 'center',
// Normal mode: show answer digit cells justifyContent: 'center',
padding: '0.5rem 0',
// Fade in when help mode active
transition: `opacity ${helpOverlayTransitionMs}ms ease-out`,
opacity: helpOverlayVisible ? 1 : 0,
pointerEvents: helpOverlayVisible ? 'auto' : 'none',
})}
onTransitionEnd={(e) => {
// Only fire for opacity transition on this element, and only when becoming visible
if (e.propertyName === 'opacity' && e.target === e.currentTarget && helpOverlayVisible) {
onHelpOverlayTransitionEnd?.()
}
}}
>
{helpOverlay}
</div>
)}
{/* Answer row layer - fades out when help mode active (inverse opacity) */}
<div <div
data-element="answer-row" data-element="answer-row"
className={css({ className={css({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '2px', gap: '2px',
position: 'relative', position: helpOverlay ? 'absolute' : 'relative',
top: helpOverlay ? 0 : undefined,
left: helpOverlay ? 0 : undefined,
right: helpOverlay ? 0 : undefined,
// Fade out when help mode active (inverse of help overlay)
transition: `opacity ${helpOverlayTransitionMs}ms ease-out`,
opacity: helpOverlayVisible ? 0 : 1,
pointerEvents: helpOverlayVisible ? 'none' : 'auto',
})} })}
> >
{/* Equals column */} {/* Equals column */}
@ -481,7 +515,7 @@ export function VerticalProblem({
) )
})} })}
</div> </div>
)} </div>
{/* Show user's incorrect answer below correct answer */} {/* Show user's incorrect answer below correct answer */}
{isCompleted && isIncorrect && ( {isCompleted && isIncorrect && (

View File

@ -367,6 +367,8 @@ export interface UseInteractionPhaseReturn {
enterHelpMode: (termIndex: number) => void enterHelpMode: (termIndex: number) => void
/** Exit help mode (helpMode → inputting) */ /** Exit help mode (helpMode → inputting) */
exitHelpMode: () => void exitHelpMode: () => void
/** Clear the current answer (used after help overlay transition completes) */
clearAnswer: () => void
/** Submit answer (inputting/helpMode → submitting) */ /** Submit answer (inputting/helpMode → submitting) */
startSubmit: () => void startSubmit: () => void
/** Handle submit result (submitting → showingFeedback) */ /** Handle submit result (submitting → showingFeedback) */
@ -478,19 +480,21 @@ export function useInteractionPhase(
if (elapsed >= AMBIGUOUS_HELP_DELAY_MS) { if (elapsed >= AMBIGUOUS_HELP_DELAY_MS) {
// Timer already elapsed - transition to help mode immediately // Timer already elapsed - transition to help mode immediately
// Keep userAnswer during transition so it shows in answer boxes while fading out
const helpContext = computeHelpContext(phase.attempt.problem.terms, ctx.helpTermIndex) const helpContext = computeHelpContext(phase.attempt.problem.terms, ctx.helpTermIndex)
setPhase({ phase: 'helpMode', attempt: { ...phase.attempt, userAnswer: '' }, helpContext }) setPhase({ phase: 'helpMode', attempt: phase.attempt, helpContext })
} else { } else {
// Set timer for remaining time // Set timer for remaining time
const remaining = AMBIGUOUS_HELP_DELAY_MS - elapsed const remaining = AMBIGUOUS_HELP_DELAY_MS - elapsed
disambiguationTimerRef.current = setTimeout(() => { disambiguationTimerRef.current = setTimeout(() => {
setPhase((prev) => { setPhase((prev) => {
if (prev.phase !== 'awaitingDisambiguation') return prev if (prev.phase !== 'awaitingDisambiguation') return prev
// Keep userAnswer during transition so it shows in answer boxes while fading out
const helpContext = computeHelpContext( const helpContext = computeHelpContext(
prev.attempt.problem.terms, prev.attempt.problem.terms,
prev.disambiguationContext.helpTermIndex prev.disambiguationContext.helpTermIndex
) )
return { phase: 'helpMode', attempt: { ...prev.attempt, userAnswer: '' }, helpContext } return { phase: 'helpMode', attempt: prev.attempt, helpContext }
}) })
}, remaining) }, remaining)
} }
@ -600,6 +604,16 @@ export function useInteractionPhase(
return prev return prev
} }
// If in help mode, exit help mode and start fresh with the new digit
if (prev.phase === 'helpMode') {
const freshAttempt = {
...prev.attempt,
userAnswer: digit,
rejectedDigit: null,
}
return { phase: 'inputting', attempt: freshAttempt }
}
const attempt = prev.attempt const attempt = prev.attempt
const sums = computePrefixSums(attempt.problem.terms) const sums = computePrefixSums(attempt.problem.terms)
@ -631,13 +645,14 @@ export function useInteractionPhase(
) { ) {
// Unambiguous intermediate prefix match (e.g., "03" for prefix sum 3) // Unambiguous intermediate prefix match (e.g., "03" for prefix sum 3)
// Immediately enter help mode // Immediately enter help mode
// Keep userAnswer during transition so it shows in answer boxes while fading out
const helpContext = computeHelpContext( const helpContext = computeHelpContext(
attempt.problem.terms, attempt.problem.terms,
newPrefixMatch.helpTermIndex newPrefixMatch.helpTermIndex
) )
return { return {
phase: 'helpMode', phase: 'helpMode',
attempt: { ...updatedAttempt, userAnswer: '' }, attempt: updatedAttempt,
helpContext, helpContext,
} }
} else { } else {
@ -730,9 +745,9 @@ export function useInteractionPhase(
return prev return prev
} }
// Keep userAnswer during transition so it shows in answer boxes while fading out
const helpContext = computeHelpContext(prev.attempt.problem.terms, termIndex) const helpContext = computeHelpContext(prev.attempt.problem.terms, termIndex)
const updatedAttempt = { ...prev.attempt, userAnswer: '' } return { phase: 'helpMode', attempt: prev.attempt, helpContext }
return { phase: 'helpMode', attempt: updatedAttempt, helpContext }
}) })
}, []) }, [])
@ -744,6 +759,14 @@ export function useInteractionPhase(
}) })
}, []) }, [])
const clearAnswer = useCallback(() => {
setPhase((prev) => {
if (prev.phase !== 'helpMode') return prev
const updatedAttempt = { ...prev.attempt, userAnswer: '' }
return { phase: 'helpMode', attempt: updatedAttempt, helpContext: prev.helpContext }
})
}, [])
const startSubmit = useCallback(() => { const startSubmit = useCallback(() => {
setPhase((prev) => { setPhase((prev) => {
// Allow submitting from inputting, awaitingDisambiguation, or helpMode // Allow submitting from inputting, awaitingDisambiguation, or helpMode
@ -844,6 +867,7 @@ export function useInteractionPhase(
handleBackspace, handleBackspace,
enterHelpMode, enterHelpMode,
exitHelpMode, exitHelpMode,
clearAnswer,
startSubmit, startSubmit,
completeSubmit, completeSubmit,
startTransition, startTransition,