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:
parent
2f7cb03c3f
commit
804d937dd9
20
README.md
20
README.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
Loading…
Reference in New Issue