From 804d937dd989a2feb9a26330b2d7ded96765b106 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sun, 7 Dec 2025 08:56:35 -0600 Subject: [PATCH] feat(practice): integrate progressive help with decomposition display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 20 + apps/web/README.md | 86 +++- .../decomposition/DecompositionDisplay.tsx | 302 +++++++++++++ .../src/components/decomposition/README.md | 350 +++++++++++++++ .../decomposition/ReasonTooltip.tsx | 376 ++++++++++++++++ .../decomposition/decomposition.css | 125 ++++++ .../web/src/components/decomposition/index.ts | 17 + .../decomposition/reason-tooltip.css | 408 ++++++++++++++++++ .../src/components/practice/ActiveSession.tsx | 215 +++++---- .../components/practice/PracticeHelpPanel.tsx | 149 ++++--- .../components/tutorial/TutorialPlayer.tsx | 103 +++-- .../web/src/contexts/DecompositionContext.tsx | 306 +++++++++++++ 12 files changed, 2248 insertions(+), 209 deletions(-) create mode 100644 apps/web/src/components/decomposition/DecompositionDisplay.tsx create mode 100644 apps/web/src/components/decomposition/README.md create mode 100644 apps/web/src/components/decomposition/ReasonTooltip.tsx create mode 100644 apps/web/src/components/decomposition/decomposition.css create mode 100644 apps/web/src/components/decomposition/index.ts create mode 100644 apps/web/src/components/decomposition/reason-tooltip.css create mode 100644 apps/web/src/contexts/DecompositionContext.tsx diff --git a/README.md b/README.md index 781f9a3e..4187e444 100644 --- a/README.md +++ b/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) diff --git a/apps/web/README.md b/apps/web/README.md index dd27da3a..62fcfa88 100644 --- a/apps/web/README.md +++ b/apps/web/README.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 diff --git a/apps/web/src/components/decomposition/DecompositionDisplay.tsx b/apps/web/src/components/decomposition/DecompositionDisplay.tsx new file mode 100644 index 00000000..a4b96e1a --- /dev/null +++ b/apps/web/src/components/decomposition/DecompositionDisplay.tsx @@ -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 + activeSegmentId: string | null + addActiveTerm: (termIndex: number, segmentId?: string) => void + removeActiveTerm: (termIndex: number, segmentId?: string) => void +} + +const InternalDecompositionContext = createContext({ + 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 {text} + } + + // 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 ( + handleTermHover(true)} + onMouseLeave={() => handleTermHover(false)} + style={{ cursor: 'pointer' }} + > + {text} + + ) +} + +// ============================================================================ +// 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 ( + + handleHighlightChange(true)} + onMouseLeave={() => handleHighlightChange(false)} + > + {children} + + + ) +} + +// ============================================================================ +// DecompositionDisplay Component +// ============================================================================ + +/** + * Renders the decomposition string with interactive terms and tooltips. + * + * Must be used inside a DecompositionProvider. + * + * @example + * ```tsx + * + * + * + * ``` + */ +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() + 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( + {fullDecomposition.slice(cursor, startIndex)} + ) + } + + // 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( + + {fullDecomposition.slice(segmentCursor, segPos.startIndex)} + + ) + } + + const segText = fullDecomposition.slice(segPos.startIndex, segPos.endIndex) + + segmentElements.push( + + ) + + segmentCursor = segPos.endIndex + } + + elements.push( + + {segmentElements} + + ) + + // 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( + + ) + 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({fullDecomposition.slice(cursor)}) + } + + return elements + } + + return ( + +
+ {renderElements()} +
+
+ ) +} diff --git a/apps/web/src/components/decomposition/README.md b/apps/web/src/components/decomposition/README.md new file mode 100644 index 00000000..34b64910 --- /dev/null +++ b/apps/web/src/components/decomposition/README.md @@ -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 ( + + + + ) +} +``` + +## 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 + +``` + +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 + + +10 + +``` + +## 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 ( + +
+ + +
+
+ ) +} +``` + +### 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 ( + +
+ +
+
+ ) +} +``` + +### With Abacus Coordination + +```typescript +function CoordinatedDisplay({ startValue, targetValue }) { + const [highlightedColumn, setHighlightedColumn] = useState(null) + + return ( + { + setHighlightedColumn(columnIndex) + }} + > + + + + ) +} +``` + +## 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 diff --git a/apps/web/src/components/decomposition/ReasonTooltip.tsx b/apps/web/src/components/decomposition/ReasonTooltip.tsx new file mode 100644 index 00000000..b22d4c12 --- /dev/null +++ b/apps/web/src/components/decomposition/ReasonTooltip.tsx @@ -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 ( + + + {children} + + + + +
+
+ {ruleInfo.emoji} +
+

+ {enhancedContent?.title || readable?.title || ruleInfo.name} +

+

+ {enhancedContent?.subtitle || readable?.subtitle || ruleInfo.description} +

+
+
+ + {/* Primary, concise explanation */} + {segment?.readable?.summary && ( +
+

{segment.readable.summary}

+
+ )} + + {/* Optional provenance nudge (avoid duplicating subtitle) */} + {provenance && + !(enhancedContent?.subtitle || readable?.subtitle || '').includes( + `${fromPrefix} ` + ) && ( +
+

+ {t('reasoning', { + addend: provenance.rhs, + place: provenance.rhsPlaceName, + digit: provenance.rhsDigit, + })} +

+
+ )} + + {/* More details disclosure for optional content */} + {((enhancedContent?.chips || readable?.chips)?.length || + readable?.carryPath || + readable?.showMath || + (readable && readable.stepsFriendly.length > 1)) && ( +
+ + + {showDetails && ( +
+ {/* Context chips */} + {(enhancedContent?.chips || readable?.chips)?.length ? ( +
+
+ {(enhancedContent?.chips || readable?.chips || []).map((chip, index) => ( +
+
{chip.label}
+
{chip.value}
+
+ ))} +
+
+ ) : null} + + {/* Carry path only when it's interesting (cascades) */} + {segment?.plan?.some((p) => p.rule === 'Cascade') && readable?.carryPath && ( +
+

+ {t('details.carryPath')} {readable.carryPath} +

+
+ )} + + {/* Math toggle */} + {readable?.showMath && ( +
+ + + {showMath && ( +
+ {readable.showMath.lines.map((line, index) => ( +

+ {line} +

+ ))} +
+ )} +
+ )} + + {/* Step-by-step breakdown */} + {readable && readable.stepsFriendly.length > 1 && ( +
+ + + {showBeadDetails && ( +
    + {readable.stepsFriendly.map((stepInstruction, idx) => ( +
  1. + + {stepInstruction} + +
  2. + ))} +
+ )} +
+ )} +
+ )} +
+ )} + + {/* Dev-only validation hint */} + {process.env.NODE_ENV !== 'production' && + segment?.readable?.validation && + !segment.readable.validation.ok && ( +
+ {t('devWarning', { + issues: segment.readable.validation.issues.join('; '), + })} +
+ )} + + {/* Original transformation shown at bottom */} + {originalValue && segment?.expression && ( +
+
+ {originalValue} + + {segment.expression} +
+
+ {t('formula', { + original: originalValue, + expanded: segment.expression, + })} +
+
+ )} +
+ + +
+
+
+ ) +} diff --git a/apps/web/src/components/decomposition/decomposition.css b/apps/web/src/components/decomposition/decomposition.css new file mode 100644 index 00000000..32385243 --- /dev/null +++ b/apps/web/src/components/decomposition/decomposition.css @@ -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; + } +} diff --git a/apps/web/src/components/decomposition/index.ts b/apps/web/src/components/decomposition/index.ts new file mode 100644 index 00000000..0032d931 --- /dev/null +++ b/apps/web/src/components/decomposition/index.ts @@ -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' diff --git a/apps/web/src/components/decomposition/reason-tooltip.css b/apps/web/src/components/decomposition/reason-tooltip.css new file mode 100644 index 00000000..12b0ab94 --- /dev/null +++ b/apps/web/src/components/decomposition/reason-tooltip.css @@ -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; + } +} diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index a918910c..25ceccfe 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -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(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} /> ) : ( )} - {/* 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 && (
+ {/* Term being helped indicator */}
- {helpContext.term >= 0 ? '+' : ''} - {helpContext.term} + Help with: + + {helpContext.term >= 0 ? '+' : ''} + {helpContext.term} +
-
- {/* Provenance breakdown - shows which digits come from the term */} - {helpState.content?.beadSteps && helpState.content.beadSteps.length > 0 && ( -
- {/* Group steps by unique provenance to show digit breakdown */} - {(() => { - const seenPlaces = new Set() - 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 ( -
- - {prov.rhsDigit} - - - {prov.rhsPlaceName} - - - = {prov.rhsValue} - -
- ) - }) - })()} -
- )} - -
)} diff --git a/apps/web/src/components/practice/PracticeHelpPanel.tsx b/apps/web/src/components/practice/PracticeHelpPanel.tsx index ce8a4395..d922833d 100644 --- a/apps/web/src/components/practice/PracticeHelpPanel.tsx +++ b/apps/web/src/components/practice/PracticeHelpPanel.tsx @@ -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 && ( -
+ {currentLevel >= 2 && + content?.decomposition && + content.decomposition.isMeaningful && + currentValue !== undefined && + targetValue !== undefined && (
- Step-by-Step -
-
- {content.decomposition.fullDecomposition} -
- - {/* Segment explanations */} - {content.decomposition.segments.length > 0 && (
- {content.decomposition.segments.map((segment) => ( -
- - {segment.readable?.title || `Column ${segment.place + 1}`}: - {' '} - - {segment.readable?.summary || segment.expression} - -
- ))} + Step-by-Step
- )} -
- )} +
+ + + +
+ + )} {/* 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 && ( diff --git a/apps/web/src/components/tutorial/TutorialPlayer.tsx b/apps/web/src/components/tutorial/TutorialPlayer.tsx index 937ee59d..ea95fedc 100644 --- a/apps/web/src/components/tutorial/TutorialPlayer.tsx +++ b/apps/web/src/components/tutorial/TutorialPlayer.tsx @@ -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 (
-

{tutorial.title}

-

+

+ {tutorial.title} +

+

{t('header.step', { current: currentStepIndex + 1, total: tutorial.steps.length, @@ -1172,10 +1168,11 @@ function TutorialPlayerContent({

-
+
{isDebugMode && ( <>