feat(practice): integrate progressive help with decomposition display

- Extract standalone DecompositionContext from TutorialContext
- Create reusable DecompositionDisplay and ReasonTooltip components
- Wire prefix-sum "Get Help" button to progressive help system (L1→L2→L3)
- Sync abacus interactions with decomposition step highlighting
- Add currentStepIndex tracking in PracticeHelpPanel
- Make HelpAbacus interactive at L3 to update decomposition display
- Update documentation linking decomposition components

The progressive help system now:
- L1: Shows coach hint when user clicks "Get Help" after typing prefix sum
- L2: Shows interactive decomposition with hoverable explanations
- L3: Shows visual abacus with arrows, synced with decomposition highlighting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-07 08:56:35 -06:00
parent 2f7cb03c3f
commit 804d937dd9
12 changed files with 2248 additions and 209 deletions

View File

@ -804,6 +804,13 @@ MIT License - see LICENSE file for details.
## 📚 Additional Documentation
### Web Application
**Location**: [`apps/web/`](./apps/web/)
**Documentation**: [`apps/web/README.md`](./apps/web/README.md)
The main Next.js web application containing tutorials, practice sessions, arcade games, and worksheet generation.
### Arcade Game System
**Location**: [`apps/web/src/arcade-games/`](./apps/web/src/arcade-games/)
@ -843,6 +850,19 @@ Create customizable math worksheets with progressive difficulty, problem space v
React component library for rendering interactive and static abacus visualizations.
### Decomposition Display
**Location**: [`apps/web/src/components/decomposition/`](./apps/web/src/components/decomposition/)
**Documentation**: [`apps/web/src/components/decomposition/README.md`](./apps/web/src/components/decomposition/README.md)
Interactive mathematical decomposition visualization showing step-by-step soroban operations. Features hoverable terms with pedagogical explanations, grouped operations, and bidirectional abacus coordination.
**Key Features**:
- **Interactive Terms** - Hover to see why each operation is performed
- **Pedagogical Grouping** - Related operations (e.g., "+10 -3" for adding 7) grouped visually
- **Step Tracking** - Integrates with tutorial and practice step progression
- **Abacus Coordination** - Bidirectional highlighting between decomposition and abacus
### Daily Practice System
**Location**: [`apps/web/docs/DAILY_PRACTICE_SYSTEM.md`](./apps/web/docs/DAILY_PRACTICE_SYSTEM.md)

View File

@ -1 +1,85 @@
# Test deployment - Mon Nov 3 16:31:57 CST 2025
# Soroban Web Application
Interactive web application for learning soroban (Japanese abacus) calculation with tutorials, practice sessions, and multiplayer arcade games.
## Features
- **Tutorials** - Step-by-step lessons for learning soroban techniques
- **Practice Sessions** - Adaptive practice with progressive help system
- **Arcade Games** - Multiplayer educational games for reinforcement
- **Worksheet Generator** - Create printable math worksheets
## Getting Started
```bash
# Install dependencies
pnpm install
# Start development server
pnpm dev
# Run type checks
npm run type-check
# Run all quality checks
npm run pre-commit
```
## Documentation
### Components
| Component | Description |
|-----------|-------------|
| [Decomposition Display](./src/components/decomposition/README.md) | Interactive mathematical decomposition visualization |
| [Worksheet Generator](./src/app/create/worksheets/README.md) | Math worksheet creation with Typst PDF generation |
### Games
| Game | Description |
|------|-------------|
| [Arcade System](./src/arcade-games/README.md) | Modular multiplayer game architecture |
| [Know Your World](./src/arcade-games/know-your-world/README.md) | Geography quiz game |
### Developer Documentation
Located in `.claude/` directory:
- `CLAUDE.md` - Project conventions and guidelines
- `CODE_QUALITY_REGIME.md` - Quality check procedures
- `GAME_SETTINGS_PERSISTENCE.md` - Game config architecture
- `Z_INDEX_MANAGEMENT.md` - Z-index layering system
- `DEPLOYMENT.md` - Deployment and CI/CD
## Project Structure
```
apps/web/
├── src/
│ ├── app/ # Next.js App Router pages
│ ├── components/ # Shared React components
│ │ ├── decomposition/ # Math decomposition display
│ │ ├── practice/ # Practice session components
│ │ └── tutorial/ # Tutorial player components
│ ├── contexts/ # React context providers
│ ├── arcade-games/ # Multiplayer game implementations
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities and libraries
│ └── db/ # Database schema and queries
├── .claude/ # Developer documentation
├── public/ # Static assets
└── styled-system/ # Generated Panda CSS
```
## Technology Stack
- **Framework**: Next.js 14 (App Router)
- **Language**: TypeScript
- **Styling**: Panda CSS
- **Database**: SQLite with Drizzle ORM
- **Abacus Visualization**: @soroban/abacus-react
## Related Documentation
**Parent**: [Main README](../../README.md) - Complete project overview
**Abacus Component**: [packages/abacus-react](../../packages/abacus-react/README.md) - Abacus visualization library

View File

@ -0,0 +1,302 @@
'use client'
import type React from 'react'
import { createContext, useContext, useMemo } from 'react'
import { useDecomposition } from '@/contexts/DecompositionContext'
import type { PedagogicalSegment, UnifiedStepData } from '@/utils/unifiedStepGenerator'
import { ReasonTooltip } from './ReasonTooltip'
import './decomposition.css'
// ============================================================================
// Internal Context for term hover coordination
// ============================================================================
interface InternalDecompositionContextType {
activeTerms: Set<number>
activeSegmentId: string | null
addActiveTerm: (termIndex: number, segmentId?: string) => void
removeActiveTerm: (termIndex: number, segmentId?: string) => void
}
const InternalDecompositionContext = createContext<InternalDecompositionContextType>({
activeTerms: new Set(),
activeSegmentId: null,
addActiveTerm: () => {},
removeActiveTerm: () => {},
})
// ============================================================================
// TermSpan Component
// ============================================================================
interface TermSpanProps {
termIndex: number
text: string
segment?: PedagogicalSegment
isCurrentStep?: boolean
}
function TermSpan({ termIndex, text, segment, isCurrentStep = false }: TermSpanProps) {
const { addActiveTerm, removeActiveTerm } = useContext(InternalDecompositionContext)
const rule = segment?.plan[0]?.rule
// Only show styling for terms that have pedagogical reasoning
if (!rule) {
return <span className="term term--plain">{text}</span>
}
// Determine CSS classes based on current step only
const cssClasses = ['term', isCurrentStep && 'term--current'].filter(Boolean).join(' ')
// Individual term hover handlers for two-level highlighting
const handleTermHover = (isHovering: boolean) => {
if (isHovering) {
addActiveTerm(termIndex, segment?.id)
} else {
removeActiveTerm(termIndex, segment?.id)
}
}
return (
<span
data-element="decomposition-term"
data-term-index={termIndex}
className={cssClasses}
onMouseEnter={() => handleTermHover(true)}
onMouseLeave={() => handleTermHover(false)}
style={{ cursor: 'pointer' }}
>
{text}
</span>
)
}
// ============================================================================
// SegmentGroup Component
// ============================================================================
interface SegmentGroupProps {
segment: PedagogicalSegment
steps: UnifiedStepData[]
children: React.ReactNode
}
function SegmentGroup({ segment, steps, children }: SegmentGroupProps) {
const { addActiveTerm, removeActiveTerm } = useContext(InternalDecompositionContext)
// Calculate the original term that was expanded
// digit * 10^place gives us the original value (e.g., digit=5, place=1 -> 50)
const originalValue = (segment.digit * 10 ** segment.place).toString()
// Get provenance from the first step in this segment
const firstStepIndex = segment.termIndices[0]
const firstStep = steps[firstStepIndex]
const provenance = firstStep?.provenance
const handleHighlightChange = (active: boolean) => {
// Only handle highlighting, let HoverCard manage its own open/close timing
if (active) {
segment.termIndices.forEach((termIndex) => addActiveTerm(termIndex, segment.id))
} else {
segment.termIndices.forEach((termIndex) => removeActiveTerm(termIndex, segment.id))
}
}
return (
<ReasonTooltip
termIndex={segment.termIndices[0]} // Use first term for tooltip ID
segment={segment}
originalValue={originalValue}
steps={steps}
provenance={provenance}
>
<span
data-element="segment-group"
data-segment-id={segment.id}
className="segment-group"
onMouseEnter={() => handleHighlightChange(true)}
onMouseLeave={() => handleHighlightChange(false)}
>
{children}
</span>
</ReasonTooltip>
)
}
// ============================================================================
// DecompositionDisplay Component
// ============================================================================
/**
* Renders the decomposition string with interactive terms and tooltips.
*
* Must be used inside a DecompositionProvider.
*
* @example
* ```tsx
* <DecompositionProvider startValue={0} targetValue={45}>
* <DecompositionDisplay />
* </DecompositionProvider>
* ```
*/
export function DecompositionDisplay() {
const {
fullDecomposition,
termPositions,
segments,
steps,
currentStepIndex,
activeTermIndices,
setActiveTermIndices,
setActiveIndividualTermIndex,
getGroupTermIndicesFromTermIndex,
} = useDecomposition()
// Build a quick lookup: termIndex -> segment
const termIndexToSegment = useMemo(() => {
const map = new Map<number, PedagogicalSegment>()
segments?.forEach((seg) => seg.termIndices.forEach((i) => map.set(i, seg)))
return map
}, [segments])
// Determine which segment should be highlighted based on active terms
const activeSegmentId = useMemo(() => {
if (activeTermIndices.size === 0) return null
// Find the segment that contains any of the active terms
for (const termIndex of activeTermIndices) {
const segment = termIndexToSegment.get(termIndex)
if (segment) {
return segment.id
}
}
return null
}, [activeTermIndices, termIndexToSegment])
// Term hover handlers
const addActiveTerm = (termIndex: number, _segmentId?: string) => {
// Set individual term highlight (orange glow)
setActiveIndividualTermIndex(termIndex)
// Set group term highlights (blue glow) - for complement groups, highlight only the target column
const groupTermIndices = getGroupTermIndicesFromTermIndex(termIndex)
if (groupTermIndices.length > 0) {
// For complement groups, highlight only the target column (rhsPlace, not individual termPlaces)
// Use any term from the group since they all share the same rhsPlace (target column)
setActiveTermIndices(new Set([termIndex]))
} else {
// This is a standalone term, just highlight it
setActiveTermIndices(new Set([termIndex]))
}
}
const removeActiveTerm = (_termIndex: number, _segmentId?: string) => {
// Clear individual term highlight
setActiveIndividualTermIndex(null)
// Clear group term highlights
setActiveTermIndices(new Set())
}
// Render elements with segment groupings
const renderElements = () => {
const elements: React.ReactNode[] = []
let cursor = 0
for (let termIndex = 0; termIndex < termPositions.length; termIndex++) {
const { startIndex, endIndex } = termPositions[termIndex]
const segment = termIndexToSegment.get(termIndex)
// Add connector text before this term
if (cursor < startIndex) {
elements.push(
<span key={`connector-${cursor}`}>{fullDecomposition.slice(cursor, startIndex)}</span>
)
}
// Check if this term starts a new segment
if (segment && segment.termIndices[0] === termIndex) {
// This is the first term of a segment - wrap all segment terms
const segmentElements: React.ReactNode[] = []
let segmentCursor = startIndex
for (const segTermIndex of segment.termIndices) {
const segPos = termPositions[segTermIndex]
if (!segPos) continue
// Add connector within segment
if (segmentCursor < segPos.startIndex) {
segmentElements.push(
<span key={`seg-connector-${segmentCursor}`}>
{fullDecomposition.slice(segmentCursor, segPos.startIndex)}
</span>
)
}
const segText = fullDecomposition.slice(segPos.startIndex, segPos.endIndex)
segmentElements.push(
<TermSpan
key={`seg-term-${segTermIndex}`}
termIndex={segTermIndex}
text={segText}
segment={segment}
isCurrentStep={segTermIndex === currentStepIndex}
/>
)
segmentCursor = segPos.endIndex
}
elements.push(
<SegmentGroup key={`segment-${segment.id}`} segment={segment} steps={steps}>
{segmentElements}
</SegmentGroup>
)
// Skip ahead past all terms in this segment
const lastSegTermIndex = segment.termIndices[segment.termIndices.length - 1]
const lastSegPos = termPositions[lastSegTermIndex]
cursor = lastSegPos?.endIndex ?? endIndex
termIndex = lastSegTermIndex // Will be incremented by for loop
} else if (!segment) {
// Regular term not in a segment
const termText = fullDecomposition.slice(startIndex, endIndex)
elements.push(
<TermSpan
key={`term-${termIndex}`}
termIndex={termIndex}
text={termText}
segment={segment}
isCurrentStep={termIndex === currentStepIndex}
/>
)
cursor = endIndex
}
// If this term is part of a segment but not the first, it was already handled above
}
// Add trailing text
if (cursor < fullDecomposition.length) {
elements.push(<span key="trailing">{fullDecomposition.slice(cursor)}</span>)
}
return elements
}
return (
<InternalDecompositionContext.Provider
value={{
activeTerms: activeTermIndices,
activeSegmentId,
addActiveTerm,
removeActiveTerm,
}}
>
<div data-element="decomposition-display" className="decomposition">
{renderElements()}
</div>
</InternalDecompositionContext.Provider>
)
}

View File

@ -0,0 +1,350 @@
# Decomposition Display Components
**Interactive mathematical decomposition visualization for soroban addition/subtraction operations.**
## Overview
The decomposition system breaks down soroban arithmetic into step-by-step operations, showing users exactly how to perform calculations using complement-based methods. It supports:
- **Interactive term highlighting** - Hover over terms to see pedagogical explanations
- **Grouped operations** - Related terms (e.g., "+10 -3" for adding 7) are grouped visually
- **Current step tracking** - Integrates with tutorial/practice step progression
- **Abacus coordination** - Bidirectional highlighting between decomposition and abacus display
## Quick Start
```typescript
import { DecompositionProvider, DecompositionDisplay } from '@/components/decomposition'
// Basic usage - just provide start and target values
function MyComponent() {
return (
<DecompositionProvider startValue={45} targetValue={72}>
<DecompositionDisplay />
</DecompositionProvider>
)
}
```
## Components
### DecompositionProvider
Context provider that generates all decomposition data from start/target values.
```typescript
interface DecompositionContextConfig {
/** Starting value on the abacus */
startValue: number
/** Target value to reach */
targetValue: number
/** Current step index for highlighting (optional) */
currentStepIndex?: number
/** Number of abacus columns for coordinate mapping (default: 5) */
abacusColumns?: number
/** Callback when segment changes (optional) */
onSegmentChange?: (segment: PedagogicalSegment | null) => void
/** Callback when term is hovered (optional) */
onTermHover?: (termIndex: number | null, columnIndex: number | null) => void
}
```
**Props:**
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `startValue` | `number` | Yes | Initial abacus value |
| `targetValue` | `number` | Yes | Target value to calculate |
| `currentStepIndex` | `number` | No | Which step to highlight (default: 0) |
| `abacusColumns` | `number` | No | Number of columns for mapping (default: 5) |
| `onSegmentChange` | `function` | No | Called when user hovers a grouped segment |
| `onTermHover` | `function` | No | Called when user hovers individual term |
### DecompositionDisplay
Renders the interactive decomposition string with hoverable terms.
```typescript
import { DecompositionDisplay } from '@/components/decomposition'
// Must be inside a DecompositionProvider
<DecompositionDisplay />
```
The display automatically:
- Renders all terms from the decomposition
- Groups related terms (pedagogical segments)
- Shows tooltips with explanations on hover
- Highlights the current step
- Coordinates with external abacus highlighting
### ReasonTooltip
Tooltip component showing pedagogical reasoning for each term.
```typescript
import { ReasonTooltip } from '@/components/decomposition'
// Usually used internally by DecompositionDisplay
<ReasonTooltip
reason={termReason}
variant="blue"
open={isOpen}
onOpenChange={setIsOpen}
>
<span>+10</span>
</ReasonTooltip>
```
## Hooks
### useDecomposition()
Access decomposition context data. Must be inside `DecompositionProvider`.
```typescript
const {
// Data
fullDecomposition, // "45 +10 -3 +20" (full string)
termPositions, // Position metadata for each term
segments, // Grouped pedagogical segments
steps, // Unified instruction steps
currentStepIndex, // Current highlighted step
// Highlighting state
activeTermIndices, // Set of currently highlighted term indices
activeIndividualTermIndex, // Single hovered term index
// Actions
setActiveTermIndices, // Highlight multiple terms
setActiveIndividualTermIndex, // Highlight single term
getGroupTermIndicesFromTermIndex, // Get all terms in a group
getColumnIndexFromTermIndex, // Map term to abacus column
} = useDecomposition()
```
### useDecompositionOptional()
Same as `useDecomposition()` but returns `null` outside provider (doesn't throw).
```typescript
const decomposition = useDecompositionOptional()
if (decomposition) {
// Use decomposition data
}
```
## Integration Examples
### Tutorial Player
```typescript
import { DecompositionProvider, DecompositionDisplay } from '@/components/decomposition'
function TutorialPlayer({ step, currentMultiStep }) {
return (
<DecompositionProvider
startValue={step.startValue}
targetValue={step.targetValue}
currentStepIndex={currentMultiStep}
abacusColumns={5}
>
<div className="tutorial-content">
<DecompositionDisplay />
<AbacusWithHighlighting />
</div>
</DecompositionProvider>
)
}
```
### Practice Help Panel
```typescript
import { DecompositionProvider, DecompositionDisplay } from '@/components/decomposition'
function PracticeHelpPanel({ currentValue, targetValue, helpLevel }) {
// Show decomposition at help level 2+
if (helpLevel < 2) return null
return (
<DecompositionProvider
startValue={currentValue}
targetValue={targetValue}
abacusColumns={3}
>
<div className="step-by-step-help">
<DecompositionDisplay />
</div>
</DecompositionProvider>
)
}
```
### With Abacus Coordination
```typescript
function CoordinatedDisplay({ startValue, targetValue }) {
const [highlightedColumn, setHighlightedColumn] = useState<number | null>(null)
return (
<DecompositionProvider
startValue={startValue}
targetValue={targetValue}
onTermHover={(termIndex, columnIndex) => {
setHighlightedColumn(columnIndex)
}}
>
<DecompositionDisplay />
<AbacusReact
value={startValue}
highlightedColumn={highlightedColumn}
/>
</DecompositionProvider>
)
}
```
## Architecture
### Data Flow
```
startValue, targetValue
generateUnifiedInstructionSequence()
┌────────────────────────────────────┐
│ DecompositionContext │
│ │
│ - fullDecomposition: "45 +10 -3" │
│ - termPositions: [{start, end}] │
│ - segments: [PedagogicalSegment] │
│ - steps: [UnifiedStep] │
│ - highlighting state │
└────────────────────────────────────┘
DecompositionDisplay
TermSpan / SegmentGroup
ReasonTooltip (on hover)
```
### Key Types
```typescript
/** Position of a term in the decomposition string */
interface TermPosition {
index: number // Term index in sequence
start: number // Character start position
end: number // Character end position
term: string // The term text (e.g., "+10")
value: number // Numeric value
columnIndex?: number // Abacus column this affects
}
/** Group of related terms */
interface PedagogicalSegment {
segmentIndex: number
termIndices: number[] // Which terms belong to this segment
ruleName: string // e.g., "Add 7 using complement"
description: string // User-friendly explanation
}
/** Pedagogical explanation for a term */
interface TermReason {
name: string // Rule name
description: string // Why this operation
emoji: string // Visual indicator
variant: 'green' | 'blue' | 'purple' | 'orange' | 'gray'
steps?: BeadStep[] // Physical bead movements
expansion?: string // Mathematical expansion
context?: string // Additional context
}
```
## Styling
The components use CSS files for styling:
- `decomposition.css` - Term and segment styling
- `reason-tooltip.css` - Tooltip appearance
### CSS Classes
```css
.decomposition { } /* Container */
.term { } /* Individual term */
.term--current { } /* Current step highlight */
.term--active { } /* Hovered/selected term */
.term--grouped { } /* Term in a segment */
.segment-group { } /* Grouped segment wrapper */
```
### Customization
Override CSS variables or classes:
```css
/* Custom term highlight color */
.term--current {
background: rgba(139, 92, 246, 0.2);
border-color: rgba(139, 92, 246, 0.6);
}
/* Custom tooltip variant */
.reason-tooltip--custom {
border-color: #10b981;
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
}
```
## File Structure
```
src/components/decomposition/
├── README.md # This file
├── index.ts # Public exports
├── DecompositionDisplay.tsx # Main display component
├── ReasonTooltip.tsx # Tooltip with explanations
├── decomposition.css # Term styling
└── reason-tooltip.css # Tooltip styling
src/contexts/
└── DecompositionContext.tsx # Provider and hooks
```
## Testing
```bash
# Type check
npm run type-check
# Lint
npm run lint
# Full pre-commit check
npm run pre-commit
```
## Related Documentation
**Parent**: [`apps/web/README.md`](../../../README.md) - Web application overview
**Tutorial System**: [`src/components/tutorial/`](../tutorial/) - Tutorial player integration
**Practice System**: [`src/components/practice/`](../practice/) - Practice help panel integration
**Instruction Generation**: [`src/utils/generateUnifiedInstructionSequence.ts`](../../utils/generateUnifiedInstructionSequence.ts) - Core algorithm
## Changelog
### v1.0.0 (December 2024)
- Initial standalone extraction from TutorialContext
- Decoupled from tutorial-specific UI context
- Added support for practice help panel integration
- Simplified API: only requires startValue and targetValue

View File

@ -0,0 +1,376 @@
'use client'
import * as HoverCard from '@radix-ui/react-hover-card'
import { useTranslations } from 'next-intl'
import type React from 'react'
import { useMemo, useState } from 'react'
import type {
PedagogicalRule,
PedagogicalSegment,
TermProvenance,
UnifiedStepData,
} from '@/utils/unifiedStepGenerator'
import './reason-tooltip.css'
// Re-export types for consumers
export type { PedagogicalRule, PedagogicalSegment }
export interface TermReason {
rule: PedagogicalRule
explanation?: string
}
interface ReasonTooltipProps {
children: React.ReactNode
termIndex: number
segment?: PedagogicalSegment
reason?: TermReason
originalValue?: string
steps?: UnifiedStepData[]
provenance?: TermProvenance
}
export function ReasonTooltip({
children,
termIndex,
segment,
reason,
originalValue,
steps,
provenance,
}: ReasonTooltipProps) {
// All hooks must be called before early return
const [showBeadDetails, setShowBeadDetails] = useState(false)
const [showMath, setShowMath] = useState(false)
const [showDetails, setShowDetails] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const t = useTranslations('tutorial.reasonTooltip')
const rule = reason?.rule ?? segment?.plan[0]?.rule
// Use readable format from segment, enhanced with provenance
const readable = segment?.readable
const enhancedContent = useMemo(() => {
if (!provenance) return null
if (rule === 'Direct') {
const rodChip = readable?.chips.find((c) => /^(this )?rod shows$/i.test(c.label))
return {
title: t('directTitle', {
place: provenance.rhsPlaceName,
digit: provenance.rhsDigit,
value: provenance.rhsValue,
}),
subtitle: t('directSubtitle', { addend: provenance.rhs }),
chips: [
{
label: t('digitChip'),
value: `${provenance.rhsDigit} (${provenance.rhsPlaceName})`,
},
...(rodChip ? [{ label: t('rodChip'), value: rodChip.value }] : []),
{
label: t('addHereChip'),
value: `+${provenance.rhsDigit} ${provenance.rhsPlaceName}${provenance.rhsValue}`,
},
],
}
}
if (readable) {
const subtitleParts = [
readable.subtitle,
t('subtitleContext', {
addend: provenance.rhs,
place: provenance.rhsPlaceName,
digit: provenance.rhsDigit,
}),
].filter(Boolean)
return {
title: readable.title,
subtitle: subtitleParts.join(' • '),
chips: [
{
label: t('sourceDigit'),
value: `${provenance.rhsDigit} from ${provenance.rhs} (${provenance.rhsPlaceName} place)`,
},
...readable.chips,
],
}
}
return null
}, [provenance, readable, rule, t])
const ruleInfo = useMemo(() => {
switch (rule) {
case 'Direct':
return {
emoji: '✨',
name: t('ruleInfo.Direct.name'),
description: t('ruleInfo.Direct.description'),
color: 'green',
}
case 'FiveComplement':
return {
emoji: '🤝',
name: t('ruleInfo.FiveComplement.name'),
description: t('ruleInfo.FiveComplement.description'),
color: 'blue',
}
case 'TenComplement':
return {
emoji: '🔟',
name: t('ruleInfo.TenComplement.name'),
description: t('ruleInfo.TenComplement.description'),
color: 'purple',
}
case 'Cascade':
return {
emoji: '🌊',
name: t('ruleInfo.Cascade.name'),
description: t('ruleInfo.Cascade.description'),
color: 'orange',
}
default:
return {
emoji: '💭',
name: t('ruleInfo.Fallback.name'),
description: t('ruleInfo.Fallback.description'),
color: 'gray',
}
}
}, [rule, t])
const fromPrefix = t('fromPrefix')
if (!rule) {
return <>{children}</>
}
const contentClasses = `reason-tooltip reason-tooltip--${ruleInfo.color}`
const tooltipId = `tooltip-${termIndex}`
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
}
return (
<HoverCard.Root open={isOpen} onOpenChange={handleOpenChange} openDelay={150} closeDelay={400}>
<HoverCard.Trigger asChild>
<span aria-describedby={`${tooltipId}-description`}>{children}</span>
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content
id={tooltipId}
className={contentClasses}
sideOffset={8}
side="top"
align="center"
>
<div className="reason-tooltip__content">
<div className="reason-tooltip__header">
<span className="reason-tooltip__emoji">{ruleInfo.emoji}</span>
<div className="reason-tooltip__title">
<h4 className="reason-tooltip__name">
{enhancedContent?.title || readable?.title || ruleInfo.name}
</h4>
<p id={`${tooltipId}-description`} className="reason-tooltip__description">
{enhancedContent?.subtitle || readable?.subtitle || ruleInfo.description}
</p>
</div>
</div>
{/* Primary, concise explanation */}
{segment?.readable?.summary && (
<div className="reason-tooltip__summary">
<p className="reason-tooltip__explanation-text">{segment.readable.summary}</p>
</div>
)}
{/* Optional provenance nudge (avoid duplicating subtitle) */}
{provenance &&
!(enhancedContent?.subtitle || readable?.subtitle || '').includes(
`${fromPrefix} `
) && (
<div className="reason-tooltip__reasoning">
<p className="reason-tooltip__explanation-text">
{t('reasoning', {
addend: provenance.rhs,
place: provenance.rhsPlaceName,
digit: provenance.rhsDigit,
})}
</p>
</div>
)}
{/* More details disclosure for optional content */}
{((enhancedContent?.chips || readable?.chips)?.length ||
readable?.carryPath ||
readable?.showMath ||
(readable && readable.stepsFriendly.length > 1)) && (
<div className="reason-tooltip__details">
<button
className="reason-tooltip__details-toggle"
onClick={() => setShowDetails(!showDetails)}
aria-expanded={showDetails}
aria-controls={`${tooltipId}-details`}
type="button"
>
<span className="reason-tooltip__details-label">
{t('details.toggle')}
<span
className="reason-tooltip__chevron"
style={{
transform: showDetails ? 'rotate(180deg)' : 'rotate(0deg)',
}}
>
</span>
</span>
</button>
{showDetails && (
<div id={`${tooltipId}-details`} className="reason-tooltip__details-content">
{/* Context chips */}
{(enhancedContent?.chips || readable?.chips)?.length ? (
<div className="reason-tooltip__context">
<dl className="reason-tooltip__chips">
{(enhancedContent?.chips || readable?.chips || []).map((chip, index) => (
<div key={index} className="reason-tooltip__chip">
<dt>{chip.label}</dt>
<dd>{chip.value}</dd>
</div>
))}
</dl>
</div>
) : null}
{/* Carry path only when it's interesting (cascades) */}
{segment?.plan?.some((p) => p.rule === 'Cascade') && readable?.carryPath && (
<div className="reason-tooltip__carry-path">
<p className="reason-tooltip__carry-description">
<strong>{t('details.carryPath')}</strong> {readable.carryPath}
</p>
</div>
)}
{/* Math toggle */}
{readable?.showMath && (
<div className="reason-tooltip__advanced">
<button
className="reason-tooltip__math-toggle"
onClick={() => setShowMath(!showMath)}
aria-expanded={showMath}
type="button"
>
<span className="reason-tooltip__math-label">
{t('details.showMath')}
<span
className="reason-tooltip__chevron"
style={{
transform: showMath ? 'rotate(180deg)' : 'rotate(0deg)',
}}
>
</span>
</span>
</button>
{showMath && (
<div className="reason-tooltip__math-content">
{readable.showMath.lines.map((line, index) => (
<p key={index} className="reason-tooltip__math-line">
{line}
</p>
))}
</div>
)}
</div>
)}
{/* Step-by-step breakdown */}
{readable && readable.stepsFriendly.length > 1 && (
<div className="reason-tooltip__steps">
<button
className="reason-tooltip__expand-button"
onClick={() => setShowBeadDetails(!showBeadDetails)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setShowBeadDetails(!showBeadDetails)
}
}}
aria-expanded={showBeadDetails}
aria-controls={`${tooltipId}-steps`}
type="button"
>
<span className="reason-tooltip__section-title">
{t('details.steps')}
<span
className="reason-tooltip__chevron"
style={{
transform: showBeadDetails ? 'rotate(180deg)' : 'rotate(0deg)',
}}
>
</span>
</span>
</button>
{showBeadDetails && (
<ol id={`${tooltipId}-steps`} className="reason-tooltip__step-list">
{readable.stepsFriendly.map((stepInstruction, idx) => (
<li key={idx} className="reason-tooltip__step">
<span className="reason-tooltip__step-instruction">
{stepInstruction}
</span>
</li>
))}
</ol>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Dev-only validation hint */}
{process.env.NODE_ENV !== 'production' &&
segment?.readable?.validation &&
!segment.readable.validation.ok && (
<div className="reason-tooltip__dev-warn">
{t('devWarning', {
issues: segment.readable.validation.issues.join('; '),
})}
</div>
)}
{/* Original transformation shown at bottom */}
{originalValue && segment?.expression && (
<div className="reason-tooltip__formula">
<div className="reason-tooltip__expansion">
<span className="reason-tooltip__original">{originalValue}</span>
<span className="reason-tooltip__arrow"></span>
<code className="reason-tooltip__expanded">{segment.expression}</code>
</div>
<div className="reason-tooltip__label">
{t('formula', {
original: originalValue,
expanded: segment.expression,
})}
</div>
</div>
)}
</div>
<HoverCard.Arrow className="reason-tooltip__arrow" />
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
)
}

View File

@ -0,0 +1,125 @@
/* CSS styling for DecompositionDisplay component */
.decomposition {
display: inline;
font-family: "JetBrains Mono", "Fira Code", "Monaco", "Consolas", monospace;
font-size: inherit;
line-height: inherit;
}
.term {
display: inline-block;
padding: 2px 4px;
margin: 0 1px;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.term--plain {
cursor: default;
padding: 0;
margin: 0;
border: none;
}
.term:focus {
outline: none;
background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.5);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
/* Active term - the one being directly hovered/clicked */
.term--active {
background: rgba(59, 130, 246, 0.25);
border-color: rgba(59, 130, 246, 0.6);
font-weight: 600;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
/* Current step - the term that matches the current step */
.term--current {
background: rgba(245, 158, 11, 0.2);
border-color: rgba(245, 158, 11, 0.6);
border-width: 2px;
font-weight: 600;
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.3);
animation: currentStepGlow 1.5s ease-in-out infinite;
}
/* Grouped terms - terms that belong to pedagogical segments */
.term--grouped {
background: transparent;
border-color: transparent;
}
/* Segment group styling */
.segment-group {
display: inline;
cursor: pointer;
border-radius: 6px;
padding: 2px 4px;
margin: 0 1px;
transition: all 0.2s ease;
}
.segment-group:hover {
background: rgba(147, 51, 234, 0.1);
box-shadow: 0 0 0 2px rgba(147, 51, 234, 0.2);
}
/* Accessibility improvements */
.term:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.segment-group:focus-visible {
outline: 2px solid #8b5cf6;
outline-offset: 2px;
}
@keyframes currentStepGlow {
0% {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.3);
}
50% {
box-shadow:
0 0 0 3px rgba(245, 158, 11, 0.5),
0 0 8px rgba(245, 158, 11, 0.3);
}
100% {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.3);
}
}
@keyframes groupPulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.02);
box-shadow: 0 0 0 1px rgba(147, 51, 234, 0.4);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.term {
transition: none;
}
.term--current {
animation: none;
}
}

View File

@ -0,0 +1,17 @@
// Decomposition Display Components
// Standalone decomposition visualization that works anywhere in the app
export { DecompositionDisplay } from './DecompositionDisplay'
export { ReasonTooltip } from './ReasonTooltip'
export type { PedagogicalRule, PedagogicalSegment, TermReason } from './ReasonTooltip'
// Re-export the context and hooks from contexts
export {
DecompositionProvider,
useDecomposition,
useDecompositionOptional,
} from '@/contexts/DecompositionContext'
export type {
DecompositionContextConfig,
DecompositionContextType,
} from '@/contexts/DecompositionContext'

View File

@ -0,0 +1,408 @@
/* Reason Tooltip Styles for DecompositionDisplay */
/* Typography clamps to keep header to 1-2 lines */
.reason-tooltip__name {
max-width: 48ch;
white-space: normal;
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.3;
color: light-dark(#1f2937, #f3f4f6);
}
.reason-tooltip__description {
max-width: 56ch;
opacity: 0.8;
margin: 4px 0 0;
font-size: 12px;
line-height: 1.3;
color: light-dark(#6b7280, #d1d5db);
}
.reason-tooltip__summary p {
margin: 8px 0 0;
line-height: 1.35;
font-size: 13px;
}
.reason-tooltip__explanation-text {
margin: 0;
line-height: 1.35;
font-size: 13px;
color: light-dark(#4b5563, #d1d5db);
}
/* Dev warning styling - quiet and subtle */
.reason-tooltip__dev-warn {
font-size: 12px;
opacity: 0.75;
margin-top: 8px;
color: #f59e0b;
background: #fef3c7;
padding: 4px 6px;
border-radius: 4px;
border: 1px solid #f59e0b;
}
/* Base tooltip styling */
.reason-tooltip {
background: light-dark(#ffffff, #1f2937);
border: 1px solid light-dark(#e5e7eb, #374151);
border-radius: 8px;
padding: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-width: 320px;
min-width: 200px;
z-index: 50;
animation: fadeIn 0.2s ease-out;
font-family: system-ui, -apple-system, sans-serif;
}
.reason-tooltip__content {
display: flex;
flex-direction: column;
gap: 8px;
}
.reason-tooltip__header {
display: flex;
align-items: flex-start;
gap: 8px;
}
.reason-tooltip__emoji {
font-size: 16px;
line-height: 1;
flex-shrink: 0;
}
.reason-tooltip__title {
flex: 1;
min-width: 0;
}
/* Chip improvements - better semantic layout */
.reason-tooltip__chips {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
}
.reason-tooltip__chip {
display: flex;
gap: 8px;
font-size: 12px;
padding: 4px 8px;
background: light-dark(#f3f4f6, #374151);
border-radius: 4px;
margin: 0;
}
.reason-tooltip__chip dt {
font-weight: 500;
color: light-dark(#6b7280, #9ca3af);
margin: 0;
}
.reason-tooltip__chip dd {
font-weight: 600;
color: light-dark(#111827, #f3f4f6);
margin: 0;
}
/* Collapsible sections */
.reason-tooltip__details-toggle,
.reason-tooltip__math-toggle,
.reason-tooltip__expand-button {
background: none;
border: none;
padding: 4px 0;
font-size: 12px;
color: light-dark(#6b7280, #9ca3af);
cursor: pointer;
width: 100%;
text-align: left;
display: flex;
align-items: center;
justify-content: space-between;
}
.reason-tooltip__details-toggle:hover,
.reason-tooltip__math-toggle:hover,
.reason-tooltip__expand-button:hover {
color: light-dark(#374151, #e5e7eb);
}
.reason-tooltip__details-toggle:focus,
.reason-tooltip__math-toggle:focus,
.reason-tooltip__expand-button:focus {
outline: 2px solid #3b82f6;
outline-offset: 1px;
border-radius: 2px;
}
.reason-tooltip__chevron {
transition: transform 0.2s ease;
font-size: 10px;
color: light-dark(#9ca3af, #6b7280);
}
.reason-tooltip__details {
margin-top: 8px;
border-top: 1px solid light-dark(#f1f5f9, #374151);
padding-top: 8px;
}
.reason-tooltip__details-label,
.reason-tooltip__math-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
font-weight: 500;
color: light-dark(#64748b, #9ca3af);
font-style: italic;
}
.reason-tooltip__details-content,
.reason-tooltip__math-content {
margin-top: 8px;
}
.reason-tooltip__context {
margin: 8px 0;
}
/* Carry path section */
.reason-tooltip__carry-path {
margin: 8px 0;
padding: 6px 8px;
background: light-dark(#f8fafc, #1e293b);
border: 1px solid light-dark(#e2e8f0, #334155);
border-radius: 4px;
}
.reason-tooltip__carry-description {
margin: 0;
font-size: 11px;
color: light-dark(#475569, #cbd5e1);
line-height: 1.4;
}
/* Advanced math toggle section */
.reason-tooltip__advanced {
margin-top: 8px;
border-top: 1px solid light-dark(#f1f5f9, #374151);
padding-top: 8px;
}
.reason-tooltip__math-content {
margin-top: 6px;
padding: 6px 8px;
background: light-dark(#fafbfc, #1e293b);
border: 1px solid light-dark(#e9ecef, #334155);
border-radius: 4px;
}
.reason-tooltip__math-line {
margin: 0 0 4px 0;
font-size: 10px;
color: light-dark(#6b7280, #9ca3af);
line-height: 1.3;
}
.reason-tooltip__math-line:last-child {
margin-bottom: 0;
}
/* Steps section */
.reason-tooltip__steps {
margin-bottom: 8px;
}
.reason-tooltip__section-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 600;
color: light-dark(#374151, #e5e7eb);
margin: 0 0 4px 0;
border-bottom: 1px solid light-dark(#e5e7eb, #4b5563);
padding-bottom: 2px;
}
.reason-tooltip__step-list {
margin: 8px 0 0;
padding-left: 16px;
font-size: 12px;
}
.reason-tooltip__step {
margin: 4px 0;
line-height: 1.4;
}
.reason-tooltip__step-instruction {
font-size: 11px;
color: light-dark(#6b7280, #d1d5db);
line-height: 1.3;
font-style: italic;
}
/* Formula display */
.reason-tooltip__formula {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid light-dark(#e5e7eb, #374151);
}
.reason-tooltip__expansion {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
padding: 8px;
background: light-dark(#fafbfc, #1e293b);
border-radius: 6px;
border: 1px solid light-dark(#e9ecef, #334155);
}
.reason-tooltip__original {
background: #fff2cc;
border: 1px solid #ffd93d;
border-radius: 4px;
padding: 4px 8px;
font-family: "JetBrains Mono", "Fira Code", "Monaco", "Consolas", monospace;
font-size: 14px;
font-weight: 600;
color: #b45309;
}
.reason-tooltip__arrow {
color: light-dark(#6b7280, #9ca3af);
font-weight: bold;
font-size: 16px;
}
.reason-tooltip__expanded {
background: light-dark(#f0f9ff, #1e3a5f);
border: 1px solid light-dark(#7dd3fc, #0ea5e9);
border-radius: 4px;
padding: 4px 8px;
font-family: "JetBrains Mono", "Fira Code", "Monaco", "Consolas", monospace;
font-size: 12px;
color: light-dark(#1e40af, #7dd3fc);
flex: 1;
}
.reason-tooltip__label {
font-size: 11px;
color: light-dark(#6b7280, #9ca3af);
text-align: center;
font-style: italic;
margin-top: 4px;
}
/* Radix tooltip arrow */
.reason-tooltip .reason-tooltip__arrow {
fill: light-dark(#ffffff, #1f2937);
stroke: light-dark(#e5e7eb, #374151);
stroke-width: 2px;
}
/* Color variants */
.reason-tooltip--green {
border-color: #10b981;
background: light-dark(
linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%),
linear-gradient(135deg, #1f2937 0%, #064e3b 100%)
);
}
.reason-tooltip--green .reason-tooltip__name {
color: light-dark(#065f46, #34d399);
}
.reason-tooltip--blue {
border-color: #3b82f6;
background: light-dark(
linear-gradient(135deg, #ffffff 0%, #eff6ff 100%),
linear-gradient(135deg, #1f2937 0%, #1e3a5f 100%)
);
}
.reason-tooltip--blue .reason-tooltip__name {
color: light-dark(#1e40af, #60a5fa);
}
.reason-tooltip--purple {
border-color: #8b5cf6;
background: light-dark(
linear-gradient(135deg, #ffffff 0%, #f5f3ff 100%),
linear-gradient(135deg, #1f2937 0%, #3b2663 100%)
);
}
.reason-tooltip--purple .reason-tooltip__name {
color: light-dark(#5b21b6, #a78bfa);
}
.reason-tooltip--orange {
border-color: #f59e0b;
background: light-dark(
linear-gradient(135deg, #ffffff 0%, #fffbeb 100%),
linear-gradient(135deg, #1f2937 0%, #78350f 100%)
);
}
.reason-tooltip--orange .reason-tooltip__name {
color: light-dark(#92400e, #fbbf24);
}
.reason-tooltip--gray {
border-color: #6b7280;
background: light-dark(
linear-gradient(135deg, #ffffff 0%, #f9fafb 100%),
linear-gradient(135deg, #1f2937 0%, #374151 100%)
);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Responsive adjustments */
@media (max-width: 480px) {
.reason-tooltip {
max-width: 280px;
}
.reason-tooltip__name {
max-width: 40ch;
}
.reason-tooltip__description {
max-width: 44ch;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.reason-tooltip__chevron {
transition: none;
}
.reason-tooltip {
animation: none;
}
}

View File

@ -20,9 +20,9 @@ import {
generateSingleProblem,
} from '@/utils/problemGenerator'
import { css } from '../../../styled-system/css'
import { HelpAbacus } from './HelpAbacus'
import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection'
import { NumericKeypad } from './NumericKeypad'
import { PracticeHelpPanel } from './PracticeHelpPanel'
import { VerticalProblem } from './VerticalProblem'
interface ActiveSessionProps {
@ -224,6 +224,8 @@ export function ActiveSession({
const [correctionCount, setCorrectionCount] = useState(0)
// Track if auto-submit was triggered (for celebration animation)
const [autoSubmitTriggered, setAutoSubmitTriggered] = useState(false)
// Track rejected digit for red X animation (null = no rejection, string = the rejected digit)
const [rejectedDigit, setRejectedDigit] = useState<string | null>(null)
const hasPhysicalKeyboard = useHasPhysicalKeyboard()
@ -333,6 +335,27 @@ export function ActiveSession({
currentTerm,
])
// Update help context when helpTermIndex changes (for "Get Help" button flow)
// This ensures helpState.content has data for the term being helped, not currentTermIndex
useEffect(() => {
if (helpTermIndex === null || !currentProblem) return
const terms = currentProblem.problem.terms
if (helpTermIndex >= terms.length) return
// Calculate the context for the help term
const helpCurrentValue = helpTermIndex === 0 ? 0 : prefixSums[helpTermIndex - 1]
const helpTargetValue = prefixSums[helpTermIndex]
const helpTerm = terms[helpTermIndex]
helpActions.resetForNewTerm({
currentValue: helpCurrentValue,
targetValue: helpTargetValue,
term: helpTerm,
termIndex: helpTermIndex,
})
}, [helpTermIndex, currentProblem?.problem.terms.join(','), prefixSums])
// Get current part and slot
const parts = plan.parts
const currentPartIndex = plan.currentPartIndex
@ -378,9 +401,45 @@ export function ActiveSession({
}
}, [currentPart, currentSlot, currentPartIndex, currentSlotIndex, currentProblem])
const handleDigit = useCallback((digit: string) => {
setUserAnswer((prev) => prev + digit)
}, [])
// Check if adding a digit would be consistent with any prefix sum
const isDigitConsistent = useCallback(
(currentAnswer: string, digit: string): boolean => {
const newAnswer = currentAnswer + digit
const newAnswerNum = parseInt(newAnswer, 10)
if (Number.isNaN(newAnswerNum)) return false
// Check if newAnswer is a prefix of any prefix sum's string representation
// e.g., if prefix sums are [23, 68, 80], and newAnswer is "6", that's consistent with "68"
// if newAnswer is "8", that's consistent with "80"
// if newAnswer is "68", that's an exact match
for (const sum of prefixSums) {
const sumStr = sum.toString()
if (sumStr.startsWith(newAnswer)) {
return true
}
}
return false
},
[prefixSums]
)
const handleDigit = useCallback(
(digit: string) => {
setUserAnswer((prev) => {
if (isDigitConsistent(prev, digit)) {
return prev + digit
} else {
// Reject the digit - show red X and count as correction
setRejectedDigit(digit)
setCorrectionCount((c) => c + 1)
// Clear the rejection after a short delay
setTimeout(() => setRejectedDigit(null), 300)
return prev // Don't change the answer
}
})
},
[isDigitConsistent]
)
const handleBackspace = useCallback(() => {
setUserAnswer((prev) => {
@ -405,13 +464,16 @@ export function ActiveSession({
setHelpTermIndex(newConfirmedCount)
// Clear the input so they can continue
setUserAnswer('')
// Start progressive help at level 1 (coach hint)
helpActions.requestHelp(1)
}
}, [matchedPrefixIndex, currentProblem?.problem.terms.length])
}, [matchedPrefixIndex, currentProblem?.problem.terms.length, helpActions])
// Handle dismissing help (continue without visual assistance)
const handleDismissHelp = useCallback(() => {
setHelpTermIndex(null)
}, [])
helpActions.dismissHelp()
}, [helpActions])
// Handle when student reaches the target value on the help abacus
const handleTargetReached = useCallback(() => {
@ -526,26 +588,27 @@ export function ActiveSession({
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
setUserAnswer((prev) => {
if (prev.length > 0) {
setCorrectionCount((c) => c + 1)
}
return prev.slice(0, -1)
})
handleBackspace()
} else if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (/^[0-9]$/.test(e.key)) {
setUserAnswer((prev) => prev + e.key)
} else if (e.key === '-' && userAnswer.length === 0) {
// Allow negative sign at start
setUserAnswer('-')
handleDigit(e.key)
}
// Note: removed negative sign handling since prefix sums are always positive
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [hasPhysicalKeyboard, isPaused, currentProblem, isSubmitting, userAnswer, handleSubmit])
}, [
hasPhysicalKeyboard,
isPaused,
currentProblem,
isSubmitting,
handleSubmit,
handleDigit,
handleBackspace,
])
const handlePause = useCallback(() => {
setIsPaused(true)
@ -934,6 +997,7 @@ export function ActiveSession({
: undefined
}
autoSubmitPending={autoSubmitTriggered}
rejectedDigit={rejectedDigit}
/>
) : (
<LinearProblem
@ -946,126 +1010,51 @@ export function ActiveSession({
/>
)}
{/* Per-term help with HelpAbacus - shown when helpTermIndex is set */}
{/* Per-term progressive help - shown when helpTermIndex is set */}
{!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && (
<div
data-section="term-help"
className={css({
padding: '1rem',
backgroundColor: isDark ? 'purple.900' : 'purple.50',
borderRadius: '12px',
border: '2px solid',
borderColor: isDark ? 'purple.700' : 'purple.200',
minWidth: '200px',
minWidth: '280px',
maxWidth: '400px',
})}
>
{/* Term being helped indicator */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '0.5rem',
})}
>
<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',
})}
>
{helpContext.term >= 0 ? '+' : ''}
{helpContext.term}
<span>Help with:</span>
<span className={css({ fontFamily: 'monospace', fontSize: '1rem' })}>
{helpContext.term >= 0 ? '+' : ''}
{helpContext.term}
</span>
</div>
<button
type="button"
onClick={handleDismissHelp}
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
_hover: { color: isDark ? 'gray.200' : 'gray.700' },
})}
>
</button>
</div>
{/* Provenance breakdown - shows which digits come from the term */}
{helpState.content?.beadSteps && helpState.content.beadSteps.length > 0 && (
<div
data-element="provenance-breakdown"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginBottom: '0.75rem',
padding: '0.5rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'purple.700' : 'purple.200',
})}
>
{/* Group steps by unique provenance to show digit breakdown */}
{(() => {
const seenPlaces = new Set<string>()
return helpState.content?.beadSteps
.filter((step) => {
if (!step.provenance) return false
const key = `${step.provenance.rhsPlace}-${step.provenance.rhsDigit}`
if (seenPlaces.has(key)) return false
seenPlaces.add(key)
return true
})
.map((step, idx) => {
const prov = step.provenance
if (!prov) return null
return (
<div
key={idx}
data-element="provenance-chip"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
backgroundColor: isDark ? 'purple.800' : 'purple.100',
borderRadius: '6px',
fontSize: '0.75rem',
})}
>
<span
className={css({
fontWeight: 'bold',
color: isDark ? 'purple.200' : 'purple.800',
})}
>
{prov.rhsDigit}
</span>
<span className={css({ color: isDark ? 'gray.400' : 'gray.600' })}>
{prov.rhsPlaceName}
</span>
<span className={css({ color: isDark ? 'gray.500' : 'gray.400' })}>
= {prov.rhsValue}
</span>
</div>
)
})
})()}
</div>
)}
<HelpAbacus
<PracticeHelpPanel
helpState={helpState}
onRequestHelp={helpActions.requestHelp}
onDismissHelp={handleDismissHelp}
isAbacusPart={currentPart?.type === 'abacus'}
currentValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
columns={3}
scaleFactor={0.9}
interactive={true}
onTargetReached={handleTargetReached}
/>
</div>
)}

View File

@ -1,10 +1,12 @@
'use client'
import { useCallback, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { HelpLevel } from '@/db/schema/session-plans'
import type { PracticeHelpState } from '@/hooks/usePracticeHelp'
import { generateUnifiedInstructionSequence } from '@/utils/unifiedStepGenerator'
import { css } from '../../../styled-system/css'
import { DecompositionDisplay, DecompositionProvider } from '../decomposition'
import { HelpAbacus } from './HelpAbacus'
interface PracticeHelpPanelProps {
@ -64,6 +66,51 @@ export function PracticeHelpPanel({
const { currentLevel, content, isAvailable, maxLevelUsed } = helpState
const [isExpanded, setIsExpanded] = useState(false)
// Track current abacus value for step synchronization
const [abacusValue, setAbacusValue] = useState(currentValue ?? 0)
// Generate the decomposition steps to determine current step from abacus value
const sequence = useMemo(() => {
if (currentValue === undefined || targetValue === undefined) return null
return generateUnifiedInstructionSequence(currentValue, targetValue)
}, [currentValue, targetValue])
// Calculate which step the user is on based on abacus value
// Find the highest step index where expectedValue <= abacusValue
const currentStepIndex = useMemo(() => {
if (!sequence || sequence.steps.length === 0) return 0
if (currentValue === undefined) return 0
// Start value is the value before any steps
const startVal = currentValue
// If abacus is still at start value, we're at step 0
if (abacusValue === startVal) return 0
// Find which step we're on by checking expected values
// The step index to highlight is the one we're working toward (next incomplete step)
for (let i = 0; i < sequence.steps.length; i++) {
const step = sequence.steps[i]
// If abacus value is less than this step's expected value, we're working on this step
if (abacusValue < step.expectedValue) {
return i
}
// If we've reached or passed this step's expected value, check next step
if (abacusValue === step.expectedValue) {
// We've completed this step, move to next
return Math.min(i + 1, sequence.steps.length - 1)
}
}
// At or past target - show last step as complete
return sequence.steps.length - 1
}, [sequence, abacusValue, currentValue])
// Handle abacus value changes
const handleAbacusValueChange = useCallback((newValue: number) => {
setAbacusValue(newValue)
}, [])
const handleRequestHelp = useCallback(() => {
if (currentLevel === 0) {
onRequestHelp(1)
@ -237,76 +284,52 @@ export function PracticeHelpPanel({
)}
{/* Level 2: Decomposition */}
{currentLevel >= 2 && content?.decomposition && content.decomposition.isMeaningful && (
<div
data-element="decomposition"
className={css({
padding: '0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
})}
>
{currentLevel >= 2 &&
content?.decomposition &&
content.decomposition.isMeaningful &&
currentValue !== undefined &&
targetValue !== undefined && (
<div
data-element="decomposition-container"
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
color: isDark ? 'blue.300' : 'blue.600',
marginBottom: '0.5rem',
textTransform: 'uppercase',
padding: '0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
})}
>
Step-by-Step
</div>
<div
className={css({
fontFamily: 'monospace',
fontSize: '1.125rem',
color: isDark ? 'gray.100' : 'gray.800',
wordBreak: 'break-word',
})}
>
{content.decomposition.fullDecomposition}
</div>
{/* Segment explanations */}
{content.decomposition.segments.length > 0 && (
<div
className={css({
marginTop: '0.75rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
fontSize: '0.75rem',
fontWeight: 'bold',
color: isDark ? 'blue.300' : 'blue.600',
marginBottom: '0.5rem',
textTransform: 'uppercase',
})}
>
{content.decomposition.segments.map((segment) => (
<div
key={segment.id}
className={css({
padding: '0.5rem',
backgroundColor: isDark ? 'gray.700' : 'gray.50',
borderRadius: '6px',
fontSize: '0.875rem',
})}
>
<span
className={css({
fontWeight: 'bold',
color: isDark ? 'gray.200' : 'gray.700',
})}
>
{segment.readable?.title || `Column ${segment.place + 1}`}:
</span>{' '}
<span className={css({ color: isDark ? 'gray.400' : 'gray.600' })}>
{segment.readable?.summary || segment.expression}
</span>
</div>
))}
Step-by-Step
</div>
)}
</div>
)}
<div
data-element="decomposition-display"
className={css({
fontFamily: 'monospace',
fontSize: '1.125rem',
color: isDark ? 'gray.100' : 'gray.800',
wordBreak: 'break-word',
})}
>
<DecompositionProvider
startValue={currentValue}
targetValue={targetValue}
currentStepIndex={currentStepIndex}
abacusColumns={3}
>
<DecompositionDisplay />
</DecompositionProvider>
</div>
</div>
)}
{/* Level 3: Visual abacus with bead arrows */}
{currentLevel >= 3 && currentValue !== undefined && targetValue !== undefined && (
@ -338,6 +361,8 @@ export function PracticeHelpPanel({
targetValue={targetValue}
columns={3}
scaleFactor={1.0}
interactive={true}
onValueChange={handleAbacusValueChange}
/>
{isAbacusPart && (

View File

@ -21,7 +21,7 @@ import type {
} from '../../types/tutorial'
import { generateUnifiedInstructionSequence } from '../../utils/unifiedStepGenerator'
import { CoachBar } from './CoachBar/CoachBar'
import { DecompositionWithReasons } from './DecompositionWithReasons'
import { DecompositionDisplay, DecompositionProvider } from '../decomposition'
import { PedagogicalDecompositionDisplay } from './PedagogicalDecompositionDisplay'
import { TutorialProvider, useTutorialContext } from './TutorialContext'
import { TutorialUIProvider } from './TutorialUIContext'
@ -317,14 +317,7 @@ function TutorialPlayerContent({
}
// Define the static expected steps using our unified step generator
const {
expectedSteps,
fullDecomposition,
isMeaningfulDecomposition,
pedagogicalSegments,
termPositions,
unifiedSteps,
} = useMemo(() => {
const { expectedSteps, fullDecomposition, isMeaningfulDecomposition } = useMemo(() => {
try {
const unifiedSequence = generateUnifiedInstructionSequence(
currentStep.startValue,
@ -343,25 +336,16 @@ function TutorialPlayerContent({
termPosition: step.termPosition, // Add the precise position information
}))
// Extract term positions from steps for DecompositionWithReasons
const positions = unifiedSequence.steps.map((step) => step.termPosition).filter(Boolean)
return {
expectedSteps: steps,
fullDecomposition: unifiedSequence.fullDecomposition,
isMeaningfulDecomposition: unifiedSequence.isMeaningfulDecomposition,
pedagogicalSegments: unifiedSequence.segments,
termPositions: positions,
unifiedSteps: unifiedSequence.steps, // NEW: Include the raw unified steps with provenance
}
} catch (_error) {
return {
expectedSteps: [],
fullDecomposition: '',
isMeaningfulDecomposition: false,
pedagogicalSegments: [],
termPositions: [],
unifiedSteps: [], // NEW: Also add empty array for error case
}
}
}, [currentStep.startValue, currentStep.targetValue])
@ -1138,6 +1122,9 @@ function TutorialPlayerContent({
return (
<div
data-component="tutorial-player"
data-step-index={currentStepIndex}
data-step-completed={isStepCompleted}
className={`${css({
display: 'flex',
flexDirection: 'column',
@ -1148,6 +1135,7 @@ function TutorialPlayerContent({
{/* Header */}
{!hideNavigation && (
<div
data-section="tutorial-header"
className={css({
borderBottom: '1px solid',
borderColor: theme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'gray.200',
@ -1162,8 +1150,16 @@ function TutorialPlayerContent({
})}
>
<div>
<h1 className={css({ fontSize: 'xl', fontWeight: 'bold' })}>{tutorial.title}</h1>
<p className={css({ fontSize: 'sm', color: 'gray.600' })}>
<h1
data-element="tutorial-title"
className={css({ fontSize: 'xl', fontWeight: 'bold' })}
>
{tutorial.title}
</h1>
<p
data-element="step-progress"
className={css({ fontSize: 'sm', color: 'gray.600' })}
>
{t('header.step', {
current: currentStepIndex + 1,
total: tutorial.steps.length,
@ -1172,10 +1168,11 @@ function TutorialPlayerContent({
</p>
</div>
<div className={hstack({ gap: 2 })}>
<div data-element="header-controls" className={hstack({ gap: 2 })}>
{isDebugMode && (
<>
<button
data-action="toggle-debug-panel"
onClick={toggleDebugPanel}
className={css({
px: 3,
@ -1193,6 +1190,7 @@ function TutorialPlayerContent({
{t('controls.debug')}
</button>
<button
data-action="toggle-step-list"
onClick={toggleStepList}
className={css({
px: 3,
@ -1317,6 +1315,7 @@ function TutorialPlayerContent({
{/* Progress bar */}
<div
data-element="progress-bar"
className={css({
mt: 2,
bg: 'gray.200',
@ -1325,6 +1324,7 @@ function TutorialPlayerContent({
})}
>
<div
data-element="progress-fill"
className={css({
bg: 'blue.500',
h: 'full',
@ -1337,10 +1337,11 @@ function TutorialPlayerContent({
</div>
)}
<div className={hstack({ flex: 1, gap: 0 })}>
<div data-section="tutorial-body" className={hstack({ flex: 1, gap: 0 })}>
{/* Step list sidebar */}
{uiState.showStepList && (
<div
data-section="step-list-sidebar"
className={css({
w: '300px',
borderRight: '1px solid',
@ -1400,13 +1401,20 @@ function TutorialPlayerContent({
)}
{/* Main content */}
<div className={css({ flex: 1, display: 'flex', flexDirection: 'column' })}>
<div
data-section="main-content"
className={css({ flex: 1, display: 'flex', flexDirection: 'column' })}
>
{/* Step content */}
<div className={css({ flex: 1, p: 6 })}>
<div data-section="step-content" className={css({ flex: 1, p: 6 })}>
<div className={vstack({ gap: 6, alignItems: 'center' })}>
{/* Step instructions */}
<div className={css({ textAlign: 'center', maxW: '600px' })}>
<div
data-element="step-instructions"
className={css({ textAlign: 'center', maxW: '600px' })}
>
<h2
data-element="problem-display"
className={css({
fontSize: '2xl',
fontWeight: 'bold',
@ -1417,6 +1425,7 @@ function TutorialPlayerContent({
{currentStep.problem}
</h2>
<p
data-element="step-description"
className={css({
fontSize: 'lg',
color: theme === 'dark' ? 'gray.400' : 'gray.700',
@ -1427,7 +1436,10 @@ function TutorialPlayerContent({
</p>
{/* Hide action description for multi-step problems since it duplicates pedagogical decomposition */}
{!currentStep.multiStepInstructions && (
<p className={css({ fontSize: 'md', color: 'blue.600' })}>
<p
data-element="action-description"
className={css({ fontSize: 'md', color: 'blue.600' })}
>
{currentStep.actionDescription}
</p>
)}
@ -1438,6 +1450,8 @@ function TutorialPlayerContent({
currentStep.multiStepInstructions &&
currentStep.multiStepInstructions.length > 0 && (
<div
data-element="guidance-panel"
data-multi-step={currentMultiStep}
className={css({
p: 5,
background:
@ -1471,6 +1485,7 @@ function TutorialPlayerContent({
})}
>
<p
data-element="guidance-title"
className={css({
fontSize: 'base',
fontWeight: 600,
@ -1486,6 +1501,7 @@ function TutorialPlayerContent({
{/* Pedagogical decomposition with interactive reasoning */}
{fullDecomposition && isMeaningfulDecomposition && (
<div
data-element="decomposition-container"
className={css({
mb: 4,
p: 3,
@ -1506,6 +1522,7 @@ function TutorialPlayerContent({
})}
>
<div
data-element="decomposition-display"
className={css({
fontSize: 'base',
color: theme === 'dark' ? 'gray.300' : 'slate.800',
@ -1515,16 +1532,20 @@ function TutorialPlayerContent({
lineHeight: '1.5',
})}
>
<DecompositionWithReasons
fullDecomposition={fullDecomposition}
termPositions={termPositions}
segments={pedagogicalSegments}
/>
<DecompositionProvider
startValue={currentStep.startValue}
targetValue={currentStep.targetValue}
currentStepIndex={currentMultiStep}
abacusColumns={abacusColumns}
>
<DecompositionDisplay />
</DecompositionProvider>
</div>
</div>
)}
<div
data-element="current-instruction-container"
className={css({
fontSize: 'sm',
color: theme === 'dark' ? 'gray.400' : 'amber.800',
@ -1568,6 +1589,7 @@ function TutorialPlayerContent({
return (
<div>
<div
data-element="current-instruction"
className={css({
mb: 1,
fontWeight: 'bold',
@ -1589,6 +1611,7 @@ function TutorialPlayerContent({
{/* Error message */}
{error && (
<div
data-element="error-message"
className={css({
p: 4,
bg: 'red.50',
@ -1607,6 +1630,7 @@ function TutorialPlayerContent({
{/* Abacus */}
<div
data-element="abacus-container"
className={css({
bg: theme === 'dark' ? 'rgba(30, 30, 40, 0.4)' : 'white',
border: '2px solid',
@ -1643,6 +1667,7 @@ function TutorialPlayerContent({
{/* Debug info */}
{isDebugMode && (
<div
data-element="debug-info"
className={css({
mt: 4,
p: 3,
@ -1694,6 +1719,7 @@ function TutorialPlayerContent({
{/* Tooltip */}
{!hideTooltip && currentStep.tooltip && (
<div
data-element="tooltip-panel"
className={css({
maxW: '500px',
p: 4,
@ -1704,6 +1730,7 @@ function TutorialPlayerContent({
})}
>
<h4
data-element="tooltip-title"
className={css({
fontWeight: 'bold',
color: 'yellow.800',
@ -1712,7 +1739,10 @@ function TutorialPlayerContent({
>
{currentStep.tooltip.content}
</h4>
<p className={css({ fontSize: 'sm', color: 'yellow.700' })}>
<p
data-element="tooltip-explanation"
className={css({ fontSize: 'sm', color: 'yellow.700' })}
>
{currentStep.tooltip.explanation}
</p>
</div>
@ -1723,6 +1753,7 @@ function TutorialPlayerContent({
{/* Navigation controls */}
{!hideNavigation && (
<div
data-section="navigation-controls"
className={css({
borderTop: '1px solid',
borderColor: 'gray.200',
@ -1732,6 +1763,7 @@ function TutorialPlayerContent({
>
<div className={hstack({ justifyContent: 'space-between' })}>
<button
data-action="previous-step"
onClick={goToPreviousStep}
disabled={!navigationState.canGoPrevious}
className={css({
@ -1749,7 +1781,10 @@ function TutorialPlayerContent({
{t('navigation.previous')}
</button>
<div className={css({ fontSize: 'sm', color: 'gray.600' })}>
<div
data-element="step-counter"
className={css({ fontSize: 'sm', color: 'gray.600' })}
>
{t('navigation.stepCounter', {
current: currentStepIndex + 1,
total: navigationState.totalSteps,
@ -1757,6 +1792,7 @@ function TutorialPlayerContent({
</div>
<button
data-action="next-step"
onClick={goToNextStep}
disabled={!navigationState.canGoNext && !isStepCompleted}
className={css({
@ -1783,6 +1819,7 @@ function TutorialPlayerContent({
{/* Debug panel */}
{uiState.showDebugPanel && (
<div
data-section="debug-panel"
className={css({
w: '400px',
borderLeft: '1px solid',

View File

@ -0,0 +1,306 @@
'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)
/**
* Hook to access decomposition context. Throws if not inside DecompositionProvider.
*/
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
}
/**
* Provider for decomposition display.
*
* Wraps any area where you want to show an interactive decomposition display.
* Only requires startValue and targetValue - all other data is derived.
*
* @example
* ```tsx
* <DecompositionProvider startValue={0} targetValue={45}>
* <DecompositionDisplay />
* </DecompositionProvider>
* ```
*/
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>
}