feat(practice): improve docked abacus UX and submit button behavior

- Force show submit button when abacus is docked (user needs manual submit)
- Disable auto-help when docked; trigger help on submit for prefix sums
- Fix dock animation to measure actual destination position
- Keep problem centered when dock appears/disappears (absolute positioning)
- Use scaleFactor prop for natural abacus sizing instead of manual calculations
- Clean up unused dock size tracking and scale calculation code

🤖 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 14:35:15 -06:00
parent 2c832c7944
commit 60fc81bc2d
2 changed files with 127 additions and 99 deletions

View File

@@ -4,12 +4,59 @@ import { animated, useSpring } from '@react-spring/web'
import { ABACUS_THEMES, AbacusReact, useAbacusConfig } from '@soroban/abacus-react' import { ABACUS_THEMES, AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useCallback, useContext, useEffect, useRef, useState } from 'react' import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal, flushSync } from 'react-dom'
import { createRoot } from 'react-dom/client'
import { HomeHeroContext } from '@/contexts/HomeHeroContext' import { HomeHeroContext } from '@/contexts/HomeHeroContext'
import { type DockAnimationState, useMyAbacus } from '@/contexts/MyAbacusContext' import { type DockAnimationState, useMyAbacus } from '@/contexts/MyAbacusContext'
import { useTheme } from '@/contexts/ThemeContext' import { useTheme } from '@/contexts/ThemeContext'
import { css } from '../../styled-system/css' import { css } from '../../styled-system/css'
/**
* Measure the size and position an AbacusReact will have when rendered into a dock element.
* Temporarily renders an invisible abacus directly into the dock to get accurate positioning
* (important for absolutely positioned docks where transforms depend on content size).
*/
function measureDockedAbacus(
dockElement: HTMLElement,
columns: number,
scaleFactor: number | undefined,
customStyles: typeof ABACUS_THEMES.light
): { x: number; y: number; width: number; height: number } {
// Create a temporary wrapper that matches how we render the docked abacus
const measureWrapper = document.createElement('div')
measureWrapper.style.visibility = 'hidden'
measureWrapper.style.pointerEvents = 'none'
// Insert directly into the dock element so it gets proper size/position
dockElement.appendChild(measureWrapper)
// Create a React root and render the abacus synchronously
const root = createRoot(measureWrapper)
flushSync(() => {
root.render(
<AbacusReact
value={0}
columns={columns}
scaleFactor={scaleFactor}
showNumbers={false}
interactive={false}
animated={false}
customStyles={customStyles}
/>
)
})
// Measure the rendered size and position (dock element now has content, so transforms apply correctly)
const rect = measureWrapper.getBoundingClientRect()
const result = { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
// Clean up
root.unmount()
dockElement.removeChild(measureWrapper)
return result
}
export function MyAbacus() { export function MyAbacus() {
const { const {
isOpen, isOpen,
@@ -33,9 +80,6 @@ export function MyAbacus() {
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark' const isDark = resolvedTheme === 'dark'
// Track dock container size for auto-scaling
const [dockSize, setDockSize] = useState<{ width: number; height: number } | null>(null)
// Local ref for the button container (we'll connect this to context's buttonRef) // Local ref for the button container (we'll connect this to context's buttonRef)
const localButtonRef = useRef<HTMLDivElement>(null) const localButtonRef = useRef<HTMLDivElement>(null)
@@ -45,29 +89,6 @@ export function MyAbacus() {
const abacusValue = homeHeroContext?.abacusValue ?? localAbacusValue const abacusValue = homeHeroContext?.abacusValue ?? localAbacusValue
const setAbacusValue = homeHeroContext?.setAbacusValue ?? setLocalAbacusValue const setAbacusValue = homeHeroContext?.setAbacusValue ?? setLocalAbacusValue
// Observe dock container size changes
useEffect(() => {
if (!dock?.element) {
setDockSize(null)
return
}
const element = dock.element
const updateSize = () => {
const rect = element.getBoundingClientRect()
setDockSize({ width: rect.width, height: rect.height })
}
// Initial size
updateSize()
// Watch for size changes
const resizeObserver = new ResizeObserver(updateSize)
resizeObserver.observe(element)
return () => resizeObserver.disconnect()
}, [dock?.element])
// Determine display mode - only hero mode on actual home page // Determine display mode - only hero mode on actual home page
const isOnHomePage = const isOnHomePage =
pathname === '/' || pathname === '/' ||
@@ -118,25 +139,6 @@ export function MyAbacus() {
// This matches /arcade, /arcade/*, and /arcade-rooms/* // This matches /arcade, /arcade/*, and /arcade-rooms/*
const isOnGameRoute = pathname?.startsWith('/arcade') const isOnGameRoute = pathname?.startsWith('/arcade')
// Calculate scale factor for docked mode
// Base abacus dimensions are approximately 120px wide per column, 200px tall
const calculateDockedScale = () => {
if (!dockSize || !dock) return 1
if (dock.scaleFactor) return dock.scaleFactor
const columns = dock.columns ?? 5
// Approximate base dimensions of AbacusReact at scale 1
const baseWidth = columns * 24 + 20 // ~24px per column + padding
const baseHeight = 55 // approximate height
const scaleX = dockSize.width / baseWidth
const scaleY = dockSize.height / baseHeight
// Use the smaller scale to fit within container, with some padding
return Math.min(scaleX, scaleY) * 0.85
}
const dockedScale = calculateDockedScale()
// Sync local button ref with context's buttonRef // Sync local button ref with context's buttonRef
useEffect(() => { useEffect(() => {
if (buttonRef && localButtonRef.current) { if (buttonRef && localButtonRef.current) {
@@ -212,13 +214,21 @@ export function MyAbacus() {
return return
} }
// Measure positions // Measure the button's current position
const buttonRect = localButtonRef.current.getBoundingClientRect() const buttonRect = localButtonRef.current.getBoundingClientRect()
const dockRect = dock.element.getBoundingClientRect()
// Calculate scales - button shows at 0.35 scale, dock uses dockedScale // Measure where the docked abacus will appear (renders temporarily to get accurate position)
const dockColumns = dock.columns ?? 5
const targetRect = measureDockedAbacus(
dock.element,
dockColumns,
dock.scaleFactor,
structuralStyles
)
// Calculate scales - button shows at 0.35 scale, dock uses scaleFactor directly
const buttonScale = 0.35 const buttonScale = 0.35
const targetScale = dockedScale const targetScale = dock.scaleFactor ?? 1
const animState: DockAnimationState = { const animState: DockAnimationState = {
phase: 'docking', phase: 'docking',
@@ -228,18 +238,13 @@ export function MyAbacus() {
width: buttonRect.width, width: buttonRect.width,
height: buttonRect.height, height: buttonRect.height,
}, },
toRect: { toRect: targetRect,
x: dockRect.x,
y: dockRect.y,
width: dockRect.width,
height: dockRect.height,
},
fromScale: buttonScale, fromScale: buttonScale,
toScale: targetScale, toScale: targetScale,
} }
startDockAnimation(animState) startDockAnimation(animState)
}, [dock, dockInto, dockedScale, startDockAnimation]) }, [dock, dockInto, structuralStyles, startDockAnimation])
// Handler to initiate undock animation // Handler to initiate undock animation
const handleUndockClick = useCallback(() => { const handleUndockClick = useCallback(() => {
@@ -249,8 +254,24 @@ export function MyAbacus() {
return return
} }
// Measure dock position (source) // The abacus is currently docked - find the actual rendered abacus element
const dockRect = dock.element.getBoundingClientRect() const dockedAbacus = dock.element.querySelector('[data-element="abacus-display"]')
let sourceRect: { x: number; y: number; width: number; height: number }
if (dockedAbacus) {
// Measure the actual docked abacus position
const rect = dockedAbacus.getBoundingClientRect()
sourceRect = { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
} else {
// Fallback: measure what it would be
const dockColumns = dock.columns ?? 5
sourceRect = measureDockedAbacus(
dock.element,
dockColumns,
dock.scaleFactor,
structuralStyles
)
}
// Calculate target button position (we don't need the ref - button has known fixed position) // Calculate target button position (we don't need the ref - button has known fixed position)
// Button is fixed at bottom-right with some margin // Button is fixed at bottom-right with some margin
@@ -262,16 +283,11 @@ export function MyAbacus() {
const buttonY = viewportHeight - buttonSize - margin const buttonY = viewportHeight - buttonSize - margin
const buttonScale = 0.35 const buttonScale = 0.35
const dockScale = dockedScale const dockScale = dock.scaleFactor ?? 1
const animState: DockAnimationState = { const animState: DockAnimationState = {
phase: 'undocking', phase: 'undocking',
fromRect: { fromRect: sourceRect,
x: dockRect.x,
y: dockRect.y,
width: dockRect.width,
height: dockRect.height,
},
toRect: { toRect: {
x: buttonX, x: buttonX,
y: buttonY, y: buttonY,
@@ -283,7 +299,7 @@ export function MyAbacus() {
} }
startUndockAnimation(animState) startUndockAnimation(animState)
}, [dock, undock, dockedScale, startUndockAnimation]) }, [dock, undock, structuralStyles, startUndockAnimation])
// Check if we're currently animating // Check if we're currently animating
const isAnimating = dockAnimationState !== null const isAnimating = dockAnimationState !== null
@@ -365,8 +381,6 @@ export function MyAbacus() {
data-mode="docked" data-mode="docked"
data-dock-id={dock.id} data-dock-id={dock.id}
className={css({ className={css({
width: '100%',
height: '100%',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -414,10 +428,7 @@ export function MyAbacus() {
</button> </button>
<div <div
data-element="abacus-display" data-element="abacus-display"
style={{ transform: `scale(${dockedScale})` }}
className={css({ className={css({
transformOrigin: 'center center',
transition: 'transform 0.3s ease',
filter: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))', filter: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
})} })}
> >
@@ -426,6 +437,7 @@ export function MyAbacus() {
value={dock.value ?? abacusValue} value={dock.value ?? abacusValue}
defaultValue={dock.defaultValue} defaultValue={dock.defaultValue}
columns={dock.columns ?? 5} columns={dock.columns ?? 5}
scaleFactor={dock.scaleFactor}
beadShape={appConfig.beadShape} beadShape={appConfig.beadShape}
showNumbers={dock.showNumbers ?? true} showNumbers={dock.showNumbers ?? true}
interactive={dock.interactive ?? true} interactive={dock.interactive ?? true}

View File

@@ -3,6 +3,7 @@
import { animated, useSpring } from '@react-spring/web' import { animated, useSpring } from '@react-spring/web'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { useTheme } from '@/contexts/ThemeContext' import { useTheme } from '@/contexts/ThemeContext'
import type { import type {
ProblemSlot, ProblemSlot,
@@ -198,6 +199,9 @@ export function ActiveSession({
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark' const isDark = resolvedTheme === 'dark'
// Check if abacus is docked (to force show submit button)
const { isDockedByUser } = useMyAbacus()
// Sound effects // Sound effects
const { playSound } = usePracticeSoundEffects() const { playSound } = usePracticeSoundEffects()
@@ -288,10 +292,12 @@ export function ActiveSession({
})) }))
// Spring for submit button entrance animation // Spring for submit button entrance animation
// Show submit button when: manual submit is required OR abacus is docked (user needs way to submit)
const showSubmitButton = attempt?.manualSubmitRequired || isDockedByUser
const submitButtonSpring = useSpring({ const submitButtonSpring = useSpring({
transform: attempt?.manualSubmitRequired ? 'translateY(0px)' : 'translateY(60px)', transform: showSubmitButton ? 'translateY(0px)' : 'translateY(60px)',
opacity: attempt?.manualSubmitRequired ? 1 : 0, opacity: showSubmitButton ? 1 : 0,
scale: attempt?.manualSubmitRequired ? 1 : 0.8, scale: showSubmitButton ? 1 : 0.8,
config: { tension: 280, friction: 14 }, config: { tension: 280, friction: 14 },
}) })
@@ -391,7 +397,11 @@ export function ActiveSession({
// Auto-trigger help when an unambiguous prefix sum is detected // Auto-trigger help when an unambiguous prefix sum is detected
// The awaitingDisambiguation phase handles the timer and auto-transitions to helpMode when it expires // The awaitingDisambiguation phase handles the timer and auto-transitions to helpMode when it expires
// This effect only handles the inputting phase case for unambiguous matches // This effect only handles the inputting phase case for unambiguous matches
// DISABLED when abacus is docked - user controls when to submit, help triggers on submit if needed
useEffect(() => { useEffect(() => {
// Skip auto-help when abacus is docked - user has manual control
if (isDockedByUser) return
// Only handle unambiguous prefix matches in inputting phase // Only handle unambiguous prefix matches in inputting phase
// Ambiguous cases are handled by awaitingDisambiguation phase, which auto-transitions to helpMode // Ambiguous cases are handled by awaitingDisambiguation phase, which auto-transitions to helpMode
if (phase.phase !== 'inputting') return if (phase.phase !== 'inputting') return
@@ -403,7 +413,7 @@ export function ActiveSession({
enterHelpMode(newConfirmedCount) enterHelpMode(newConfirmedCount)
} }
} }
}, [phase, matchedPrefixIndex, prefixSums.length, enterHelpMode]) }, [phase, matchedPrefixIndex, prefixSums.length, enterHelpMode, isDockedByUser])
// Handle when student reaches target value on help abacus // Handle when student reaches target value on help abacus
// Sequence: show target value → dismiss abacus → show value in answer boxes → fade to empty → exit // Sequence: show target value → dismiss abacus → show value in answer boxes → fade to empty → exit
@@ -458,6 +468,21 @@ export function ActiveSession({
const answerNum = parseInt(attemptData.userAnswer, 10) const answerNum = parseInt(attemptData.userAnswer, 10)
if (Number.isNaN(answerNum)) return if (Number.isNaN(answerNum)) return
// When abacus is docked and not already in help mode, check if answer is a prefix sum
// If so, trigger help mode instead of submitting (mimic auto-help behavior on submit)
if (isDockedByUser && phase.phase !== 'helpMode') {
// Check if the answer matches a prefix sum (but not the final answer)
const prefixIndex = prefixSums.indexOf(answerNum)
if (prefixIndex >= 0 && prefixIndex < prefixSums.length - 1) {
// Answer matches a prefix sum - enter help mode instead of submitting
const newConfirmedCount = prefixIndex + 1
if (newConfirmedCount < attemptData.problem.terms.length) {
enterHelpMode(newConfirmedCount)
return
}
}
}
// Transition to submitting phase // Transition to submitting phase
startSubmit() startSubmit()
@@ -512,10 +537,14 @@ export function ActiveSession({
onAnswer, onAnswer,
currentSlotIndex, currentSlotIndex,
currentPart, currentPart,
currentPartIndex,
startSubmit, startSubmit,
completeSubmit, completeSubmit,
startTransition, startTransition,
clearToLoading, clearToLoading,
isDockedByUser,
prefixSums,
enterHelpMode,
]) ])
// Auto-submit when correct answer is entered // Auto-submit when correct answer is entered
@@ -1160,8 +1189,7 @@ export function ActiveSession({
</DecompositionProvider> </DecompositionProvider>
</div> </div>
)} )}
{/* Abacus dock - positioned absolutely so it doesn't affect problem centering */}
{/* Abacus dock - positioned to the right of the problem when in abacus mode and not in help mode */}
{currentPart.type === 'abacus' && !showHelpOverlay && ( {currentPart.type === 'abacus' && !showHelpOverlay && (
<AbacusDock <AbacusDock
id="practice-abacus" id="practice-abacus"
@@ -1169,22 +1197,14 @@ export function ActiveSession({
interactive={true} interactive={true}
showNumbers={false} showNumbers={false}
animated={true} animated={true}
scaleFactor={2.5}
onValueChange={handleAbacusDockValueChange} onValueChange={handleAbacusDockValueChange}
className={css({ className={css({
position: 'absolute', position: 'absolute',
left: '100%', left: '100%',
top: 0, top: '50%',
bottom: 0, transform: 'translateY(-50%)',
marginLeft: '1rem', marginLeft: '1.5rem',
width: '120px',
'@media (min-width: 768px)': {
marginLeft: '1.5rem',
width: '150px',
},
'@media (min-width: 1024px)': {
marginLeft: '2rem',
width: '180px',
},
})} })}
/> />
)} )}
@@ -1226,9 +1246,9 @@ export function ActiveSession({
<animated.button <animated.button
type="button" type="button"
data-action="submit" data-action="submit"
data-visible={attempt.manualSubmitRequired} data-visible={showSubmitButton}
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit || isSubmitting || !attempt.manualSubmitRequired} disabled={!canSubmit || isSubmitting || !showSubmitButton}
style={submitButtonSpring} style={submitButtonSpring}
className={css({ className={css({
padding: '0.75rem 2rem', padding: '0.75rem 2rem',
@@ -1236,16 +1256,12 @@ export function ActiveSession({
fontWeight: 'bold', fontWeight: 'bold',
borderRadius: '8px', borderRadius: '8px',
border: 'none', border: 'none',
cursor: !canSubmit || !attempt.manualSubmitRequired ? 'not-allowed' : 'pointer', cursor: !canSubmit || !showSubmitButton ? 'not-allowed' : 'pointer',
backgroundColor: canSubmit ? 'blue.500' : isDark ? 'gray.700' : 'gray.300', backgroundColor: canSubmit ? 'blue.500' : isDark ? 'gray.700' : 'gray.300',
color: !canSubmit ? (isDark ? 'gray.400' : 'gray.500') : 'white', color: !canSubmit ? (isDark ? 'gray.400' : 'gray.500') : 'white',
_hover: { _hover: {
backgroundColor: backgroundColor:
canSubmit && attempt.manualSubmitRequired canSubmit && showSubmitButton ? 'blue.600' : isDark ? 'gray.600' : 'gray.300',
? 'blue.600'
: isDark
? 'gray.600'
: 'gray.300',
}, },
})} })}
> >
@@ -1261,7 +1277,7 @@ export function ActiveSession({
onSubmit={handleSubmit} onSubmit={handleSubmit}
disabled={isSubmitting} disabled={isSubmitting}
currentValue={attempt.userAnswer} currentValue={attempt.userAnswer}
showSubmitButton={attempt.manualSubmitRequired} showSubmitButton={showSubmitButton}
/> />
)} )}
</div> </div>