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 { usePathname } from 'next/navigation'
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 { type DockAnimationState, useMyAbacus } from '@/contexts/MyAbacusContext'
import { useTheme } from '@/contexts/ThemeContext'
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() {
const {
isOpen,
@ -33,9 +80,6 @@ export function MyAbacus() {
const { resolvedTheme } = useTheme()
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)
const localButtonRef = useRef<HTMLDivElement>(null)
@ -45,29 +89,6 @@ export function MyAbacus() {
const abacusValue = homeHeroContext?.abacusValue ?? localAbacusValue
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
const isOnHomePage =
pathname === '/' ||
@ -118,25 +139,6 @@ export function MyAbacus() {
// This matches /arcade, /arcade/*, and /arcade-rooms/*
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
useEffect(() => {
if (buttonRef && localButtonRef.current) {
@ -212,13 +214,21 @@ export function MyAbacus() {
return
}
// Measure positions
// Measure the button's current position
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 targetScale = dockedScale
const targetScale = dock.scaleFactor ?? 1
const animState: DockAnimationState = {
phase: 'docking',
@ -228,18 +238,13 @@ export function MyAbacus() {
width: buttonRect.width,
height: buttonRect.height,
},
toRect: {
x: dockRect.x,
y: dockRect.y,
width: dockRect.width,
height: dockRect.height,
},
toRect: targetRect,
fromScale: buttonScale,
toScale: targetScale,
}
startDockAnimation(animState)
}, [dock, dockInto, dockedScale, startDockAnimation])
}, [dock, dockInto, structuralStyles, startDockAnimation])
// Handler to initiate undock animation
const handleUndockClick = useCallback(() => {
@ -249,8 +254,24 @@ export function MyAbacus() {
return
}
// Measure dock position (source)
const dockRect = dock.element.getBoundingClientRect()
// The abacus is currently docked - find the actual rendered abacus element
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)
// Button is fixed at bottom-right with some margin
@ -262,16 +283,11 @@ export function MyAbacus() {
const buttonY = viewportHeight - buttonSize - margin
const buttonScale = 0.35
const dockScale = dockedScale
const dockScale = dock.scaleFactor ?? 1
const animState: DockAnimationState = {
phase: 'undocking',
fromRect: {
x: dockRect.x,
y: dockRect.y,
width: dockRect.width,
height: dockRect.height,
},
fromRect: sourceRect,
toRect: {
x: buttonX,
y: buttonY,
@ -283,7 +299,7 @@ export function MyAbacus() {
}
startUndockAnimation(animState)
}, [dock, undock, dockedScale, startUndockAnimation])
}, [dock, undock, structuralStyles, startUndockAnimation])
// Check if we're currently animating
const isAnimating = dockAnimationState !== null
@ -365,8 +381,6 @@ export function MyAbacus() {
data-mode="docked"
data-dock-id={dock.id}
className={css({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -414,10 +428,7 @@ export function MyAbacus() {
</button>
<div
data-element="abacus-display"
style={{ transform: `scale(${dockedScale})` }}
className={css({
transformOrigin: 'center center',
transition: 'transform 0.3s ease',
filter: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
})}
>
@ -426,6 +437,7 @@ export function MyAbacus() {
value={dock.value ?? abacusValue}
defaultValue={dock.defaultValue}
columns={dock.columns ?? 5}
scaleFactor={dock.scaleFactor}
beadShape={appConfig.beadShape}
showNumbers={dock.showNumbers ?? true}
interactive={dock.interactive ?? true}

View File

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