(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() {
@@ -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}
diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx
index d517eacd..7d2cf57c 100644
--- a/apps/web/src/components/practice/ActiveSession.tsx
+++ b/apps/web/src/components/practice/ActiveSession.tsx
@@ -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({
)}
-
- {/* 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 && (
)}
@@ -1226,9 +1246,9 @@ export function ActiveSession({
@@ -1261,7 +1277,7 @@ export function ActiveSession({
onSubmit={handleSubmit}
disabled={isSubmitting}
currentValue={attempt.userAnswer}
- showSubmitButton={attempt.manualSubmitRequired}
+ showSubmitButton={showSubmitButton}
/>
)}