From bcb1c7a1735c1b628871be10663e8f7ec5c9f699 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 11 Dec 2025 06:06:57 -0600 Subject: [PATCH] feat(practice): improve help mode UX with crossfade and dismiss behaviors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/components/practice/ActiveSession.tsx | 89 +++++++++++++++++-- .../src/components/practice/HelpAbacus.tsx | 5 +- .../practice/PracticeHelpOverlay.tsx | 51 +++++++++++ .../components/practice/VerticalProblem.tsx | 72 +++++++++++---- .../practice/hooks/useInteractionPhase.ts | 34 +++++-- 5 files changed, 218 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index e007a284..deeb6341 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -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(null) const activeRef = useRef(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 ? ( { + 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 && (
+ {/* Close button for help panel */} + + {/* Coach hint */} {(() => { const hint = generateCoachHint( diff --git a/apps/web/src/components/practice/HelpAbacus.tsx b/apps/web/src/components/practice/HelpAbacus.tsx index cb0815df..96ac5214 100644 --- a/apps/web/src/components/practice/HelpAbacus.tsx +++ b/apps/web/src/components/practice/HelpAbacus.tsx @@ -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', })} diff --git a/apps/web/src/components/practice/PracticeHelpOverlay.tsx b/apps/web/src/components/practice/PracticeHelpOverlay.tsx index 11123ad5..7e725096 100644 --- a/apps/web/src/components/practice/PracticeHelpOverlay.tsx +++ b/apps/web/src/components/practice/PracticeHelpOverlay.tsx @@ -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 (
{/* Interactive abacus with bead arrows - just the abacus, no extra UI */} + + {/* Dismiss button - small X in top-right corner */} + {onDismiss && ( + + )}
) } diff --git a/apps/web/src/components/practice/VerticalProblem.tsx b/apps/web/src/components/practice/VerticalProblem.tsx index 767c6d96..ef4d44b2 100644 --- a/apps/web/src/components/practice/VerticalProblem.tsx +++ b/apps/web/src/components/practice/VerticalProblem.tsx @@ -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 -
- {helpOverlay} -
- ) : ( - // Normal mode: show answer digit cells + {/* Answer row container - both help overlay and answer cells rendered for crossfade */} +
+ {/* Help overlay layer - fades in when in help mode */} + {helpOverlay && ( +
{ + // Only fire for opacity transition on this element, and only when becoming visible + if (e.propertyName === 'opacity' && e.target === e.currentTarget && helpOverlayVisible) { + onHelpOverlayTransitionEnd?.() + } + }} + > + {helpOverlay} +
+ )} + + {/* Answer row layer - fades out when help mode active (inverse opacity) */}
{/* Equals column */} @@ -481,7 +515,7 @@ export function VerticalProblem({ ) })}
- )} +
{/* Show user's incorrect answer below correct answer */} {isCompleted && isIncorrect && ( diff --git a/apps/web/src/components/practice/hooks/useInteractionPhase.ts b/apps/web/src/components/practice/hooks/useInteractionPhase.ts index 9d85d376..ebb12730 100644 --- a/apps/web/src/components/practice/hooks/useInteractionPhase.ts +++ b/apps/web/src/components/practice/hooks/useInteractionPhase.ts @@ -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,