feat(practice): improve help UX with coach hints and simplified UI
- Add coach hint generator using same hints as tutorial (segment.readable.summary) - Hide abacus numerals in help mode for cleaner display - Restructure layout: abacus centered, coaching panel on right side - Exit help mode completely when student finishes adding term to abacus - Remove checkmarks from term indicators, keep only arrow for current term - Clean up unused props (confirmedTermCount, detectedPrefixIndex, countdownElement) - Add HelpCountdown component (pie chart timer, not currently used) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9a4ab8296e
commit
19169ad9fe
|
|
@ -20,6 +20,8 @@ import {
|
||||||
generateSingleProblem,
|
generateSingleProblem,
|
||||||
} from '@/utils/problemGenerator'
|
} from '@/utils/problemGenerator'
|
||||||
import { css } from '../../../styled-system/css'
|
import { css } from '../../../styled-system/css'
|
||||||
|
import { DecompositionDisplay, DecompositionProvider } from '../decomposition'
|
||||||
|
import { generateCoachHint } from './coachHintGenerator'
|
||||||
import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection'
|
import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection'
|
||||||
import { NumericKeypad } from './NumericKeypad'
|
import { NumericKeypad } from './NumericKeypad'
|
||||||
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
|
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
|
||||||
|
|
@ -529,24 +531,16 @@ export function ActiveSession({
|
||||||
}, [helpActions])
|
}, [helpActions])
|
||||||
|
|
||||||
// Handle when student reaches the target value on the help abacus
|
// Handle when student reaches the target value on the help abacus
|
||||||
|
// This exits help mode completely and resets the problem to normal state
|
||||||
const handleTargetReached = useCallback(() => {
|
const handleTargetReached = useCallback(() => {
|
||||||
if (helpTermIndex === null || !currentProblem) return
|
if (helpTermIndex === null || !currentProblem) return
|
||||||
|
|
||||||
// Current term is now confirmed
|
// Brief delay so user sees the success feedback, then exit help mode completely
|
||||||
const newConfirmedCount = helpTermIndex + 1
|
|
||||||
setConfirmedTermCount(newConfirmedCount)
|
|
||||||
|
|
||||||
// Brief delay so user sees the success feedback
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// If there's another term after this one, move to it
|
// Reset all help-related state - problem returns to as if they never entered a prefix
|
||||||
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)
|
setHelpTermIndex(null)
|
||||||
|
setConfirmedTermCount(0)
|
||||||
setUserAnswer('')
|
setUserAnswer('')
|
||||||
}
|
|
||||||
}, 800) // 800ms delay to show "Perfect!" feedback
|
}, 800) // 800ms delay to show "Perfect!" feedback
|
||||||
}, [helpTermIndex, currentProblem])
|
}, [helpTermIndex, currentProblem])
|
||||||
|
|
||||||
|
|
@ -1022,7 +1016,18 @@ export function ActiveSession({
|
||||||
{currentSlot?.purpose}
|
{currentSlot?.purpose}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Problem display - vertical or linear based on part type */}
|
{/* Problem display - horizontal layout with help panel on right when in help mode */}
|
||||||
|
<div
|
||||||
|
data-element="problem-with-help"
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '1.5rem',
|
||||||
|
width: '100%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Center: Problem display */}
|
||||||
{currentPart.format === 'vertical' ? (
|
{currentPart.format === 'vertical' ? (
|
||||||
<VerticalProblem
|
<VerticalProblem
|
||||||
terms={currentProblem.problem.terms}
|
terms={currentProblem.problem.terms}
|
||||||
|
|
@ -1031,54 +1036,11 @@ export function ActiveSession({
|
||||||
isCompleted={feedback !== 'none'}
|
isCompleted={feedback !== 'none'}
|
||||||
correctAnswer={currentProblem.problem.answer}
|
correctAnswer={currentProblem.problem.answer}
|
||||||
size="large"
|
size="large"
|
||||||
confirmedTermCount={confirmedTermCount}
|
|
||||||
currentHelpTermIndex={helpTermIndex ?? undefined}
|
currentHelpTermIndex={helpTermIndex ?? undefined}
|
||||||
detectedPrefixIndex={
|
|
||||||
matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1
|
|
||||||
? matchedPrefixIndex
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
autoSubmitPending={autoSubmitTriggered}
|
autoSubmitPending={autoSubmitTriggered}
|
||||||
rejectedDigit={rejectedDigit}
|
rejectedDigit={rejectedDigit}
|
||||||
helpOverlay={
|
helpOverlay={
|
||||||
!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext ? (
|
!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext ? (
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.25rem',
|
|
||||||
backgroundColor: isDark
|
|
||||||
? 'rgba(30, 58, 138, 0.95)'
|
|
||||||
: 'rgba(219, 234, 254, 0.95)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '0.5rem',
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{/* Term being helped indicator */}
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.5rem',
|
|
||||||
padding: '0.25rem 0.75rem',
|
|
||||||
backgroundColor: isDark ? 'purple.900' : 'purple.100',
|
|
||||||
borderRadius: '20px',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: isDark ? 'purple.200' : 'purple.700',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<span>Adding:</span>
|
|
||||||
<span className={css({ fontFamily: 'monospace', fontSize: '1rem' })}>
|
|
||||||
{helpContext.term >= 0 ? '+' : ''}
|
|
||||||
{helpContext.term}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Interactive abacus with arrows - just the abacus, no extra UI */}
|
|
||||||
{/* Columns = max digits between current and target values (minimum 1) */}
|
|
||||||
<PracticeHelpOverlay
|
<PracticeHelpOverlay
|
||||||
currentValue={helpContext.currentValue}
|
currentValue={helpContext.currentValue}
|
||||||
targetValue={helpContext.targetValue}
|
targetValue={helpContext.targetValue}
|
||||||
|
|
@ -1088,7 +1050,6 @@ export function ActiveSession({
|
||||||
)}
|
)}
|
||||||
onTargetReached={handleTargetReached}
|
onTargetReached={handleTargetReached}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1108,6 +1069,98 @@ export function ActiveSession({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Right: Help panel with coach hint and decomposition (only in help mode) */}
|
||||||
|
{!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && (
|
||||||
|
<div
|
||||||
|
data-element="help-panel"
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.75rem',
|
||||||
|
padding: '1rem',
|
||||||
|
backgroundColor: isDark ? 'blue.900' : 'blue.50',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: isDark ? 'blue.700' : 'blue.200',
|
||||||
|
minWidth: '200px',
|
||||||
|
maxWidth: '280px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Coach hint */}
|
||||||
|
{(() => {
|
||||||
|
const hint = generateCoachHint(helpContext.currentValue, helpContext.targetValue)
|
||||||
|
if (!hint) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-element="coach-hint"
|
||||||
|
className={css({
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isDark ? 'blue.800' : 'blue.100',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: isDark ? 'gray.300' : 'gray.700',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
margin: 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Decomposition display */}
|
||||||
|
<div
|
||||||
|
data-element="decomposition-display"
|
||||||
|
className={css({
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isDark ? 'blue.800' : 'blue.100',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.625rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: isDark ? 'blue.300' : 'blue.600',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Step-by-Step
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: isDark ? 'gray.100' : 'gray.800',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<DecompositionProvider
|
||||||
|
startValue={helpContext.currentValue}
|
||||||
|
targetValue={helpContext.targetValue}
|
||||||
|
currentStepIndex={0}
|
||||||
|
abacusColumns={Math.max(
|
||||||
|
1,
|
||||||
|
Math.max(helpContext.currentValue, helpContext.targetValue).toString().length
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DecompositionDisplay />
|
||||||
|
</DecompositionProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Feedback message */}
|
{/* Feedback message */}
|
||||||
{feedback !== 'none' && (
|
{feedback !== 'none' && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
|
||||||
import {
|
import {
|
||||||
type AbacusOverlay,
|
type AbacusOverlay,
|
||||||
AbacusReact,
|
AbacusReact,
|
||||||
|
|
@ -9,6 +8,7 @@ import {
|
||||||
useAbacusDisplay,
|
useAbacusDisplay,
|
||||||
} from '@soroban/abacus-react'
|
} from '@soroban/abacus-react'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
import { css } from '../../../styled-system/css'
|
import { css } from '../../../styled-system/css'
|
||||||
|
|
||||||
/** Bead change from calculateBeadDiffFromValues */
|
/** Bead change from calculateBeadDiffFromValues */
|
||||||
|
|
@ -213,6 +213,7 @@ export function HelpAbacus({
|
||||||
colorScheme={abacusConfig.colorScheme}
|
colorScheme={abacusConfig.colorScheme}
|
||||||
beadShape={abacusConfig.beadShape}
|
beadShape={abacusConfig.beadShape}
|
||||||
hideInactiveBeads={abacusConfig.hideInactiveBeads}
|
hideInactiveBeads={abacusConfig.hideInactiveBeads}
|
||||||
|
showNumbers={false} // Hide numerals to keep display tight
|
||||||
soundEnabled={false} // Disable sound in help mode
|
soundEnabled={false} // Disable sound in help mode
|
||||||
stepBeadHighlights={isAtTarget ? undefined : stepBeadHighlights}
|
stepBeadHighlights={isAtTarget ? undefined : stepBeadHighlights}
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HelpCountdown - Pie chart style countdown timer for help escalation
|
||||||
|
*
|
||||||
|
* Shows a circular countdown that depletes as time passes, with seconds
|
||||||
|
* remaining displayed in the center. Positioned in place of the "=" sign
|
||||||
|
* in the VerticalProblem component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { css } from '../../../styled-system/css'
|
||||||
|
|
||||||
|
export interface HelpCountdownProps {
|
||||||
|
/** Time elapsed since problem started (ms) */
|
||||||
|
elapsedTimeMs: number
|
||||||
|
/** Target time for next help level (ms) */
|
||||||
|
targetTimeMs: number
|
||||||
|
/** Size of the countdown circle (matches cell size) */
|
||||||
|
size?: string
|
||||||
|
/** Whether dark mode is active */
|
||||||
|
isDark?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color based on percentage remaining
|
||||||
|
* Green (>66%) → Yellow (33-66%) → Orange (15-33%) → Red (<15%)
|
||||||
|
*/
|
||||||
|
function getColor(percentRemaining: number, isDark: boolean): string {
|
||||||
|
if (percentRemaining > 66) {
|
||||||
|
return isDark ? '#22c55e' : '#16a34a' // green
|
||||||
|
} else if (percentRemaining > 33) {
|
||||||
|
return isDark ? '#eab308' : '#ca8a04' // yellow
|
||||||
|
} else if (percentRemaining > 15) {
|
||||||
|
return isDark ? '#f97316' : '#ea580c' // orange
|
||||||
|
} else {
|
||||||
|
return isDark ? '#ef4444' : '#dc2626' // red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HelpCountdown - Pie chart countdown timer
|
||||||
|
*
|
||||||
|
* Renders a circular progress indicator that depletes clockwise,
|
||||||
|
* with the seconds remaining shown in the center.
|
||||||
|
*/
|
||||||
|
export function HelpCountdown({
|
||||||
|
elapsedTimeMs,
|
||||||
|
targetTimeMs,
|
||||||
|
size = '2.4rem',
|
||||||
|
isDark = false,
|
||||||
|
}: HelpCountdownProps) {
|
||||||
|
const remainingMs = Math.max(0, targetTimeMs - elapsedTimeMs)
|
||||||
|
const remainingSeconds = Math.ceil(remainingMs / 1000)
|
||||||
|
const percentRemaining = (remainingMs / targetTimeMs) * 100
|
||||||
|
|
||||||
|
// SVG parameters
|
||||||
|
const viewBoxSize = 100
|
||||||
|
const center = viewBoxSize / 2
|
||||||
|
const radius = 40
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
|
||||||
|
// Calculate stroke dash for remaining time (pie depletes as time passes)
|
||||||
|
const strokeDashoffset = circumference * (1 - percentRemaining / 100)
|
||||||
|
|
||||||
|
const color = getColor(percentRemaining, isDark)
|
||||||
|
|
||||||
|
// Background color for the circle
|
||||||
|
const bgColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-element="help-countdown"
|
||||||
|
data-seconds-remaining={remainingSeconds}
|
||||||
|
className={css({
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
||||||
|
className={css({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
transform: 'rotate(-90deg)', // Start from top
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Background circle */}
|
||||||
|
<circle cx={center} cy={center} r={radius} fill="none" stroke={bgColor} strokeWidth="12" />
|
||||||
|
{/* Progress arc */}
|
||||||
|
<circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="12"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
className={css({
|
||||||
|
transition: 'stroke-dashoffset 0.3s ease-out, stroke 0.3s ease',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* Seconds remaining in center */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontFamily: 'var(--font-mono, monospace)',
|
||||||
|
color: color,
|
||||||
|
lineHeight: 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{remainingSeconds}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HelpCountdown
|
||||||
|
|
@ -17,12 +17,8 @@ interface VerticalProblemProps {
|
||||||
correctAnswer?: number
|
correctAnswer?: number
|
||||||
/** Size variant */
|
/** Size variant */
|
||||||
size?: 'normal' | 'large'
|
size?: 'normal' | 'large'
|
||||||
/** Index of terms that have been confirmed (0 = first term done, 1 = first two terms done, etc.) */
|
/** Index of the term currently being helped with (shows arrow indicator) */
|
||||||
confirmedTermCount?: number
|
|
||||||
/** Index of the term currently being helped with (highlighted) */
|
|
||||||
currentHelpTermIndex?: number
|
currentHelpTermIndex?: number
|
||||||
/** Detected prefix index - shows preview checkmarks/arrow before user clicks "Get Help" */
|
|
||||||
detectedPrefixIndex?: number
|
|
||||||
/** Whether auto-submit is about to trigger (shows celebration animation) */
|
/** Whether auto-submit is about to trigger (shows celebration animation) */
|
||||||
autoSubmitPending?: boolean
|
autoSubmitPending?: boolean
|
||||||
/** Rejected digit to show as red X (null = no rejection) */
|
/** Rejected digit to show as red X (null = no rejection) */
|
||||||
|
|
@ -47,9 +43,7 @@ export function VerticalProblem({
|
||||||
isCompleted = false,
|
isCompleted = false,
|
||||||
correctAnswer,
|
correctAnswer,
|
||||||
size = 'normal',
|
size = 'normal',
|
||||||
confirmedTermCount = 0,
|
|
||||||
currentHelpTermIndex,
|
currentHelpTermIndex,
|
||||||
detectedPrefixIndex,
|
|
||||||
autoSubmitPending = false,
|
autoSubmitPending = false,
|
||||||
rejectedDigit = null,
|
rejectedDigit = null,
|
||||||
helpOverlay,
|
helpOverlay,
|
||||||
|
|
@ -125,89 +119,23 @@ export function VerticalProblem({
|
||||||
const absValue = Math.abs(term)
|
const absValue = Math.abs(term)
|
||||||
const digits = absValue.toString().padStart(maxDigits, ' ').split('')
|
const digits = absValue.toString().padStart(maxDigits, ' ').split('')
|
||||||
|
|
||||||
// Term status for highlighting
|
// Check if this term row should show the help overlay
|
||||||
const isConfirmed = index < confirmedTermCount
|
|
||||||
const isCurrentHelp = index === currentHelpTermIndex
|
const isCurrentHelp = index === currentHelpTermIndex
|
||||||
// Preview states - shown when user's input matches a prefix sum (before clicking "Get Help")
|
|
||||||
const isPreviewConfirmed =
|
|
||||||
detectedPrefixIndex !== undefined && index <= detectedPrefixIndex && !isConfirmed
|
|
||||||
const isPreviewNext =
|
|
||||||
detectedPrefixIndex !== undefined &&
|
|
||||||
index === detectedPrefixIndex + 1 &&
|
|
||||||
!isCurrentHelp &&
|
|
||||||
detectedPrefixIndex < terms.length - 1 // Don't show if prefix is the full answer
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
data-element="term-row"
|
data-element="term-row"
|
||||||
data-term-status={
|
data-term-status={isCurrentHelp ? 'current' : 'pending'}
|
||||||
isConfirmed
|
|
||||||
? 'confirmed'
|
|
||||||
: isCurrentHelp
|
|
||||||
? 'current'
|
|
||||||
: isPreviewConfirmed
|
|
||||||
? 'preview-confirmed'
|
|
||||||
: isPreviewNext
|
|
||||||
? 'preview-next'
|
|
||||||
: 'pending'
|
|
||||||
}
|
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '2px',
|
gap: '2px',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
// Confirmed terms are dimmed with checkmark
|
|
||||||
opacity: isConfirmed ? 0.5 : isPreviewConfirmed ? 0.7 : 1,
|
|
||||||
// Current help term is highlighted
|
|
||||||
backgroundColor: isCurrentHelp
|
|
||||||
? isDark
|
|
||||||
? 'purple.800'
|
|
||||||
: 'purple.100'
|
|
||||||
: isPreviewNext
|
|
||||||
? isDark
|
|
||||||
? 'yellow.900'
|
|
||||||
: 'yellow.50'
|
|
||||||
: 'transparent',
|
|
||||||
borderRadius: isCurrentHelp || isPreviewNext ? '4px' : '0',
|
|
||||||
padding: isCurrentHelp || isPreviewNext ? '2px 4px' : '0',
|
|
||||||
marginLeft: isCurrentHelp || isPreviewNext ? '-4px' : '0',
|
|
||||||
marginRight: isCurrentHelp || isPreviewNext ? '-4px' : '0',
|
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* Checkmark for confirmed terms */}
|
{/* Arrow indicator for current help term (the term being added) */}
|
||||||
{isConfirmed && (
|
|
||||||
<div
|
|
||||||
data-element="confirmed-check"
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
left: '-1.5rem',
|
|
||||||
color: isDark ? 'green.400' : 'green.500',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Preview checkmark for detected prefix terms (shown in muted color with subtle pulse) */}
|
|
||||||
{isPreviewConfirmed && (
|
|
||||||
<div
|
|
||||||
data-element="preview-check"
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
left: '-1.5rem',
|
|
||||||
color: isDark ? 'yellow.400' : 'yellow.600',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
opacity: 0.8,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Arrow indicator for current help term */}
|
|
||||||
{isCurrentHelp && (
|
{isCurrentHelp && (
|
||||||
<div
|
<div
|
||||||
data-element="current-arrow"
|
data-element="current-arrow"
|
||||||
|
|
@ -222,21 +150,6 @@ export function VerticalProblem({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Preview arrow for next term after detected prefix */}
|
|
||||||
{isPreviewNext && (
|
|
||||||
<div
|
|
||||||
data-element="preview-arrow"
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
left: '-1.5rem',
|
|
||||||
color: isDark ? 'yellow.400' : 'yellow.600',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Operator column (only show minus for negative) */}
|
{/* Operator column (only show minus for negative) */}
|
||||||
<div
|
<div
|
||||||
data-element="operator"
|
data-element="operator"
|
||||||
|
|
@ -352,27 +265,19 @@ export function VerticalProblem({
|
||||||
<span>Perfect!</span>
|
<span>Perfect!</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Equals sign column - show "..." for prefix sums (mathematically incomplete), "=" for final answer */}
|
{/* Equals column */}
|
||||||
<div
|
<div
|
||||||
data-element="equals"
|
data-element="equals"
|
||||||
data-prefix-mode={detectedPrefixIndex !== undefined ? 'true' : undefined}
|
|
||||||
className={css({
|
className={css({
|
||||||
width: cellWidth,
|
width: cellWidth,
|
||||||
height: cellHeight,
|
height: cellHeight,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
color:
|
color: isDark ? 'gray.400' : 'gray.500',
|
||||||
detectedPrefixIndex !== undefined
|
|
||||||
? isDark
|
|
||||||
? 'yellow.400'
|
|
||||||
: 'yellow.600'
|
|
||||||
: isDark
|
|
||||||
? 'gray.400'
|
|
||||||
: 'gray.500',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{detectedPrefixIndex !== undefined ? '…' : '='}
|
=
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Answer digit cells - show maxDigits cells total */}
|
{/* Answer digit cells - show maxDigits cells total */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
/**
|
||||||
|
* Coach hint generator for practice help system
|
||||||
|
*
|
||||||
|
* Uses the same readable.summary from unifiedStepGenerator that the
|
||||||
|
* tutorial CoachBar uses, ensuring consistent hints across the app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateUnifiedInstructionSequence } from '@/utils/unifiedStepGenerator'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a coach hint based on the current step
|
||||||
|
*
|
||||||
|
* Returns the segment's readable.summary if available, or null if not.
|
||||||
|
* This matches the tutorial CoachBar behavior which only renders when
|
||||||
|
* readable.summary exists.
|
||||||
|
*/
|
||||||
|
export function generateCoachHint(
|
||||||
|
startValue: number,
|
||||||
|
targetValue: number,
|
||||||
|
currentStepIndex: number = 0
|
||||||
|
): string | null {
|
||||||
|
const sequence = generateUnifiedInstructionSequence(startValue, targetValue)
|
||||||
|
|
||||||
|
if (!sequence || sequence.steps.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current step
|
||||||
|
const currentStep = sequence.steps[currentStepIndex]
|
||||||
|
if (!currentStep) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the segment this step belongs to
|
||||||
|
const segment = sequence.segments.find((s) => s.id === currentStep.segmentId)
|
||||||
|
|
||||||
|
// Return the segment's readable summary if available (same as tutorial CoachBar)
|
||||||
|
return segment?.readable?.summary ?? null
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue