feat(practice): add progressive help overlay with proper positioning

- Create PracticeHelpOverlay component showing interactive abacus with
  bead arrows and tooltips (uses same system as TutorialPlayer)
- Extract BeadTooltipContent to shared component for consistency
- Add helpOverlay prop to VerticalProblem for proper positioning
- Position help abacus directly above the term being helped using
  bottom: 100% on the term row (overflow visible)
- Dynamically size abacus columns based on max(currentValue, targetValue)
- Add timing configuration in helpTiming.ts (debug vs production)
- Add beadTooltipUtils.ts for tooltip positioning calculations

The help overlay now correctly covers the confirmed terms in the
vertical problem, with the "Adding: +X" badge and interactive abacus
positioned above the term being worked on.

🤖 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-07 15:38:17 -06:00
parent 804d937dd9
commit 9a4ab8296e
17 changed files with 1965 additions and 399 deletions

View File

@ -0,0 +1,560 @@
# Plan: Factor Out DecompositionContext
## Goal
Create a standalone `DecompositionContext` that can be used anywhere in the app where we want to show an interactive decomposition display for an abacus problem. The context only needs `startValue` and `targetValue` as inputs and provides all the derived data and interaction handlers.
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ DecompositionProvider │
│ Input: startValue, targetValue │
│ Optional: currentStepIndex, onSegmentChange, onTermHover │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ generateUnifiedInstructionSequence(start, target) │ │
│ │ → fullDecomposition, segments, steps, termPositions │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Derived Functions │ │
│ │ • getColumnFromTermIndex(termIndex) │ │
│ │ • getTermIndicesFromColumn(columnIndex) │ │
│ │ • getGroupTermIndicesFromTermIndex(termIndex) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Interactive State │ │
│ │ • activeTermIndices: Set<number> │ │
│ │ • activeIndividualTermIndex: number | null │ │
│ │ • handleTermHover, handleColumnHover │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ TutorialPlayer │ │ Practice Help │ │ Future Uses │
│ │ │ Panel │ │ (Flashcards, │
│ Wraps with │ │ │ │ Games, etc.) │
│ Provider, │ │ Wraps term help │ │ │
│ syncs step │ │ with Provider │ │ │
└───────────────┘ └─────────────────┘ └─────────────────┘
```
## Implementation Steps
### Step 1: Create DecompositionContext
**File:** `src/contexts/DecompositionContext.tsx`
```typescript
'use client'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import {
generateUnifiedInstructionSequence,
type PedagogicalSegment,
type UnifiedInstructionSequence,
type UnifiedStepData,
} from '@/utils/unifiedStepGenerator'
// ============================================================================
// Types
// ============================================================================
export interface DecompositionContextConfig {
/** Starting abacus value */
startValue: number
/** Target abacus value to reach */
targetValue: number
/** Current step index for highlighting (optional) */
currentStepIndex?: number
/** Callback when active segment changes (optional) */
onSegmentChange?: (segment: PedagogicalSegment | null) => void
/** Callback when a term is hovered (optional) */
onTermHover?: (termIndex: number | null, columnIndex: number | null) => void
/** Number of abacus columns for column mapping (default: 5) */
abacusColumns?: number
}
export interface DecompositionContextType {
// Generated data
sequence: UnifiedInstructionSequence
fullDecomposition: string
isMeaningfulDecomposition: boolean
termPositions: Array<{ startIndex: number; endIndex: number }>
segments: PedagogicalSegment[]
steps: UnifiedStepData[]
// Configuration
startValue: number
targetValue: number
currentStepIndex: number
abacusColumns: number
// Highlighting state
activeTermIndices: Set<number>
setActiveTermIndices: (indices: Set<number>) => void
activeIndividualTermIndex: number | null
setActiveIndividualTermIndex: (index: number | null) => void
// Derived functions
getColumnFromTermIndex: (termIndex: number, useGroupColumn?: boolean) => number | null
getTermIndicesFromColumn: (columnIndex: number) => number[]
getGroupTermIndicesFromTermIndex: (termIndex: number) => number[]
// Event handlers
handleTermHover: (termIndex: number, isHovering: boolean) => void
handleColumnHover: (columnIndex: number, isHovering: boolean) => void
}
// ============================================================================
// Context
// ============================================================================
const DecompositionContext = createContext<DecompositionContextType | null>(null)
export function useDecomposition(): DecompositionContextType {
const context = useContext(DecompositionContext)
if (!context) {
throw new Error('useDecomposition must be used within a DecompositionProvider')
}
return context
}
// Optional hook that returns null if not in provider (for conditional usage)
export function useDecompositionOptional(): DecompositionContextType | null {
return useContext(DecompositionContext)
}
// ============================================================================
// Provider
// ============================================================================
interface DecompositionProviderProps extends DecompositionContextConfig {
children: ReactNode
}
export function DecompositionProvider({
startValue,
targetValue,
currentStepIndex = 0,
onSegmentChange,
onTermHover,
abacusColumns = 5,
children,
}: DecompositionProviderProps) {
// -------------------------------------------------------------------------
// Generate sequence (memoized on value changes)
// -------------------------------------------------------------------------
const sequence = useMemo(
() => generateUnifiedInstructionSequence(startValue, targetValue),
[startValue, targetValue]
)
// -------------------------------------------------------------------------
// Highlighting state
// -------------------------------------------------------------------------
const [activeTermIndices, setActiveTermIndices] = useState<Set<number>>(new Set())
const [activeIndividualTermIndex, setActiveIndividualTermIndex] = useState<number | null>(null)
// -------------------------------------------------------------------------
// Derived: term positions from steps
// -------------------------------------------------------------------------
const termPositions = useMemo(
() => sequence.steps.map((step) => step.termPosition),
[sequence.steps]
)
// -------------------------------------------------------------------------
// Derived function: Get column index from term index
// -------------------------------------------------------------------------
const getColumnFromTermIndex = useCallback(
(termIndex: number, useGroupColumn = false): number | null => {
const step = sequence.steps[termIndex]
if (!step?.provenance) return null
// For group highlighting: use rhsPlace (target column of the operation)
// For individual term: use termPlace (specific column this term affects)
const placeValue = useGroupColumn
? step.provenance.rhsPlace
: (step.provenance.termPlace ?? step.provenance.rhsPlace)
// Convert place value to column index (rightmost column is highest index)
return abacusColumns - 1 - placeValue
},
[sequence.steps, abacusColumns]
)
// -------------------------------------------------------------------------
// Derived function: Get term indices that affect a given column
// -------------------------------------------------------------------------
const getTermIndicesFromColumn = useCallback(
(columnIndex: number): number[] => {
const placeValue = abacusColumns - 1 - columnIndex
return sequence.steps
.map((step, index) => ({ step, index }))
.filter(({ step }) => {
if (!step.provenance) return false
return (
step.provenance.rhsPlace === placeValue ||
step.provenance.termPlace === placeValue
)
})
.map(({ index }) => index)
},
[sequence.steps, abacusColumns]
)
// -------------------------------------------------------------------------
// Derived function: Get all term indices in the same complement group
// -------------------------------------------------------------------------
const getGroupTermIndicesFromTermIndex = useCallback(
(termIndex: number): number[] => {
const step = sequence.steps[termIndex]
if (!step?.provenance) return [termIndex]
const groupId = step.provenance.groupId
if (!groupId) return [termIndex]
// Find all steps with the same groupId
return sequence.steps
.map((s, i) => ({ step: s, index: i }))
.filter(({ step: s }) => s.provenance?.groupId === groupId)
.map(({ index }) => index)
},
[sequence.steps]
)
// -------------------------------------------------------------------------
// Event handler: Term hover
// -------------------------------------------------------------------------
const handleTermHover = useCallback(
(termIndex: number, isHovering: boolean) => {
if (isHovering) {
// Set individual term highlight
setActiveIndividualTermIndex(termIndex)
// Set group highlights
const groupIndices = getGroupTermIndicesFromTermIndex(termIndex)
setActiveTermIndices(new Set(groupIndices))
// Notify external listener
if (onTermHover) {
const columnIndex = getColumnFromTermIndex(termIndex, true)
onTermHover(termIndex, columnIndex)
}
} else {
setActiveIndividualTermIndex(null)
setActiveTermIndices(new Set())
onTermHover?.(null, null)
}
},
[getGroupTermIndicesFromTermIndex, getColumnFromTermIndex, onTermHover]
)
// -------------------------------------------------------------------------
// Event handler: Column hover (for bidirectional abacus ↔ decomposition)
// -------------------------------------------------------------------------
const handleColumnHover = useCallback(
(columnIndex: number, isHovering: boolean) => {
if (isHovering) {
const termIndices = getTermIndicesFromColumn(columnIndex)
setActiveTermIndices(new Set(termIndices))
} else {
setActiveTermIndices(new Set())
}
},
[getTermIndicesFromColumn]
)
// -------------------------------------------------------------------------
// Effect: Notify when active segment changes
// -------------------------------------------------------------------------
useEffect(() => {
if (!onSegmentChange) return
const segment = sequence.segments.find((seg) =>
seg.stepIndices?.includes(currentStepIndex)
)
onSegmentChange(segment || null)
}, [currentStepIndex, sequence.segments, onSegmentChange])
// -------------------------------------------------------------------------
// Context value
// -------------------------------------------------------------------------
const value: DecompositionContextType = useMemo(
() => ({
// Generated data
sequence,
fullDecomposition: sequence.fullDecomposition,
isMeaningfulDecomposition: sequence.isMeaningfulDecomposition,
termPositions,
segments: sequence.segments,
steps: sequence.steps,
// Configuration
startValue,
targetValue,
currentStepIndex,
abacusColumns,
// Highlighting state
activeTermIndices,
setActiveTermIndices,
activeIndividualTermIndex,
setActiveIndividualTermIndex,
// Derived functions
getColumnFromTermIndex,
getTermIndicesFromColumn,
getGroupTermIndicesFromTermIndex,
// Event handlers
handleTermHover,
handleColumnHover,
}),
[
sequence,
termPositions,
startValue,
targetValue,
currentStepIndex,
abacusColumns,
activeTermIndices,
activeIndividualTermIndex,
getColumnFromTermIndex,
getTermIndicesFromColumn,
getGroupTermIndicesFromTermIndex,
handleTermHover,
handleColumnHover,
]
)
return (
<DecompositionContext.Provider value={value}>
{children}
</DecompositionContext.Provider>
)
}
```
### Step 2: Create Standalone DecompositionDisplay Component
**File:** `src/components/decomposition/DecompositionDisplay.tsx`
This will be a refactored version of `DecompositionWithReasons` that:
1. Uses `useDecomposition()` instead of `useTutorialContext()`
2. Receives no props (gets everything from context)
3. Can be dropped anywhere inside a `DecompositionProvider`
```typescript
'use client'
import { useDecomposition } from '@/contexts/DecompositionContext'
import { ReasonTooltip } from './ReasonTooltip' // Moved here
import './decomposition.css'
export function DecompositionDisplay() {
const {
fullDecomposition,
termPositions,
segments,
steps,
currentStepIndex,
activeTermIndices,
activeIndividualTermIndex,
handleTermHover,
getGroupTermIndicesFromTermIndex,
} = useDecomposition()
// ... rendering logic (adapted from DecompositionWithReasons)
}
```
### Step 3: Refactor SegmentGroup
Pass `steps` as a prop instead of reading from TutorialContext:
```typescript
// Before:
function SegmentGroup({ segment, ... }) {
const { unifiedSteps: steps } = useTutorialContext()
// ...
}
// After:
function SegmentGroup({ segment, steps, ... }) {
// steps comes from props (DecompositionDisplay passes it from context)
}
```
### Step 4: Update ReasonTooltip
The tooltip already has a conditional import pattern for TutorialUIContext. We keep that but also:
1. Move it to `src/components/decomposition/ReasonTooltip.tsx`
2. Receive `steps` as a prop instead of from context
### Step 5: Update TutorialPlayer Integration
**File:** `src/components/tutorial/TutorialPlayer.tsx`
Wrap the decomposition area with `DecompositionProvider`:
```typescript
// In TutorialPlayer:
<DecompositionProvider
startValue={currentStep.startValue}
targetValue={currentStep.targetValue}
currentStepIndex={currentMultiStep}
onSegmentChange={(segment) => ui.setActiveSegment(segment)}
onTermHover={(termIndex, columnIndex) => {
// Update abacus column highlighting
setHighlightedColumn(columnIndex)
}}
>
<div data-element="decomposition-container">
<DecompositionDisplay />
</div>
</DecompositionProvider>
```
### Step 6: Integrate into Practice Help Panel
**File:** `src/components/practice/ActiveSession.tsx`
Add decomposition to the help panel:
```typescript
{/* Per-term help panel */}
{helpTermIndex !== null && helpContext && (
<div data-section="term-help">
{/* Header and dismiss button ... */}
{/* NEW: Decomposition display */}
<DecompositionProvider
startValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
currentStepIndex={currentHelpStepIndex}
>
<div data-element="decomposition-container">
<DecompositionDisplay />
</div>
</DecompositionProvider>
{/* Existing: Provenance breakdown */}
{/* Existing: HelpAbacus */}
</div>
)}
```
## File Structure After Refactoring
```
src/
├── contexts/
│ └── DecompositionContext.tsx # NEW: Standalone context
├── components/
│ ├── decomposition/ # NEW: Shared decomposition components
│ │ ├── DecompositionDisplay.tsx
│ │ ├── TermSpan.tsx
│ │ ├── SegmentGroup.tsx
│ │ ├── ReasonTooltip.tsx # MOVED from tutorial/
│ │ ├── decomposition.css # MOVED from tutorial/
│ │ └── index.ts # Re-exports
│ │
│ ├── tutorial/
│ │ ├── TutorialPlayer.tsx # UPDATED: Uses DecompositionProvider
│ │ ├── TutorialContext.tsx # SIMPLIFIED: Remove decomposition logic
│ │ └── ...
│ │
│ └── practice/
│ ├── ActiveSession.tsx # UPDATED: Uses DecompositionProvider
│ └── ...
```
## Migration Strategy
### Phase 1: Create New Context (Non-Breaking)
1. Create `DecompositionContext.tsx` with all logic
2. Create `DecompositionDisplay.tsx` using new context
3. Keep existing `DecompositionWithReasons.tsx` working
### Phase 2: Update TutorialPlayer
1. Wrap decomposition area with `DecompositionProvider`
2. Update TutorialPlayer to sync state via callbacks
3. Verify tutorial still works identically
### Phase 3: Integrate into Practice
1. Add `DecompositionProvider` to help panel
2. Render `DecompositionDisplay`
3. Test practice help flow
### Phase 4: Cleanup (Optional)
1. Remove decomposition logic from `TutorialContext`
2. Delete old `DecompositionWithReasons.tsx`
3. Update imports throughout codebase
## Testing Checklist
### Tutorial Mode
- [ ] Decomposition shows correctly for each step
- [ ] Current step is highlighted
- [ ] Term hover shows tooltip
- [ ] Term hover highlights related terms
- [ ] Term hover highlights abacus column
- [ ] Abacus column hover highlights related terms
### Practice Mode
- [ ] Decomposition shows when help is active
- [ ] Correct decomposition for current term (start → target)
- [ ] Tooltips work on hover
- [ ] Dark mode styling correct
- [ ] No console errors
### Edge Cases
- [ ] Single-digit addition (no meaningful decomposition)
- [ ] Multi-column carries
- [ ] Complement operations (five/ten complements)
- [ ] Very large numbers
- [ ] Empty/invalid values handled gracefully
## Risks and Mitigations
| Risk | Mitigation |
|------|------------|
| Breaking tutorial functionality | Phase 2: Keep old code working in parallel during migration |
| Performance: Re-generating sequence | useMemo ensures sequence only regenerates on value changes |
| CSS conflicts | Move CSS to shared location, use consistent naming |
| Missing data in practice context | `usePracticeHelp` already generates sequence - verify compatibility |
## Notes
### Why Not Just Pass Props?
We could pass all data as props, but:
1. Deep prop drilling through TermSpan, SegmentGroup, ReasonTooltip
2. Many components need same data
3. Interactive state (hover) needs to be shared
4. Context pattern is cleaner and more React-idiomatic
### Compatibility with usePracticeHelp
The `usePracticeHelp` hook already calls `generateUnifiedInstructionSequence()` and stores the result. For practice mode, we have two options:
1. **Option A:** Let `DecompositionProvider` regenerate (simple, slightly redundant)
2. **Option B:** Accept pre-generated `sequence` as prop (more efficient)
Recommend starting with Option A for simplicity, optimize later if needed.

View File

@ -0,0 +1,64 @@
# Progressive Help Overlay Feature Plan
## Executive Summary
**What:** When kid enters a prefix sum, show interactive abacus covering completed terms with time-based hint escalation.
**Why:** Makes help discoverable without reading - kid just enters what's on their abacus and help appears.
**Key insight:** We already have all the coaching/decomposition infrastructure extracted. Only need to:
1. Extract bead tooltip positioning from TutorialPlayer
2. Build new overlay component using existing decomposition system
3. Wire up time-based escalation
## Visual Layout
```
11 ← covered by abacus
+ 1 ← covered by abacus
+ 1 ← covered by abacus
┌─────────────────┐
│ ABACUS: 13→33 │ ← positioned above next term
└─────────────────┘
+ 20 ← term being added (visible)
+ 10 ← remaining terms (visible)
──────────
… [ 13 ]
```
## Time-Based Escalation
| Time | What appears |
|------|--------------|
| 0s | Abacus with arrows |
| +5s (debug: 1s) | Coach hint (from decomposition system) |
| +10s (debug: 3s) | Bead tooltip pointing at beads |
## Shared Infrastructure (Already Exists)
- `generateUnifiedInstructionSequence()` - step/segment data
- `DecompositionProvider` / `DecompositionDisplay` - visual decomposition
- `generateDynamicCoachHint()` - context-aware hints
- `HelpAbacus` - interactive abacus with arrows
## To Extract from TutorialPlayer
- `findTopmostBeadWithArrows()` - bead selection
- `calculateTooltipSide()` - smart collision detection
- `createTooltipTarget()` - overlay target creation
## Files
| File | Action |
|------|--------|
| `src/utils/beadTooltipUtils.ts` | CREATE - extracted tooltip utils |
| `src/constants/helpTiming.ts` | CREATE - timing config |
| `src/components/practice/PracticeHelpOverlay.tsx` | CREATE - main component |
| `src/components/practice/PracticeHelpOverlay.stories.tsx` | CREATE - stories |
| `src/components/practice/HelpAbacus.tsx` | MODIFY - add overlays prop |
| `src/components/practice/ActiveSession.tsx` | MODIFY - integrate overlay |
| `src/components/tutorial/TutorialPlayer.tsx` | MODIFY - use shared utils |
## Deferred
Positioning challenge (fixed abacus height vs variable prefix terms) - handle in follow-up.

View File

@ -138,7 +138,5 @@
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sqlite"
]
"enabledMcpjsonServers": ["sqlite"]
}

View File

@ -22,7 +22,7 @@ import {
import { css } from '../../../styled-system/css'
import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection'
import { NumericKeypad } from './NumericKeypad'
import { PracticeHelpPanel } from './PracticeHelpPanel'
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
import { VerticalProblem } from './VerticalProblem'
interface ActiveSessionProps {
@ -116,6 +116,7 @@ function LinearProblem({
isCompleted,
correctAnswer,
isDark,
detectedPrefixIndex,
}: {
terms: number[]
userAnswer: string
@ -123,6 +124,8 @@ function LinearProblem({
isCompleted: boolean
correctAnswer: number
isDark: boolean
/** Detected prefix index - shows "..." instead of "=" for partial sums */
detectedPrefixIndex?: number
}) {
// Build the equation string
const equation = terms
@ -132,9 +135,14 @@ function LinearProblem({
})
.join('')
// Use "..." for prefix sums (mathematically incomplete), "=" for final answer
const isPrefixSum = detectedPrefixIndex !== undefined
const operator = isPrefixSum ? '…' : '='
return (
<div
data-component="linear-problem"
data-prefix-mode={isPrefixSum ? 'true' : undefined}
className={css({
display: 'flex',
alignItems: 'center',
@ -145,7 +153,22 @@ function LinearProblem({
fontWeight: 'bold',
})}
>
<span className={css({ color: isDark ? 'gray.200' : 'gray.800' })}>{equation} =</span>
<span className={css({ color: isDark ? 'gray.200' : 'gray.800' })}>
{equation}{' '}
<span
className={css({
color: isPrefixSum
? isDark
? 'yellow.400'
: 'yellow.600'
: isDark
? 'gray.200'
: 'gray.800',
})}
>
{operator}
</span>
</span>
<span
className={css({
minWidth: '80px',
@ -356,6 +379,36 @@ export function ActiveSession({
})
}, [helpTermIndex, currentProblem?.problem.terms.join(','), prefixSums])
// Auto-trigger help when prefix sum is detected (don't make them click twice)
useEffect(() => {
// Only auto-trigger if:
// 1. We detected a prefix sum match (buttonState === 'help')
// 2. We're not already showing help for this term
// 3. The matched prefix is the next term they need help with
if (
buttonState === 'help' &&
helpTermIndex === null &&
matchedPrefixIndex >= 0 &&
matchedPrefixIndex < prefixSums.length - 1
) {
const newConfirmedCount = matchedPrefixIndex + 1
setConfirmedTermCount(newConfirmedCount)
if (newConfirmedCount < (currentProblem?.problem.terms.length || 0)) {
setHelpTermIndex(newConfirmedCount)
setUserAnswer('')
helpActions.requestHelp(1)
}
}
}, [
buttonState,
helpTermIndex,
matchedPrefixIndex,
prefixSums.length,
currentProblem?.problem.terms.length,
helpActions,
])
// Get current part and slot
const parts = plan.parts
const currentPartIndex = plan.currentPartIndex
@ -969,96 +1022,91 @@ export function ActiveSession({
{currentSlot?.purpose}
</div>
{/* 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}
detectedPrefixIndex={
matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1
? matchedPrefixIndex
: undefined
}
autoSubmitPending={autoSubmitTriggered}
rejectedDigit={rejectedDigit}
/>
) : (
<LinearProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
isDark={isDark}
/>
)}
{/* Per-term progressive help - shown when helpTermIndex is set */}
{!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && (
<div
data-section="term-help"
className={css({
minWidth: '280px',
maxWidth: '400px',
})}
>
{/* Term being helped indicator */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '0.5rem',
})}
>
{/* 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}
detectedPrefixIndex={
matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1
? matchedPrefixIndex
: undefined
}
autoSubmitPending={autoSubmitTriggered}
rejectedDigit={rejectedDigit}
helpOverlay={
!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext ? (
<div
className={css({
display: 'inline-flex',
display: 'flex',
flexDirection: 'column',
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',
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)',
})}
>
<span>Help with:</span>
<span className={css({ fontFamily: 'monospace', fontSize: '1rem' })}>
{helpContext.term >= 0 ? '+' : ''}
{helpContext.term}
</span>
</div>
</div>
{/* 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>
<PracticeHelpPanel
helpState={helpState}
onRequestHelp={helpActions.requestHelp}
onDismissHelp={handleDismissHelp}
isAbacusPart={currentPart?.type === 'abacus'}
currentValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
/>
</div>
)}
</div>
{/* Interactive abacus with arrows - just the abacus, no extra UI */}
{/* Columns = max digits between current and target values (minimum 1) */}
<PracticeHelpOverlay
currentValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
columns={Math.max(
1,
Math.max(helpContext.currentValue, helpContext.targetValue).toString().length
)}
onTargetReached={handleTargetReached}
/>
</div>
) : undefined
}
/>
) : (
<LinearProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
isDark={isDark}
detectedPrefixIndex={
matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1
? matchedPrefixIndex
: undefined
}
/>
)}
{/* Feedback message */}
{feedback !== 'none' && (

View File

@ -2,12 +2,13 @@
import { useTheme } from '@/contexts/ThemeContext'
import {
type AbacusOverlay,
AbacusReact,
calculateBeadDiffFromValues,
type StepBeadHighlight,
useAbacusDisplay,
} from '@soroban/abacus-react'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { css } from '../../../styled-system/css'
/** Bead change from calculateBeadDiffFromValues */
@ -19,7 +20,7 @@ interface BeadChange {
order?: number
}
interface HelpAbacusProps {
export interface HelpAbacusProps {
/** Initial value to start the abacus at */
currentValue: number
/** Target value we want to reach */
@ -34,6 +35,16 @@ interface HelpAbacusProps {
onValueChange?: (value: number) => void
/** Whether the abacus is interactive (default: false for help mode) */
interactive?: boolean
/** Optional overlays (e.g., tooltips pointing at beads) */
overlays?: AbacusOverlay[]
/** Whether to show the summary instruction above abacus (default: true) */
showSummary?: boolean
/** Whether to show the value labels below abacus (default: true) */
showValueLabels?: boolean
/** Whether to show the target reached message (default: true) */
showTargetReached?: boolean
/** Callback to receive bead highlights for tooltip positioning */
onBeadHighlightsChange?: (highlights: StepBeadHighlight[] | undefined) => void
}
/**
@ -51,11 +62,17 @@ export function HelpAbacus({
onTargetReached,
onValueChange,
interactive = false,
overlays,
showSummary = true,
showValueLabels = true,
showTargetReached = true,
onBeadHighlightsChange,
}: HelpAbacusProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { config: abacusConfig } = useAbacusDisplay()
const [currentStep] = useState(0)
const onBeadHighlightsChangeRef = useRef(onBeadHighlightsChange)
// Track the displayed value for bead diff calculations
// This is updated via onValueChange from AbacusReact
@ -113,6 +130,14 @@ export function HelpAbacus({
}
}, [displayedValue, targetValue, columns])
// Keep callback ref up to date
onBeadHighlightsChangeRef.current = onBeadHighlightsChange
// Notify parent when bead highlights change
useEffect(() => {
onBeadHighlightsChangeRef.current?.(stepBeadHighlights)
}, [stepBeadHighlights])
// Custom styles for help mode - highlight the arrows more prominently
const customStyles = useMemo(() => {
return {
@ -151,7 +176,7 @@ export function HelpAbacus({
})}
>
{/* Summary instruction */}
{summary && (
{showSummary && summary && (
<div
data-element="help-summary"
className={css({
@ -194,64 +219,79 @@ export function HelpAbacus({
showDirectionIndicators={!isAtTarget}
customStyles={customStyles}
onValueChange={handleValueChange}
overlays={overlays}
/>
</div>
{/* Value labels */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
gap: '2rem',
fontSize: '0.875rem',
})}
>
{showValueLabels && (
<div
className={css({
color: isAtTarget ? (isDark ? 'green.400' : 'green.600') : isDark ? 'gray.400' : 'gray.600',
display: 'flex',
justifyContent: 'center',
gap: '2rem',
fontSize: '0.875rem',
})}
>
Current:{' '}
<span
<div
className={css({
fontWeight: 'bold',
color: isAtTarget
? isDark
? 'green.300'
: 'green.700'
? 'green.400'
: 'green.600'
: isDark
? 'gray.200'
: 'gray.800',
? 'gray.400'
: 'gray.600',
})}
>
{displayedValue}
</span>
</div>
<div
className={css({
color: isAtTarget ? (isDark ? 'green.400' : 'green.600') : isDark ? 'blue.400' : 'blue.600',
})}
>
Target:{' '}
<span
Current:{' '}
<span
className={css({
fontWeight: 'bold',
color: isAtTarget
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'gray.200'
: 'gray.800',
})}
>
{displayedValue}
</span>
</div>
<div
className={css({
fontWeight: 'bold',
color: isAtTarget
? isDark
? 'green.300'
: 'green.700'
? 'green.400'
: 'green.600'
: isDark
? 'blue.300'
: 'blue.800',
? 'blue.400'
: 'blue.600',
})}
>
{targetValue}
</span>
Target:{' '}
<span
className={css({
fontWeight: 'bold',
color: isAtTarget
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'blue.300'
: 'blue.800',
})}
>
{targetValue}
</span>
</div>
</div>
</div>
)}
{/* Success feedback when target reached */}
{isAtTarget && (
{showTargetReached && isAtTarget && (
<div
data-element="target-reached"
className={css({

View File

@ -450,7 +450,9 @@ export function PlanReview({ plan, studentName, onApprove, onCancel }: PlanRevie
<strong>Created:</strong> {new Date(plan.createdAt).toLocaleString()}
</div>
<hr className={css({ margin: '0.5rem 0', borderColor: isDark ? 'gray.600' : 'gray.300' })} />
<hr
className={css({ margin: '0.5rem 0', borderColor: isDark ? 'gray.600' : 'gray.300' })}
/>
{parts.map((part: SessionPart) => (
<details key={part.partNumber}>

View File

@ -0,0 +1,200 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useCallback, useState } from 'react'
import { css } from '../../../styled-system/css'
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
const meta: Meta<typeof PracticeHelpOverlay> = {
title: 'Practice/PracticeHelpOverlay',
component: PracticeHelpOverlay,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof PracticeHelpOverlay>
/**
* Interactive demo showing the help overlay with time-based escalation
*/
function InteractiveDemo() {
const [currentValue, setCurrentValue] = useState(13)
const [targetValue] = useState(33) // 13 + 20
const [completed, setCompleted] = useState(false)
const handleTargetReached = useCallback(() => {
setCompleted(true)
console.log('Target reached!')
// After celebration, could reset or advance
setTimeout(() => {
setCompleted(false)
setCurrentValue(33)
}, 1000)
}, [])
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1rem',
padding: '2rem',
maxWidth: '500px',
})}
>
<div
className={css({
textAlign: 'center',
marginBottom: '1rem',
})}
>
<h3 className={css({ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' })}>
Progressive Help Overlay Demo
</h3>
<p className={css({ fontSize: '0.875rem', color: 'gray.500' })}>
Just the abacus with arrows. Bead tooltip appears after 3s (debug timing).
</p>
<p className={css({ fontSize: '0.75rem', color: 'gray.400' })}>
Tooltip uses same system as TutorialPlayer.
</p>
</div>
{/* Simulating how it appears in ActiveSession - with "Adding: +20" badge above */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.5rem',
})}
>
<div
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.25rem 0.75rem',
backgroundColor: 'purple.100',
borderRadius: '20px',
fontSize: '0.875rem',
fontWeight: 'bold',
color: 'purple.700',
})}
>
<span>Adding:</span>
<span className={css({ fontFamily: 'monospace', fontSize: '1rem' })}>+20</span>
</div>
{!completed && (
<PracticeHelpOverlay
currentValue={currentValue}
targetValue={targetValue}
columns={3}
onTargetReached={handleTargetReached}
debugTiming={true}
/>
)}
</div>
{completed && (
<div
className={css({
padding: '2rem',
backgroundColor: 'green.100',
borderRadius: '12px',
textAlign: 'center',
})}
>
<span className={css({ fontSize: '2rem' })}>🎉</span>
<p className={css({ fontSize: '1rem', color: 'green.700', fontWeight: 'bold' })}>
Target Reached!
</p>
</div>
)}
</div>
)
}
export const Interactive: Story = {
render: () => <InteractiveDemo />,
}
/**
* Shows just the abacus overlay without any surrounding UI
*/
function MinimalDemo() {
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '2rem',
padding: '2rem',
})}
>
<div className={css({ textAlign: 'center' })}>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: 'gray.500',
marginBottom: '0.5rem',
})}
>
Minimal: Just the Abacus
</h3>
<PracticeHelpOverlay currentValue={0} targetValue={23} columns={3} debugTiming={true} />
</div>
</div>
)
}
export const Minimal: Story = {
render: () => <MinimalDemo />,
}
// Static examples
export const Simple: Story = {
args: {
currentValue: 0,
targetValue: 5,
columns: 3,
debugTiming: true,
},
}
export const TwoDigit: Story = {
args: {
currentValue: 23,
targetValue: 68,
columns: 3,
debugTiming: true,
},
}
export const ThreeDigit: Story = {
args: {
currentValue: 123,
targetValue: 456,
columns: 3,
debugTiming: true,
},
}
export const ProductionTiming: Story = {
args: {
currentValue: 13,
targetValue: 33,
columns: 3,
debugTiming: false, // Use production timing (10s for tooltip)
},
parameters: {
docs: {
description: {
story: 'Uses production timing: Bead tooltip at 10s',
},
},
},
}

View File

@ -0,0 +1,296 @@
'use client'
/**
* PracticeHelpOverlay - Simplified help overlay for practice sessions
*
* Shows just the interactive abacus with bead arrows and tooltip.
* Uses the same tooltip system as TutorialPlayer for consistency.
*
* Time-based escalation:
* - 0s: Abacus with arrows
* - +5s: Coach hint (shown in parent component)
* - +10s: Bead tooltip appears
*/
import type { AbacusOverlay, StepBeadHighlight } from '@soroban/abacus-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { getHelpTiming, shouldUseDebugTiming } from '@/constants/helpTiming'
import { generateUnifiedInstructionSequence } from '@/utils/unifiedStepGenerator'
import { calculateTooltipPositioning } from '@/utils/beadTooltipUtils'
import { calculateBeadDiffFromValues } from '@/utils/beadDiff'
import { BeadTooltipContent } from '../shared/BeadTooltipContent'
import { HelpAbacus } from './HelpAbacus'
export interface PracticeHelpOverlayProps {
/** Current abacus value (prefix sum the kid entered) */
currentValue: number
/** Target value to reach (current prefix sum + next term) */
targetValue: number
/** Number of columns in the abacus */
columns?: number
/** Called when kid reaches the target value */
onTargetReached?: () => void
/** Called when abacus value changes */
onValueChange?: (value: number) => void
/** Whether to show debug timing */
debugTiming?: boolean
}
/**
* Help escalation phases
*/
type HelpPhase = 'abacus' | 'bead-tooltip'
/**
* PracticeHelpOverlay - Progressive help overlay for practice sessions
*
* Shows just the interactive abacus with bead arrows and optional tooltip.
* No extra UI (no current/target labels, no dismiss button).
*/
export function PracticeHelpOverlay({
currentValue,
targetValue,
columns = 3,
onTargetReached,
onValueChange,
debugTiming,
}: PracticeHelpOverlayProps) {
const { resolvedTheme } = useTheme()
const theme = resolvedTheme === 'dark' ? 'dark' : 'light'
// Determine timing config
const timing = useMemo(() => {
const useDebug = debugTiming ?? shouldUseDebugTiming()
return getHelpTiming(useDebug)
}, [debugTiming])
// Track current help phase and escalation
const [currentPhase, setCurrentPhase] = useState<HelpPhase>('abacus')
const [abacusValue, setAbacusValue] = useState(currentValue)
const [beadHighlights, setBeadHighlights] = useState<StepBeadHighlight[] | undefined>()
// Refs for timers
const beadTooltipTimerRef = useRef<NodeJS.Timeout | null>(null)
const celebrationTimerRef = useRef<NodeJS.Timeout | null>(null)
// Calculate if at target
const isAtTarget = abacusValue === targetValue
// Generate the decomposition steps
const sequence = useMemo(() => {
return generateUnifiedInstructionSequence(currentValue, targetValue)
}, [currentValue, targetValue])
// Calculate current step summary using bead diff (same as TutorialPlayer)
const currentStepSummary = useMemo(() => {
if (isAtTarget) return null
try {
const beadDiff = calculateBeadDiffFromValues(abacusValue, targetValue)
return beadDiff.hasChanges ? beadDiff.summary : null
} catch {
return null
}
}, [abacusValue, targetValue, isAtTarget])
// Check if decomposition is meaningful (same as TutorialPlayer)
const isMeaningfulDecomposition = sequence?.isMeaningfulDecomposition ?? false
// Calculate current step index based on abacus value
const currentStepIndex = useMemo(() => {
if (!sequence || sequence.steps.length === 0) return 0
// If abacus is still at start value, we're at step 0
if (abacusValue === currentValue) return 0
// Find which step we're on
for (let i = 0; i < sequence.steps.length; i++) {
const step = sequence.steps[i]
if (abacusValue < step.expectedValue) {
return i
}
if (abacusValue === step.expectedValue) {
return Math.min(i + 1, sequence.steps.length - 1)
}
}
return sequence.steps.length - 1
}, [sequence, abacusValue, currentValue])
// Generate highlighted decomposition (same pattern as TutorialPlayer)
const renderHighlightedDecomposition = useCallback(() => {
if (!sequence?.fullDecomposition || sequence.steps.length === 0) return null
const currentStep = sequence.steps[currentStepIndex]
if (!currentStep?.mathematicalTerm) return null
const mathTerm = currentStep.mathematicalTerm
// Try to use precise position first
if (currentStep.termPosition) {
const { startIndex, endIndex } = currentStep.termPosition
const highlighted = sequence.fullDecomposition.substring(startIndex, endIndex)
// Validate that the highlighted text makes sense
if (highlighted.includes(mathTerm.replace('-', '')) || highlighted === mathTerm) {
return {
before: sequence.fullDecomposition.substring(0, startIndex),
highlighted,
after: sequence.fullDecomposition.substring(endIndex),
}
}
}
// Fallback: search for the mathematical term in the decomposition
const searchTerm = mathTerm.startsWith('-') ? mathTerm.substring(1) : mathTerm
const searchIndex = sequence.fullDecomposition.indexOf(searchTerm)
if (searchIndex !== -1) {
const startIndex = mathTerm.startsWith('-') ? Math.max(0, searchIndex - 1) : searchIndex
const endIndex = mathTerm.startsWith('-')
? searchIndex + searchTerm.length
: searchIndex + mathTerm.length
return {
before: sequence.fullDecomposition.substring(0, startIndex),
highlighted: sequence.fullDecomposition.substring(startIndex, endIndex),
after: sequence.fullDecomposition.substring(endIndex),
}
}
// Final fallback: highlight the first occurrence of just the number part
const numberMatch = mathTerm.match(/\d+/)
if (numberMatch) {
const number = numberMatch[0]
const numberIndex = sequence.fullDecomposition.indexOf(number)
if (numberIndex !== -1) {
return {
before: sequence.fullDecomposition.substring(0, numberIndex),
highlighted: sequence.fullDecomposition.substring(
numberIndex,
numberIndex + number.length
),
after: sequence.fullDecomposition.substring(numberIndex + number.length),
}
}
}
return null
}, [sequence, currentStepIndex])
// Calculate tooltip positioning using shared utility
const tooltipPositioning = useMemo(() => {
if (currentPhase !== 'bead-tooltip' || isAtTarget) return null
return calculateTooltipPositioning(abacusValue, beadHighlights, columns)
}, [currentPhase, isAtTarget, abacusValue, beadHighlights, columns])
// Create tooltip overlay for HelpAbacus using shared BeadTooltipContent
const tooltipOverlay: AbacusOverlay | undefined = useMemo(() => {
// Show tooltip in bead-tooltip phase with instructions, or when celebrating
const showCelebration = isAtTarget
const showInstructions =
!showCelebration && currentPhase === 'bead-tooltip' && currentStepSummary
if (!showCelebration && !showInstructions) return undefined
if (!tooltipPositioning && !showCelebration) return undefined
// For celebration, use a default position if no bead highlights
const side = tooltipPositioning?.side ?? 'top'
const target = tooltipPositioning?.target ?? {
type: 'bead' as const,
columnIndex: columns - 1, // rightmost column
beadType: 'heaven' as const,
beadPosition: 0,
}
return {
id: 'practice-help-tooltip',
type: 'tooltip',
target,
content: (
<BeadTooltipContent
showCelebration={showCelebration}
currentStepSummary={currentStepSummary}
isMeaningfulDecomposition={isMeaningfulDecomposition}
decomposition={renderHighlightedDecomposition()}
side={side}
theme={theme}
/>
),
offset: { x: 0, y: 0 },
visible: true,
}
}, [
tooltipPositioning,
isAtTarget,
currentPhase,
currentStepSummary,
isMeaningfulDecomposition,
renderHighlightedDecomposition,
columns,
theme,
])
// Start time-based escalation when mounted
useEffect(() => {
// Start bead tooltip timer
beadTooltipTimerRef.current = setTimeout(() => {
setCurrentPhase('bead-tooltip')
}, timing.beadTooltipDelayMs)
return () => {
if (beadTooltipTimerRef.current) clearTimeout(beadTooltipTimerRef.current)
if (celebrationTimerRef.current) clearTimeout(celebrationTimerRef.current)
}
}, [timing])
// Handle target reached - show celebration then notify
useEffect(() => {
if (isAtTarget) {
celebrationTimerRef.current = setTimeout(() => {
onTargetReached?.()
}, timing.celebrationDurationMs)
}
}, [isAtTarget, timing, onTargetReached])
// Handle abacus value change
const handleValueChange = useCallback(
(newValue: number) => {
setAbacusValue(newValue)
onValueChange?.(newValue)
},
[onValueChange]
)
// Handle bead highlights change from HelpAbacus
const handleBeadHighlightsChange = useCallback((highlights: StepBeadHighlight[] | undefined) => {
setBeadHighlights(highlights)
}, [])
return (
<div
data-component="practice-help-overlay"
data-phase={currentPhase}
data-at-target={isAtTarget}
>
{/* Interactive abacus with bead arrows - just the abacus, no extra UI */}
<HelpAbacus
currentValue={currentValue}
targetValue={targetValue}
columns={columns}
scaleFactor={1.2}
interactive={true}
onValueChange={handleValueChange}
onTargetReached={onTargetReached}
onBeadHighlightsChange={handleBeadHighlightsChange}
overlays={tooltipOverlay ? [tooltipOverlay] : undefined}
showSummary={false}
showValueLabels={false}
showTargetReached={false}
/>
</div>
)
}
export default PracticeHelpOverlay

View File

@ -9,6 +9,81 @@ import { css } from '../../../styled-system/css'
import { DecompositionDisplay, DecompositionProvider } from '../decomposition'
import { HelpAbacus } from './HelpAbacus'
/**
* Generate a dynamic coach hint based on the current step
*/
function generateDynamicCoachHint(
sequence: ReturnType<typeof generateUnifiedInstructionSequence> | null,
currentStepIndex: number,
abacusValue: number,
targetValue: number
): string {
if (!sequence || sequence.steps.length === 0) {
return 'Take your time and think through each step.'
}
// Check if we're done
if (abacusValue === targetValue) {
return 'You did it! Move on to the next step.'
}
// Get the current step
const currentStep = sequence.steps[currentStepIndex]
if (!currentStep) {
return 'Take your time and think through each step.'
}
// Find the segment this step belongs to
const segment = sequence.segments.find((s) => s.id === currentStep.segmentId)
// Use the segment's readable summary if available
if (segment?.readable?.summary) {
return segment.readable.summary
}
// Fall back to generating from the rule
if (segment) {
const rule = segment.plan[0]?.rule
switch (rule) {
case 'Direct':
return `Add ${segment.digit} directly to the ${getPlaceName(segment.place)} column.`
case 'FiveComplement':
return `Think about friends of 5. What plus ${5 - segment.digit} makes 5?`
case 'TenComplement':
return `Think about friends of 10. What plus ${10 - segment.digit} makes 10?`
case 'Cascade':
return 'This will carry through multiple columns. Start from the left.'
default:
break
}
}
// Fall back to english instruction from the step
if (currentStep.englishInstruction) {
return currentStep.englishInstruction
}
return 'Think about which beads need to move.'
}
/**
* Get place name from place value
*/
function getPlaceName(place: number): string {
switch (place) {
case 0:
return 'ones'
case 1:
return 'tens'
case 2:
return 'hundreds'
case 3:
return 'thousands'
default:
return `10^${place}`
}
}
interface PracticeHelpPanelProps {
/** Current help state from usePracticeHelp hook */
helpState: PracticeHelpState
@ -106,19 +181,27 @@ export function PracticeHelpPanel({
return sequence.steps.length - 1
}, [sequence, abacusValue, currentValue])
// Generate dynamic coach hint based on current step
const dynamicCoachHint = useMemo(() => {
return generateDynamicCoachHint(sequence, currentStepIndex, abacusValue, targetValue ?? 0)
}, [sequence, currentStepIndex, abacusValue, targetValue])
// Handle abacus value changes
const handleAbacusValueChange = useCallback((newValue: number) => {
setAbacusValue(newValue)
}, [])
// Calculate effective level here so handleRequestHelp can use it
// (effectiveLevel treats L0 as L1 since we auto-show help on prefix sum detection)
const effectiveLevel = currentLevel === 0 ? 1 : currentLevel
const handleRequestHelp = useCallback(() => {
if (currentLevel === 0) {
onRequestHelp(1)
// Always request the next level above effectiveLevel
if (effectiveLevel < 3) {
onRequestHelp((effectiveLevel + 1) as HelpLevel)
setIsExpanded(true)
} else if (currentLevel < 3) {
onRequestHelp((currentLevel + 1) as HelpLevel)
}
}, [currentLevel, onRequestHelp])
}, [effectiveLevel, onRequestHelp])
const handleDismiss = useCallback(() => {
onDismissHelp()
@ -130,55 +213,11 @@ export function PracticeHelpPanel({
return null
}
// Level 0: Just show the help request button
if (currentLevel === 0) {
return (
<div
data-component="practice-help-panel"
data-level={0}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
<button
type="button"
data-action="request-help"
onClick={handleRequestHelp}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
width: '100%',
padding: '0.75rem',
fontSize: '0.875rem',
color: isDark ? 'blue.300' : 'blue.600',
backgroundColor: isDark ? 'blue.900' : 'blue.50',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.200',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: isDark ? 'blue.800' : 'blue.100',
borderColor: isDark ? 'blue.600' : 'blue.300',
},
})}
>
<span>💡</span>
<span>Need Help?</span>
</button>
</div>
)
}
// Levels 1-3: Show the help content
// Levels 1-3: Show the help content (effectiveLevel is calculated above)
return (
<div
data-component="practice-help-panel"
data-level={currentLevel}
data-level={effectiveLevel}
className={css({
display: 'flex',
flexDirection: 'column',
@ -206,7 +245,7 @@ export function PracticeHelpPanel({
gap: '0.5rem',
})}
>
<span className={css({ fontSize: '1.25rem' })}>{HELP_LEVEL_ICONS[currentLevel]}</span>
<span className={css({ fontSize: '1.25rem' })}>{HELP_LEVEL_ICONS[effectiveLevel]}</span>
<span
className={css({
fontSize: '0.875rem',
@ -214,7 +253,7 @@ export function PracticeHelpPanel({
color: isDark ? 'blue.200' : 'blue.700',
})}
>
{HELP_LEVEL_LABELS[currentLevel]}
{HELP_LEVEL_LABELS[effectiveLevel]}
</span>
{/* Help level indicator dots */}
<div
@ -232,7 +271,7 @@ export function PracticeHelpPanel({
height: '8px',
borderRadius: '50%',
backgroundColor:
level <= currentLevel ? 'blue.500' : isDark ? 'blue.700' : 'blue.200',
level <= effectiveLevel ? 'blue.500' : isDark ? 'blue.700' : 'blue.200',
})}
/>
))}
@ -259,10 +298,11 @@ export function PracticeHelpPanel({
</button>
</div>
{/* Level 1: Coach hint */}
{currentLevel >= 1 && content?.coachHint && (
{/* Level 1: Coach hint - uses dynamic hint that updates with abacus progress */}
{effectiveLevel >= 1 && dynamicCoachHint && (
<div
data-element="coach-hint"
data-step-index={currentStepIndex}
className={css({
padding: '0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
@ -278,13 +318,13 @@ export function PracticeHelpPanel({
lineHeight: '1.5',
})}
>
{content.coachHint}
{dynamicCoachHint}
</p>
</div>
)}
{/* Level 2: Decomposition */}
{currentLevel >= 2 &&
{effectiveLevel >= 2 &&
content?.decomposition &&
content.decomposition.isMeaningful &&
currentValue !== undefined &&
@ -332,7 +372,7 @@ export function PracticeHelpPanel({
)}
{/* Level 3: Visual abacus with bead arrows */}
{currentLevel >= 3 && currentValue !== undefined && targetValue !== undefined && (
{effectiveLevel >= 3 && currentValue !== undefined && targetValue !== undefined && (
<div
data-element="help-abacus"
className={css({
@ -384,7 +424,7 @@ export function PracticeHelpPanel({
)}
{/* Fallback: Text bead steps if abacus values not provided */}
{currentLevel >= 3 &&
{effectiveLevel >= 3 &&
(currentValue === undefined || targetValue === undefined) &&
content?.beadSteps &&
content.beadSteps.length > 0 && (
@ -447,7 +487,7 @@ export function PracticeHelpPanel({
)}
{/* More help button (if not at max level) */}
{currentLevel < 3 && (
{effectiveLevel < 3 && (
<button
type="button"
data-action="escalate-help"
@ -472,8 +512,8 @@ export function PracticeHelpPanel({
},
})}
>
<span>{HELP_LEVEL_ICONS[(currentLevel + 1) as HelpLevel]}</span>
<span>More Help: {HELP_LEVEL_LABELS[(currentLevel + 1) as HelpLevel]}</span>
<span>{HELP_LEVEL_ICONS[(effectiveLevel + 1) as HelpLevel]}</span>
<span>More Help: {HELP_LEVEL_LABELS[(effectiveLevel + 1) as HelpLevel]}</span>
</button>
)}

View File

@ -73,9 +73,7 @@ function getMasteryColor(level: MasteryLevel, isDark: boolean): { bg: string; te
: { bg: 'yellow.100', text: 'yellow.700' }
default:
// 'learning' and any unknown values use gray
return isDark
? { bg: 'gray.700', text: 'gray.300' }
: { bg: 'gray.100', text: 'gray.600' }
return isDark ? { bg: 'gray.700', text: 'gray.300' } : { bg: 'gray.100', text: 'gray.600' }
}
}

View File

@ -42,7 +42,13 @@ function StudentCard({ student, isSelected, onSelect }: StudentCardProps) {
borderRadius: '12px',
border: isSelected ? '3px solid' : '2px solid',
borderColor: isSelected ? 'blue.500' : isDark ? 'gray.600' : 'gray.200',
backgroundColor: isSelected ? (isDark ? 'blue.900' : 'blue.50') : isDark ? 'gray.800' : 'white',
backgroundColor: isSelected
? isDark
? 'blue.900'
: 'blue.50'
: isDark
? 'gray.800'
: 'white',
cursor: 'pointer',
transition: 'all 0.2s ease',
minWidth: '100px',
@ -258,7 +264,11 @@ export function StudentSelector({
marginBottom: '1rem',
})}
>
Selected: <strong className={css({ color: isDark ? 'gray.100' : 'inherit' })}>{selectedStudent.name}</strong> {selectedStudent.emoji}
Selected:{' '}
<strong className={css({ color: isDark ? 'gray.100' : 'inherit' })}>
{selectedStudent.name}
</strong>{' '}
{selectedStudent.emoji}
</p>
<button
type="button"

View File

@ -1,5 +1,6 @@
'use client'
import type { ReactNode } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { css } from '../../../styled-system/css'
@ -24,6 +25,10 @@ interface VerticalProblemProps {
detectedPrefixIndex?: number
/** Whether auto-submit is about to trigger (shows celebration animation) */
autoSubmitPending?: boolean
/** Rejected digit to show as red X (null = no rejection) */
rejectedDigit?: string | null
/** Help overlay to render adjacent to the current help term (positioned above the term row) */
helpOverlay?: ReactNode
}
/**
@ -46,6 +51,8 @@ export function VerticalProblem({
currentHelpTermIndex,
detectedPrefixIndex,
autoSubmitPending = false,
rejectedDigit = null,
helpOverlay,
}: VerticalProblemProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@ -78,6 +85,8 @@ export function VerticalProblem({
gap: '2px',
padding: '0.5rem',
borderRadius: '8px',
// Allow help overlay to overflow outside the component bounds
overflow: 'visible',
backgroundColor: isCompleted
? isCorrect
? isDark
@ -267,6 +276,24 @@ export function VerticalProblem({
{digit}
</div>
))}
{/* Help overlay - positioned above this term row, translated up by its height */}
{isCurrentHelp && helpOverlay && (
<div
data-section="term-help"
data-help-term-index={index}
className={css({
position: 'absolute',
// Position at bottom of this row, then translate up by 100% to sit above it
bottom: '100%',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 10,
})}
>
{helpOverlay}
</div>
)}
</div>
)
})}
@ -325,19 +352,27 @@ export function VerticalProblem({
<span>Perfect!</span>
</div>
)}
{/* Equals sign column */}
{/* Equals sign column - show "..." for prefix sums (mathematically incomplete), "=" for final answer */}
<div
data-element="equals"
data-prefix-mode={detectedPrefixIndex !== undefined ? 'true' : undefined}
className={css({
width: cellWidth,
height: cellHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: isDark ? 'gray.400' : 'gray.500',
color:
detectedPrefixIndex !== undefined
? isDark
? 'yellow.400'
: 'yellow.600'
: isDark
? 'gray.400'
: 'gray.500',
})}
>
=
{detectedPrefixIndex !== undefined ? '…' : '='}
</div>
{/* Answer digit cells - show maxDigits cells total */}
@ -351,55 +386,74 @@ export function VerticalProblem({
const digit = paddedValue[index] || ''
const isEmpty = digit === ''
// Check if this is the cell where a rejected digit should show
// Digits are entered left-to-right, filling from left side of the answer area
// So the next digit position is right after the current answer length
const nextDigitIndex = userAnswer.length
const isRejectedCell = rejectedDigit && isEmpty && index === nextDigitIndex
return (
<div
key={index}
data-element={isEmpty ? 'empty-cell' : 'answer-cell'}
data-element={
isRejectedCell ? 'rejected-cell' : isEmpty ? 'empty-cell' : 'answer-cell'
}
className={css({
width: cellWidth,
height: cellHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: autoSubmitPending
position: 'relative',
backgroundColor: isRejectedCell
? isDark
? 'green.800'
: 'green.100'
: isCompleted
? isCorrect
? isDark
? 'green.800'
: 'green.100'
? 'red.900'
: 'red.100'
: autoSubmitPending
? isDark
? 'green.800'
: 'green.100'
: isCompleted
? isCorrect
? isDark
? 'green.800'
: 'green.100'
: isDark
? 'red.800'
: 'red.100'
: isDark
? 'red.800'
: 'red.100'
: isDark
? 'gray.700'
: 'white',
? 'gray.700'
: 'white',
borderRadius: '4px',
border:
isEmpty && !isCompleted && !autoSubmitPending ? '1px dashed' : '1px solid',
borderColor: autoSubmitPending
isEmpty && !isCompleted && !autoSubmitPending && !isRejectedCell
? '1px dashed'
: '1px solid',
borderColor: isRejectedCell
? isDark
? 'green.500'
: 'green.400'
: isCompleted
? isCorrect
? isDark
? 'green.600'
: 'green.300'
: isDark
? 'red.600'
: 'red.300'
: isEmpty
? isFocused
? 'blue.400'
? 'red.500'
: 'red.400'
: autoSubmitPending
? isDark
? 'green.500'
: 'green.400'
: isCompleted
? isCorrect
? isDark
? 'green.600'
: 'green.300'
: isDark
? 'red.600'
: 'red.300'
: isEmpty
? isFocused
? 'blue.400'
: isDark
? 'gray.600'
: 'gray.300'
: isDark
? 'gray.600'
: 'gray.300'
: isDark
? 'gray.600'
: 'gray.300',
: 'gray.300',
transition: 'all 0.15s ease-out',
color: isCompleted
? isCorrect
@ -412,9 +466,24 @@ export function VerticalProblem({
: isDark
? 'gray.200'
: 'gray.800',
// Shake animation for rejected cell
...(isRejectedCell && {
animation: 'shake 0.3s ease-out',
}),
})}
>
{digit}
{isRejectedCell ? (
<span
className={css({
color: isDark ? 'red.400' : 'red.600',
fontWeight: 'bold',
})}
>
</span>
) : (
digit
)}
</div>
)
})}

View File

@ -0,0 +1,132 @@
'use client'
/**
* BeadTooltipContent - Shared tooltip content for bead instruction overlays
*
* Extracted from TutorialPlayer for reuse in practice help overlay.
* Ensures consistent tooltip display across tutorial and practice modes.
*/
import * as Tooltip from '@radix-ui/react-tooltip'
import { PedagogicalDecompositionDisplay } from '../tutorial/PedagogicalDecompositionDisplay'
export interface BeadTooltipContentProps {
/** Whether to show celebration state (step completed) */
showCelebration?: boolean
/** The bead diff summary (e.g., "add 2 earth beads in tens") */
currentStepSummary: string | null
/** Whether the decomposition is pedagogically meaningful */
isMeaningfulDecomposition?: boolean
/** Rendered decomposition with highlighted term (from renderHighlightedDecomposition) */
decomposition?: {
before: string
highlighted: string
after: string
} | null
/** Which side the tooltip appears on */
side: 'top' | 'left'
/** Theme: 'light' or 'dark' */
theme?: 'light' | 'dark'
}
/**
* Tooltip content that matches TutorialPlayer's exact implementation
*
* Shows either:
* - Celebration state with 🎉 emoji
* - Instructions with optional decomposition display and bead diff summary
*/
export function BeadTooltipContent({
showCelebration = false,
currentStepSummary,
isMeaningfulDecomposition = false,
decomposition,
side,
theme = 'light',
}: BeadTooltipContentProps) {
const isDark = theme === 'dark'
return (
<Tooltip.Provider>
<Tooltip.Root open={true}>
<Tooltip.Trigger asChild>
<div style={{ width: '1px', height: '1px', opacity: 0 }} />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side={side}
align="center"
sideOffset={20}
style={{
background: showCelebration
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.95) 0%, rgba(21, 128, 61, 0.95) 100%)'
: isDark
? '#1e40af'
: '#1e3a8a',
color: '#ffffff',
padding: '12px 16px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 700,
boxShadow: showCelebration
? '0 8px 25px rgba(34, 197, 94, 0.4), 0 0 0 2px rgba(255, 255, 255, 0.2)'
: isDark
? '0 4px 12px rgba(0,0,0,0.3)'
: '0 4px 12px rgba(0,0,0,0.2)',
whiteSpace: 'normal',
maxWidth: '200px',
minWidth: '150px',
wordBreak: 'break-word',
zIndex: 50,
opacity: 0.95,
transition: 'all 0.3s ease',
transform: showCelebration ? 'scale(1.05)' : 'scale(1)',
animation: showCelebration ? 'celebrationPulse 0.6s ease-out' : 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.85'
}}
>
<div style={{ fontSize: '12px', opacity: 0.9 }}>
{showCelebration ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
fontWeight: 'bold',
}}
>
<span style={{ fontSize: '18px' }}>🎉</span>
<span>Excellent work!</span>
</div>
) : (
<>
{isMeaningfulDecomposition && decomposition && (
<PedagogicalDecompositionDisplay
variant="tooltip"
showLabel={true}
decomposition={decomposition}
/>
)}
<span style={{ fontSize: '18px' }}>💡</span> {currentStepSummary}
</>
)}
</div>
<Tooltip.Arrow
style={{
fill: showCelebration ? '#15803d' : '#1e40af',
}}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)
}
export default BeadTooltipContent

View File

@ -1,6 +1,5 @@
'use client'
import * as Tooltip from '@radix-ui/react-tooltip'
import {
type AbacusOverlay,
AbacusReact,
@ -19,55 +18,15 @@ import type {
TutorialStep,
UIState,
} from '../../types/tutorial'
import { findTopmostBeadWithArrows, hasActiveBeadsToLeft } from '../../utils/beadTooltipUtils'
import { generateUnifiedInstructionSequence } from '../../utils/unifiedStepGenerator'
import { CoachBar } from './CoachBar/CoachBar'
import { DecompositionDisplay, DecompositionProvider } from '../decomposition'
import { PedagogicalDecompositionDisplay } from './PedagogicalDecompositionDisplay'
import { BeadTooltipContent } from '../shared/BeadTooltipContent'
import { TutorialProvider, useTutorialContext } from './TutorialContext'
import { TutorialUIProvider } from './TutorialUIContext'
import './CoachBar/coachbar.css'
// Helper function to find the topmost bead with arrows
function findTopmostBeadWithArrows(
stepBeadHighlights: StepBeadHighlight[] | undefined
): StepBeadHighlight | null {
if (!stepBeadHighlights || stepBeadHighlights.length === 0) return null
// Filter only beads that have direction arrows (should have highlights)
const beadsWithArrows = stepBeadHighlights.filter(
(bead) => bead.direction && bead.direction !== 'none'
)
if (beadsWithArrows.length === 0) {
console.warn('No beads with arrows found in step highlights:', stepBeadHighlights)
return null
}
// Sort by place value (highest first, since place value 4 = leftmost = highest value)
// Then by bead type (heaven beads are higher than earth beads)
// Then by position for earth beads (lower position = higher on abacus)
const sortedBeads = [...beadsWithArrows].sort((a, b) => {
// First sort by place value (higher place value = more significant = topmost priority)
if (a.placeValue !== b.placeValue) {
return b.placeValue - a.placeValue
}
// If same place value, heaven beads come before earth beads
if (a.beadType !== b.beadType) {
return a.beadType === 'heaven' ? -1 : 1
}
// If both earth beads in same column, lower position number = higher on abacus
if (a.beadType === 'earth' && b.beadType === 'earth') {
return (a.position || 0) - (b.position || 0)
}
return 0
})
return sortedBeads[0] || null
}
// Reducer state and actions
interface TutorialPlayerState {
currentStepIndex: number
@ -534,49 +493,17 @@ function TutorialPlayerContent({
return null
}
// Smart positioning logic: avoid covering active beads
// Convert placeValue to columnIndex based on actual number of columns
// Smart positioning logic: avoid covering active beads using shared utility
const targetColumnIndex = abacusColumns - 1 - topmostBead.placeValue
// Check if there are any active beads (against reckoning bar OR with arrows) in columns to the left
const hasActiveBeadsToLeft = (() => {
// Get current abacus state - we need to check which beads are against the reckoning bar
const abacusDigits = currentValue
.toString()
.padStart(abacusColumns, '0')
.split('')
.map(Number)
for (let col = 0; col < targetColumnIndex; col++) {
const placeValue = abacusColumns - 1 - col // Convert columnIndex back to placeValue
const digitValue = abacusDigits[col]
// Check if any beads are active (against reckoning bar) in this column
if (digitValue >= 5) {
// Heaven bead is active
return true
}
if (digitValue % 5 > 0) {
// Earth beads are active
return true
}
// Also check if this column has beads with direction arrows (from current step)
const hasArrowsInColumn =
currentStepBeads?.some((bead) => {
const beadColumnIndex = abacusColumns - 1 - bead.placeValue
return beadColumnIndex === col && bead.direction && bead.direction !== 'none'
}) ?? false
if (hasArrowsInColumn) {
return true
}
}
return false
})()
const activeToLeft = hasActiveBeadsToLeft(
currentValue,
currentStepBeads,
abacusColumns,
targetColumnIndex
)
// Determine tooltip position and target
const shouldPositionAbove = hasActiveBeadsToLeft
const shouldPositionAbove = activeToLeft
const tooltipSide = shouldPositionAbove ? 'top' : 'left'
const tooltipTarget = shouldPositionAbove
? {
@ -600,85 +527,14 @@ function TutorialPlayerContent({
type: 'tooltip',
target: tooltipTarget,
content: (
<Tooltip.Provider>
<Tooltip.Root open={true}>
<Tooltip.Trigger asChild>
<div style={{ width: '1px', height: '1px', opacity: 0 }} />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side={tooltipSide}
align="center"
sideOffset={20}
style={{
background: showCelebration
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.95) 0%, rgba(21, 128, 61, 0.95) 100%)'
: theme === 'dark'
? '#1e40af'
: '#1e3a8a',
color: '#ffffff',
padding: '12px 16px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 700,
boxShadow: showCelebration
? '0 8px 25px rgba(34, 197, 94, 0.4), 0 0 0 2px rgba(255, 255, 255, 0.2)'
: theme === 'dark'
? '0 4px 12px rgba(0,0,0,0.3)'
: '0 4px 12px rgba(0,0,0,0.2)',
whiteSpace: 'normal',
maxWidth: '200px',
minWidth: '150px',
wordBreak: 'break-word',
zIndex: 50,
opacity: 0.95,
transition: 'all 0.3s ease',
transform: showCelebration ? 'scale(1.05)' : 'scale(1)',
animation: showCelebration ? 'celebrationPulse 0.6s ease-out' : 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.85'
}}
>
<div style={{ fontSize: '12px', opacity: 0.9 }}>
{showCelebration ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
fontWeight: 'bold',
}}
>
<span style={{ fontSize: '18px' }}>🎉</span>
<span>Excellent work!</span>
</div>
) : (
<>
{isMeaningfulDecomposition && (
<PedagogicalDecompositionDisplay
variant="tooltip"
showLabel={true}
decomposition={renderHighlightedDecomposition()}
/>
)}
<span style={{ fontSize: '18px' }}>💡</span> {currentStepSummary}
</>
)}
</div>
<Tooltip.Arrow
style={{
fill: showCelebration ? '#15803d' : '#1e40af',
}}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<BeadTooltipContent
showCelebration={showCelebration}
currentStepSummary={currentStepSummary}
isMeaningfulDecomposition={isMeaningfulDecomposition}
decomposition={renderHighlightedDecomposition()}
side={tooltipSide}
theme={theme}
/>
),
offset: { x: 0, y: 0 },
visible: true,
@ -695,6 +551,7 @@ function TutorialPlayerContent({
currentStep,
isMeaningfulDecomposition,
abacusColumns,
theme,
])
// Timer for smart help detection

View File

@ -0,0 +1,62 @@
/**
* Timing configuration for progressive help system
*
* Production values give the kid time to try on their own before hints appear.
* Debug values allow fast iteration during development.
*/
export const HELP_TIMING = {
production: {
/** Delay before showing coach hint */
coachHintDelayMs: 5000,
/** Delay before showing bead tooltip */
beadTooltipDelayMs: 10000,
/** Duration of celebration animation */
celebrationDurationMs: 800,
/** Duration of fade-out transition */
transitionDurationMs: 300,
},
debug: {
/** Delay before showing coach hint */
coachHintDelayMs: 1000,
/** Delay before showing bead tooltip */
beadTooltipDelayMs: 3000,
/** Duration of celebration animation */
celebrationDurationMs: 500,
/** Duration of fade-out transition */
transitionDurationMs: 200,
},
} as const
export type HelpTimingConfig = {
readonly coachHintDelayMs: number
readonly beadTooltipDelayMs: number
readonly celebrationDurationMs: number
readonly transitionDurationMs: number
}
/**
* Get timing configuration based on debug mode
*/
export function getHelpTiming(debug: boolean): HelpTimingConfig {
return debug ? HELP_TIMING.debug : HELP_TIMING.production
}
/**
* Check if we should use debug timing
* - Always false in production builds
* - True in development if localStorage flag is set or storybook
*/
export function shouldUseDebugTiming(): boolean {
if (typeof window === 'undefined') return false
if (process.env.NODE_ENV === 'production') return false
// Check for storybook
if (window.location?.href?.includes('storybook')) return true
// Check for localStorage flag
try {
return localStorage.getItem('helpDebugTiming') === 'true'
} catch {
return false
}
}

View File

@ -0,0 +1,188 @@
/**
* Bead Tooltip Utilities
*
* Extracted from TutorialPlayer for reuse in practice help overlay.
* Handles smart tooltip positioning to avoid covering active beads.
*/
import type { StepBeadHighlight } from '@soroban/abacus-react'
/**
* Target specification for tooltip overlay
*/
export interface TooltipTarget {
type: 'bead'
columnIndex: number
beadType: 'heaven' | 'earth'
beadPosition: number | undefined
}
/**
* Result of tooltip positioning calculation
*/
export interface TooltipPositioning {
/** Which side to show tooltip on */
side: 'top' | 'left'
/** Target bead for the tooltip */
target: TooltipTarget
/** The topmost bead that was selected */
topmostBead: StepBeadHighlight
/** Column index of the target */
targetColumnIndex: number
}
/**
* Find the topmost bead with arrows in a step
*
* Priority order:
* 1. Higher place value (leftmost columns = more significant)
* 2. Heaven beads before earth beads
* 3. Lower position number for earth beads (higher on abacus)
*
* @param stepBeadHighlights - Array of bead highlights for the current step
* @returns The topmost bead, or null if none have arrows
*/
export function findTopmostBeadWithArrows(
stepBeadHighlights: StepBeadHighlight[] | undefined
): StepBeadHighlight | null {
if (!stepBeadHighlights || stepBeadHighlights.length === 0) return null
// Filter only beads that have direction arrows
const beadsWithArrows = stepBeadHighlights.filter(
(bead) => bead.direction && bead.direction !== 'none'
)
if (beadsWithArrows.length === 0) {
return null
}
// Sort by priority
const sortedBeads = [...beadsWithArrows].sort((a, b) => {
// First sort by place value (higher place value = more significant = topmost priority)
if (a.placeValue !== b.placeValue) {
return b.placeValue - a.placeValue
}
// If same place value, heaven beads come before earth beads
if (a.beadType !== b.beadType) {
return a.beadType === 'heaven' ? -1 : 1
}
// If both earth beads in same column, lower position number = higher on abacus
if (a.beadType === 'earth' && b.beadType === 'earth') {
return (a.position || 0) - (b.position || 0)
}
return 0
})
return sortedBeads[0] || null
}
/**
* Check if there are active beads to the left of a target column
*
* Active beads are:
* - Beads against the reckoning bar (based on current value)
* - Beads with direction arrows in the current step
*
* @param currentValue - Current abacus value
* @param stepBeadHighlights - Bead highlights for current step
* @param abacusColumns - Number of columns on the abacus
* @param targetColumnIndex - Column index to check left of
* @returns True if there are active beads to the left
*/
export function hasActiveBeadsToLeft(
currentValue: number,
stepBeadHighlights: StepBeadHighlight[] | undefined,
abacusColumns: number,
targetColumnIndex: number
): boolean {
// Get current abacus state - check which beads are against the reckoning bar
const abacusDigits = currentValue.toString().padStart(abacusColumns, '0').split('').map(Number)
for (let col = 0; col < targetColumnIndex; col++) {
const digitValue = abacusDigits[col]
// Check if any beads are active (against reckoning bar) in this column
if (digitValue >= 5) {
// Heaven bead is active
return true
}
if (digitValue % 5 > 0) {
// Earth beads are active
return true
}
// Also check if this column has beads with direction arrows
const hasArrowsInColumn =
stepBeadHighlights?.some((bead) => {
const beadColumnIndex = abacusColumns - 1 - bead.placeValue
return beadColumnIndex === col && bead.direction && bead.direction !== 'none'
}) ?? false
if (hasArrowsInColumn) {
return true
}
}
return false
}
/**
* Calculate smart tooltip positioning
*
* Determines which side to show the tooltip on and which bead to target,
* avoiding covering active beads.
*
* @param currentValue - Current abacus value
* @param stepBeadHighlights - Bead highlights for current step
* @param abacusColumns - Number of columns on the abacus
* @returns Positioning info, or null if no beads with arrows
*/
export function calculateTooltipPositioning(
currentValue: number,
stepBeadHighlights: StepBeadHighlight[] | undefined,
abacusColumns: number
): TooltipPositioning | null {
const topmostBead = findTopmostBeadWithArrows(stepBeadHighlights)
if (!topmostBead) return null
// Convert placeValue to columnIndex
const targetColumnIndex = abacusColumns - 1 - topmostBead.placeValue
// Check if there are active beads to the left
const activeToLeft = hasActiveBeadsToLeft(
currentValue,
stepBeadHighlights,
abacusColumns,
targetColumnIndex
)
// Determine tooltip position and target
const shouldPositionAbove = activeToLeft
const side = shouldPositionAbove ? 'top' : 'left'
const target: TooltipTarget = shouldPositionAbove
? {
// Target the heaven bead position for the column
type: 'bead',
columnIndex: targetColumnIndex,
beadType: 'heaven',
beadPosition: 0, // Heaven beads are always at position 0
}
: {
// Target the actual bead
type: 'bead',
columnIndex: targetColumnIndex,
beadType: topmostBead.beadType,
beadPosition: topmostBead.position,
}
return {
side,
target,
topmostBead,
targetColumnIndex,
}
}

View File

@ -1,6 +1,6 @@
// Utility to extract and convert the existing GuidedAdditionTutorial data
import type { Tutorial } from '../types/tutorial'
import type { Locale } from '../i18n/messages'
import { generateAbacusInstructions } from './abacusInstructionGenerator'
// Import the existing tutorial step interface to match the current structure
@ -218,7 +218,9 @@ export const guidedAdditionSteps: ExistingTutorialStep[] = [
]
// Convert the existing tutorial format to our new format
export function convertGuidedAdditionTutorial(tutorialMessages: Record<string, any>): Tutorial {
export function convertGuidedAdditionTutorial(
tutorialMessages: Record<string, any> = {}
): Tutorial {
// Convert existing static steps to progressive step data
const convertedSteps = guidedAdditionSteps.map((step) => {
// Generate progressive instruction data
@ -284,7 +286,7 @@ export function convertGuidedAdditionTutorial(tutorialMessages: Record<string, a
}
// Helper to validate that the existing tutorial steps work with our new interfaces
export function validateTutorialConversion(tutorialMessages: Record<string, any>): {
export function validateTutorialConversion(tutorialMessages: Record<string, any> = {}): {
isValid: boolean
errors: string[]
} {
@ -332,7 +334,7 @@ export function validateTutorialConversion(tutorialMessages: Record<string, any>
}
// Helper to export tutorial data for use in the editor
export function getTutorialForEditor(tutorialMessages: Record<string, any>): Tutorial {
export function getTutorialForEditor(tutorialMessages: Record<string, any> = {}): Tutorial {
const validation = validateTutorialConversion(tutorialMessages)
if (!validation.isValid) {