feat(abacus-react): add defaultValue prop for uncontrolled mode

Add standard React controlled/uncontrolled component pattern to AbacusReact:
- Add `defaultValue` prop to support uncontrolled mode (component owns state)
- When `value` is provided, component operates in controlled mode (syncs to prop)
- When only `defaultValue` is provided, component operates in uncontrolled mode
- Update HelpAbacus to use defaultValue for interactive help

This enables interactive abacus in help mode where the component tracks its own
state while parent monitors via onValueChange callback.

🤖 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-06 19:11:43 -06:00
parent 373ec34e46
commit 3ce12c59fc
5 changed files with 2829 additions and 2315 deletions

View File

@ -3,7 +3,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type {
GeneratedProblem,
HelpLevel,
ProblemConstraints,
ProblemSlot,
SessionHealth,
@ -12,7 +11,7 @@ import type {
SlotResult,
} from '@/db/schema/session-plans'
import type { StudentHelpSettings } from '@/db/schema/players'
import { usePracticeHelp, type TermContext } from '@/hooks/usePracticeHelp'
import { usePracticeHelp } from '@/hooks/usePracticeHelp'
import { createBasicSkillSet, type SkillSet } from '@/types/tutorial'
import {
analyzeRequiredSkills,
@ -20,9 +19,9 @@ import {
generateSingleProblem,
} from '@/utils/problemGenerator'
import { css } from '../../../styled-system/css'
import { HelpAbacus } from './HelpAbacus'
import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection'
import { NumericKeypad } from './NumericKeypad'
import { PracticeHelpPanel } from './PracticeHelpPanel'
import { VerticalProblem } from './VerticalProblem'
interface ActiveSessionProps {
@ -187,10 +186,13 @@ export function ActiveSession({
const [userAnswer, setUserAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [showAbacus, setShowAbacus] = useState(false)
const [feedback, setFeedback] = useState<'none' | 'correct' | 'incorrect'>('none')
const [currentTermIndex, setCurrentTermIndex] = useState(0)
const [incorrectAttempts, setIncorrectAttempts] = useState(0)
// Help mode: which terms have been confirmed correct so far
const [confirmedTermCount, setConfirmedTermCount] = useState(0)
// Which term we're currently showing help for (null = not showing help)
const [helpTermIndex, setHelpTermIndex] = useState<number | null>(null)
const hasPhysicalKeyboard = useHasPhysicalKeyboard()
@ -217,6 +219,56 @@ export function ActiveSession({
return currentProblem.problem.terms[currentTermIndex]
}, [currentProblem, currentTermIndex])
// Compute all prefix sums for the current problem
// prefixSums[i] = sum of terms[0..i] (inclusive)
// e.g., for [23, 45, 12]: prefixSums = [23, 68, 80]
const prefixSums = useMemo(() => {
if (!currentProblem) return []
const terms = currentProblem.problem.terms
const sums: number[] = []
let total = 0
for (const term of terms) {
total += term
sums.push(total)
}
return sums
}, [currentProblem])
// Check if user's input matches any prefix sum
// Returns the index of the matched prefix, or -1 if no match
const matchedPrefixIndex = useMemo(() => {
const answerNum = parseInt(userAnswer, 10)
if (Number.isNaN(answerNum)) return -1
return prefixSums.indexOf(answerNum)
}, [userAnswer, prefixSums])
// Determine button state based on input
const buttonState = useMemo((): 'help' | 'submit' | 'disabled' => {
if (!userAnswer) return 'disabled'
const answerNum = parseInt(userAnswer, 10)
if (Number.isNaN(answerNum)) return 'disabled'
// If it matches a prefix sum (but not the final answer), offer help
if (matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1) {
return 'help'
}
// Otherwise it's a submit (final answer or wrong answer)
return 'submit'
}, [userAnswer, matchedPrefixIndex, prefixSums.length])
// Compute context for help abacus when showing help
const helpContext = useMemo(() => {
if (helpTermIndex === null || !currentProblem) return null
const terms = currentProblem.problem.terms
// Current value is the prefix sum up to helpTermIndex (exclusive)
const currentValue = helpTermIndex === 0 ? 0 : prefixSums[helpTermIndex - 1]
// Target is the prefix sum including this term
const targetValue = prefixSums[helpTermIndex]
const term = terms[helpTermIndex]
return { currentValue, targetValue, term }
}, [helpTermIndex, currentProblem, prefixSums])
// Initialize help system
const [helpState, helpActions] = usePracticeHelp({
settings: helpSettings || {
@ -326,6 +378,50 @@ export function ActiveSession({
setUserAnswer((prev) => prev.slice(0, -1))
}, [])
// Handle "Get Help" button - show help for the next term
const handleGetHelp = useCallback(() => {
if (matchedPrefixIndex < 0) return
// User has confirmed up through matchedPrefixIndex (inclusive)
// Set confirmed count and show help for the NEXT term
const newConfirmedCount = matchedPrefixIndex + 1
setConfirmedTermCount(newConfirmedCount)
// If there's a next term to help with, show it
if (newConfirmedCount < (currentProblem?.problem.terms.length || 0)) {
setHelpTermIndex(newConfirmedCount)
// Clear the input so they can continue
setUserAnswer('')
}
}, [matchedPrefixIndex, currentProblem?.problem.terms.length])
// Handle dismissing help (continue without visual assistance)
const handleDismissHelp = useCallback(() => {
setHelpTermIndex(null)
}, [])
// Handle when student reaches the target value on the help abacus
const handleTargetReached = useCallback(() => {
if (helpTermIndex === null || !currentProblem) return
// Current term is now confirmed
const newConfirmedCount = helpTermIndex + 1
setConfirmedTermCount(newConfirmedCount)
// Brief delay so user sees the success feedback
setTimeout(() => {
// If there's another term after this one, move to it
if (newConfirmedCount < currentProblem.problem.terms.length) {
setHelpTermIndex(newConfirmedCount)
setUserAnswer('')
} else {
// This was the last term - hide help and let them type the final answer
setHelpTermIndex(null)
setUserAnswer('')
}
}, 800) // 800ms delay to show "Perfect!" feedback
}, [helpTermIndex, currentProblem])
const handleSubmit = useCallback(async () => {
if (!currentProblem || isSubmitting || !userAnswer) return
@ -353,7 +449,7 @@ export function ActiveSession({
isCorrect,
responseTimeMs,
skillsExercised: currentProblem.problem.skillsRequired,
usedOnScreenAbacus: showAbacus,
usedOnScreenAbacus: confirmedTermCount > 0 || helpTermIndex !== null,
// Help tracking fields
helpLevelUsed: helpState.maxLevelUsed,
incorrectAttempts,
@ -368,6 +464,8 @@ export function ActiveSession({
setCurrentProblem(null)
setCurrentTermIndex(0)
setIncorrectAttempts(0)
setConfirmedTermCount(0)
setHelpTermIndex(null)
setIsSubmitting(false)
},
isCorrect ? 500 : 1500
@ -376,7 +474,8 @@ export function ActiveSession({
currentProblem,
isSubmitting,
userAnswer,
showAbacus,
confirmedTermCount,
helpTermIndex,
onAnswer,
helpState.maxLevelUsed,
helpState.trigger,
@ -615,25 +714,98 @@ export function ActiveSession({
{currentSlot?.purpose}
</div>
{/* Problem display - vertical or linear based on part type */}
{currentPart.format === 'vertical' ? (
<VerticalProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
size="large"
/>
) : (
<LinearProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
/>
)}
{/* Problem and Help Abacus - side by side layout */}
<div
data-section="problem-and-help"
className={css({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
gap: '1.5rem',
flexWrap: 'wrap',
})}
>
{/* Problem display - vertical or linear based on part type */}
{currentPart.format === 'vertical' ? (
<VerticalProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
size="large"
confirmedTermCount={confirmedTermCount}
currentHelpTermIndex={helpTermIndex ?? undefined}
/>
) : (
<LinearProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
/>
)}
{/* Per-term help with HelpAbacus - shown when helpTermIndex is set */}
{!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && (
<div
data-section="term-help"
className={css({
padding: '1rem',
backgroundColor: 'purple.50',
borderRadius: '12px',
border: '2px solid',
borderColor: 'purple.200',
minWidth: '200px',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.5rem',
})}
>
<div
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: 'purple.700',
})}
>
{helpContext.term >= 0 ? '+' : ''}
{helpContext.term}
</div>
<button
type="button"
onClick={handleDismissHelp}
className={css({
fontSize: '0.75rem',
color: 'gray.500',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
_hover: { color: 'gray.700' },
})}
>
</button>
</div>
<HelpAbacus
currentValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
columns={3}
scaleFactor={0.9}
interactive={true}
onTargetReached={handleTargetReached}
/>
</div>
)}
</div>
{/* Feedback message */}
{feedback !== 'none' && (
@ -653,23 +825,59 @@ export function ActiveSession({
: `The answer was ${currentProblem.problem.answer}`}
</div>
)}
{/* Help panel - show when not submitting and feedback hasn't been shown yet */}
{!isSubmitting && feedback === 'none' && (
<div data-section="help-area" className={css({ width: '100%' })}>
<PracticeHelpPanel
helpState={helpState}
onRequestHelp={helpActions.requestHelp}
onDismissHelp={helpActions.dismissHelp}
isAbacusPart={currentPart.type === 'abacus'}
/>
</div>
)}
</div>
{/* Input area */}
{!isPaused && feedback === 'none' && (
<div data-section="input-area">
{/* Dynamic action button */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '1rem',
})}
>
<button
type="button"
data-action={buttonState}
onClick={buttonState === 'help' ? handleGetHelp : handleSubmit}
disabled={buttonState === 'disabled' || isSubmitting}
className={css({
padding: '0.75rem 2rem',
fontSize: '1.125rem',
fontWeight: 'bold',
borderRadius: '8px',
border: 'none',
cursor: buttonState === 'disabled' ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
// Different styles based on button state
backgroundColor:
buttonState === 'help'
? 'purple.500'
: buttonState === 'submit'
? 'blue.500'
: 'gray.300',
color: buttonState === 'disabled' ? 'gray.500' : 'white',
opacity: buttonState === 'disabled' ? 0.5 : 1,
_hover: {
backgroundColor:
buttonState === 'help'
? 'purple.600'
: buttonState === 'submit'
? 'blue.600'
: 'gray.300',
},
})}
>
{buttonState === 'help'
? 'Get Help →'
: buttonState === 'submit'
? 'Submit ✓'
: 'Enter Total'}
</button>
</div>
{/* Physical keyboard hint */}
{hasPhysicalKeyboard && (
<div
@ -680,7 +888,8 @@ export function ActiveSession({
marginBottom: '1rem',
})}
>
Type your answer and press Enter
Type your abacus total
{buttonState === 'help' ? ' to get help, or your final answer' : ''}
</div>
)}
@ -689,7 +898,7 @@ export function ActiveSession({
<NumericKeypad
onDigit={handleDigit}
onBackspace={handleBackspace}
onSubmit={handleSubmit}
onSubmit={buttonState === 'help' ? handleGetHelp : handleSubmit}
disabled={isSubmitting}
currentValue={userAnswer}
/>
@ -697,58 +906,6 @@ export function ActiveSession({
</div>
)}
{/* Abacus toggle (only for abacus part) */}
{currentPart.type === 'abacus' && (
<div data-section="abacus-tools">
<button
type="button"
data-action="toggle-abacus"
onClick={() => setShowAbacus(!showAbacus)}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
width: '100%',
padding: '0.75rem',
fontSize: '0.875rem',
color: showAbacus ? 'blue.700' : 'gray.600',
backgroundColor: showAbacus ? 'blue.50' : 'gray.100',
border: '1px solid',
borderColor: showAbacus ? 'blue.200' : 'gray.200',
borderRadius: '8px',
cursor: 'pointer',
_hover: {
backgroundColor: showAbacus ? 'blue.100' : 'gray.200',
},
})}
>
{showAbacus ? 'Hide On-Screen Abacus' : 'Need Help? Show Abacus'}
</button>
{showAbacus && (
<div
data-element="abacus-reminder"
className={css({
marginTop: '0.5rem',
padding: '0.75rem',
backgroundColor: 'yellow.50',
borderRadius: '8px',
border: '1px solid',
borderColor: 'yellow.200',
fontSize: '0.875rem',
color: 'yellow.700',
textAlign: 'center',
})}
>
Try using your physical abacus first! The on-screen version is for checking only.
</div>
)}
{/* TODO: Add AbacusReact component here when showAbacus is true */}
</div>
)}
{/* Teacher controls */}
<div
data-section="teacher-controls"

View File

@ -0,0 +1,245 @@
'use client'
import {
AbacusReact,
calculateBeadDiffFromValues,
type StepBeadHighlight,
useAbacusDisplay,
} from '@soroban/abacus-react'
import { useCallback, useMemo, useState } from 'react'
import { css } from '../../../styled-system/css'
/** Bead change from calculateBeadDiffFromValues */
interface BeadChange {
placeValue: number
beadType: 'heaven' | 'earth'
position?: number
direction: 'up' | 'down' | 'activate' | 'deactivate' | 'none'
order?: number
}
interface HelpAbacusProps {
/** Initial value to start the abacus at */
currentValue: number
/** Target value we want to reach */
targetValue: number
/** Number of columns to display (default: 3) */
columns?: number
/** Scale factor for the abacus (default: 1.2) */
scaleFactor?: number
/** Callback when target is reached */
onTargetReached?: () => void
/** Optional callback when value changes (if interactive) */
onValueChange?: (value: number) => void
/** Whether the abacus is interactive (default: false for help mode) */
interactive?: boolean
}
/**
* HelpAbacus - Shows an abacus with bead movement arrows
*
* Uses AbacusReact in uncontrolled mode (defaultValue) so interactions
* work automatically. Tracks value changes via onValueChange to update
* the bead diff arrows and detect when target is reached.
*/
export function HelpAbacus({
currentValue,
targetValue,
columns = 3,
scaleFactor = 1.2,
onTargetReached,
onValueChange,
interactive = false,
}: HelpAbacusProps) {
const { config: abacusConfig } = useAbacusDisplay()
const [currentStep] = useState(0)
// Track the displayed value for bead diff calculations
// This is updated via onValueChange from AbacusReact
const [displayedValue, setDisplayedValue] = useState(currentValue)
// Handle value changes from user interaction
const handleValueChange = useCallback(
(newValue: number | bigint) => {
const numValue = typeof newValue === 'bigint' ? Number(newValue) : newValue
setDisplayedValue(numValue)
onValueChange?.(numValue)
// Check if target reached
if (numValue === targetValue) {
onTargetReached?.()
}
},
[targetValue, onTargetReached, onValueChange]
)
// Check if currently at target (for showing success state)
const isAtTarget = displayedValue === targetValue
// Generate bead movement highlights using the bead diff algorithm
// Updates as user moves beads closer to (or away from) the target
const { stepBeadHighlights, hasChanges, summary } = useMemo(() => {
try {
const beadDiff = calculateBeadDiffFromValues(displayedValue, targetValue)
if (!beadDiff.hasChanges) {
return { stepBeadHighlights: undefined, hasChanges: false, summary: '' }
}
// Convert bead diff to StepBeadHighlight format
// Filter to only columns that exist in our display
const highlights: StepBeadHighlight[] = (beadDiff.changes as BeadChange[])
.filter((change: BeadChange) => change.placeValue < columns)
.map((change: BeadChange) => ({
placeValue: change.placeValue,
beadType: change.beadType,
position: change.position,
direction: change.direction,
stepIndex: 0, // All in step 0 for now (could be multi-step later)
order: change.order,
}))
return {
stepBeadHighlights: highlights.length > 0 ? highlights : undefined,
hasChanges: true,
summary: beadDiff.summary,
}
} catch (error) {
console.error('HelpAbacus: Error generating bead diff:', error)
return { stepBeadHighlights: undefined, hasChanges: false, summary: '' }
}
}, [displayedValue, targetValue, columns])
// Custom styles for help mode - highlight the arrows more prominently
const customStyles = useMemo(() => {
return {
// Subtle background to indicate this is a help visualization
frame: {
fill: 'rgba(59, 130, 246, 0.05)',
},
}
}, [])
if (!hasChanges) {
return (
<div
data-component="help-abacus"
data-status="complete"
className={css({
textAlign: 'center',
padding: '1rem',
color: 'green.600',
fontSize: '0.875rem',
})}
>
Already at target value
</div>
)
}
return (
<div
data-component="help-abacus"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.75rem',
})}
>
{/* Summary instruction */}
{summary && (
<div
data-element="help-summary"
className={css({
padding: '0.5rem 1rem',
backgroundColor: 'blue.50',
borderRadius: '8px',
fontSize: '0.875rem',
color: 'blue.700',
fontWeight: 'medium',
textAlign: 'center',
})}
>
💡 {summary}
</div>
)}
{/* The abacus with bead arrows - uses defaultValue for uncontrolled mode */}
<div
className={css({
padding: '1rem',
backgroundColor: 'white',
borderRadius: '12px',
border: '2px solid',
borderColor: 'blue.200',
boxShadow: 'md',
})}
>
<AbacusReact
defaultValue={currentValue}
columns={columns}
interactive={interactive}
animated={true}
scaleFactor={scaleFactor}
colorScheme={abacusConfig.colorScheme}
beadShape={abacusConfig.beadShape}
hideInactiveBeads={abacusConfig.hideInactiveBeads}
soundEnabled={false} // Disable sound in help mode
stepBeadHighlights={isAtTarget ? undefined : stepBeadHighlights}
currentStep={currentStep}
showDirectionIndicators={!isAtTarget}
customStyles={customStyles}
onValueChange={handleValueChange}
/>
</div>
{/* Value labels */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
gap: '2rem',
fontSize: '0.875rem',
})}
>
<div className={css({ color: isAtTarget ? 'green.600' : 'gray.600' })}>
Current:{' '}
<span
className={css({ fontWeight: 'bold', color: isAtTarget ? 'green.700' : 'gray.800' })}
>
{displayedValue}
</span>
</div>
<div className={css({ color: isAtTarget ? 'green.600' : 'blue.600' })}>
Target:{' '}
<span
className={css({ fontWeight: 'bold', color: isAtTarget ? 'green.700' : 'blue.800' })}
>
{targetValue}
</span>
</div>
</div>
{/* Success feedback when target reached */}
{isAtTarget && (
<div
data-element="target-reached"
className={css({
padding: '0.5rem 1rem',
backgroundColor: 'green.100',
borderRadius: '8px',
fontSize: '0.875rem',
color: 'green.700',
fontWeight: 'bold',
textAlign: 'center',
})}
>
Perfect! Moving to next term...
</div>
)}
</div>
)
}
export default HelpAbacus

View File

@ -2,8 +2,9 @@
import { useCallback, useState } from 'react'
import type { HelpLevel } from '@/db/schema/session-plans'
import type { HelpContent, PracticeHelpState } from '@/hooks/usePracticeHelp'
import type { PracticeHelpState } from '@/hooks/usePracticeHelp'
import { css } from '../../../styled-system/css'
import { HelpAbacus } from './HelpAbacus'
interface PracticeHelpPanelProps {
/** Current help state from usePracticeHelp hook */
@ -14,6 +15,10 @@ interface PracticeHelpPanelProps {
onDismissHelp: () => void
/** Whether this is the abacus part (enables bead arrows at L3) */
isAbacusPart?: boolean
/** Current value on the abacus (for bead arrows at L3) */
currentValue?: number
/** Target value to reach (for bead arrows at L3) */
targetValue?: number
}
/**
@ -50,6 +55,8 @@ export function PracticeHelpPanel({
onRequestHelp,
onDismissHelp,
isAbacusPart = false,
currentValue,
targetValue,
}: PracticeHelpPanelProps) {
const { currentLevel, content, isAvailable, maxLevelUsed } = helpState
const [isExpanded, setIsExpanded] = useState(false)
@ -297,16 +304,16 @@ export function PracticeHelpPanel({
</div>
)}
{/* Level 3: Bead steps */}
{currentLevel >= 3 && content?.beadSteps && content.beadSteps.length > 0 && (
{/* Level 3: Visual abacus with bead arrows */}
{currentLevel >= 3 && currentValue !== undefined && targetValue !== undefined && (
<div
data-element="bead-steps"
data-element="help-abacus"
className={css({
padding: '0.75rem',
backgroundColor: 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: 'blue.100',
borderColor: 'purple.200',
})}
>
<div
@ -314,43 +321,20 @@ export function PracticeHelpPanel({
fontSize: '0.75rem',
fontWeight: 'bold',
color: 'purple.600',
marginBottom: '0.5rem',
marginBottom: '0.75rem',
textTransform: 'uppercase',
textAlign: 'center',
})}
>
Bead Movements
🧮 Follow the Arrows
</div>
<ol
className={css({
listStyle: 'decimal',
paddingLeft: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
{content.beadSteps.map((step, index) => (
<li
key={index}
className={css({
fontSize: '0.875rem',
color: 'gray.700',
})}
>
<span
className={css({
fontWeight: 'bold',
color: 'purple.700',
})}
>
{step.mathematicalTerm}
</span>
{step.englishInstruction && (
<span className={css({ color: 'gray.600' })}> {step.englishInstruction}</span>
)}
</li>
))}
</ol>
<HelpAbacus
currentValue={currentValue}
targetValue={targetValue}
columns={3}
scaleFactor={1.0}
/>
{isAbacusPart && (
<div
@ -364,12 +348,72 @@ export function PracticeHelpPanel({
textAlign: 'center',
})}
>
Try following these steps on your abacus
Try following these movements on your physical abacus
</div>
)}
</div>
)}
{/* Fallback: Text bead steps if abacus values not provided */}
{currentLevel >= 3 &&
(currentValue === undefined || targetValue === undefined) &&
content?.beadSteps &&
content.beadSteps.length > 0 && (
<div
data-element="bead-steps-text"
className={css({
padding: '0.75rem',
backgroundColor: 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: 'blue.100',
})}
>
<div
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
color: 'purple.600',
marginBottom: '0.5rem',
textTransform: 'uppercase',
})}
>
Bead Movements
</div>
<ol
className={css({
listStyle: 'decimal',
paddingLeft: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
{content.beadSteps.map((step, index) => (
<li
key={index}
className={css({
fontSize: '0.875rem',
color: 'gray.700',
})}
>
<span
className={css({
fontWeight: 'bold',
color: 'purple.700',
})}
>
{step.mathematicalTerm}
</span>
{step.englishInstruction && (
<span className={css({ color: 'gray.600' })}> {step.englishInstruction}</span>
)}
</li>
))}
</ol>
</div>
)}
{/* More help button (if not at max level) */}
{currentLevel < 3 && (
<button

View File

@ -15,6 +15,10 @@ interface VerticalProblemProps {
correctAnswer?: number
/** Size variant */
size?: 'normal' | 'large'
/** Index of terms that have been confirmed (0 = first term done, 1 = first two terms done, etc.) */
confirmedTermCount?: number
/** Index of the term currently being helped with (highlighted) */
currentHelpTermIndex?: number
}
/**
@ -33,6 +37,8 @@ export function VerticalProblem({
isCompleted = false,
correctAnswer,
size = 'normal',
confirmedTermCount = 0,
currentHelpTermIndex,
}: VerticalProblemProps) {
// Calculate max digits needed for alignment
const maxDigits = Math.max(
@ -86,16 +92,60 @@ export function VerticalProblem({
const absValue = Math.abs(term)
const digits = absValue.toString().padStart(maxDigits, ' ').split('')
// Term status for highlighting
const isConfirmed = index < confirmedTermCount
const isCurrentHelp = index === currentHelpTermIndex
return (
<div
key={index}
data-element="term-row"
data-term-status={isConfirmed ? 'confirmed' : isCurrentHelp ? 'current' : 'pending'}
className={css({
display: 'flex',
alignItems: 'center',
gap: '2px',
position: 'relative',
// Confirmed terms are dimmed with checkmark
opacity: isConfirmed ? 0.5 : 1,
// Current help term is highlighted
backgroundColor: isCurrentHelp ? 'purple.100' : 'transparent',
borderRadius: isCurrentHelp ? '4px' : '0',
padding: isCurrentHelp ? '2px 4px' : '0',
marginLeft: isCurrentHelp ? '-4px' : '0',
marginRight: isCurrentHelp ? '-4px' : '0',
})}
>
{/* Checkmark for confirmed terms */}
{isConfirmed && (
<div
data-element="confirmed-check"
className={css({
position: 'absolute',
left: '-1.5rem',
color: 'green.500',
fontSize: '0.875rem',
})}
>
</div>
)}
{/* Arrow indicator for current help term */}
{isCurrentHelp && (
<div
data-element="current-arrow"
className={css({
position: 'absolute',
left: '-1.5rem',
color: 'purple.600',
fontSize: '0.875rem',
})}
>
</div>
)}
{/* Operator column (only show minus for negative) */}
<div
data-element="operator"
@ -122,7 +172,8 @@ export function VerticalProblem({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'gray.800',
color: isCurrentHelp ? 'purple.800' : 'gray.800',
fontWeight: isCurrentHelp ? 'bold' : 'inherit',
})}
>
{digit}

File diff suppressed because it is too large Load Diff