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'
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 { useTheme } from '@/contexts/ThemeContext'
import type {
@ -236,6 +236,7 @@ export function ActiveSession({
handleBackspace,
enterHelpMode,
exitHelpMode,
clearAnswer,
startSubmit,
completeSubmit,
startTransition,
@ -248,6 +249,19 @@ export function ActiveSession({
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
const outgoingRef = useRef<HTMLDivElement>(null)
const activeRef = useRef<HTMLDivElement>(null)
@ -471,10 +485,24 @@ export function ActiveSession({
if (!hasPhysicalKeyboard || !canAcceptInput) return
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') {
e.preventDefault()
handleBackspace()
} else if (e.key === 'Enter') {
if (showHelpOverlay) {
exitHelpMode()
} else {
handleBackspace()
}
return
}
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (/^[0-9]$/.test(e.key)) {
@ -484,7 +512,15 @@ export function ActiveSession({
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [hasPhysicalKeyboard, canAcceptInput, handleSubmit, handleDigit, handleBackspace])
}, [
hasPhysicalKeyboard,
canAcceptInput,
handleSubmit,
handleDigit,
handleBackspace,
showHelpOverlay,
exitHelpMode,
])
const handlePause = useCallback(() => {
pause()
@ -559,7 +595,6 @@ export function ActiveSession({
padding: '1rem',
maxWidth: '600px',
margin: '0 auto',
minHeight: '100vh',
})}
>
{/* Practice Session HUD - Control bar with session info and tape-deck controls */}
@ -914,6 +949,7 @@ export function ActiveSession({
}
rejectedDigit={attempt.rejectedDigit}
helpOverlay={
// Always render overlay when in help mode (for exit transition)
showHelpOverlay && helpContext ? (
<PracticeHelpOverlay
currentValue={helpContext.currentValue}
@ -924,9 +960,17 @@ export function ActiveSession({
.length
)}
onTargetReached={handleTargetReached}
onDismiss={() => {
setHelpAbacusDismissed(true)
clearAnswer()
}}
visible={!helpAbacusDismissed}
/>
) : undefined
}
helpOverlayVisible={showHelpOverlay && !helpAbacusDismissed}
helpOverlayTransitionMs={helpAbacusDismissed ? 300 : 1000}
onHelpOverlayTransitionEnd={clearAnswer}
generationTrace={attempt.problem.generationTrace}
complexityBudget={currentSlot?.constraints?.maxComplexityBudgetPerTerm}
/>
@ -947,7 +991,7 @@ export function ActiveSession({
)}
{/* Help panel - absolutely positioned to the right of the problem */}
{showHelpOverlay && helpContext && (
{showHelpOverlay && helpContext && !helpPanelDismissed && (
<div
data-element="help-panel"
className={css({
@ -967,6 +1011,39 @@ export function ActiveSession({
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 */}
{(() => {
const hint = generateCoachHint(

View File

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

View File

@ -14,6 +14,7 @@
import type { AbacusOverlay, StepBeadHighlight } from '@soroban/abacus-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { css } from '../../../styled-system/css'
import { useTheme } from '@/contexts/ThemeContext'
import { getHelpTiming, shouldUseDebugTiming } from '@/constants/helpTiming'
import { generateUnifiedInstructionSequence } from '@/utils/unifiedStepGenerator'
@ -33,8 +34,12 @@ export interface PracticeHelpOverlayProps {
onTargetReached?: () => void
/** Called when abacus value changes */
onValueChange?: (value: number) => void
/** Called when user dismisses the help abacus */
onDismiss?: () => void
/** Whether to show debug timing */
debugTiming?: boolean
/** Whether the overlay is visible (false = being dismissed, suppress tooltip) */
visible?: boolean
}
/**
@ -54,7 +59,9 @@ export function PracticeHelpOverlay({
columns = 3,
onTargetReached,
onValueChange,
onDismiss,
debugTiming,
visible = true,
}: PracticeHelpOverlayProps) {
const { resolvedTheme } = useTheme()
const theme = resolvedTheme === 'dark' ? 'dark' : 'light'
@ -187,6 +194,9 @@ export function PracticeHelpOverlay({
// Create tooltip overlay for HelpAbacus using shared BeadTooltipContent
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
const showCelebration = isAtTarget
const showInstructions =
@ -222,6 +232,7 @@ export function PracticeHelpOverlay({
visible: true,
}
}, [
visible,
tooltipPositioning,
isAtTarget,
currentPhase,
@ -268,11 +279,16 @@ export function PracticeHelpOverlay({
setBeadHighlights(highlights)
}, [])
const isDark = theme === 'dark'
return (
<div
data-component="practice-help-overlay"
data-phase={currentPhase}
data-at-target={isAtTarget}
className={css({
position: 'relative',
})}
>
{/* Interactive abacus with bead arrows - just the abacus, no extra UI */}
<HelpAbacus
@ -289,6 +305,41 @@ export function PracticeHelpOverlay({
showValueLabels={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>
)
}

View File

@ -28,6 +28,12 @@ interface VerticalProblemProps {
rejectedDigit?: string | null
/** Help overlay to render in place of answer boxes when in help mode */
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) */
generationTrace?: GenerationTrace
/** Complexity budget constraint (for debug overlay) */
@ -54,6 +60,9 @@ export function VerticalProblem({
needHelpTermIndex,
rejectedDigit = null,
helpOverlay,
helpOverlayVisible = false,
helpOverlayTransitionMs = 1000,
onHelpOverlayTransitionEnd,
generationTrace,
complexityBudget,
}: VerticalProblemProps) {
@ -338,30 +347,55 @@ export function VerticalProblem({
})}
/>
{/* Answer row - shows help abacus when in help mode, otherwise answer cells */}
{currentHelpTermIndex !== undefined && helpOverlay ? (
// Help mode: show the help abacus in place of answer boxes
<div
data-element="help-area"
data-help-term-index={currentHelpTermIndex}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0.5rem 0',
})}
>
{helpOverlay}
</div>
) : (
// Normal mode: show answer digit cells
{/* Answer row container - both help overlay and answer cells rendered for crossfade */}
<div
data-element="answer-area-container"
className={css({
position: 'relative',
minHeight: '50px', // Ensure container has height for absolute positioning
})}
>
{/* Help overlay layer - fades in when in help mode */}
{helpOverlay && (
<div
data-element="help-area"
data-help-term-index={currentHelpTermIndex}
className={css({
display: 'flex',
alignItems: 'center',
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
data-element="answer-row"
className={css({
display: 'flex',
alignItems: 'center',
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 */}
@ -481,7 +515,7 @@ export function VerticalProblem({
)
})}
</div>
)}
</div>
{/* Show user's incorrect answer below correct answer */}
{isCompleted && isIncorrect && (

View File

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