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:
parent
a27fb0c9a4
commit
bcb1c7a173
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue