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:
parent
804d937dd9
commit
9a4ab8296e
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -138,7 +138,5 @@
|
|||
"ask": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"sqlite"
|
||||
]
|
||||
"enabledMcpjsonServers": ["sqlite"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue